merge: design v3 (framework, multi-project, mDock deploy)

inventor shift done by picasso. docs/design.md (760 lines) + CLAUDE.md
locked. m approved coder shift.

Next: slice 1 (bootstrap + project CRUD) on mai/picasso/slice-1.
This commit is contained in:
mAi
2026-05-15 16:38:02 +02:00
2 changed files with 891 additions and 30 deletions

141
CLAUDE.md
View File

@@ -2,66 +2,147 @@
## Project Overview
Cable management tool for m's setup. Visual web interface backed by a Go HTTP API and a tiny SQLite inventory. Generates and updates Excalidraw drawings via mExDraw.
Cable-management **framework** for m's setup. Each cable-managed environment
(LOFT, OFFICE, …) is a separate **mCables project**, and each project is
backed by exactly one Excalidraw drawing. The framework provides a visual
web interface backed by a Go HTTP API and SQLite, plus an export pipeline
that writes `.excalidraw` files via mExDraw.
**Memory group_id:** `mcables`
**No CLI.** Frontend-first — every interaction is through the visual interface. The backend serves the UI and the API; there's no `mcables`-binary for shell users.
**No CLI.** Frontend-first — every interaction is through the visual
interface. The backend serves the UI and the API; there is no
`mcables` shell binary intended for humans.
## Goal
- Inventory of devices, ports, cables, cable types, bundles, frames.
- Visual editor in the browser: drag devices around, click ports to connect, pick cable types from a legend.
- Live-sync with an Excalidraw drawing on mxdrw.msbls.de — pick up m's existing drawing as the seed, keep it as the authoritative visual.
- Bundle detection: parallel cables along the same path get grouped + colour-bundled in the diagram.
- A reusable framework for tracking devices, ports, cables, cable types,
bundles, frames — **scoped per project** (LOFT and OFFICE are separate
projects, each a separate drawing).
- A visual editor in the browser: switch projects, add frames/devices/ports,
click ports to wire up cables, pick cable types from a per-project legend.
- A one-way export from the DB to the corresponding `.excalidraw` drawing
on `mxdrw.msbls.de` whenever m clicks Export — DB is authoritative,
Excalidraw is the projection.
- Bundle detection: parallel cables along the same path within a project
get grouped + colour-bundled in the diagram.
## Architecture
| Layer | Tech | Notes |
|---|---|---|
| DB | SQLite | `~/.m/mcables.db` (per-user). Schema migrations in `internal/db/migrations/`. |
| Backend | Go | HTTP API + static frontend serving. Standard library + minimal deps. |
| Frontend | TBD (vanilla TS / Svelte / Preact — first design pass decides) | No build-step preferred if vanilla works. |
| Diagram I/O | mExDraw MCP (`mcp__mexdraw__*`) | Read existing drawings to import, write to update. |
| DB | SQLite | `./data/mcables.db` (project-local, gitignored). Driver: `modernc.org/sqlite` (cgo-free). |
| Backend | Go | `net/http` HTTP API + static frontend via `embed.FS`. Standard library + minimal deps. Single binary. |
| Frontend | Vanilla JS modules + SVG, no build step | TypeScript types via JSDoc, optional `tsc --noEmit` in CI. Preact-via-CDN-ESM is the documented fallback if vanilla state gets painful — no build step either way. |
| Diagram I/O | mExDraw HTTP API | `PUT https://mxdrw.msbls.de/api/drawings/<name>.excalidraw` with `Authorization: Bearer $MEXDRAW_TOKEN`. (The `mcp__mexdraw__*` MCP tools are not currently configured for this project — workers use the raw HTTP API.) |
## Hierarchy
- **Project** (`projects` table) is the top-level concept. LOFT, OFFICE,
HOMELAB, … are separate projects. One project ↔ one `.excalidraw`
drawing in mExDraw. `projects.drawing_name` defaults to
`<name>.excalidraw` server-side when omitted on create; editable later.
- **Frames** sub-divide a project (LOFT has `desk`, `rack`, `media`;
OFFICE has `desk`, `server`). Frames are not projects — they're zones
within one drawing.
- Every device, port, cable, IO marker, and bundle is **project-scoped**
(`project_id` denormalised onto every row, with `ON DELETE CASCADE` from
`projects`). `UNIQUE (project_id, devices.name)` — no two devices in
one project share a name.
- **Cable types are global.** A single shared `cable_types` table —
no `project_id`. The five defaults (Power/USB/HDMI/DP/RJ45) are seeded
by migration 001 once, not per project. Renaming or recolouring a type
affects every project's legend immediately.
- **Project deletion guardrail.** `DELETE /api/projects/:pid` requires
`?confirm=<name>` matching the project's current name. 400 otherwise.
## Branch Strategy
- `main` = production-deployable.
- `feat/*` / `fix/*` = short-lived branches via mai worker workflow.
- `mai/<worker>/<slug>` = worker branches via the mai workflow.
- No `dev` branch — too small a project for staging.
- Merge with `--no-ff` to main, delete branches after merge.
## Tech Stack
- **Go** for the backend (matches m's other tools: `m`, `mai`, youpcms).
- **SQLite** as inventory store. Driver: `modernc.org/sqlite` (cgo-free) or `mattn/go-sqlite3` (cgo) — design pass decides.
- **mExDraw MCP** for diagram I/O — never touch raw `.excalidraw` files outside the MCP.
- No deploy yet — runs locally on m's machine as `go run ./cmd/mcables` (or similar). Production deploy can come later if needed.
- **Go** for the backend (matches m's other tools: `m`, `mai`,
youpcms, mExDraw).
- **SQLite** via `modernc.org/sqlite` (cgo-free → clean `scratch`/distroless
container, no toolchain pain).
- **mExDraw** via HTTP for diagram export. Never edit raw `.excalidraw`
files directly outside the mExDraw API.
- **Vanilla JS + SVG** for the frontend — no build step. JSDoc-typed.
## Seed Data
## Deployment — mDock, raw docker (NOT Dokploy)
m's existing Excalidraw drawing on `mxdrw.msbls.de/draw/Cable-Management.excalidraw` is the bootstrap. Devices found there (NAS, eQ, fritz, Switch, IOx3/IOx6/IOx8 hubs, ChromeCast, Soundbar, TV, PC, Mac, SteamLink, Notebooks) + their port topology should map to the initial DB rows.
mCables runs on **mDock** (`192.168.178.131` on the LAN, Tailscale `mdock`)
as a **plain docker-compose service**. Dokploy is for public mlake/mRiver
stuff; mDock uses raw `docker compose` per the conventions of the existing
mDock services (mgreen, mgeo, msports-garmin, paperless, …).
Conventions used in the drawing:
- Repo layout on mDock: `/home/m/stacks/mcables/` with `docker-compose.yml`,
`data/` bind-mount, secrets in `/home/m/secrets/mcables/.env`.
- Image: `mgit.msbls.de/m/mcables:latest` (built and pushed by a Gitea
Actions workflow on push to `main`, runs on the self-hosted runner on
mDock with label `self-hosted:host`).
- Port mapping: `7777:7777`, exposed on the LAN — no reverse proxy.
- Restart policy: `unless-stopped`.
- LAN URL: `http://mdock:7777`.
- No auth — LAN-trusted.
Local dev (no Docker): `go run ./cmd/mcables` against `./data/mcables.db`.
## Seed drawing — visual grammar reference, **not** a runtime importer
`mxdrw.msbls.de/draw/Cable-Management.excalidraw` is **reference material
only**. mCables does **not** auto-ingest it. m will rebuild LOFT and OFFICE
from scratch inside the tool — the seed exists so the **exporter** mimics
its visual grammar:
- **Devices** = rectangles with a text label.
- **Ports** = small ellipses (~12×9) positioned on the device edge.
- **Cables** = arrows from port-ellipse to port-ellipse (Excalidraw bindings via `from=` / `to=`).
- **Cable types** = colour, with a legend at top-left listing RJ45, DP, HDMI, USB, Power.
- **IO labels** (diamond shapes) mark interface clusters.
- **Frames** group setups by location (one frame per room / rack).
- **Ports** = small ellipses (~12×9) positioned on a device edge.
Positional, *not* containerId-bound. Stroke colour = cable type.
- **Cables** = arrows with `startBinding` / `endBinding` to ports or
devices or IO diamonds.
- **Cable types** = colour, with a legend at the top-left of the project's
first frame listing the project's cable_types.
- **IO markers** = small diamonds. Semantically **wall outlets / power
entry points** — a cable terminating at an IO marker means "this end is
plugged into a wall socket outside the diagram". They are *not*
inter-frame bridges and they do *not* pair up.
- **Frames** = sub-zones inside a project (`desk`, `rack`, `media`, …).
- **Lines** = decorative only (legend separators in the seed). Ignored on
export.
The importer should preserve this convention so m's existing layout doesn't get rewritten.
Legend colours (global, seeded once by migration 001):
## Out of scope (for now)
| Type | Hex |
|---|---|
| Power | `#e03131` |
| USB | `#2f9e44` |
| HDMI | `#1971c2` |
| DP | `#9c36b5` |
| RJ45 | `#ffd500` |
## Out of scope (v0)
- Multi-user. mCables is m-only.
- Auth / sharing — LAN-trusted on mDock.
- Mobile / responsive — desktop browser only.
- Cable inventory beyond visual structure (no length tracking, no purchase history, no SKU mapping — add later if useful).
- Auth / sharing — runs locally.
- Cable inventory beyond visual structure (no length, no purchase history,
no SKU). Strictly visual structure for v0.
- Import from `.excalidraw` at runtime. If a one-shot migration is ever
needed, a separate `mcables-migrate` CLI tool is the right shape, not a
hot API endpoint.
## Worker Preferences
- **First shift = inventor** (design pass): pick frontend stack, sketch UI flows, define schema, plan importer for existing drawing. Output: `docs/design.md` + open questions for m.
- **Second shift = coder** (after m's go on the design): bootstrap repo skeleton (Go module, SQLite migrations, importer skeleton, server, frontend scaffold).
- Use **Sonnet** for both — this is greenfield, structure matters more than depth.
- **First shift = inventor** (design pass): conventions, schema, API,
export pipeline, mDock deploy plan, UI flows, slices. Output:
`docs/design.md` + open questions for m.
- **Second shift = coder** (after m's go on the design): bootstrap repo
skeleton (Go module, SQLite migrations, server, exporter, frontend
scaffold). Take slices 14 first (project CRUD, frames/devices, ports
and cables, IO + cable-type editing); slice 5 (Excalidraw export) closes
the round-trip.
- Use **Sonnet** for both — greenfield, structure matters more than depth.

780
docs/design.md Normal file
View File

@@ -0,0 +1,780 @@
# mCables — Design v3
Cable-management **framework** for m's setup. Inventor shift 1 design,
revised after m's round-4 answers (2026-05-15) — for m's review.
Sources: 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 v3** (mechanical deltas on top of v2)
> - `cable_types` is now a **global** table — one set shared across all
> projects. Migration 001 seeds the 5 defaults once. `POST /api/projects`
> no longer seeds types. API moved to top-level `/api/cable-types`.
> Renaming/recolouring a type affects every project.
> - `devices` gains `UNIQUE (project_id, name)` — no two devices in the
> same project can share a name.
> - `projects.drawing_name` is auto-filled `<name>.excalidraw` server-side
> when omitted on POST; editable via PATCH.
> - `DELETE /api/projects/:pid` requires `?confirm=<name>` query param;
> server checks it matches the project's current name. 400 otherwise.
>
> **What carried over from v2**
> - mCables is a framework: top-level `projects` table; LOFT and OFFICE
> are separate projects, each backed by one drawing.
> - No runtime importer. The seed drawing is reference material only.
> `/api/sync/import` is out of MVP; only `POST .../sync/export` ships.
> - IO diamonds are wall-outlet terminators (type=Power by convention,
> not enforced in schema). UI soft-warns on non-Power cables to an IO.
> - No cable inventory metadata. Purely visual structure for v0.
> - DB at `./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).
```sql
-- 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: GLOBAL legend, one set shared across all projects.
-- Migration 001 seeds the 5 defaults (Power/USB/HDMI/DP/RJ45) once.
-- Renaming or recolouring a type from anywhere in the UI propagates to
-- every project's legend and to every cable already typed as it.
CREATE TABLE cable_types (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE, -- "Power", "USB", "HDMI", "DP", "RJ45"
color TEXT NOT NULL, -- "#e03131"
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- 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, name), -- no two devices in one project share a name
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 project-scoped 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 project-scoped 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.
`cable_types` is the **one global table** — it has no `project_id`.
Cables reference it cross-project. Renaming or recolouring a type updates
the legend everywhere immediately and re-renders every cable of that type
on the next paint.
`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". The
cascade does **not** touch `cable_types` (no FK to projects).
---
## 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?}
If drawing_name is omitted, server defaults to
"<name>.excalidraw". No cable-type seeding —
cable_types is global (see /api/cable-types).
GET /api/projects/:pid → full snapshot
{project, frames, devices, ports, cables,
io_markers, bundles}
Plus the global cable_types (clients can also
fetch them via /api/cable-types). Editor's
one-shot loader.
PATCH /api/projects/:pid ← partial {name, drawing_name, description}
DELETE /api/projects/:pid?confirm=<name> Confirmation guardrail — the query param must
equal the project's current name. 400 if missing
or mismatched. Cascades through all child rows
(frames, devices, ports, cables, io_markers,
bundles, bundle_cables). Does NOT touch
cable_types.
# Cable types — GLOBAL, NOT under a project
GET /api/cable-types → [CableType, …]
POST /api/cable-types ← {name, color} # name must be unique globally
PATCH /api/cable-types/:id ← {name?, color?} # affects every project's legend + every cable using this type
DELETE /api/cable-types/:id # blocked if any cable still references it (ON DELETE RESTRICT)
# 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/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 (global) | one `type=text` row per `cable_types` row, top-left of the project's first frame | `strokeColor=color`, `text=name`. Pulled from the global table, 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`), `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
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 and **global** (the same legend shows up
in every project). Click a row → that type becomes the active "drawing
type" for the current project's session. Drag the swatch → colour picker →
updates `cable_types.color` via `PATCH /api/cable-types/:id`. `+ Type` at
the bottom → "new cable type" modal — `POST /api/cable-types`. Names are
globally unique.
The modal for editing / adding shows a banner:
*"Cable types are shared across all projects. Renaming or recolouring
affects every project that uses this type."* Deleting a type that's still
in use by any cable returns a 400 with the offending cable count — the
client surfaces it as an inline error in the 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. Migration 001 seeds the 5 default cable types (Power/USB/HDMI/DP/RJ45) **globally, once**. `internal/db` store. `POST /api/projects` auto-fills `drawing_name = <name>.excalidraw` when omitted. `DELETE /api/projects/:pid?confirm=<name>` with name-match guardrail. `GET /api/projects` lists them. `GET /api/projects/:pid` returns a (mostly empty) snapshot. `GET /api/cable-types` returns the 5 seeded rows. Frontend `index.html` + `main.js` shows the project picker, a "+ New Project" modal, and an empty SVG canvas with the legend rendered from the global `cable_types` table. 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_id`s 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 — all resolved in v3
All six v2 questions are now answered. Locked answers:
1. **Drawing-name policy** → server-side default `<name>.excalidraw` on
POST when omitted; editable via PATCH. (§3)
2. **Device-name uniqueness within a project** → `UNIQUE (project_id,
devices.name)` enforced at the schema level. (§2)
3. **Non-Power IO markers** → no `type_id` on `io_markers` for v0.
Power-by-convention; UI soft-warns on non-Power cables to an IO. (§2, §7)
4. **Bundle render in export v1** → bundles ignored on export until slice
6+. (§4, §5)
5. **Cross-project cable types** → `cable_types` is fully **global**. One
shared legend; renaming/recolouring affects every project. (§2, §3, §7)
6. **Project deletion guardrail** → `DELETE /api/projects/:pid?confirm=<name>`
required; server validates name match, returns 400 otherwise. (§3)
No open design questions remain. The coder shift is gated on m's
go/no-go for v3 — not on any unanswered design question from picasso.
---
## 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.
```dockerfile
# 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/`)
```yaml
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`:
```yaml
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 v3 READY — coder shift gated