Files
CableGUI/docs/design.md
mAi b734e7f874 docs: design v2 — framework rescope, mDock deploy, no runtime importer
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.
2026-05-15 16:13:24 +02:00

34 KiB
Raw Blame History

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 projects table; 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/export stays in the MVP API.
  • IO diamonds are wall-outlet terminators (type=Power), not inter-frame bridges. paired_with_id is 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:7777 on 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):

  1. 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/boundElements link) — 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.
  2. 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.elementId to whichever Excalidraw element ID we wrote for the port / device / IO marker.
  3. 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 .excalidraw collaborator-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=line elements in the seed) — purely visual, m said they're not load-bearing.
  • Big "enclosure" rectangles like the seed's tAs8zMDI desk-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), descriptionPOST /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

  1. + Frm in the left toolbar (or F).
  2. Click + drag on the canvas → rubber-band rectangle becomes a frame.
  3. 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 14 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.

  1. 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.
  2. 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 enforcing UNIQUE(project_id, devices.name) for that reason — confirm.
  3. 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_id nullable column to io_markers now ("this wall outlet is a network jack"), or wait until you actually want to model network outlets?
  4. Bundle render in export v1. v0 ignores bundles on export. Eventually you'll want the bundle visualised in the .excalidraw too (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?
  5. 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?
  6. Project deletion guardrail. DELETE /api/projects/:pid cascades 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 single docker-compose.yml. Older services in /home/m/<project>/ use the same pattern; the canonical-new path is stacks/.
  • Compose v2 (docker compose), images built from Gitea container registry (mgit.msbls.de/m/<project>:latest), restart: unless-stopped on 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/data is the canonical pattern (mgreen). For project-local data we put data/ next to the compose file so a git pull && docker compose up -d is the whole deploy: /home/m/stacks/mcables/data:/app/data.
  • Secrets via env_file: /home/m/secrets/<project>/.env (msports-garmin pattern). mCables only needs MEXDRAW_TOKEN for export.
  • No reverse proxy on mDock. Services expose ports directly on the LAN (mDock = 192.168.178.131 / Tailscale mdock). 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/, label self-hosted:host). Push to main → 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