diff --git a/CLAUDE.md b/CLAUDE.md index 7d2a202..a563568 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/.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 + `.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=` matching the project's current name. 400 otherwise. ## Branch Strategy - `main` = production-deployable. -- `feat/*` / `fix/*` = short-lived branches via mai worker workflow. +- `mai//` = 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 1–4 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. diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..ef46d7b --- /dev/null +++ b/docs/design.md @@ -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 `.excalidraw` server-side +> when omitted on POST; editable via PATCH. +> - `DELETE /api/projects/:pid` requires `?confirm=` 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 + ".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= 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/.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) ┌────────────────────────┐ + │ .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 +`.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 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. 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 = .excalidraw` when omitted. `DELETE /api/projects/:pid?confirm=` 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 `.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=` + 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//` + with a single `docker-compose.yml`. Older services in `/home/m//` + use the same pattern; the canonical-new path is `stacks/`. +- Compose v2 (`docker compose`), images built from Gitea container registry + (`mgit.msbls.de/m/:latest`), `restart: unless-stopped` on every + service, `container_name: ` 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/-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//.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