Full project rename per m's call. Single atomic commit because the codebase rename is a coupled change — go module path, env vars, DB default, Docker artefact names, and on-disk mDock paths all flip together. - go.mod: module mgit.msbls.de/m/mcables → mgit.msbls.de/m/cablegui - cmd/mcables → cmd/cablegui (git mv) - All Go imports rewritten to the new module path - Env vars: MCABLES_ADDR/MCABLES_DB → CABLEGUI_ADDR/CABLEGUI_DB - DB default path: data/mcables.db → data/cablegui.db - Dockerfile + docker-compose.yml: image, container_name, env vars, bind-mount /home/m/stacks/mcables → /home/m/stacks/cablegui, secrets /home/m/secrets/mcables → /home/m/secrets/cablegui - Makefile: bin target + run/build commands point at cmd/cablegui - .gitignore + .dockerignore: /mcables → /cablegui - README, docs/design.md, CLAUDE.md: prose + paths + image name - web/static/index.html: <title> + brand - web/static/main.js + web/web.go: header comment - internal/exporter: Scene.Source "mcables" → "cablegui" - internal/server/export.go: error-detail secrets path - internal/db/migrations/*.sql: header comments (mCables vN → CableGUI vN) Memory group_id kept as "mcables" to preserve existing memory continuity. Documented as historical in CLAUDE.md. go build ./... clean; go test -race ./... green
1666 lines
86 KiB
Markdown
1666 lines
86 KiB
Markdown
# CableGUI — Design v4.1
|
||
|
||
Cable-management **framework + solver** for m's setup. Inventor shift 1
|
||
design, revised through v2 (rescope to multi-project framework), v3
|
||
(global cable_types + guardrails), v4 (solver-as-core), and now
|
||
**v4.1 — six locked answers from m's v4 review**.
|
||
|
||
> **What changed in v4.1** (tight pass on v4)
|
||
> 1. **CableGUI is a schematic, not a physical-routing tool.** Cables are
|
||
> straight lines between endpoints; the solver and the renderer do not
|
||
> care about paths, trunks, frame edges, or cable-tray polylines.
|
||
> "Maximum bundling" reduces to the v3 rule: **≥2 cables between the
|
||
> same endpoint pair → bundle them.** All path-routing language has
|
||
> been stripped from §5b.1, §5b.2, §7, §8, §9.
|
||
> 2. **Solver fires on the Solve button (v0).** Live-solve stays in §8
|
||
> slices 9+ as an opt-in toggle.
|
||
> 3. **Unmet-requirement quick-fix**: when the solver returns
|
||
> `unsatisfied[]`, the device inspector renders a red badge per unmet
|
||
> requirement with a single button — **"+ Add <type> port to
|
||
> <device> and re-solve"** — that POSTs a new port to the
|
||
> device AND immediately re-runs `POST /api/projects/:pid/solve` in
|
||
> the same UI action. See §5b.4 + §7 inspector-states.
|
||
> 4. **Setup templates fold INTO v4.1.** New tables `setup_templates`,
|
||
> `setup_template_devices`, `setup_template_requirements` in
|
||
> migration 004 + 3 built-in templates ('Living Room', 'Home Office',
|
||
> 'Server Rack'). New endpoints `GET /api/setup-templates` and
|
||
> `POST /api/projects/:pid/apply-template`. UI: a "Templates" panel
|
||
> in the New Project flow + an "Apply template" action on an empty
|
||
> project. See new §2.4 + slice 6 fold-in below.
|
||
> 5. **Catalog distribution: SQL seed** in migration 002 (no change).
|
||
> 6. **Promote to manual: explicit button** on the cable inspector
|
||
> (no change).
|
||
|
||
Sources: the live `Cable-Management.excalidraw` on mxdrw.msbls.de (used as
|
||
the *visual-grammar reference*, not a bootstrap import target),
|
||
`mai-memory` (`cablegui`, `m`), and the live mDock services for deploy
|
||
conventions (§10). v4 driven by m's product-vision clarification:
|
||
|
||
> "we provide a cable manager — I say what devices we have, the app tells
|
||
> me how to bundle cables and how the most efficient connection looks like"
|
||
|
||
CableGUI shifts from a manual draw-and-click editor to a **solver** that
|
||
takes a list of devices + the connections m needs and emits the cable
|
||
plan + bundle recommendations. The manual editor stays (it's the only way
|
||
to inspect + tweak the plan) but is no longer the primary surface.
|
||
|
||
> **What changed in v4** (new mental model on top of v3 mechanics)
|
||
> - **Hybrid device-type catalog** (§2.1, §3.1). A built-in `device_types`
|
||
> table seeds common devices (NAS, PC, Mac, TV, Soundbar, Switch, fritz,
|
||
> ChromeCast, SteamLink, IOx-3/6/8, Notebook, …) with default port
|
||
> profiles (`device_type_ports` rows: cable_type + count + label).
|
||
> Adding a device → pick a type → ports auto-seed. m can override per
|
||
> instance (this PC has 3 USB, not 2). Catalog is extendable per project.
|
||
> - **`connection_requirements` table** (§2.2). m declares "NAS must
|
||
> connect to Switch via RJ45" once. Many per device. The solver consumes
|
||
> these.
|
||
> - **`POST /api/projects/:pid/solve` endpoint** (§3.2). Reads devices +
|
||
> their ports + connection_requirements + frame positions, emits a diff
|
||
> of `cables` + `bundles`. Two modes: `?preview=1` returns the diff
|
||
> without applying; default applies.
|
||
> - **Solver objective: maximum bundling** (§5b.1). Schematic only: when
|
||
> two or more cables share the same endpoint pair, group them into one
|
||
> bundle. No path or trunk geometry — CableGUI is a wiring schematic,
|
||
> not a routing tool. v4.1 strips all path/trunk language from the v4
|
||
> draft.
|
||
> - **UI: device-type dropdown** on device-create, **Connection
|
||
> Requirements** left panel, **Solve** button next to Export. Inspector
|
||
> shows type + ports + unmet requirements (selected device) or the
|
||
> driving requirement + bundle (selected cable).
|
||
> - **Slices reshape** (§8). Catalog seeding lands early (slice 1.5); the
|
||
> solver MVP and connection-requirements UI move ahead of the
|
||
> bundle-rendering polish.
|
||
>
|
||
> **What carried over from v3 (unchanged in v4)**
|
||
> - CableGUI is a framework: top-level `projects`, each backed by one
|
||
> `.excalidraw` drawing. `UNIQUE(projects.name)`.
|
||
> - `cable_types` is global. Migration 001 seeds Power/USB/HDMI/DP/RJ45.
|
||
> - `devices` UNIQUE(project_id, name); `frame_id` nullable; FrameRef
|
||
> tri-state on PATCH.
|
||
> - IO diamonds = wall-outlet terminators (type=Power by convention).
|
||
> - `projects.drawing_name` auto-defaults to `<name>.excalidraw`.
|
||
> - `DELETE /api/projects/:pid?confirm=<name>` guardrail.
|
||
> - No cable inventory metadata; visual + connectivity structure only.
|
||
> - DB at `./data/cablegui.db` (gitignored). Bind `0.0.0.0:7777` LAN, no auth.
|
||
> - Deploy on mDock under `/home/m/stacks/cablegui/`, raw docker-compose.
|
||
>
|
||
> **What's superseded in v4**
|
||
> - The "manual draw-a-cable port-to-port" flow from v3 §7 is *kept* as a
|
||
> tweak path on the solver output, but is no longer the *primary* device-
|
||
> connecting flow. The solve button is the headline action.
|
||
> - The v3 §8 slice order changes — catalog + types-driven devices + solver
|
||
> come earlier; the manual-draw-cable slice slides later. See new §8.
|
||
|
||
---
|
||
|
||
## 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 CableGUI, 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 CableGUI 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/cablegui.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'))
|
||
);
|
||
|
||
-- v4 — device-type catalog. Seeded built-in types live globally (so
|
||
-- multiple projects share the "NAS" definition without duplication).
|
||
-- Per-project custom types are also allowed (project_id non-null for those).
|
||
-- Renaming a built-in type doesn't propagate retroactively to existing
|
||
-- devices that already had their ports seeded — they own their port set
|
||
-- from the moment they were created.
|
||
CREATE TABLE device_types (
|
||
id INTEGER PRIMARY KEY,
|
||
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
|
||
-- NULL = built-in (shared), non-null = project-custom
|
||
name TEXT NOT NULL, -- "NAS", "PC", "TV", "Switch", "IOx-8", "Custom-Foo"
|
||
kind TEXT NOT NULL DEFAULT 'generic',
|
||
-- coarse category for UI grouping: 'storage', 'compute',
|
||
-- 'display', 'audio', 'network', 'hub', 'accessory',
|
||
-- 'generic'
|
||
icon TEXT, -- emoji or short symbol (🖥, 📺, 🔊, 📡) — UI hint
|
||
description TEXT NOT NULL DEFAULT '',
|
||
built_in INTEGER NOT NULL DEFAULT 0, -- 1 for migration-seeded rows, 0 for user-created
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||
UNIQUE (project_id, name) -- two projects can both have a custom "Foo";
|
||
-- built-ins (project_id NULL) get UNIQUE on name globally
|
||
);
|
||
CREATE INDEX device_types_project_idx ON device_types(project_id);
|
||
|
||
-- v4 — port profile per device type. "NAS has 1 Power + 1 RJ45" is two
|
||
-- rows; "PC has 1 Power + 1 RJ45 + 1 HDMI + 2 USB" is four rows.
|
||
-- When a device is created with type_id=X, the seeder inserts `count`
|
||
-- rows into the `ports` table for each device_type_ports entry,
|
||
-- numbering label as "<label_prefix> N" if count > 1.
|
||
CREATE TABLE device_type_ports (
|
||
id INTEGER PRIMARY KEY,
|
||
device_type_id INTEGER NOT NULL REFERENCES device_types(id) ON DELETE CASCADE,
|
||
cable_type_id INTEGER NOT NULL REFERENCES cable_types(id) ON DELETE RESTRICT,
|
||
label_prefix TEXT NOT NULL DEFAULT '', -- "HDMI", "USB", "Power" — UI label root
|
||
count INTEGER NOT NULL DEFAULT 1 CHECK (count >= 1),
|
||
-- Position hint: the seeder lays ports along the device edge using
|
||
-- these biases (0..1 along the edge fraction). NULL = even spread.
|
||
edge TEXT NOT NULL DEFAULT 'bottom' CHECK (edge IN ('top','bottom','left','right')),
|
||
sort_order INTEGER NOT NULL DEFAULT 0
|
||
);
|
||
CREATE INDEX device_type_ports_type_idx ON device_type_ports(device_type_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.
|
||
--
|
||
-- v4 — type_id (nullable) lets a device inherit its port profile from
|
||
-- a `device_types` row. Once ports are seeded the device "owns" them;
|
||
-- changing/clearing type_id later does not retroactively re-seed (m's
|
||
-- per-instance overrides survive). Custom freeform devices (no template)
|
||
-- keep type_id NULL — that's the v3 "just a rectangle" device.
|
||
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,
|
||
type_id INTEGER REFERENCES device_types(id) ON DELETE SET NULL,
|
||
-- v4: nullable; SET NULL on type delete so we don't
|
||
-- cascade-delete a device the user still wants
|
||
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);
|
||
CREATE INDEX devices_type_idx ON devices(type_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);
|
||
|
||
-- v4 — connection_requirements: the input m gives the solver.
|
||
-- "NAS must connect to Switch via RJ45" is one row. Many per device.
|
||
--
|
||
-- preferred_cable_type_id is the cable type m intends — the solver
|
||
-- needs it to match port colours. NULL means "solver picks" (the solver
|
||
-- will pick the unique cable_type that is compatible with both ends'
|
||
-- available port types; if ambiguous it surfaces an error for m).
|
||
--
|
||
-- must_connect = 1 (default) means the solver MUST satisfy this; an
|
||
-- unsatisfiable must_connect surfaces as a hard error in the solve
|
||
-- result. must_connect = 0 = "nice to have, drop if you run out of
|
||
-- ports". Used for templates that over-spec.
|
||
--
|
||
-- The (from_device_id, to_device_id) pair is normalised on insert so
|
||
-- (A,B) and (B,A) are the same requirement — UNIQUE on the unordered
|
||
-- pair + cable type prevents duplicates.
|
||
CREATE TABLE connection_requirements (
|
||
id INTEGER PRIMARY KEY,
|
||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||
from_device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||
to_device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||
preferred_cable_type_id INTEGER REFERENCES cable_types(id) ON DELETE SET NULL,
|
||
must_connect INTEGER NOT NULL DEFAULT 1 CHECK (must_connect IN (0, 1)),
|
||
notes TEXT NOT NULL DEFAULT '',
|
||
-- Order-normalised pair: lo = MIN(from, to), hi = MAX(from, to). Set
|
||
-- in code on insert; the UNIQUE then prevents (A,B,Power) AND
|
||
-- (B,A,Power) from coexisting. Stored alongside the m-facing
|
||
-- from/to so the UI doesn't have to denormalise.
|
||
pair_lo INTEGER NOT NULL,
|
||
pair_hi INTEGER NOT NULL,
|
||
CHECK (from_device_id != to_device_id),
|
||
UNIQUE (project_id, pair_lo, pair_hi, preferred_cable_type_id),
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
);
|
||
CREATE INDEX conn_reqs_project_idx ON connection_requirements(project_id);
|
||
CREATE INDEX conn_reqs_pair_idx ON connection_requirements(project_id, pair_lo, pair_hi);
|
||
CREATE INDEX conn_reqs_from_idx ON connection_requirements(from_device_id);
|
||
CREATE INDEX conn_reqs_to_idx ON connection_requirements(to_device_id);
|
||
```
|
||
|
||
### 2.1 Migration sequence
|
||
|
||
- **001_init.sql** (v3) — projects, frames, devices (no type_id), ports,
|
||
cable_types (5 seeded), io_markers, cables, bundles, bundle_cables.
|
||
- **002_device_catalog.sql** (v4) — `device_types` +
|
||
`device_type_ports`. Seeds the built-in catalog (§2.2). Adds
|
||
`devices.type_id` (`ALTER TABLE devices ADD COLUMN type_id INTEGER
|
||
REFERENCES device_types(id) ON DELETE SET NULL`) and the matching
|
||
index.
|
||
- **003_connection_requirements.sql** (v4) — `connection_requirements`.
|
||
Also adds `cables.auto` (`ALTER TABLE cables ADD COLUMN auto INTEGER
|
||
NOT NULL DEFAULT 0`) so the solver can distinguish its rows from
|
||
m's hand-drawn ones (§5b.3).
|
||
- **004_setup_templates.sql** (v4.1 NEW) — `setup_templates` +
|
||
`setup_template_devices` + `setup_template_requirements`. Seeds 3
|
||
built-in templates ('Living Room', 'Home Office', 'Server Rack').
|
||
|
||
Slices 1 and 2 already shipped 001. Slice 4 lands 002; slice 5 lands
|
||
003; slice 6 lands 004 alongside the solver MVP + templates UI.
|
||
|
||
### 2.2 Built-in catalog seed (002 INSERTs)
|
||
|
||
The 14 built-in types m's setup uses today, with their default port
|
||
profiles. Stored as `(project_id NULL, built_in 1)`. v4.1 added the
|
||
three peripheral types (Screen, Keyboard, Mouse) to support the Home
|
||
Office setup template:
|
||
|
||
| `device_types.name` | `kind` | Default ports (cable_type × count) |
|
||
|---|---|---|
|
||
| NAS | storage | Power × 1; RJ45 × 1 |
|
||
| PC | compute | Power × 1; RJ45 × 1; HDMI × 1; USB × 2 |
|
||
| Mac | compute | Power × 1; HDMI × 1; USB × 2 |
|
||
| Notebook | compute | Power × 1; USB × 2 |
|
||
| TV | display | Power × 1; HDMI × 2 |
|
||
| Soundbar | audio | Power × 1; HDMI × 1 |
|
||
| Switch | network | Power × 1; RJ45 × 5 |
|
||
| fritz | network | Power × 1; RJ45 × 4 |
|
||
| ChromeCast | display | Power × 1; HDMI × 1 |
|
||
| SteamLink | compute | Power × 1; HDMI × 1; USB × 2 |
|
||
| IOx-3 | hub | Power In × 1 (top/back); Power Out × 3 (bottom/front) |
|
||
| IOx-6 | hub | Power In × 1 (top/back); Power Out × 6 (bottom/front) |
|
||
| IOx-8 | hub | Power In × 1 (top/back); Power Out × 8 (bottom/front) |
|
||
| **Screen** | display | Power × 1; HDMI × 1 |
|
||
| **Keyboard** | accessory | USB × 1 |
|
||
| **Mouse** | accessory | USB × 1 |
|
||
| **Multi-plug 3** | hub | Power In × 1 (top/back); Power Out × 3 (bottom/front) |
|
||
| **Multi-plug 4** | hub | Power In × 1 (top/back); Power Out × 4 (bottom/front) |
|
||
| **Multi-plug 5** | hub | Power In × 1 (top/back); Power Out × 5 (bottom/front) |
|
||
| **Multi-plug 6** | hub | Power In × 1 (top/back); Power Out × 6 (bottom/front) |
|
||
| **Wifi-plug** | accessory | Power In × 1 (top/back); Power Out × 1 (bottom/front) — pass-through outlet |
|
||
|
||
v5 (migration 005) added the Multi-plug 3–6 strips and the Wifi-plug
|
||
pass-through outlet. v6 (migration 006) re-shaped the IOx-* and
|
||
Multi-plug-* profiles to the "1 in on top / N out on bottom" layout —
|
||
the IOx-* devices are physical power strips, not USB hubs (m's
|
||
hardware), and the Multi-plug-* outputs are now visually distinct from
|
||
the input. Convention: `top = back`, `bottom = front`. Existing device
|
||
instances keep their already-seeded ports per §2.3 — to pick up the
|
||
new layout, delete + re-create the instance.
|
||
|
||
m can also add **project-custom types** at any time (UI: "+ New device
|
||
type" inside the device-create modal) with `project_id = current`.
|
||
|
||
### 2.3 Why ports are still instance-owned
|
||
|
||
When m picks a type to create a device, the seeder calls `count` × INSERT
|
||
into `ports`. From that moment on, ports are instance-level rows owned by
|
||
that device. Deleting a port from this PC doesn't touch other PCs;
|
||
changing a type's port profile (in slice 4.5) doesn't retroactively
|
||
re-seed already-created devices — it only affects subsequent device
|
||
creations.
|
||
|
||
Trade-off acknowledged: m may want a "re-seed from type" action later
|
||
(slice 5+) to wipe + reset a device's ports. Out of v0 scope; not
|
||
blocked by the schema.
|
||
|
||
### 2.4 Setup templates (v4.1 NEW)
|
||
|
||
A setup template is a named recipe of "device-types to add + connection
|
||
requirements between them" that bootstraps a project from blank to
|
||
solver-ready in one click. m's three archetypes:
|
||
|
||
| Template name | Devices | Default requirements |
|
||
|---|---|---|
|
||
| **Living Room** | TV, Soundbar, ChromeCast | TV ↔ Soundbar (HDMI, must); TV ↔ ChromeCast (HDMI, must) |
|
||
| **Home Office** | PC, Screen, Keyboard, Mouse | PC ↔ Screen (HDMI, must); PC ↔ Keyboard (USB, must); PC ↔ Mouse (USB, must) |
|
||
| **Server Rack** | NAS, Switch, fritz | NAS ↔ Switch (RJ45, must); Switch ↔ fritz (RJ45, must); fritz ↔ NAS (Power, nice) |
|
||
|
||
> "Screen", "Keyboard", "Mouse" are added to the v4 built-in catalog
|
||
> alongside the existing 11 (Screen: Power × 1 + HDMI × 1; Keyboard: USB × 1;
|
||
> Mouse: USB × 1). Migration 002 grows to seed 14 built-ins.
|
||
|
||
Schema (`004_setup_templates.sql`):
|
||
|
||
```sql
|
||
-- A named recipe: a list of device types + requirements between them.
|
||
CREATE TABLE setup_templates (
|
||
id INTEGER PRIMARY KEY,
|
||
name TEXT NOT NULL UNIQUE,
|
||
description TEXT NOT NULL DEFAULT '',
|
||
built_in INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
);
|
||
|
||
-- The devices a template stamps into a project. suggested_name is
|
||
-- pre-filled into the apply-template form; m can override.
|
||
CREATE TABLE setup_template_devices (
|
||
id INTEGER PRIMARY KEY,
|
||
template_id INTEGER NOT NULL REFERENCES setup_templates(id) ON DELETE CASCADE,
|
||
device_type_id INTEGER NOT NULL REFERENCES device_types(id) ON DELETE RESTRICT,
|
||
suggested_name TEXT, -- "TV", "Bedroom TV", "Mac (work)"
|
||
sort_order INTEGER NOT NULL DEFAULT 0
|
||
);
|
||
CREATE INDEX setup_template_devices_template_idx ON setup_template_devices(template_id);
|
||
|
||
-- Requirements between devices in the template, addressed by
|
||
-- `setup_template_devices.id` (not the runtime device id — they're
|
||
-- resolved at apply time).
|
||
CREATE TABLE setup_template_requirements (
|
||
id INTEGER PRIMARY KEY,
|
||
template_id INTEGER NOT NULL REFERENCES setup_templates(id) ON DELETE CASCADE,
|
||
from_template_device_id INTEGER NOT NULL REFERENCES setup_template_devices(id) ON DELETE CASCADE,
|
||
to_template_device_id INTEGER NOT NULL REFERENCES setup_template_devices(id) ON DELETE CASCADE,
|
||
preferred_cable_type_id INTEGER REFERENCES cable_types(id) ON DELETE SET NULL,
|
||
must_connect INTEGER NOT NULL DEFAULT 1 CHECK (must_connect IN (0, 1)),
|
||
CHECK (from_template_device_id != to_template_device_id)
|
||
);
|
||
CREATE INDEX setup_template_reqs_template_idx ON setup_template_requirements(template_id);
|
||
```
|
||
|
||
API:
|
||
|
||
```
|
||
GET /api/setup-templates → [SetupTemplate {id, name, description, built_in,
|
||
devices: [{id, device_type_id,
|
||
device_type: {…},
|
||
suggested_name, sort_order}],
|
||
requirements: [{id, from_template_device_id,
|
||
to_template_device_id,
|
||
preferred_cable_type_id,
|
||
must_connect}]}, …]
|
||
Read-only; built-ins are not editable via API in v4.1.
|
||
|
||
POST /api/projects/:pid/apply-template ← {
|
||
template_id: <int>,
|
||
name_overrides: { <template_device_id>: "<name>", … },
|
||
skip_devices: [<template_device_id>, …] # optional
|
||
}
|
||
→ {
|
||
devices_added: [Device, …],
|
||
requirements_added: [ConnectionRequirement, …],
|
||
skipped_devices: [{template_device_id, reason}, …]
|
||
}
|
||
Idempotency:
|
||
- A name collision with an existing device in the
|
||
project skips that template device (reason = "name
|
||
already in use"). Caller can pass `name_overrides`
|
||
to resolve.
|
||
- Requirements whose endpoints both resolve fire;
|
||
any whose endpoint was skipped are themselves
|
||
skipped (logged in `requirements_skipped[]` — same
|
||
shape).
|
||
The whole call runs in a single transaction.
|
||
```
|
||
|
||
The seed migration creates the 3 built-ins + their template_devices and
|
||
template_requirements rows referencing the 14 built-in `device_types` and
|
||
the 5 built-in `cable_types`. No project_id anywhere — templates are
|
||
global.
|
||
|
||
**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/cablegui`, `net/http`, no router framework. Listens on
|
||
`0.0.0.0:7777` by default (overridable via `CABLEGUI_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, type_id?, frame_id?, x, y, width, height, color?}
|
||
v4: type_id (optional) seeds ports from the catalog;
|
||
without it, a freeform device (no ports) is created.
|
||
PATCH /api/projects/:pid/devices/:id (e.g. {x, y} on drag). type_id can be set or cleared;
|
||
clearing does NOT delete existing ports (instance-owned).
|
||
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
|
||
|
||
# v4 — Device-type catalog (mostly global, project-scoped writes for custom rows)
|
||
GET /api/device-types → built-in catalog (project_id NULL) — read-only listing
|
||
GET /api/projects/:pid/device-types → built-ins + this project's custom types, merged
|
||
POST /api/projects/:pid/device-types ← {name, kind?, icon?, description?, ports: [{cable_type_id, count, label_prefix?, edge?}]}
|
||
Creates a project-custom row (built_in=0); inserts
|
||
device_type_ports rows in the same transaction.
|
||
PATCH /api/projects/:pid/device-types/:id ← partial. Only project-custom types are PATCHable;
|
||
mutating a built-in row → 403 (UI hides edit affordance).
|
||
Editing ports replaces the device_type_ports rows;
|
||
existing devices' ports are NOT retroactively reseeded.
|
||
DELETE /api/projects/:pid/device-types/:id Only project-custom; built-ins → 403.
|
||
ON DELETE SET NULL on devices.type_id so devices
|
||
keep their already-seeded ports.
|
||
|
||
# v4 — Connection requirements (the solver's input)
|
||
GET /api/projects/:pid/connection-requirements → [ConnectionRequirement, …]
|
||
POST /api/projects/:pid/connection-requirements ← {from_device_id, to_device_id,
|
||
preferred_cable_type_id?, must_connect?, notes?}
|
||
Server normalises (from, to) into (pair_lo, pair_hi)
|
||
before insert; duplicate (project, pair_lo, pair_hi,
|
||
preferred_cable_type_id) → 409 conflict.
|
||
PATCH /api/projects/:pid/connection-requirements/:id
|
||
DELETE /api/projects/:pid/connection-requirements/:id
|
||
|
||
# v4 — Solver
|
||
POST /api/projects/:pid/solve ← {} (or {?preview=1} to compute without applying)
|
||
→ {
|
||
cables_added: [Cable, …],
|
||
cables_kept: [int, …], # ids preserved by the diff
|
||
cables_removed: [int, …], # ids deleted (auto cables only)
|
||
bundles_added: [{Bundle, cable_ids: [int]}, …],
|
||
bundles_removed: [int, …],
|
||
unsatisfied: [{requirement_id, reason}, …],
|
||
warnings: [string, …],
|
||
}
|
||
Default applies in a single transaction. ?preview=1
|
||
returns the same shape without writing. User-created
|
||
cables (auto=0 in the cables table; see §5.1) are
|
||
never touched — the solver only adds/removes its own.
|
||
|
||
# v4 — Solver quick-fix combo endpoint (powers the inspector's
|
||
# "+ Add <type> port to <device> and re-solve" button — §5b.4).
|
||
POST /api/projects/:pid/devices/:id/ports-and-resolve
|
||
← {type_id: <int>,
|
||
label?: <str>,
|
||
x_offset?: <num>, y_offset?: <num>}
|
||
→ {port: Port, solve: <solve response>}
|
||
Single tx: inserts the port + re-runs solve. Used by
|
||
the quick-fix UI so the unmet badge resolves in one
|
||
server round-trip.
|
||
|
||
# v4.1 — Setup templates
|
||
GET /api/setup-templates → [SetupTemplate, …]
|
||
Read-only listing of built-in (and any project-custom,
|
||
post-v4.1) templates with their device/requirement
|
||
shapes (see §2.4).
|
||
POST /api/projects/:pid/apply-template ← {template_id: <int>,
|
||
name_overrides?: { <template_device_id>: "<name>" },
|
||
skip_devices?: [<template_device_id>, …]}
|
||
→ {devices_added: [Device, …],
|
||
requirements_added: [ConnectionRequirement, …],
|
||
skipped_devices: [{template_device_id, reason}, …],
|
||
requirements_skipped: [{template_requirement_id, reason}, …]}
|
||
Idempotent in spirit: name collisions surface in
|
||
skipped_devices; m resolves with name_overrides on
|
||
re-apply. Whole call is one transaction.
|
||
|
||
# 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)
|
||
```
|
||
|
||
### 3.1 v4 wire-shape additions
|
||
|
||
- `ConnectionRequirement` (response):
|
||
`{id, project_id, from_device_id, to_device_id, preferred_cable_type_id|null, must_connect: bool, notes, created_at, updated_at}`.
|
||
- `DeviceType` (response):
|
||
`{id, project_id|null, name, kind, icon|null, description, built_in: bool, ports: [{cable_type_id, count, label_prefix, edge, sort_order}]}`.
|
||
- `cables` gets an `auto: bool` field on the row (slice 5.5 migration adds
|
||
the column with default 0; the solver sets 1 on its own creations). The
|
||
v3 cable rows m hand-drew keep `auto=0`. `POST /api/.../cables`
|
||
continues to default `auto=0`; only the solver writes `auto=1`.
|
||
|
||
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)
|
||
|
||
CableGUI 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 CableGUI 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).
|
||
|
||
---
|
||
|
||
## 5b. v4 — Solver
|
||
|
||
The solver is the headline addition in v4. m's product-vision sentence
|
||
maps onto it directly:
|
||
|
||
> "I say what devices we have, the app tells me how to bundle cables and
|
||
> how the most efficient connection looks like"
|
||
|
||
The solver reads a project's `devices` (with their `ports`) and
|
||
`connection_requirements`, and writes a set of solver-owned `cables`
|
||
(rows with `auto=1`) + `bundles`. m's hand-drawn cables (`auto=0`) are
|
||
left strictly alone — the solver only adds and removes its own.
|
||
|
||
### 5b.1 Objective: maximum bundling — schematic only
|
||
|
||
CableGUI is a **schematic**, not a physical-routing tool. Cables are
|
||
straight lines between endpoints; the solver has no model of walls,
|
||
floors, cable trays, or path geometry. "Maximum bundling" therefore
|
||
reduces to a single rule on the schematic:
|
||
|
||
> When two or more cables share the same endpoint pair (device A ↔
|
||
> device B), group them into one bundle.
|
||
|
||
This is the v3 endpoint-pair rule, applied to the solver's output. m's
|
||
"visually cleaner setups" benefit comes from the bundle being a single
|
||
labelled set in the inspector + a single mixed-colour glyph in the
|
||
render (slice 9+), rather than from any path optimisation. Anything
|
||
about trunks, frame-edge corridors, or auto-routing is out of scope —
|
||
filed for "post-v0 ambient" in §8.
|
||
|
||
### 5b.2 Algorithm (v0)
|
||
|
||
Pure function. No graph search; no LP; no path optimisation. Single
|
||
pass with greedy port allocation.
|
||
|
||
```
|
||
solve(project) ⇒ {add, remove, bundles, unsatisfied}:
|
||
let auto_cables_before = SELECT * FROM cables WHERE project=p AND auto=1
|
||
let port_free := {port_id -> bool} initialised TRUE for every port
|
||
minus ports already used by manual cables (auto=0)
|
||
|
||
for each requirement r in order(must_connect DESC, id ASC):
|
||
let ct = r.preferred_cable_type_id
|
||
?? auto_pick_cable_type(r.from_device, r.to_device)
|
||
?? fail("ambiguous")
|
||
let pa = first_free_port(r.from_device, ct, port_free)
|
||
let pb = first_free_port(r.to_device, ct, port_free)
|
||
if !pa or !pb:
|
||
if r.must_connect: unsatisfied.push({r.id, reason})
|
||
else: skip
|
||
continue
|
||
port_free[pa] = port_free[pb] = false
|
||
add.push(cable{type=ct, from_port=pa, to_port=pb, auto=1})
|
||
|
||
// Bundle by endpoint-pair (v3 rule, applied only to auto cables).
|
||
for each (device_a, device_b) pair with ≥ 2 add-cables:
|
||
bundles_add.push({auto=1, cables: those add-cables})
|
||
|
||
// Diff against auto_cables_before to compute remove[] (any prior auto
|
||
// cable whose (from, to, type) doesn't appear in add[]).
|
||
remove = auto_cables_before - add
|
||
return {add, remove, bundles_add, unsatisfied}
|
||
```
|
||
|
||
`first_free_port(device, cable_type, free_map)` picks the lowest-id port
|
||
on the device whose `type_id` matches and that is still free, returning
|
||
NULL if none. The `lowest-id` tiebreak is deterministic so repeated
|
||
solves produce the same plan.
|
||
|
||
`auto_pick_cable_type(from, to)` (used when `preferred_cable_type_id` is
|
||
NULL): find the set of cable types `T = ports(from).types ∩
|
||
ports(to).types`. If `|T| == 1`, return it. If `|T| > 1`, fail
|
||
("ambiguous; specify preferred_cable_type_id"). The UI surfaces this
|
||
as a "specify type" inline edit on the requirement.
|
||
|
||
### 5b.3 Solver-owned vs. user-owned cables
|
||
|
||
`cables.auto` distinguishes them.
|
||
|
||
| Operation | Effect on `auto=0` cables | Effect on `auto=1` cables |
|
||
|---|---|---|
|
||
| POST /api/.../cables (m draws by hand) | inserts auto=0 | n/a |
|
||
| PATCH cables (m moves endpoint, relabels) | applies | applies (and the cable is "promoted" to auto=0 — m owns it now) |
|
||
| DELETE cables | applies | applies |
|
||
| POST /api/.../solve | left alone (their used ports are reserved before the solver runs) | replaced wholesale (remove[] + add[] in one tx) |
|
||
|
||
This way a manual cable m doesn't want the solver to second-guess
|
||
survives every solve. If m wants the solver to take it over, he deletes
|
||
his hand-drawn cable and re-solves; the solver re-creates an equivalent
|
||
auto cable.
|
||
|
||
### 5b.4 When solver fails — quick-fix UX
|
||
|
||
Three classes of failure surface in the response's `unsatisfied[]`:
|
||
|
||
1. **No compatible cable type** — `T = ports(from).types ∩
|
||
ports(to).types` is empty (e.g. a Power-only device to an HDMI-only
|
||
device).
|
||
2. **Ambiguous cable type** — `|T| > 1`, no preferred set on the
|
||
requirement.
|
||
3. **No free port** — the cable type matches but every port on one side
|
||
is already used.
|
||
|
||
The solver does **not** auto-add ports without m's consent. v4.1 ships
|
||
an explicit one-click quick-fix per class of failure, surfaced as a red
|
||
badge on the affected device in the inspector (§7) and as a button on
|
||
each `unsatisfied[]` entry in the preview-diff modal:
|
||
|
||
| Failure class | Quick-fix button | What it does |
|
||
|---|---|---|
|
||
| No compatible cable type | **"+ Add <preferred_type> port to <device> and re-solve"** | POST `/api/projects/:pid/devices/:id/ports` with `type_id=preferred_type` + sensible default offset, then immediately POST `/solve` again. The preferred_type is the requirement's `preferred_cable_type_id`. If the requirement has no preferred type, the button reads "Specify cable type" and opens an inline cable-type picker on the requirement instead. |
|
||
| Ambiguous cable type | **"Specify cable type"** | Opens an inline picker on the requirement row with the candidates from `T` pre-listed. On select → PATCH the requirement → re-solve. |
|
||
| No free port | **"+ Add <type> port to <device> and re-solve"** | Same as the no-compat case but the `type` is already determined (it's the requirement's preferred or auto-picked type). Adds a port on whichever side ran out (the response's `reason` carries `which_side`). |
|
||
|
||
All three quick-fixes do their work in a single round-trip request from
|
||
the UI perspective: the click fires a POST that either chains the port
|
||
insert + the re-solve server-side, or fires both calls back-to-back from
|
||
the client (server-side chaining is simpler — see §3.2 for the endpoint
|
||
shape).
|
||
|
||
The quick-fix never adds a port silently; the button text always names
|
||
the device + cable type so m sees what's about to mutate.
|
||
|
||
### 5b.5 Preview vs. apply
|
||
|
||
`?preview=1` returns the same shape without writing. The UI shows a diff
|
||
modal with `add[]`, `remove[]`, `unsatisfied[]`; m clicks Apply to fire
|
||
the same endpoint without `preview=1`. Default (no flag) applies
|
||
immediately. Live-solve (no Solve button — every requirement edit
|
||
triggers a debounced re-solve) is parked at slice 9+ as an opt-in.
|
||
|
||
---
|
||
|
||
## 6. Sync — export-only for v0
|
||
|
||
```
|
||
┌─────────────────────┐
|
||
│ CableGUI DB (truth) │
|
||
└──────────┬──────────┘
|
||
│
|
||
export ▼
|
||
(push) ┌────────────────────────┐
|
||
│ <project>.excalidraw │
|
||
│ on mxdrw.msbls.de │
|
||
└────────────────────────┘
|
||
```
|
||
|
||
- CableGUI 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 CableGUI.
|
||
|
||
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
|
||
`cablegui-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:
|
||
|
||
```
|
||
┌────────────────────────────────────────────────────────────────────┐
|
||
│ CableGUI [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.
|
||
|
||
### v4.1 — Flow: apply a setup template
|
||
|
||
The New Project modal gains a **"or start from a template"** section
|
||
under the description field. Each built-in template ('Living Room',
|
||
'Home Office', 'Server Rack') is a clickable card listing its devices +
|
||
the requirement edges between them. Selecting one expands an inline
|
||
override form:
|
||
|
||
- A pre-filled name for each template device (m can edit each, e.g.
|
||
rename `TV` to `Bedroom TV`).
|
||
- Per-device "skip" checkbox.
|
||
|
||
On Create, the server does `POST /api/projects` first; on success,
|
||
immediately fires `POST /api/projects/:pid/apply-template` with the
|
||
collected overrides. The response's `devices_added` + `requirements_added`
|
||
are merged into the local snapshot and the project switches to it,
|
||
already populated.
|
||
|
||
For an already-existing empty project, the inspector's project header
|
||
shows an **"Apply template"** action that opens the same override form
|
||
without the project-create round-trip.
|
||
|
||
Once the template has stamped its devices + requirements, hit **Solve**
|
||
(§7 "Flow: run the solver") to produce the wired diagram.
|
||
|
||
### 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 (v4 — type-aware)
|
||
|
||
1. `+ Dev` (or `D`) → click on canvas → device placeholder appears.
|
||
2. **First field in the inline namer: type dropdown** (replaces the
|
||
v1 plain-name input). Options pulled from
|
||
`GET /api/projects/:pid/device-types` — built-ins listed first
|
||
grouped by `kind`, then project-custom rows, then `Custom (no type)`.
|
||
Typing in the dropdown filters by `name` (m types "n" → NAS jumps
|
||
to top). Below the dropdown: a name input pre-filled with the type
|
||
name + a digit if a same-named device already exists ("PC", "PC-2").
|
||
3. Hit Enter → `POST .../devices` with `type_id` + name. The server
|
||
seeds the ports from `device_type_ports` in the same transaction
|
||
and returns the device with its `ports`.
|
||
4. Picking `Custom (no type)` keeps the v3 behaviour: rectangle, no
|
||
ports, m adds ports manually via the inspector.
|
||
5. The device renders with its ports already visible along the
|
||
configured edge.
|
||
|
||
### 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.
|
||
|
||
### v4 — Flow: declare connection requirements
|
||
|
||
The left sidebar gains a **Requirements** section under the legend:
|
||
|
||
```
|
||
Cable types
|
||
Power, USB, HDMI, DP, RJ45, + Type
|
||
|
||
Requirements ← new in v4
|
||
NAS ↔ Switch RJ45 must
|
||
PC ↔ TV HDMI must
|
||
Mac ↔ Soundbar HDMI nice
|
||
+ Requirement
|
||
```
|
||
|
||
Click `+ Requirement` → modal with two device pickers (autocomplete from
|
||
the project's current devices), a cable-type picker (defaults to
|
||
auto-resolve if the device pair has only one matching type), and a
|
||
must/nice toggle. `POST .../connection-requirements`.
|
||
|
||
Alternative gesture (no tool armed, no selection): **drag from device A
|
||
to device B** to seed a requirement modal with the pair pre-filled. The
|
||
solver-edge preview drags out from the source device's edge in a thin
|
||
dashed line until release.
|
||
|
||
m can also right-click a requirement row → edit / delete.
|
||
|
||
### v4 — Flow: run the solver
|
||
|
||
Header gains a **Solve** button next to **Export**.
|
||
|
||
1. Click Solve (or `S`) → `POST /api/projects/:pid/solve?preview=1`.
|
||
2. A diff modal opens listing `add[]`, `remove[]`, `unsatisfied[]` — the
|
||
canvas behind it dims and previews the new cables in a translucent
|
||
stroke + the to-be-removed cables in a strikethrough red.
|
||
3. Buttons:
|
||
- **Apply** → fires `POST .../solve` (no `preview`), applies in one
|
||
transaction, closes the modal, re-renders canvas with the real
|
||
cables in place.
|
||
- **Cancel** → leaves everything as it was.
|
||
4. Unsatisfied requirements get their own list at the bottom of the
|
||
modal, each with a quick-action button: "Specify type", "+ Add port
|
||
to device X", or "Drop requirement (set must=0)".
|
||
|
||
If `unsatisfied[]` is non-empty, the Solve button stays in a
|
||
soft-error state (yellow) until either every requirement is satisfiable
|
||
or m explicitly accepts the partial plan.
|
||
|
||
### v4 — Inspector states
|
||
|
||
| Selection | Inspector shows |
|
||
|---|---|
|
||
| nothing | empty, with "Bundle suggestions" + "Project requirements" headlines |
|
||
| project header | name, drawing_name, description (editable), device count, requirement count, Solve / Export buttons |
|
||
| frame | name (editable), x/y/w/h, contained-device count, delete |
|
||
| **device** | name + type + icon, ports grid (type / label / connected? / +Port), **unmet requirements list** with red badges. Each badge carries a single quick-fix button — "+ Add <type> port to <device> and re-solve" (no-compat-type / no-free-port cases) or "Specify cable type" (ambiguous case) per §5b.4. delete |
|
||
| **port** | type, label, parent device, current cable (if any), delete |
|
||
| **cable (auto=1)** | source/target, type, driving requirement (clickable → opens requirement edit), parent bundle (if any), label, "Promote to manual" (sets auto=0) |
|
||
| cable (auto=0) | as v3 — type, source/target, label, delete |
|
||
| bundle | name, member cables (clickable to focus), the endpoint pair (`Device A ↔ Device B`), auto-detected flag |
|
||
|
||
### Keyboard
|
||
|
||
`P` switch project, `F` add frame, `D` add device, `I` add IO marker,
|
||
`T` start cable from selected port, `R` add requirement,
|
||
**`S` solve project (v4)**, `E` export, `Esc` cancel, `Backspace` delete
|
||
selection, `?` show shortcuts.
|
||
|
||
---
|
||
|
||
## 8. First slices — v4 reshape
|
||
|
||
Slices 1 + 2 have shipped (see git history). v4 inserts new slices ahead
|
||
of the original 3-5 because the solver depends on the catalog + the
|
||
requirements model, not on manual cable drawing. The old "manual port +
|
||
cable draw" slice is still in scope as a tweak path on the solver
|
||
output, but it follows the solver instead of leading.
|
||
|
||
| # | Slice | Status | What's shipped |
|
||
|---|---|---|---|
|
||
| 1 | **Bootstrap + project CRUD + global cable_types** | ✅ shipped | See git: branch `mai/picasso/slice-1-bootstrap`. |
|
||
| 2 | **Frames + devices + drag** | ✅ shipped | See git: branch `mai/picasso/slice-2-frames-devices`. |
|
||
| **3 (was 4)** | **IO markers + cable-type editing** | pending | Unchanged scope. `+ IO` places a wall-outlet diamond. Legend swatch is a colour picker; renaming a type updates the legend on the fly. `+ Type` adds new global types. |
|
||
| **4 (NEW)** | **Device-type catalog + type-aware device create** | pending | Migration 002: `device_types` + `device_type_ports`, seeded with the 11 built-ins (§2.2). Migration adds `devices.type_id`. API: `GET /api/device-types`, `GET /api/projects/:pid/device-types`. Frontend: the +Dev inline namer becomes a type dropdown + name input; choosing a built-in type seeds the device's ports on the backend. Picking `Custom (no type)` falls back to v3 freeform. m can create a typed NAS + see its Power + RJ45 ports appear on the canvas. |
|
||
| **4.5 (NEW)** | **Manage device-type catalog (per project)** | pending | Modal: `POST/PATCH/DELETE /api/projects/:pid/device-types` for project-custom rows. Edit affordance hidden for built-ins. Lets m add an exotic device type without contributing to the built-in catalog. Validation: a custom type can't share a name with a built-in (already enforced by `UNIQUE(project_id, name)` + a separate code-level check against built-ins). |
|
||
| **5 (NEW)** | **Connection requirements UI + CRUD** | pending | Migration 003: `connection_requirements`. API: full CRUD under `/api/projects/:pid/connection-requirements`. Frontend: left-sidebar "Requirements" section, `+ Requirement` modal (autocomplete from project's current devices, cable-type picker, must/nice toggle). Drag from device A to device B gestures the same modal pre-filled. Inspector for a selected device lists its requirements. |
|
||
| **6 (v4.1 EXPANDED)** | **Solver MVP + Solve button + setup templates** | pending | `POST /api/projects/:pid/solve` with `?preview=1` support. v0 algorithm (§5b.2): pure-function, greedy port allocation, endpoint-pair bundling. Migration 003 adds `cables.auto`. Header gains a Solve button that opens the preview-diff modal. m clicks Solve → sees the cable plan + unmet requirements (each with its quick-fix button per §5b.4) → applies. **Folded in v4.1: setup templates.** Migration 004 adds `setup_templates` + `setup_template_devices` + `setup_template_requirements` and seeds 3 built-ins ('Living Room', 'Home Office', 'Server Rack'). API: `GET /api/setup-templates`, `POST /api/projects/:pid/apply-template`. UI: a "Templates" section in the New Project modal + an "Apply template" action on empty projects → seeds devices + requirements in one transaction → Solve produces the wired diagram. |
|
||
| **7 (was 3, slimmed)** | **Manual port + manual cable draw** | pending | The v3 flow as a tweak path on solver output. `+ Port` on an instance-owned device; click-port → click-port creates a hand-drawn cable (`auto=0`). Used to override the solver's choices or to extend its plan. |
|
||
| **8 (was 5)** | **Export to mxdrw.msbls.de** | pending | `POST .../sync/export` writes a `.excalidraw` scene per the visual grammar (§4). Bundles ignored on export in v0. |
|
||
|
||
Slices 9+ (not promised for the first coder shift):
|
||
- Live-solve mode: re-run solver on every device/requirement edit with a debounce + previewed-but-not-applied diff in a toast. Opt-in toggle in project settings.
|
||
- Bundle rendering in the SVG (a single thick line with mixed-colour stops between the endpoint pair, plus a small badge with the cable count). Cables in a bundle still render as their individual lines underneath; the bundle is a visual overlay m can toggle.
|
||
- "Re-seed from type" action on a device.
|
||
- Custom setup templates (m authors them in-UI, not just the built-in three).
|
||
- Cable inventory metadata (length/SKU) if m later wants it.
|
||
- Dark mode.
|
||
|
||
Out of scope, period (would change CableGUI's mental model): path
|
||
routing, cable-tray polylines, frame-edge corridors, wall-axis bundling,
|
||
3D, anything that treats a cable as more than a labelled endpoint pair.
|
||
|
||
---
|
||
|
||
## 9. Open questions for m — all closed in v4.1
|
||
|
||
The six v4 questions are now answered. Locked answers:
|
||
|
||
1. **Where do paths come from?** → **Nowhere — CableGUI is a schematic.**
|
||
Cables are straight lines between endpoints. The solver does not
|
||
route, the renderer does not route, and "maximum bundling" reduces to
|
||
the endpoint-pair rule (§5b.1). Anything resembling a path, trunk,
|
||
cable tray, or frame-edge corridor is **out of scope, period**
|
||
(§8 "Out of scope, period").
|
||
2. **Live solve or button-only?** → **Button-only for v0.** Live-solve
|
||
stays parked at slice 9+ as an opt-in.
|
||
3. **No-compatible-port-pair UX.** → **Explicit quick-fix.** The
|
||
unsatisfied-requirement badge in the inspector carries a single
|
||
button — "+ Add <type> port to <device> and re-solve" —
|
||
that POSTs the port AND fires `/solve` in one UI action. The button
|
||
text always names the device + type, so m sees what's about to
|
||
mutate (§5b.4 + §7).
|
||
4. **Setup templates.** → **Folded INTO v4.1, in slice 6.** Migration 004
|
||
adds `setup_templates` + child tables + 3 built-ins. `GET
|
||
/api/setup-templates` and `POST /api/projects/:pid/apply-template`
|
||
ship alongside the solver (§2.4 + §3 + slice 6 in §8). Custom
|
||
templates (m authors his own) parked at slice 9+.
|
||
5. **Catalog distribution.** → **SQL seed in migration 002.** No
|
||
external file loader.
|
||
6. **Promote to manual.** → **Explicit button** on the cable inspector
|
||
(§7 row "cable (auto=1)"). PATCHes that only update labels stay auto.
|
||
|
||
No open design questions remain. The coder shift is gated on m's
|
||
go/no-go for v4.1 — 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 CableGUI.
|
||
- 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/cablegui/data:/app/data`.
|
||
- Secrets via `env_file: /home/m/secrets/<project>/.env` (msports-garmin
|
||
pattern). CableGUI 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 CableGUI (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 CableGUI
|
||
|
||
```
|
||
CableGUI/
|
||
├── cmd/cablegui/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/ # CableGUI 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/cablegui ./cmd/cablegui
|
||
|
||
FROM gcr.io/distroless/static-debian12:nonroot
|
||
WORKDIR /app
|
||
COPY --from=build /out/cablegui /app/cablegui
|
||
ENV CABLEGUI_ADDR=0.0.0.0:7777
|
||
ENV CABLEGUI_DB=/app/data/cablegui.db
|
||
USER nonroot:nonroot
|
||
EXPOSE 7777
|
||
ENTRYPOINT ["/app/cablegui"]
|
||
```
|
||
|
||
### docker-compose.yml (on mDock at `/home/m/stacks/cablegui/`)
|
||
|
||
```yaml
|
||
services:
|
||
cablegui:
|
||
image: mgit.msbls.de/m/cablegui:latest
|
||
container_name: cablegui
|
||
restart: unless-stopped
|
||
ports:
|
||
- "7777:7777"
|
||
environment:
|
||
- TZ=Europe/Berlin
|
||
- CABLEGUI_ADDR=0.0.0.0:7777
|
||
- CABLEGUI_DB=/app/data/cablegui.db
|
||
- MEXDRAW_BASE_URL=https://mxdrw.msbls.de
|
||
env_file:
|
||
- /home/m/secrets/cablegui/.env # contains MEXDRAW_TOKEN
|
||
volumes:
|
||
- /home/m/stacks/cablegui/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/cablegui:latest .
|
||
- name: Push image
|
||
run: |
|
||
echo "${{ secrets.GITEA_TOKEN }}" | \
|
||
docker login mgit.msbls.de -u mAi --password-stdin
|
||
docker push mgit.msbls.de/m/cablegui:latest
|
||
- name: Up
|
||
run: |
|
||
cd /home/m/stacks/cablegui
|
||
docker compose pull
|
||
docker compose up -d
|
||
```
|
||
|
||
### Local-development run (no Docker)
|
||
|
||
```
|
||
make run # go run ./cmd/cablegui → :7777 against ./data/cablegui.db
|
||
make typecheck # tsc --noEmit on web/
|
||
make test # go test ./...
|
||
```
|
||
|
||
The repo has `data/` checked-in-empty (with `.gitkeep`); `data/*.db*` is
|
||
gitignored.
|
||
|
||
---
|
||
|
||
## 11. v5 — Cable routing via clamps
|
||
|
||
m's bundling primitive: a **clamp** is a physical anchor on the canvas
|
||
(think cable tie / clip). A cable routes from its `from` endpoint,
|
||
through zero or more clamps **in order**, to its `to` endpoint. Two
|
||
cables that share an ordered pair of consecutive clamps are visibly
|
||
bundled along that segment — no detection pass, no inference: the
|
||
overlap *is* the bundle.
|
||
|
||
This replaces the abandoned waypoints + segment-detection approach.
|
||
v0's straight-line schematic stays as the empty-clamps case
|
||
(`cable_clamps` is empty for a fresh solver-emitted cable).
|
||
|
||
### 11.1 Schema (migration 007)
|
||
|
||
```sql
|
||
CREATE TABLE clamps (
|
||
id INTEGER PRIMARY KEY,
|
||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||
x REAL NOT NULL,
|
||
y REAL NOT NULL,
|
||
label TEXT NOT NULL DEFAULT '',
|
||
frame_id INTEGER REFERENCES frames(id) ON DELETE SET 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 clamps_project_idx ON clamps(project_id);
|
||
|
||
CREATE TABLE cable_clamps (
|
||
cable_id INTEGER NOT NULL REFERENCES cables(id) ON DELETE CASCADE,
|
||
clamp_id INTEGER NOT NULL REFERENCES clamps(id) ON DELETE CASCADE,
|
||
ord INTEGER NOT NULL, -- 1..N along from→to
|
||
PRIMARY KEY (cable_id, ord),
|
||
UNIQUE (cable_id, clamp_id) -- a cable can't visit the same clamp twice
|
||
);
|
||
CREATE INDEX cable_clamps_clamp_idx ON cable_clamps(clamp_id);
|
||
```
|
||
|
||
`frame_id` on clamps mirrors devices + IO markers — m can put a clamp
|
||
inside a frame and the frame-drag carries it.
|
||
|
||
`UNIQUE (cable_id, clamp_id)` blocks loops. `ord` is a small int, 1-based;
|
||
nothing requires it to be contiguous (m can renumber 1, 2, 3 → 1, 3, 5
|
||
during edits and the renderer is fine with that), but the UI keeps them
|
||
contiguous on every mutation for sanity.
|
||
|
||
### 11.2 Cable rendering model
|
||
|
||
Each cable resolves to a polyline `[from-anchor, clamp₁, clamp₂, …, clampₙ, to-anchor]`
|
||
where:
|
||
- `from-anchor` / `to-anchor` come from the existing `anchorForEndpoint`
|
||
resolver (port / device / IO).
|
||
- clamp anchors are `(clamp.x, clamp.y)` directly — clamps don't have a
|
||
width/height to centre.
|
||
|
||
For N=0 clamps the result is the v0 straight line. For N≥1 we render
|
||
a `<polyline>` instead of a `<line>`.
|
||
|
||
The endpoint-replug handles from §10 (cable-replug) stay on the **first
|
||
and last** vertices. Mid-polyline vertices get their own clamp-handle —
|
||
small grab points only on the selected cable, which behave like
|
||
clamp-detach when dragged onto empty canvas (drop a clamp off the
|
||
cable's path).
|
||
|
||
### 11.3 Bundle visualisation — derived from shared segments
|
||
|
||
A **segment** is a directed pair `(A, B)` where A and B are consecutive
|
||
nodes of a cable's polyline. Two cables share a segment when their
|
||
polyline contains the same A→B (or B→A — segment matching is
|
||
undirected).
|
||
|
||
For each segment, compute `cables[]` — the cables that traverse it.
|
||
If `len(cables) ≥ 2`, render the segment as a single thick line on top
|
||
of the individual ones:
|
||
|
||
- **Width**: `2 + N` px (N = cable count). Caps at ~12 px.
|
||
- **Colour**: a striped pattern, one stripe per distinct cable type in
|
||
the bundle, ordered by cable_type.id. SVG `<linearGradient>` with
|
||
hard stops produces the stripe band cheaply; render it on a sibling
|
||
`<polyline>` over the individual lines.
|
||
- **Tooltip**: `<title>` child listing the cables ("Power · USB · HDMI").
|
||
|
||
At a clamp where ≥ 2 cables meet, the clamp icon (10×10 rounded square)
|
||
shows a small count badge (`×N`) when N > 1. At fan-out points
|
||
(endpoint with no clamp before it on the polyline) the individual
|
||
coloured lines re-emerge, so m sees which port each strand goes to.
|
||
|
||
Shared-segment computation is O(C·N̄) where C = #cables and N̄ = average
|
||
polyline length. For a v0-sized project (≤ ~30 cables, ≤ ~5 clamps per
|
||
cable) this is trivial. We rebuild the segment map on every renderCanvas
|
||
— no caching layer.
|
||
|
||
### 11.4 UI gestures
|
||
|
||
**+ Clamp tool (`C` shortcut, also a sidebar button):**
|
||
- Click empty canvas → place a clamp at the cursor (POST `/clamps`).
|
||
Standalone clamp — not on any cable yet.
|
||
- Click a cable line → insert this clamp into that cable. The new clamp
|
||
sits at the click position (snapped to the nearest point on the
|
||
cable's polyline) and its `ord` is computed so it falls between the
|
||
two existing vertices it lies between.
|
||
|
||
**Drag a cable's mid-segment:**
|
||
- Pointerdown on a cable line (not on an endpoint handle) and drag.
|
||
Live preview shows a bend at the cursor. Pointerup:
|
||
- If the cursor is within snap-radius (~16 px) of an existing clamp:
|
||
insert that clamp into the cable's polyline at the right `ord`.
|
||
- Otherwise: create a fresh clamp at the release point and insert it.
|
||
|
||
**Clamp inspector** (selecting a clamp on the canvas):
|
||
- Position (x, y editable + label)
|
||
- "Cables through this clamp": list with each cable's two endpoints,
|
||
click → select that cable
|
||
- "Remove from this cable" (per row) → DELETE the matching cable_clamps
|
||
row; cable's polyline collapses around the gap.
|
||
- "Delete clamp" → cascade-removes from every cable_clamps row.
|
||
|
||
**Right-click on a clamp icon ON a cable** → "Remove from this cable"
|
||
inline.
|
||
|
||
**Frame drag** carries clamps the same way it carries devices + IO
|
||
markers (clamp.frame_id mirrors the existing pattern, drag handler
|
||
already iterates frame-contained items).
|
||
|
||
### 11.5 Relationship to the existing `bundles` table
|
||
|
||
**Recommendation: keep `bundles` and `bundle_cables`, repurpose them.**
|
||
|
||
- Implicit/auto bundles → derived live from shared clamp segments. No
|
||
DB rows. The §5 `GET /bundles/suggestions` endpoint stays useful as a
|
||
"you might want to route these through the same clamps" hint.
|
||
- Explicit named bundles → still in the `bundles` table. m names a
|
||
group ("desk → wall trunk"), the UI offers "route all members through
|
||
these clamps" as a one-click action. Useful for the case where m
|
||
wants a stable label on a logical bundle that isn't yet routed.
|
||
|
||
Migration 007 leaves `bundles` + `bundle_cables` untouched. A v6 cleanup
|
||
can drop them if m decides the explicit-named path isn't worth keeping.
|
||
|
||
### 11.6 Solver coupling
|
||
|
||
The v0 solver still emits **straight cables** — no clamp rows. m
|
||
hand-routes after Solve. The solver's preview-diff is unaffected
|
||
(solver compares endpoint pairs; clamp routing is independent of the
|
||
endpoint identity).
|
||
|
||
Future v5.1: solver-suggested clamps based on shared paths between
|
||
endpoint pairs. Out of scope here.
|
||
|
||
### 11.7 Export to mxdrw
|
||
|
||
Clamps map to small diamond elements (separate from IO markers — IO
|
||
diamonds are red wall-outlets; clamps are grey routing points).
|
||
`excalidraw_id` is stable across re-exports per the existing pattern.
|
||
|
||
Cable arrows become Excalidraw `arrow` elements with mid-points (the
|
||
clamp positions) when N≥1 — Excalidraw supports multi-vertex arrows
|
||
via the `points` array. Each `startBinding` / `endBinding` resolves to
|
||
the from/to anchor's excalidraw_id; mid-vertices are unbound.
|
||
|
||
Bundle visualisation (thick striped lines on shared segments) is **not
|
||
exported** in v0 — Excalidraw doesn't natively support gradient strokes,
|
||
and the mxdrw round-trip would lose them. We export each cable as its
|
||
own polyline; bundling is a viewer-only concept.
|
||
|
||
### 11.8 API additions
|
||
|
||
```
|
||
POST /api/projects/:pid/clamps { x, y, label?, frame_id? } → Clamp
|
||
PATCH /api/projects/:pid/clamps/:id { x?, y?, label?, frame_id? } → Clamp
|
||
DELETE /api/projects/:pid/clamps/:id
|
||
|
||
POST /api/projects/:pid/cables/:cid/clamps { clamp_id, ord? } → CableClamp
|
||
DELETE /api/projects/:pid/cables/:cid/clamps/:cmid
|
||
|
||
# Convenience: re-order clamps on a cable in one call
|
||
PUT /api/projects/:pid/cables/:cid/clamps { clamp_ids: [int, int, …] }
|
||
```
|
||
|
||
Snapshot endpoint grows two arrays:
|
||
- `clamps: []Clamp`
|
||
- `cable_clamps: []{ cable_id, clamp_id, ord }`
|
||
|
||
### 11.9 Open questions for m
|
||
|
||
1. **Clamp icon shape.** Diamond (overlaps visually with IO markers
|
||
when zoomed out), small filled circle (overlaps with port circles),
|
||
or rounded square `▢` 10×10? Recommend rounded square — distinct from
|
||
everything else on the canvas today.
|
||
2. **Snap radius when inserting onto a cable.** ~16 px world-units feels
|
||
right at 1× zoom. Should it scale with zoom (visual constant) or stay
|
||
world-constant (gesture stays the same regardless of zoom)? Recommend
|
||
visual constant — divide by current zoom.
|
||
3. **Clamp deletion when shared.** If a clamp is used by 4 cables and m
|
||
clicks "Delete clamp", do we (a) refuse with a "still in use" prompt,
|
||
(b) cascade-remove from all 4 cables, or (c) cascade silently? Current
|
||
draft says cascade silently. Worth a confirmation?
|
||
4. **Bundle stripe order.** Cable-type id is stable but arbitrary; visual
|
||
order on a thick line affects readability. Order by stripe-count
|
||
(Power first if 3 Power + 1 USB), or by cable-type-id (deterministic
|
||
but unrelated to importance)? Recommend by-count, ties broken by id.
|
||
5. **Solver respect for existing routing.** When m re-runs Solve after
|
||
hand-routing, should the solver preserve existing clamp routing on
|
||
user-owned (`auto=0`) cables? Auto cables are wiped + rebuilt, so
|
||
their clamps disappear with them — that's expected. But manual cables
|
||
with clamps should clearly keep them. Confirm.
|
||
|
||
### 11.10 Slice plan (post-design)
|
||
|
||
1. Schema migration + tx-aware store helpers (Create/Update/DeleteClamp,
|
||
AttachClampToCable, DetachClampFromCable, ReorderClamps).
|
||
2. HTTP endpoints + snapshot extension.
|
||
3. Frontend: clamp render + + Clamp tool + canvas placement (no
|
||
cable attach yet).
|
||
4. Cable polyline render via clamps, mid-segment drag-to-clamp,
|
||
clamp inspector.
|
||
5. Shared-segment bundle visualisation (gradient stripe + count badge).
|
||
6. Export pipeline extension — mxdrw arrows with mid-points + clamp
|
||
diamonds. Bundle viz stays viewer-only.
|
||
|
||
---
|
||
|
||
DESIGN v5 READY FOR REVIEW
|