Revision after m's answers (2026-05-15): - mCables is a framework. Top-level `projects` table; LOFT and OFFICE are separate projects, each backed by one drawing. project_id is denormalised onto every row for cheap project-scoped queries; CASCADE from projects wipes a project's whole subgraph. - IO diamonds are wall-outlet terminators (type=Power), not inter-frame bridges. paired_with_id removed. - No runtime importer. The seed Cable-Management.excalidraw is the visual-grammar reference for the exporter only. /api/sync/import is dropped from MVP; only /api/sync/export remains (one-way, manual). - No cable inventory fields. Strictly visual structure for v0. - DB at ./data/mcables.db (project-local, gitignored). - Deploy: raw docker on mDock under /home/m/stacks/mcables/ (NOT Dokploy). Conventions verified live (mgreen, mgeo, msports-garmin patterns). Port 7777, container_name mcables, image from Gitea registry, Gitea Actions self-hosted runner builds + deploys on push to main. - Bind 0.0.0.0:7777 on the LAN. No auth. - UI gains a projects picker; all CRUD endpoints scoped under /api/projects/:pid/. - Slices re-planned: empty bootstrap → frame+device → port+cable → IO+cable-type editing → export. - Open questions trimmed; six new ones (drawing-name policy, device uniqueness, non-Power IO, bundle export, cross-project cable types, delete guardrail). Ends with DESIGN v2 READY FOR REVIEW.
34 KiB
mCables — Design v2
Cable-management framework for m's setup. Inventor shift 1 design, revised after m's answers (2026-05-15) — for m's review.
Sources read for v2: the live Cable-Management.excalidraw on
mxdrw.msbls.de (used as the visual-grammar reference, not as a bootstrap
import target), mai-memory (mcables, m), and a live survey of mDock
services for the deploy conventions (§10).
What changed in v2
- mCables is a framework: a top-level
projectstable; LOFT and OFFICE are separate mCables projects, each backed by one drawing.- No runtime importer. The seed drawing is reference material only; m rebuilds LOFT and OFFICE from scratch in the tool. Only
POST /api/sync/exportstays in the MVP API.- IO diamonds are wall-outlet terminators (type=Power), not inter-frame bridges.
paired_with_idis gone.- No cable inventory metadata. Purely visual structure for v0.
- DB:
./data/mcables.db(project-local, gitignored).- Deploy: raw docker / docker-compose on mDock — not Dokploy.
- Bind:
0.0.0.0:7777on the LAN, no auth.
0. The seed drawing — visual grammar reference
Cable-Management.excalidraw on mxdrw.msbls.de is not ingested at
runtime. It is the visual-grammar reference we lock the export onto so that
when m rebuilds LOFT and OFFICE inside mCables, the exported .excalidraw
looks like the seed.
Concrete numbers from the live file (180 elements):
| Kind | Count | Excalidraw shape | What it represents |
|---|---|---|---|
| Frames | 2 | frame (name) |
Sub-areas inside a project (desk, rack, …) |
| Devices | 27 | rectangle with bound text |
Hardware items |
| Ports | 74 | ellipse ~12×9 |
Connectors on a device edge, colour = cable type |
| Cables | 31 | arrow |
Typed connections between ports/devices/outlets |
| IO markers | 6 | diamond text=IO |
Wall outlet / power-entry terminators (type=Power) |
| Legend | 5 | text |
Colour key in the top-left of the frame |
| Lines | 5 | line |
Decorative (separator under the legend). Ignored. |
Legend → cable type → colour, picked up directly from the seed:
| Type | Colour | Hex |
|---|---|---|
| Power | red | #e03131 |
| USB | green | #2f9e44 |
| HDMI | blue | #1971c2 |
| DP | purple | #9c36b5 |
| RJ45 | yellow | #ffd500 |
Three observations about the seed's visual grammar — these constrain the exporter (§4):
- Ports sit on a device edge as small ellipses (~12×9), coloured by
cable type. They are not children of the device in the Excalidraw sense
(no
containerId/boundElementslink) — purely positional. When we export from mCables we mimic that: port ellipse at(device.x + port.x_offset, device.y + port.y_offset), stroke colour = type colour. - Cable arrows bind to elements. In the seed: 44 endpoints to ellipses
(ports), 12 to whole rectangles (device-level, no specific port), 3 to
diamonds (wall outlets). Our exporter sets
startBinding.elementId/endBinding.elementIdto whichever Excalidraw element ID we wrote for the port / device / IO marker. - IO diamonds = wall outlets. They are terminals: a cable goes from a device-port → an IO marker, meaning "this cable plugs into a wall socket outside the diagram". They are always type=Power in m's setup but the schema doesn't enforce that (a future "network jack in the wall" wouldn't fit, and we can lift the constraint then).
1. Frontend stack — vanilla JS + SVG
Locked: vanilla ES modules (TS-typed via JSDoc, no build step) + SVG
diagram surface, served from a single Go binary via embed.FS.
Why this fits m: matches the no-build-step preference; same single-binary
aesthetic as m, mai, youpcms, mExDraw. Type-checking is opt-in via
make typecheck (tsc --noEmit), not gating runtime. SVG is one DOM node
per port/device/cable → trivial hit-testing, CSS-driven colouring by
data-type=hdmi, drag via pointer events + getScreenCTM().
Escape hatch only if state for half-drawn cables + multi-select gets painful: switch to Preact-via-CDN-ESM (still no build step). Not v0.
2. SQLite schema
./data/mcables.db (project-local, gitignored). WAL mode, FKs on.
Driver: modernc.org/sqlite (cgo-free — clean cross-compile, simple
Dockerfile).
-- 001_init.sql
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
-- A project IS a drawing. LOFT and OFFICE are separate projects.
-- One project ↔ one .excalidraw file in mExDraw.
CREATE TABLE projects (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE, -- "LOFT", "OFFICE"
drawing_name TEXT NOT NULL, -- mExDraw drawing name, e.g. "LOFT.excalidraw"
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Cable types: legend rows. Project-scoped so LOFT and OFFICE can diverge
-- (e.g. add Audio-jack only in LOFT). The five seed types are inserted
-- when a project is created — not as a global table.
CREATE TABLE cable_types (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL, -- "Power", "USB", …
color TEXT NOT NULL, -- "#e03131"
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, name)
);
CREATE INDEX cable_types_project_idx ON cable_types(project_id);
-- A frame is a named container *inside* a project: 'desk', 'rack', 'media'.
CREATE TABLE frames (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
x REAL NOT NULL DEFAULT 0,
y REAL NOT NULL DEFAULT 0,
width REAL NOT NULL DEFAULT 1200,
height REAL NOT NULL DEFAULT 800,
excalidraw_id TEXT, -- stable across exports
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, name),
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX frames_project_idx ON frames(project_id);
-- Devices live in a frame (and transitively in a project).
-- Stored project_id is denormalised for cheap project-scoped queries; FK
-- to frame_id is the structural truth. Both are kept consistent in code.
CREATE TABLE devices (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
frame_id INTEGER REFERENCES frames(id) ON DELETE SET NULL,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#1e1e1e',
x REAL NOT NULL,
y REAL NOT NULL,
width REAL NOT NULL,
height REAL NOT NULL,
excalidraw_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX devices_project_idx ON devices(project_id);
CREATE INDEX devices_frame_idx ON devices(frame_id);
-- Ports belong to a device. x_offset/y_offset are relative to the device's
-- top-left so ports follow when the device moves. project_id denormalised.
CREATE TABLE ports (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
type_id INTEGER NOT NULL REFERENCES cable_types(id) ON DELETE RESTRICT,
label TEXT, -- optional ("HDMI 1", "USB-C rear")
x_offset REAL NOT NULL,
y_offset REAL NOT NULL,
excalidraw_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX ports_project_idx ON ports(project_id);
CREATE INDEX ports_device_idx ON ports(device_id);
CREATE INDEX ports_type_idx ON ports(type_id);
-- IO markers = wall outlets / power-entry terminators.
-- One end of a Power cable. They are NOT bridges and they do NOT pair.
CREATE TABLE io_markers (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
frame_id INTEGER REFERENCES frames(id) ON DELETE SET NULL,
label TEXT NOT NULL DEFAULT 'IO', -- "Wall A", "UPS rear", …
x REAL NOT NULL,
y REAL NOT NULL,
excalidraw_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX io_markers_project_idx ON io_markers(project_id);
CREATE INDEX io_markers_frame_idx ON io_markers(frame_id);
-- A cable. Each endpoint is exactly one of (port, device, io-marker).
-- All foreign-key targets must be in the same project_id as the cable —
-- enforced in code (the CHECK below only enforces the one-non-null rule).
CREATE TABLE cables (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
type_id INTEGER NOT NULL REFERENCES cable_types(id) ON DELETE RESTRICT,
label TEXT,
from_port_id INTEGER REFERENCES ports(id) ON DELETE SET NULL,
from_device_id INTEGER REFERENCES devices(id) ON DELETE SET NULL,
from_io_id INTEGER REFERENCES io_markers(id) ON DELETE SET NULL,
to_port_id INTEGER REFERENCES ports(id) ON DELETE SET NULL,
to_device_id INTEGER REFERENCES devices(id) ON DELETE SET NULL,
to_io_id INTEGER REFERENCES io_markers(id) ON DELETE SET NULL,
excalidraw_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
CHECK (
(from_port_id IS NOT NULL) + (from_device_id IS NOT NULL) + (from_io_id IS NOT NULL) = 1
),
CHECK (
(to_port_id IS NOT NULL) + (to_device_id IS NOT NULL) + (to_io_id IS NOT NULL) = 1
),
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX cables_project_idx ON cables(project_id);
CREATE INDEX cables_from_port_idx ON cables(from_port_id);
CREATE INDEX cables_to_port_idx ON cables(to_port_id);
CREATE INDEX cables_from_device_idx ON cables(from_device_id);
CREATE INDEX cables_to_device_idx ON cables(to_device_id);
CREATE INDEX cables_type_idx ON cables(type_id);
-- Bundles: named groups of cables that physically run together, within
-- a single project (a bundle does not span LOFT ↔ OFFICE).
CREATE TABLE bundles (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
auto INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, name)
);
CREATE INDEX bundles_project_idx ON bundles(project_id);
CREATE TABLE bundle_cables (
bundle_id INTEGER NOT NULL REFERENCES bundles(id) ON DELETE CASCADE,
cable_id INTEGER NOT NULL REFERENCES cables(id) ON DELETE CASCADE,
PRIMARY KEY (bundle_id, cable_id)
);
CREATE INDEX bundle_cables_cable_idx ON bundle_cables(cable_id);
FK shape — why project_id on every row, not just transitively:
The structural truth is cable → port → device → frame → project. But
project-scoped queries ("give me all cables in OFFICE") would otherwise need
three joins. Denormalising project_id onto every row is a small,
load-bearing pragma: cables WHERE project_id=? is a one-column index hit.
The cost: code must keep project_id consistent with frame_id /
device_id on insert+update. That's enforced at the Go layer
(internal/db/store.go setter functions), not by SQL — CHECK constraints
in SQLite can't reference another table.
ON DELETE CASCADE from projects cleanly wipes a project's whole subgraph
in one statement, which is what we want when m says "delete OFFICE".
3. Go HTTP API
Single binary cmd/mcables, net/http, no router framework. Listens on
0.0.0.0:7777 by default (overridable via MCABLES_ADDR). Static frontend
from embed.FS at /, JSON API under /api/.
GET / → index.html (embedded)
GET /assets/* → JS/CSS/SVG (embedded)
GET /api/healthz → 200 ok
# Projects — top-level
GET /api/projects → [Project, …]
POST /api/projects ← {name, drawing_name, description?}
(seeds the 5 default cable types)
GET /api/projects/:pid → full snapshot
{project, frames, devices, ports, cables,
cable_types, io_markers, bundles}
— editor's one-shot loader
PATCH /api/projects/:pid ← partial {name, drawing_name, description}
DELETE /api/projects/:pid (cascades through all child rows)
# Inside a project — everything below scoped under :pid
GET /api/projects/:pid/frames
POST /api/projects/:pid/frames ← {name, x, y, width, height}
PATCH /api/projects/:pid/frames/:id
DELETE /api/projects/:pid/frames/:id
GET /api/projects/:pid/devices
POST /api/projects/:pid/devices ← {name, frame_id?, x, y, width, height, color?}
PATCH /api/projects/:pid/devices/:id (e.g. {x, y} on drag)
DELETE /api/projects/:pid/devices/:id
GET /api/projects/:pid/devices/:id/ports
POST /api/projects/:pid/devices/:id/ports ← {type_id, x_offset, y_offset, label?}
PATCH /api/projects/:pid/ports/:id
DELETE /api/projects/:pid/ports/:id
GET /api/projects/:pid/cables
POST /api/projects/:pid/cables ← {type_id, from_{port|device|io}_id,
to_{port|device|io}_id, label?}
PATCH /api/projects/:pid/cables/:id
DELETE /api/projects/:pid/cables/:id
GET /api/projects/:pid/cable-types
POST /api/projects/:pid/cable-types ← {name, color}
PATCH /api/projects/:pid/cable-types/:id
DELETE /api/projects/:pid/cable-types/:id
GET /api/projects/:pid/io-markers
POST /api/projects/:pid/io-markers ← {frame_id?, label, x, y}
PATCH /api/projects/:pid/io-markers/:id
DELETE /api/projects/:pid/io-markers/:id
GET /api/projects/:pid/bundles → [{Bundle, cable_ids: [int]}, …]
POST /api/projects/:pid/bundles ← {name, cable_ids: [int]}
GET /api/projects/:pid/bundles/suggestions → [{name, cable_ids}, …] (see §5)
PATCH /api/projects/:pid/bundles/:id
DELETE /api/projects/:pid/bundles/:id
# Sync — export only in MVP
POST /api/projects/:pid/sync/export → writes the project's drawing to mExDraw
(overwrites previous version; mExDraw keeps
git-version-history sidecar)
No POST /api/sync/import in MVP. Import is post-MVP and only ever serves
a one-shot migration use case (e.g. seeding LOFT from the legacy
Cable-Management drawing if m later changes his mind).
All write endpoints return the updated row. Errors are
{error: "string", details?: any}. No auth.
mExDraw HTTP credentials live in MEXDRAW_BASE_URL (e.g.
https://mxdrw.msbls.de) + MEXDRAW_TOKEN (bearer). The exporter calls
PUT $MEXDRAW_BASE_URL/api/drawings/<drawing_name>.excalidraw with the
generated scene JSON.
4. Export — DB → Excalidraw (visual-grammar conformance)
mCables generates a .excalidraw scene from a project's rows. The seed
drawing's grammar is the contract.
4.1 Element mapping
| DB row | Excalidraw element | Notes |
|---|---|---|
projects.drawing_name |
drawing filename in mExDraw | one drawing per project |
frames |
type=frame, name=frames.name |
x/y/width/height straight across |
devices |
type=rectangle + bound text with name |
strokeColor=color, frameId=frames.excalidraw_id |
ports |
type=ellipse, ~12×9 |
strokeColor=type.color, absolute pos = (device.x + port.x_offset, device.y + port.y_offset), no containerId binding (matches seed) |
io_markers |
type=diamond with bound text=label |
small (~30×30), strokeColor = the Power cable type's colour |
cables |
type=arrow |
strokeColor=type.color, startBinding.elementId = port/device/io excalidraw_id, same for end |
cable_types legend |
5 type=text rows in top-left of the project's first frame |
strokeColor=color, text=name. Regenerated each export. |
bundles |
(rendering open question — see §5) | post-MVP: render as a thick path; v0: ignored on export |
4.2 Element IDs are stable across exports
Every mCables row carries excalidraw_id (TEXT, generated on first export
via crypto/rand → 21-char Excalidraw-style ID). On re-export the same row
reuses the same ID. This means:
- m's
.excalidrawcollaborator-cursors, element-comments, and undo history survive a re-export. - If m manually edits a port colour in Excalidraw (someday, once import exists), we can match it back to the right DB row by ID.
4.3 What is not in the export
- The legend's decorative separator lines (the 5
type=lineelements in the seed) — purely visual, m said they're not load-bearing. - Big "enclosure" rectangles like the seed's
tAs8zMDIdesk-surface. In v0 those are imported as plain devices when m draws them, and exported as plain rectangles too. No zone/enclosure concept in the schema.
4.4 Wall-outlet IO markers
A cable with to_io_id != NULL exports to an arrow whose endBinding
points to the IO diamond's element ID. The diamond is rendered with a small
IO text label (or m.label if customised). No pair link.
5. Bundle detection — project-scoped
A bundle is a set of cables that physically run together. Bundles never cross projects (a LOFT bundle and an OFFICE bundle are separate).
MVP detection rule, on GET /api/projects/:pid/bundles/suggestions:
Within project :pid, group cables by (from_endpoint, to_endpoint):
from_endpoint = (kind, id) where kind ∈ {port, device, io} and id = whichever *_id is set
to_endpoint = same shape
Treat the endpoint pair as unordered: {A, B} == {B, A}
A candidate suggestion = any group with ≥ 2 cables.
i.e. "two or more cables run between the same two endpoints" → almost certainly a bundle. Types in the group can be mixed (Power + USB + HDMI from desk → wall).
Suggestions are reviewed in the UI; clicking Accept creates a real
bundles row (auto=0). m can also create bundles manually by
shift-clicking cables.
Rendering bundles in the SVG view is a slice 6+ concern; in the export they're ignored in v0 (open question §9).
6. Sync — export-only for v0
┌─────────────────────┐
│ mCables DB (truth) │
└──────────┬──────────┘
│
export ▼
(push) ┌────────────────────────┐
│ <project>.excalidraw │
│ on mxdrw.msbls.de │
└────────────────────────┘
- mCables UI → DB: synchronous (every drag/add/remove persists immediately).
- DB → Excalidraw: manual button "Export to Excalidraw" in the header,
per project. Calls
POST /api/projects/:pid/sync/export. - Excalidraw → DB: not implemented in v0. Anything m draws in Excalidraw stays in Excalidraw until he redraws it in mCables.
This keeps the v0 scope tight: no conflict resolution, no element-diff import, no auto-debounce. mExDraw keeps its own version history (git sidecar in the mdraw deploy) so a bad export is recoverable from there.
When mxdrw is unreachable: the export button shows a tooltip and disables; the editor keeps working against the local DB.
Post-MVP, import returns as a one-shot migration tool (separate
mcables-migrate CLI tool, not part of the running server) for seeding
new projects from existing .excalidraw files.
7. UI flows
The editor lives at /. Layout:
┌────────────────────────────────────────────────────────────────────┐
│ mCables [LOFT ▾ projects-picker] [Export] [+ Project] │ ← header
├────────┬───────────────────────────────────────────────────────────┤
│ │ │
│ Legend │ │
│ │ │
│ Power │ Diagram surface (SVG) │
│ USB │ │
│ HDMI │ ┌─desk─────────────┐ ┌─rack──────────┐ │
│ DP │ │ [Mac] [Screen] … │ │ [NAS] [fritz] │ │
│ RJ45 │ └──────────────────┘ └───────────────┘ │
│ + Type │ │
│ │ │
│ Tools │ │
│ + Dev │ │
│ + Frm │ │
│ + IO │ │
│ draw │ │
│ │ │
├────────┴───────────────────────────────────────────────────────────┤
│ Inspector (selection-dependent: project / frame / device / port / │
│ cable / bundle details and actions) │
└────────────────────────────────────────────────────────────────────┘
Flow: pick a project
Header has a dropdown "LOFT ▾". Clicking it lists all projects from
GET /api/projects; clicking one swaps the diagram (GET /api/projects/:pid
loads the full snapshot in one round-trip). The picker also shows a
+ New Project action → modal with name, drawing_name (defaults to
<name>.excalidraw), description → POST /api/projects → switches to
the new project (which has 5 seeded cable types and no frames yet).
The currently active project's id is kept in URL state
(/?project=LOFT) so reload returns to the same project.
Flow: add a frame
+ Frmin the left toolbar (orF).- Click + drag on the canvas → rubber-band rectangle becomes a frame.
- Name prompt centered in the frame; Enter →
POST .../frames.
Flow: add a device
Unchanged from v1: + Dev (or D) → click on canvas → rectangle placed
(falls into whichever frame it lands in) → name → POST .../devices.
Flow: add a port
Select a device → inspector shows + Port button. Click → cursor becomes
a "ghost port" of the active cable type (legend selection). Snap to device
edge → click commits → POST .../devices/:id/ports.
Flow: draw a cable
Click a port → port highlights. Hover any other endpoint (port / device /
IO marker) → preview cable drawn in the source's type colour. Click commits
→ POST .../cables. Shift-click to bind to a whole device. Click an IO
diamond to terminate at a wall outlet.
Flow: add an IO marker (wall outlet)
+ IO (or I) → click on canvas → small diamond placed → optional label
text edit → POST .../io-markers. By design, the only cables that
terminate at an IO marker are Power cables, but the schema doesn't enforce
that — the UI shows a soft warning if m draws a non-Power cable to an IO.
Flow: pick / edit a cable type
Legend on the left is interactive: click a row → that type becomes the
active "drawing type". Drag the swatch → colour picker → updates
cable_types.color. + Type at the bottom → "new cable type" modal.
Flow: drag a device
Pointer-drag → live transform on the SVG; on pointerup,
PATCH .../devices/:id persists x, y. Ports follow because their
offsets are relative.
Flow: bundles
In the inspector with nothing else selected, "Bundle suggestions" pulls
.../bundles/suggestions. Each suggestion shows the cables highlighted
on the diagram + an Accept button. Manual: shift-click multiple cables →
"Group as bundle" → name it → save.
Keyboard
P switch project (opens picker), F add frame, D add device,
I add IO marker, T start cable from selected port,
E export current project, Esc cancel, Backspace delete selection,
? show shortcuts.
8. First slices
Each slice ends with something m can click. The first coder shift takes slices 1–4 as the MVP; slice 5 (export) is the round-trip end.
| # | Slice | What's shipped |
|---|---|---|
| 1 | Bootstrap + project CRUD | cmd/mcables Go binary, SQLite migrations, internal/db store. POST /api/projects creates a project and seeds 5 cable types. GET /api/projects lists them. GET /api/projects/:pid returns a (mostly empty) snapshot. Frontend index.html + main.js shows the project picker, a "+ New Project" modal, and an empty SVG canvas with the legend rendered from cable_types. m can create LOFT, see it picked, see no devices. |
| 2 | Add frame, add device, drag-to-position | + Frm and + Dev tools work. Devices and frames persist. Drag-to-position writes back to DB on pointerup. Reload returns to the same layout. m builds LOFT's desk and rack frames and drops in his first devices. |
| 3 | Add port, draw cable | + Port (with a device selected) places type-coloured ports on device edges with offsets. Click-port → click-port creates a cable. Cables auto-route as straight lines. Inspector shows the cable's type, endpoints, label. m wires up the first end-to-end cable. |
| 4 | IO markers + cable-type editing | + IO places a wall-outlet diamond. Cable-from-port → IO commits as to_io_id. Legend swatch is a colour picker; renaming a type updates the legend on the fly. + Type adds new types. m can fully recreate LOFT's visual model from scratch. |
| 5 | Export to mxdrw.msbls.de | POST .../sync/export generates a .excalidraw scene that reproduces the seed's visual grammar (ports as positional ellipses, IO as diamonds, legend as text in the top-left), writes it via mExDraw API, and stores the assigned excalidraw_ids for stability on re-export. m sees LOFT in Excalidraw and confirms the look matches the seed. |
Slices 6+ (not promised for the first coder shift): bundle suggestions UI; bundle rendering (thick path with mixed-colour fan-out); cable type "warn on cross-type port-to-port"; cable inventory metadata (length/SKU) if m later wants it; dark mode.
9. Open questions for m
Below are only the new questions raised by the rescope. Everything m already answered (stack, DB path, auth, sync direction, inventory fields, big red rectangles) is locked in.
- Drawing-name policy. Should mCables enforce
projects.drawing_name == projects.name + ".excalidraw", or let m set them independently? I default to "enforce on create, editable on update" — fastest to use, still escapeable. - One project per device-name uniqueness? Two LOFTs is impossible by
UNIQUE(projects.name), but two devices named "PC" within OFFICE — fine (one is m's, one is the work PC)? I'm not enforcingUNIQUE(project_id, devices.name)for that reason — confirm. - Non-Power IO markers. I'm modelling IO markers as "wall outlets,
typically Power", with the soft-warning UI rule that non-Power cables to
an IO are unusual. Future-proofing question: should I add a
type_idnullable column toio_markersnow ("this wall outlet is a network jack"), or wait until you actually want to model network outlets? - Bundle render in export v1. v0 ignores bundles on export. Eventually
you'll want the bundle visualised in the
.excalidrawtoo (thick path, coloured fan-out). Are you happy waiting for slice 6+, or want a placeholder rendering (e.g. one heavy black arrow with a bundle label) in the v1 export? - Cross-project cable types? Right now each project has its own
cable_types. Means renaming "HDMI" to "HDMI-2.1" in LOFT doesn't touch OFFICE. Future-proof, but also means if you want to change Power-red across both projects you do it twice. Fine, or should there be a "global defaults" template that new projects copy from but updates don't propagate? - Project deletion guardrail.
DELETE /api/projects/:pidcascades through everything. Want a confirmation token in the API (?confirm=<name>) before it accepts the delete? Cheap to add, hard to recover from if it isn't there.
10. Deployment on mDock (raw docker)
Inspected mDock's live services on 2026-05-15 to lock the conventions before writing this:
- All m-built services on mDock live under
/home/m/stacks/<project>/with a singledocker-compose.yml. Older services in/home/m/<project>/use the same pattern; the canonical-new path isstacks/. - Compose v2 (
docker compose), images built from Gitea container registry (mgit.msbls.de/m/<project>:latest),restart: unless-stoppedon every service,container_name: <project>explicit. - Host port mappings: deliberately collision-free across the host. Existing high ports in use include 3300 (mgreen), 3077 (paperless-ai), 7878 (radarr), 8082 (mgeo-tileserver), 8989 (sonarr), 9696 (prowlarr). Port 7777 is free — taking it for mCables.
- Bind-mount volumes:
/home/m/<project>-data:/app/datais the canonical pattern (mgreen). For project-local data we putdata/next to the compose file so agit pull && docker compose up -dis the whole deploy:/home/m/stacks/mcables/data:/app/data. - Secrets via
env_file: /home/m/secrets/<project>/.env(msports-garmin pattern). mCables only needsMEXDRAW_TOKENfor export. - No reverse proxy on mDock. Services expose ports directly on the LAN
(mDock =
192.168.178.131/ Tailscalemdock). Public exposure goes via mlake/Dokploy + Caddy when needed — out of scope for mCables (LAN-only). - Auto-deploy via the Gitea Actions self-hosted runner already installed
on mDock (
/home/m/act-runner/, labelself-hosted:host). Push tomain→ workflow on mDock →docker compose up --build -d.
Repo layout for mCables
mCables/
├── cmd/mcables/main.go # Go binary
├── internal/
│ ├── db/ # migrations + store
│ ├── importer/ # post-MVP only (not in MVP)
│ ├── exporter/ # DB → .excalidraw
│ └── server/ # net/http handlers
├── web/ # embedded static frontend
│ ├── index.html
│ ├── main.js # ES module entry
│ ├── style.css
│ └── lib/... # SVG helpers, store, components
├── data/ # mCables runtime DB lives here (gitignored)
│ └── .gitkeep
├── docs/design.md # this file
├── Dockerfile
├── docker-compose.yml
├── .gitea/workflows/deploy.yml
├── .gitignore # data/, *.db, *.db-wal, *.db-shm
├── Makefile # build, typecheck, test, run
├── go.mod / go.sum
└── README.md
Dockerfile sketch
Multi-stage; the final image is scratch because modernc.org/sqlite is
pure Go.
# syntax=docker/dockerfile:1.7
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \
-o /out/mcables ./cmd/mcables
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build /out/mcables /app/mcables
ENV MCABLES_ADDR=0.0.0.0:7777
ENV MCABLES_DB=/app/data/mcables.db
USER nonroot:nonroot
EXPOSE 7777
ENTRYPOINT ["/app/mcables"]
docker-compose.yml (on mDock at /home/m/stacks/mcables/)
services:
mcables:
image: mgit.msbls.de/m/mcables:latest
container_name: mcables
restart: unless-stopped
ports:
- "7777:7777"
environment:
- TZ=Europe/Berlin
- MCABLES_ADDR=0.0.0.0:7777
- MCABLES_DB=/app/data/mcables.db
- MEXDRAW_BASE_URL=https://mxdrw.msbls.de
env_file:
- /home/m/secrets/mcables/.env # contains MEXDRAW_TOKEN
volumes:
- /home/m/stacks/mcables/data:/app/data
LAN URL: http://mdock:7777 (or http://192.168.178.131:7777).
Gitea Actions deploy workflow
.gitea/workflows/deploy.yml:
name: deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t mgit.msbls.de/m/mcables:latest .
- name: Push image
run: |
echo "${{ secrets.GITEA_TOKEN }}" | \
docker login mgit.msbls.de -u mAi --password-stdin
docker push mgit.msbls.de/m/mcables:latest
- name: Up
run: |
cd /home/m/stacks/mcables
docker compose pull
docker compose up -d
Local-development run (no Docker)
make run # go run ./cmd/mcables → :7777 against ./data/mcables.db
make typecheck # tsc --noEmit on web/
make test # go test ./...
The repo has data/ checked-in-empty (with .gitkeep); data/*.db* is
gitignored.
DESIGN v2 READY FOR REVIEW