merge: design v3 (framework, multi-project, mDock deploy)
inventor shift done by picasso. docs/design.md (760 lines) + CLAUDE.md locked. m approved coder shift. Next: slice 1 (bootstrap + project CRUD) on mai/picasso/slice-1.
This commit is contained in:
141
CLAUDE.md
141
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/<name>.excalidraw` with `Authorization: Bearer $MEXDRAW_TOKEN`. (The `mcp__mexdraw__*` MCP tools are not currently configured for this project — workers use the raw HTTP API.) |
|
||||
|
||||
## Hierarchy
|
||||
|
||||
- **Project** (`projects` table) is the top-level concept. LOFT, OFFICE,
|
||||
HOMELAB, … are separate projects. One project ↔ one `.excalidraw`
|
||||
drawing in mExDraw. `projects.drawing_name` defaults to
|
||||
`<name>.excalidraw` server-side when omitted on create; editable later.
|
||||
- **Frames** sub-divide a project (LOFT has `desk`, `rack`, `media`;
|
||||
OFFICE has `desk`, `server`). Frames are not projects — they're zones
|
||||
within one drawing.
|
||||
- Every device, port, cable, IO marker, and bundle is **project-scoped**
|
||||
(`project_id` denormalised onto every row, with `ON DELETE CASCADE` from
|
||||
`projects`). `UNIQUE (project_id, devices.name)` — no two devices in
|
||||
one project share a name.
|
||||
- **Cable types are global.** A single shared `cable_types` table —
|
||||
no `project_id`. The five defaults (Power/USB/HDMI/DP/RJ45) are seeded
|
||||
by migration 001 once, not per project. Renaming or recolouring a type
|
||||
affects every project's legend immediately.
|
||||
- **Project deletion guardrail.** `DELETE /api/projects/:pid` requires
|
||||
`?confirm=<name>` matching the project's current name. 400 otherwise.
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
- `main` = production-deployable.
|
||||
- `feat/*` / `fix/*` = short-lived branches via mai worker workflow.
|
||||
- `mai/<worker>/<slug>` = worker branches via the mai workflow.
|
||||
- No `dev` branch — too small a project for staging.
|
||||
- Merge with `--no-ff` to main, delete branches after merge.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Go** for the backend (matches m's other tools: `m`, `mai`, youpcms).
|
||||
- **SQLite** as inventory store. Driver: `modernc.org/sqlite` (cgo-free) or `mattn/go-sqlite3` (cgo) — design pass decides.
|
||||
- **mExDraw MCP** for diagram I/O — never touch raw `.excalidraw` files outside the MCP.
|
||||
- No deploy yet — runs locally on m's machine as `go run ./cmd/mcables` (or similar). Production deploy can come later if needed.
|
||||
- **Go** for the backend (matches m's other tools: `m`, `mai`,
|
||||
youpcms, mExDraw).
|
||||
- **SQLite** via `modernc.org/sqlite` (cgo-free → clean `scratch`/distroless
|
||||
container, no toolchain pain).
|
||||
- **mExDraw** via HTTP for diagram export. Never edit raw `.excalidraw`
|
||||
files directly outside the mExDraw API.
|
||||
- **Vanilla JS + SVG** for the frontend — no build step. JSDoc-typed.
|
||||
|
||||
## Seed Data
|
||||
## Deployment — mDock, raw docker (NOT Dokploy)
|
||||
|
||||
m's existing Excalidraw drawing on `mxdrw.msbls.de/draw/Cable-Management.excalidraw` is the bootstrap. Devices found there (NAS, eQ, fritz, Switch, IOx3/IOx6/IOx8 hubs, ChromeCast, Soundbar, TV, PC, Mac, SteamLink, Notebooks) + their port topology should map to the initial DB rows.
|
||||
mCables runs on **mDock** (`192.168.178.131` on the LAN, Tailscale `mdock`)
|
||||
as a **plain docker-compose service**. Dokploy is for public mlake/mRiver
|
||||
stuff; mDock uses raw `docker compose` per the conventions of the existing
|
||||
mDock services (mgreen, mgeo, msports-garmin, paperless, …).
|
||||
|
||||
Conventions used in the drawing:
|
||||
- Repo layout on mDock: `/home/m/stacks/mcables/` with `docker-compose.yml`,
|
||||
`data/` bind-mount, secrets in `/home/m/secrets/mcables/.env`.
|
||||
- Image: `mgit.msbls.de/m/mcables:latest` (built and pushed by a Gitea
|
||||
Actions workflow on push to `main`, runs on the self-hosted runner on
|
||||
mDock with label `self-hosted:host`).
|
||||
- Port mapping: `7777:7777`, exposed on the LAN — no reverse proxy.
|
||||
- Restart policy: `unless-stopped`.
|
||||
- LAN URL: `http://mdock:7777`.
|
||||
- No auth — LAN-trusted.
|
||||
|
||||
Local dev (no Docker): `go run ./cmd/mcables` against `./data/mcables.db`.
|
||||
|
||||
## Seed drawing — visual grammar reference, **not** a runtime importer
|
||||
|
||||
`mxdrw.msbls.de/draw/Cable-Management.excalidraw` is **reference material
|
||||
only**. mCables does **not** auto-ingest it. m will rebuild LOFT and OFFICE
|
||||
from scratch inside the tool — the seed exists so the **exporter** mimics
|
||||
its visual grammar:
|
||||
|
||||
- **Devices** = rectangles with a text label.
|
||||
- **Ports** = small ellipses (~12×9) positioned on the device edge.
|
||||
- **Cables** = arrows from port-ellipse to port-ellipse (Excalidraw bindings via `from=` / `to=`).
|
||||
- **Cable types** = colour, with a legend at top-left listing RJ45, DP, HDMI, USB, Power.
|
||||
- **IO labels** (diamond shapes) mark interface clusters.
|
||||
- **Frames** group setups by location (one frame per room / rack).
|
||||
- **Ports** = small ellipses (~12×9) positioned on a device edge.
|
||||
Positional, *not* containerId-bound. Stroke colour = cable type.
|
||||
- **Cables** = arrows with `startBinding` / `endBinding` to ports or
|
||||
devices or IO diamonds.
|
||||
- **Cable types** = colour, with a legend at the top-left of the project's
|
||||
first frame listing the project's cable_types.
|
||||
- **IO markers** = small diamonds. Semantically **wall outlets / power
|
||||
entry points** — a cable terminating at an IO marker means "this end is
|
||||
plugged into a wall socket outside the diagram". They are *not*
|
||||
inter-frame bridges and they do *not* pair up.
|
||||
- **Frames** = sub-zones inside a project (`desk`, `rack`, `media`, …).
|
||||
- **Lines** = decorative only (legend separators in the seed). Ignored on
|
||||
export.
|
||||
|
||||
The importer should preserve this convention so m's existing layout doesn't get rewritten.
|
||||
Legend colours (global, seeded once by migration 001):
|
||||
|
||||
## Out of scope (for now)
|
||||
| Type | Hex |
|
||||
|---|---|
|
||||
| Power | `#e03131` |
|
||||
| USB | `#2f9e44` |
|
||||
| HDMI | `#1971c2` |
|
||||
| DP | `#9c36b5` |
|
||||
| RJ45 | `#ffd500` |
|
||||
|
||||
## Out of scope (v0)
|
||||
|
||||
- Multi-user. mCables is m-only.
|
||||
- Auth / sharing — LAN-trusted on mDock.
|
||||
- Mobile / responsive — desktop browser only.
|
||||
- Cable inventory beyond visual structure (no length tracking, no purchase history, no SKU mapping — add later if useful).
|
||||
- Auth / sharing — runs locally.
|
||||
- Cable inventory beyond visual structure (no length, no purchase history,
|
||||
no SKU). Strictly visual structure for v0.
|
||||
- Import from `.excalidraw` at runtime. If a one-shot migration is ever
|
||||
needed, a separate `mcables-migrate` CLI tool is the right shape, not a
|
||||
hot API endpoint.
|
||||
|
||||
## Worker Preferences
|
||||
|
||||
- **First shift = inventor** (design pass): pick frontend stack, sketch UI flows, define schema, plan importer for existing drawing. Output: `docs/design.md` + open questions for m.
|
||||
- **Second shift = coder** (after m's go on the design): bootstrap repo skeleton (Go module, SQLite migrations, importer skeleton, server, frontend scaffold).
|
||||
- Use **Sonnet** for both — this is greenfield, structure matters more than depth.
|
||||
- **First shift = inventor** (design pass): conventions, schema, API,
|
||||
export pipeline, mDock deploy plan, UI flows, slices. Output:
|
||||
`docs/design.md` + open questions for m.
|
||||
- **Second shift = coder** (after m's go on the design): bootstrap repo
|
||||
skeleton (Go module, SQLite migrations, server, exporter, frontend
|
||||
scaffold). Take slices 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.
|
||||
|
||||
780
docs/design.md
Normal file
780
docs/design.md
Normal file
@@ -0,0 +1,780 @@
|
||||
# mCables — Design v3
|
||||
|
||||
Cable-management **framework** for m's setup. Inventor shift 1 design,
|
||||
revised after m's round-4 answers (2026-05-15) — for m's review.
|
||||
|
||||
Sources: the live `Cable-Management.excalidraw` on mxdrw.msbls.de (used as
|
||||
the *visual-grammar reference*, not as a bootstrap import target),
|
||||
`mai-memory` (`mcables`, `m`), and a live survey of mDock services for the
|
||||
deploy conventions (§10).
|
||||
|
||||
> **What changed in v3** (mechanical deltas on top of v2)
|
||||
> - `cable_types` is now a **global** table — one set shared across all
|
||||
> projects. Migration 001 seeds the 5 defaults once. `POST /api/projects`
|
||||
> no longer seeds types. API moved to top-level `/api/cable-types`.
|
||||
> Renaming/recolouring a type affects every project.
|
||||
> - `devices` gains `UNIQUE (project_id, name)` — no two devices in the
|
||||
> same project can share a name.
|
||||
> - `projects.drawing_name` is auto-filled `<name>.excalidraw` server-side
|
||||
> when omitted on POST; editable via PATCH.
|
||||
> - `DELETE /api/projects/:pid` requires `?confirm=<name>` query param;
|
||||
> server checks it matches the project's current name. 400 otherwise.
|
||||
>
|
||||
> **What carried over from v2**
|
||||
> - mCables is a framework: top-level `projects` table; LOFT and OFFICE
|
||||
> are separate projects, each backed by one drawing.
|
||||
> - No runtime importer. The seed drawing is reference material only.
|
||||
> `/api/sync/import` is out of MVP; only `POST .../sync/export` ships.
|
||||
> - IO diamonds are wall-outlet terminators (type=Power by convention,
|
||||
> not enforced in schema). UI soft-warns on non-Power cables to an IO.
|
||||
> - No cable inventory metadata. Purely visual structure for v0.
|
||||
> - DB at `./data/mcables.db` (project-local, gitignored).
|
||||
> - Deploy: raw docker / docker-compose on mDock (not Dokploy).
|
||||
> - Bind `0.0.0.0:7777` on the LAN, no auth.
|
||||
|
||||
---
|
||||
|
||||
## 0. The seed drawing — visual grammar reference
|
||||
|
||||
`Cable-Management.excalidraw` on mxdrw.msbls.de is **not** ingested at
|
||||
runtime. It is the visual-grammar reference we lock the export onto so that
|
||||
when m rebuilds LOFT and OFFICE inside mCables, the exported `.excalidraw`
|
||||
looks like the seed.
|
||||
|
||||
Concrete numbers from the live file (180 elements):
|
||||
|
||||
| Kind | Count | Excalidraw shape | What it represents |
|
||||
|---|---|---|---|
|
||||
| Frames | 2 | `frame` (`name`) | Sub-areas inside a project (`desk`, `rack`, …) |
|
||||
| Devices | 27 | `rectangle` with bound text | Hardware items |
|
||||
| Ports | 74 | `ellipse` ~12×9 | Connectors on a device edge, colour = cable type |
|
||||
| Cables | 31 | `arrow` | Typed connections between ports/devices/outlets |
|
||||
| IO markers | 6 | `diamond` text=`IO` | **Wall outlet / power-entry terminators** (type=Power) |
|
||||
| Legend | 5 | `text` | Colour key in the top-left of the frame |
|
||||
| Lines | 5 | `line` | **Decorative** (separator under the legend). Ignored. |
|
||||
|
||||
**Legend → cable type → colour**, picked up directly from the seed:
|
||||
|
||||
| Type | Colour | Hex |
|
||||
|---|---|---|
|
||||
| Power | red | `#e03131` |
|
||||
| USB | green | `#2f9e44` |
|
||||
| HDMI | blue | `#1971c2` |
|
||||
| DP | purple | `#9c36b5` |
|
||||
| RJ45 | yellow | `#ffd500` |
|
||||
|
||||
Three observations about the seed's visual grammar — these constrain the
|
||||
**exporter** (§4):
|
||||
|
||||
1. **Ports sit on a device edge as small ellipses (~12×9)**, coloured by
|
||||
cable type. They are not children of the device in the Excalidraw sense
|
||||
(no `containerId`/`boundElements` link) — purely positional. When we
|
||||
export from mCables we mimic that: port ellipse at `(device.x +
|
||||
port.x_offset, device.y + port.y_offset)`, stroke colour = type colour.
|
||||
2. **Cable arrows bind to elements**. In the seed: 44 endpoints to ellipses
|
||||
(ports), 12 to whole rectangles (device-level, no specific port), 3 to
|
||||
diamonds (wall outlets). Our exporter sets `startBinding.elementId` /
|
||||
`endBinding.elementId` to whichever Excalidraw element ID we wrote for
|
||||
the port / device / IO marker.
|
||||
3. **IO diamonds = wall outlets.** They are terminals: a cable goes from a
|
||||
device-port → an IO marker, meaning "this cable plugs into a wall socket
|
||||
outside the diagram". They are always type=Power in m's setup but the
|
||||
schema doesn't enforce that (a future "network jack in the wall"
|
||||
wouldn't fit, and we can lift the constraint then).
|
||||
|
||||
---
|
||||
|
||||
## 1. Frontend stack — vanilla JS + SVG
|
||||
|
||||
**Locked**: vanilla ES modules (TS-typed via JSDoc, no build step) + SVG
|
||||
diagram surface, served from a single Go binary via `embed.FS`.
|
||||
|
||||
Why this fits m: matches the no-build-step preference; same single-binary
|
||||
aesthetic as `m`, `mai`, `youpcms`, `mExDraw`. Type-checking is opt-in via
|
||||
`make typecheck` (`tsc --noEmit`), not gating runtime. SVG is one DOM node
|
||||
per port/device/cable → trivial hit-testing, CSS-driven colouring by
|
||||
`data-type=hdmi`, drag via pointer events + `getScreenCTM()`.
|
||||
|
||||
Escape hatch only if state for half-drawn cables + multi-select gets
|
||||
painful: switch to Preact-via-CDN-ESM (still no build step). Not v0.
|
||||
|
||||
---
|
||||
|
||||
## 2. SQLite schema
|
||||
|
||||
`./data/mcables.db` (project-local, gitignored). WAL mode, FKs on.
|
||||
Driver: **`modernc.org/sqlite`** (cgo-free — clean cross-compile, simple
|
||||
Dockerfile).
|
||||
|
||||
```sql
|
||||
-- 001_init.sql
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- A project IS a drawing. LOFT and OFFICE are separate projects.
|
||||
-- One project ↔ one .excalidraw file in mExDraw.
|
||||
CREATE TABLE projects (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE, -- "LOFT", "OFFICE"
|
||||
drawing_name TEXT NOT NULL, -- mExDraw drawing name, e.g. "LOFT.excalidraw"
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Cable types: GLOBAL legend, one set shared across all projects.
|
||||
-- Migration 001 seeds the 5 defaults (Power/USB/HDMI/DP/RJ45) once.
|
||||
-- Renaming or recolouring a type from anywhere in the UI propagates to
|
||||
-- every project's legend and to every cable already typed as it.
|
||||
CREATE TABLE cable_types (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE, -- "Power", "USB", "HDMI", "DP", "RJ45"
|
||||
color TEXT NOT NULL, -- "#e03131"
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- A frame is a named container *inside* a project: 'desk', 'rack', 'media'.
|
||||
CREATE TABLE frames (
|
||||
id INTEGER PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
x REAL NOT NULL DEFAULT 0,
|
||||
y REAL NOT NULL DEFAULT 0,
|
||||
width REAL NOT NULL DEFAULT 1200,
|
||||
height REAL NOT NULL DEFAULT 800,
|
||||
excalidraw_id TEXT, -- stable across exports
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (project_id, name),
|
||||
UNIQUE (project_id, excalidraw_id)
|
||||
);
|
||||
CREATE INDEX frames_project_idx ON frames(project_id);
|
||||
|
||||
-- Devices live in a frame (and transitively in a project).
|
||||
-- Stored project_id is denormalised for cheap project-scoped queries; FK
|
||||
-- to frame_id is the structural truth. Both are kept consistent in code.
|
||||
CREATE TABLE devices (
|
||||
id INTEGER PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
frame_id INTEGER REFERENCES frames(id) ON DELETE SET NULL,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#1e1e1e',
|
||||
x REAL NOT NULL,
|
||||
y REAL NOT NULL,
|
||||
width REAL NOT NULL,
|
||||
height REAL NOT NULL,
|
||||
excalidraw_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (project_id, name), -- no two devices in one project share a name
|
||||
UNIQUE (project_id, excalidraw_id)
|
||||
);
|
||||
CREATE INDEX devices_project_idx ON devices(project_id);
|
||||
CREATE INDEX devices_frame_idx ON devices(frame_id);
|
||||
|
||||
-- Ports belong to a device. x_offset/y_offset are relative to the device's
|
||||
-- top-left so ports follow when the device moves. project_id denormalised.
|
||||
CREATE TABLE ports (
|
||||
id INTEGER PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
type_id INTEGER NOT NULL REFERENCES cable_types(id) ON DELETE RESTRICT,
|
||||
label TEXT, -- optional ("HDMI 1", "USB-C rear")
|
||||
x_offset REAL NOT NULL,
|
||||
y_offset REAL NOT NULL,
|
||||
excalidraw_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (project_id, excalidraw_id)
|
||||
);
|
||||
CREATE INDEX ports_project_idx ON ports(project_id);
|
||||
CREATE INDEX ports_device_idx ON ports(device_id);
|
||||
CREATE INDEX ports_type_idx ON ports(type_id);
|
||||
|
||||
-- IO markers = wall outlets / power-entry terminators.
|
||||
-- One end of a Power cable. They are NOT bridges and they do NOT pair.
|
||||
CREATE TABLE io_markers (
|
||||
id INTEGER PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
frame_id INTEGER REFERENCES frames(id) ON DELETE SET NULL,
|
||||
label TEXT NOT NULL DEFAULT 'IO', -- "Wall A", "UPS rear", …
|
||||
x REAL NOT NULL,
|
||||
y REAL NOT NULL,
|
||||
excalidraw_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (project_id, excalidraw_id)
|
||||
);
|
||||
CREATE INDEX io_markers_project_idx ON io_markers(project_id);
|
||||
CREATE INDEX io_markers_frame_idx ON io_markers(frame_id);
|
||||
|
||||
-- A cable. Each endpoint is exactly one of (port, device, io-marker).
|
||||
-- All foreign-key targets must be in the same project_id as the cable —
|
||||
-- enforced in code (the CHECK below only enforces the one-non-null rule).
|
||||
CREATE TABLE cables (
|
||||
id INTEGER PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
type_id INTEGER NOT NULL REFERENCES cable_types(id) ON DELETE RESTRICT,
|
||||
label TEXT,
|
||||
from_port_id INTEGER REFERENCES ports(id) ON DELETE SET NULL,
|
||||
from_device_id INTEGER REFERENCES devices(id) ON DELETE SET NULL,
|
||||
from_io_id INTEGER REFERENCES io_markers(id) ON DELETE SET NULL,
|
||||
to_port_id INTEGER REFERENCES ports(id) ON DELETE SET NULL,
|
||||
to_device_id INTEGER REFERENCES devices(id) ON DELETE SET NULL,
|
||||
to_io_id INTEGER REFERENCES io_markers(id) ON DELETE SET NULL,
|
||||
excalidraw_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
CHECK (
|
||||
(from_port_id IS NOT NULL) + (from_device_id IS NOT NULL) + (from_io_id IS NOT NULL) = 1
|
||||
),
|
||||
CHECK (
|
||||
(to_port_id IS NOT NULL) + (to_device_id IS NOT NULL) + (to_io_id IS NOT NULL) = 1
|
||||
),
|
||||
UNIQUE (project_id, excalidraw_id)
|
||||
);
|
||||
CREATE INDEX cables_project_idx ON cables(project_id);
|
||||
CREATE INDEX cables_from_port_idx ON cables(from_port_id);
|
||||
CREATE INDEX cables_to_port_idx ON cables(to_port_id);
|
||||
CREATE INDEX cables_from_device_idx ON cables(from_device_id);
|
||||
CREATE INDEX cables_to_device_idx ON cables(to_device_id);
|
||||
CREATE INDEX cables_type_idx ON cables(type_id);
|
||||
|
||||
-- Bundles: named groups of cables that physically run together, within
|
||||
-- a single project (a bundle does not span LOFT ↔ OFFICE).
|
||||
CREATE TABLE bundles (
|
||||
id INTEGER PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
auto INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (project_id, name)
|
||||
);
|
||||
CREATE INDEX bundles_project_idx ON bundles(project_id);
|
||||
|
||||
CREATE TABLE bundle_cables (
|
||||
bundle_id INTEGER NOT NULL REFERENCES bundles(id) ON DELETE CASCADE,
|
||||
cable_id INTEGER NOT NULL REFERENCES cables(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (bundle_id, cable_id)
|
||||
);
|
||||
CREATE INDEX bundle_cables_cable_idx ON bundle_cables(cable_id);
|
||||
```
|
||||
|
||||
**FK shape — why `project_id` on every project-scoped row, not just transitively:**
|
||||
|
||||
The structural truth is `cable → port → device → frame → project`. But
|
||||
project-scoped queries ("give me all cables in OFFICE") would otherwise need
|
||||
three joins. Denormalising `project_id` onto every project-scoped row is a
|
||||
small, load-bearing pragma: `cables WHERE project_id=?` is a one-column
|
||||
index hit. The cost: code must keep `project_id` consistent with `frame_id`
|
||||
/ `device_id` on insert+update. That's enforced at the Go layer
|
||||
(`internal/db/store.go` setter functions), not by SQL — `CHECK` constraints
|
||||
in SQLite can't reference another table.
|
||||
|
||||
`cable_types` is the **one global table** — it has no `project_id`.
|
||||
Cables reference it cross-project. Renaming or recolouring a type updates
|
||||
the legend everywhere immediately and re-renders every cable of that type
|
||||
on the next paint.
|
||||
|
||||
`ON DELETE CASCADE` from `projects` cleanly wipes a project's whole subgraph
|
||||
in one statement, which is what we want when m says "delete OFFICE". The
|
||||
cascade does **not** touch `cable_types` (no FK to projects).
|
||||
|
||||
---
|
||||
|
||||
## 3. Go HTTP API
|
||||
|
||||
Single binary `cmd/mcables`, `net/http`, no router framework. Listens on
|
||||
`0.0.0.0:7777` by default (overridable via `MCABLES_ADDR`). Static frontend
|
||||
from `embed.FS` at `/`, JSON API under `/api/`.
|
||||
|
||||
```
|
||||
GET / → index.html (embedded)
|
||||
GET /assets/* → JS/CSS/SVG (embedded)
|
||||
GET /api/healthz → 200 ok
|
||||
|
||||
# Projects — top-level
|
||||
GET /api/projects → [Project, …]
|
||||
POST /api/projects ← {name, drawing_name?, description?}
|
||||
If drawing_name is omitted, server defaults to
|
||||
"<name>.excalidraw". No cable-type seeding —
|
||||
cable_types is global (see /api/cable-types).
|
||||
GET /api/projects/:pid → full snapshot
|
||||
{project, frames, devices, ports, cables,
|
||||
io_markers, bundles}
|
||||
Plus the global cable_types (clients can also
|
||||
fetch them via /api/cable-types). Editor's
|
||||
one-shot loader.
|
||||
PATCH /api/projects/:pid ← partial {name, drawing_name, description}
|
||||
DELETE /api/projects/:pid?confirm=<name> Confirmation guardrail — the query param must
|
||||
equal the project's current name. 400 if missing
|
||||
or mismatched. Cascades through all child rows
|
||||
(frames, devices, ports, cables, io_markers,
|
||||
bundles, bundle_cables). Does NOT touch
|
||||
cable_types.
|
||||
|
||||
# Cable types — GLOBAL, NOT under a project
|
||||
GET /api/cable-types → [CableType, …]
|
||||
POST /api/cable-types ← {name, color} # name must be unique globally
|
||||
PATCH /api/cable-types/:id ← {name?, color?} # affects every project's legend + every cable using this type
|
||||
DELETE /api/cable-types/:id # blocked if any cable still references it (ON DELETE RESTRICT)
|
||||
|
||||
# Inside a project — everything below scoped under :pid
|
||||
GET /api/projects/:pid/frames
|
||||
POST /api/projects/:pid/frames ← {name, x, y, width, height}
|
||||
PATCH /api/projects/:pid/frames/:id
|
||||
DELETE /api/projects/:pid/frames/:id
|
||||
|
||||
GET /api/projects/:pid/devices
|
||||
POST /api/projects/:pid/devices ← {name, frame_id?, x, y, width, height, color?}
|
||||
PATCH /api/projects/:pid/devices/:id (e.g. {x, y} on drag)
|
||||
DELETE /api/projects/:pid/devices/:id
|
||||
|
||||
GET /api/projects/:pid/devices/:id/ports
|
||||
POST /api/projects/:pid/devices/:id/ports ← {type_id, x_offset, y_offset, label?}
|
||||
PATCH /api/projects/:pid/ports/:id
|
||||
DELETE /api/projects/:pid/ports/:id
|
||||
|
||||
GET /api/projects/:pid/cables
|
||||
POST /api/projects/:pid/cables ← {type_id, from_{port|device|io}_id,
|
||||
to_{port|device|io}_id, label?}
|
||||
PATCH /api/projects/:pid/cables/:id
|
||||
DELETE /api/projects/:pid/cables/:id
|
||||
|
||||
GET /api/projects/:pid/io-markers
|
||||
POST /api/projects/:pid/io-markers ← {frame_id?, label, x, y}
|
||||
PATCH /api/projects/:pid/io-markers/:id
|
||||
DELETE /api/projects/:pid/io-markers/:id
|
||||
|
||||
GET /api/projects/:pid/bundles → [{Bundle, cable_ids: [int]}, …]
|
||||
POST /api/projects/:pid/bundles ← {name, cable_ids: [int]}
|
||||
GET /api/projects/:pid/bundles/suggestions → [{name, cable_ids}, …] (see §5)
|
||||
PATCH /api/projects/:pid/bundles/:id
|
||||
DELETE /api/projects/:pid/bundles/:id
|
||||
|
||||
# Sync — export only in MVP
|
||||
POST /api/projects/:pid/sync/export → writes the project's drawing to mExDraw
|
||||
(overwrites previous version; mExDraw keeps
|
||||
git-version-history sidecar)
|
||||
```
|
||||
|
||||
No `POST /api/sync/import` in MVP. Import is post-MVP and only ever serves
|
||||
a one-shot migration use case (e.g. seeding LOFT from the legacy
|
||||
Cable-Management drawing if m later changes his mind).
|
||||
|
||||
All write endpoints return the updated row. Errors are
|
||||
`{error: "string", details?: any}`. No auth.
|
||||
|
||||
mExDraw HTTP credentials live in `MEXDRAW_BASE_URL` (e.g.
|
||||
`https://mxdrw.msbls.de`) + `MEXDRAW_TOKEN` (bearer). The exporter calls
|
||||
`PUT $MEXDRAW_BASE_URL/api/drawings/<drawing_name>.excalidraw` with the
|
||||
generated scene JSON.
|
||||
|
||||
---
|
||||
|
||||
## 4. Export — DB → Excalidraw (visual-grammar conformance)
|
||||
|
||||
mCables generates a `.excalidraw` scene from a project's rows. The seed
|
||||
drawing's grammar is the contract.
|
||||
|
||||
### 4.1 Element mapping
|
||||
|
||||
| DB row | Excalidraw element | Notes |
|
||||
|---|---|---|
|
||||
| `projects.drawing_name` | drawing filename in mExDraw | one drawing per project |
|
||||
| `frames` | `type=frame`, `name=frames.name` | x/y/width/height straight across |
|
||||
| `devices` | `type=rectangle` + bound `text` with `name` | `strokeColor=color`, `frameId=frames.excalidraw_id` |
|
||||
| `ports` | `type=ellipse`, ~12×9 | `strokeColor=type.color`, absolute pos = `(device.x + port.x_offset, device.y + port.y_offset)`, no containerId binding (matches seed) |
|
||||
| `io_markers` | `type=diamond` with bound `text=label` | small (~30×30), `strokeColor` = the Power cable type's colour |
|
||||
| `cables` | `type=arrow` | `strokeColor=type.color`, `startBinding.elementId` = port/device/io excalidraw_id, same for end |
|
||||
| `cable_types` legend (global) | one `type=text` row per `cable_types` row, top-left of the project's first frame | `strokeColor=color`, `text=name`. Pulled from the global table, regenerated each export. |
|
||||
| `bundles` | (rendering open question — see §5) | post-MVP: render as a thick path; v0: ignored on export |
|
||||
|
||||
### 4.2 Element IDs are stable across exports
|
||||
|
||||
Every mCables row carries `excalidraw_id` (TEXT, generated on first export
|
||||
via `crypto/rand` → 21-char Excalidraw-style ID). On re-export the same row
|
||||
reuses the same ID. This means:
|
||||
|
||||
- m's `.excalidraw` collaborator-cursors, element-comments, and undo
|
||||
history survive a re-export.
|
||||
- If m manually edits a port colour in Excalidraw (someday, once import
|
||||
exists), we can match it back to the right DB row by ID.
|
||||
|
||||
### 4.3 What is *not* in the export
|
||||
|
||||
- The legend's decorative separator lines (the 5 `type=line` elements in
|
||||
the seed) — purely visual, m said they're not load-bearing.
|
||||
- Big "enclosure" rectangles like the seed's `tAs8zMDI` desk-surface. In
|
||||
v0 those are imported as plain devices when m draws them, and exported
|
||||
as plain rectangles too. No zone/enclosure concept in the schema.
|
||||
|
||||
### 4.4 Wall-outlet IO markers
|
||||
|
||||
A cable with `to_io_id != NULL` exports to an arrow whose `endBinding`
|
||||
points to the IO diamond's element ID. The diamond is rendered with a small
|
||||
`IO` text label (or `m.label` if customised). No pair link.
|
||||
|
||||
---
|
||||
|
||||
## 5. Bundle detection — project-scoped
|
||||
|
||||
A *bundle* is a set of cables that physically run together. Bundles never
|
||||
cross projects (a LOFT bundle and an OFFICE bundle are separate).
|
||||
|
||||
MVP detection rule, on `GET /api/projects/:pid/bundles/suggestions`:
|
||||
|
||||
```
|
||||
Within project :pid, group cables by (from_endpoint, to_endpoint):
|
||||
from_endpoint = (kind, id) where kind ∈ {port, device, io} and id = whichever *_id is set
|
||||
to_endpoint = same shape
|
||||
Treat the endpoint pair as unordered: {A, B} == {B, A}
|
||||
A candidate suggestion = any group with ≥ 2 cables.
|
||||
```
|
||||
|
||||
i.e. "two or more cables run between the same two endpoints" → almost
|
||||
certainly a bundle. Types in the group can be mixed (Power + USB + HDMI
|
||||
from desk → wall).
|
||||
|
||||
Suggestions are reviewed in the UI; clicking *Accept* creates a real
|
||||
`bundles` row (`auto=0`). m can also create bundles manually by
|
||||
shift-clicking cables.
|
||||
|
||||
Rendering bundles in the SVG view is a slice 6+ concern; in the export
|
||||
they're ignored in v0 (open question §9).
|
||||
|
||||
---
|
||||
|
||||
## 6. Sync — export-only for v0
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ mCables DB (truth) │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
export ▼
|
||||
(push) ┌────────────────────────┐
|
||||
│ <project>.excalidraw │
|
||||
│ on mxdrw.msbls.de │
|
||||
└────────────────────────┘
|
||||
```
|
||||
|
||||
- mCables UI → DB: synchronous (every drag/add/remove persists immediately).
|
||||
- DB → Excalidraw: **manual** button "Export to Excalidraw" in the header,
|
||||
per project. Calls `POST /api/projects/:pid/sync/export`.
|
||||
- Excalidraw → DB: **not implemented** in v0. Anything m draws in
|
||||
Excalidraw stays in Excalidraw until he redraws it in mCables.
|
||||
|
||||
This keeps the v0 scope tight: no conflict resolution, no element-diff
|
||||
import, no auto-debounce. mExDraw keeps its own version history (git
|
||||
sidecar in the mdraw deploy) so a bad export is recoverable from there.
|
||||
|
||||
When mxdrw is unreachable: the export button shows a tooltip and disables;
|
||||
the editor keeps working against the local DB.
|
||||
|
||||
Post-MVP, import returns as a one-shot migration tool (separate
|
||||
`mcables-migrate` CLI tool, not part of the running server) for seeding
|
||||
new projects from existing `.excalidraw` files.
|
||||
|
||||
---
|
||||
|
||||
## 7. UI flows
|
||||
|
||||
The editor lives at `/`. Layout:
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ mCables [LOFT ▾ projects-picker] [Export] [+ Project] │ ← header
|
||||
├────────┬───────────────────────────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ Legend │ │
|
||||
│ │ │
|
||||
│ Power │ Diagram surface (SVG) │
|
||||
│ USB │ │
|
||||
│ HDMI │ ┌─desk─────────────┐ ┌─rack──────────┐ │
|
||||
│ DP │ │ [Mac] [Screen] … │ │ [NAS] [fritz] │ │
|
||||
│ RJ45 │ └──────────────────┘ └───────────────┘ │
|
||||
│ + Type │ │
|
||||
│ │ │
|
||||
│ Tools │ │
|
||||
│ + Dev │ │
|
||||
│ + Frm │ │
|
||||
│ + IO │ │
|
||||
│ draw │ │
|
||||
│ │ │
|
||||
├────────┴───────────────────────────────────────────────────────────┤
|
||||
│ Inspector (selection-dependent: project / frame / device / port / │
|
||||
│ cable / bundle details and actions) │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Flow: pick a project
|
||||
|
||||
Header has a dropdown "LOFT ▾". Clicking it lists all projects from
|
||||
`GET /api/projects`; clicking one swaps the diagram (`GET /api/projects/:pid`
|
||||
loads the full snapshot in one round-trip). The picker also shows a
|
||||
`+ New Project` action → modal with `name`, `drawing_name` (defaults to
|
||||
`<name>.excalidraw`), `description` → `POST /api/projects` → switches to
|
||||
the new project (which has 5 seeded cable types and no frames yet).
|
||||
|
||||
The currently active project's id is kept in URL state
|
||||
(`/?project=LOFT`) so reload returns to the same project.
|
||||
|
||||
### Flow: add a frame
|
||||
|
||||
1. `+ Frm` in the left toolbar (or `F`).
|
||||
2. Click + drag on the canvas → rubber-band rectangle becomes a frame.
|
||||
3. Name prompt centered in the frame; Enter → `POST .../frames`.
|
||||
|
||||
### Flow: add a device
|
||||
|
||||
Unchanged from v1: `+ Dev` (or `D`) → click on canvas → rectangle placed
|
||||
(falls into whichever frame it lands in) → name → `POST .../devices`.
|
||||
|
||||
### Flow: add a port
|
||||
|
||||
Select a device → inspector shows `+ Port` button. Click → cursor becomes
|
||||
a "ghost port" of the active cable type (legend selection). Snap to device
|
||||
edge → click commits → `POST .../devices/:id/ports`.
|
||||
|
||||
### Flow: draw a cable
|
||||
|
||||
Click a port → port highlights. Hover any other endpoint (port / device /
|
||||
IO marker) → preview cable drawn in the source's type colour. Click commits
|
||||
→ `POST .../cables`. `Shift`-click to bind to a whole device. Click an IO
|
||||
diamond to terminate at a wall outlet.
|
||||
|
||||
### Flow: add an IO marker (wall outlet)
|
||||
|
||||
`+ IO` (or `I`) → click on canvas → small diamond placed → optional label
|
||||
text edit → `POST .../io-markers`. By design, the only cables that
|
||||
terminate at an IO marker are Power cables, but the schema doesn't enforce
|
||||
that — the UI shows a soft warning if m draws a non-Power cable to an IO.
|
||||
|
||||
### Flow: pick / edit a cable type
|
||||
|
||||
Legend on the left is interactive and **global** (the same legend shows up
|
||||
in every project). Click a row → that type becomes the active "drawing
|
||||
type" for the current project's session. Drag the swatch → colour picker →
|
||||
updates `cable_types.color` via `PATCH /api/cable-types/:id`. `+ Type` at
|
||||
the bottom → "new cable type" modal — `POST /api/cable-types`. Names are
|
||||
globally unique.
|
||||
|
||||
The modal for editing / adding shows a banner:
|
||||
*"Cable types are shared across all projects. Renaming or recolouring
|
||||
affects every project that uses this type."* Deleting a type that's still
|
||||
in use by any cable returns a 400 with the offending cable count — the
|
||||
client surfaces it as an inline error in the modal.
|
||||
|
||||
### Flow: drag a device
|
||||
|
||||
Pointer-drag → live `transform` on the SVG; on `pointerup`,
|
||||
`PATCH .../devices/:id` persists `x, y`. Ports follow because their
|
||||
offsets are relative.
|
||||
|
||||
### Flow: bundles
|
||||
|
||||
In the inspector with nothing else selected, "Bundle suggestions" pulls
|
||||
`.../bundles/suggestions`. Each suggestion shows the cables highlighted
|
||||
on the diagram + an Accept button. Manual: shift-click multiple cables →
|
||||
"Group as bundle" → name it → save.
|
||||
|
||||
### Keyboard
|
||||
|
||||
`P` switch project (opens picker), `F` add frame, `D` add device,
|
||||
`I` add IO marker, `T` start cable from selected port,
|
||||
`E` export current project, `Esc` cancel, `Backspace` delete selection,
|
||||
`?` show shortcuts.
|
||||
|
||||
---
|
||||
|
||||
## 8. First slices
|
||||
|
||||
Each slice ends with something m can click. The first coder shift takes
|
||||
slices 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 = <name>.excalidraw` when omitted. `DELETE /api/projects/:pid?confirm=<name>` with name-match guardrail. `GET /api/projects` lists them. `GET /api/projects/:pid` returns a (mostly empty) snapshot. `GET /api/cable-types` returns the 5 seeded rows. Frontend `index.html` + `main.js` shows the project picker, a "+ New Project" modal, and an empty SVG canvas with the legend rendered from the global `cable_types` table. m can create LOFT, see it picked, see no devices. |
|
||||
| 2 | **Add frame, add device, drag-to-position** | `+ Frm` and `+ Dev` tools work. Devices and frames persist. Drag-to-position writes back to DB on `pointerup`. Reload returns to the same layout. m builds LOFT's `desk` and `rack` frames and drops in his first devices. |
|
||||
| 3 | **Add port, draw cable** | `+ Port` (with a device selected) places type-coloured ports on device edges with offsets. Click-port → click-port creates a cable. Cables auto-route as straight lines. Inspector shows the cable's type, endpoints, label. m wires up the first end-to-end cable. |
|
||||
| 4 | **IO markers + cable-type editing** | `+ IO` places a wall-outlet diamond. Cable-from-port → IO commits as `to_io_id`. Legend swatch is a colour picker; renaming a type updates the legend on the fly. `+ Type` adds new types. m can fully recreate LOFT's visual model from scratch. |
|
||||
| 5 | **Export to mxdrw.msbls.de** | `POST .../sync/export` generates a `.excalidraw` scene that reproduces the seed's visual grammar (ports as positional ellipses, IO as diamonds, legend as text in the top-left), writes it via mExDraw API, and stores the assigned `excalidraw_id`s for stability on re-export. m sees LOFT in Excalidraw and confirms the look matches the seed. |
|
||||
|
||||
Slices 6+ (not promised for the first coder shift):
|
||||
bundle suggestions UI; bundle rendering (thick path with mixed-colour
|
||||
fan-out); cable type "warn on cross-type port-to-port"; cable inventory
|
||||
metadata (length/SKU) if m later wants it; dark mode.
|
||||
|
||||
---
|
||||
|
||||
## 9. Open questions for m — all resolved in v3
|
||||
|
||||
All six v2 questions are now answered. Locked answers:
|
||||
|
||||
1. **Drawing-name policy** → server-side default `<name>.excalidraw` on
|
||||
POST when omitted; editable via PATCH. (§3)
|
||||
2. **Device-name uniqueness within a project** → `UNIQUE (project_id,
|
||||
devices.name)` enforced at the schema level. (§2)
|
||||
3. **Non-Power IO markers** → no `type_id` on `io_markers` for v0.
|
||||
Power-by-convention; UI soft-warns on non-Power cables to an IO. (§2, §7)
|
||||
4. **Bundle render in export v1** → bundles ignored on export until slice
|
||||
6+. (§4, §5)
|
||||
5. **Cross-project cable types** → `cable_types` is fully **global**. One
|
||||
shared legend; renaming/recolouring affects every project. (§2, §3, §7)
|
||||
6. **Project deletion guardrail** → `DELETE /api/projects/:pid?confirm=<name>`
|
||||
required; server validates name match, returns 400 otherwise. (§3)
|
||||
|
||||
No open design questions remain. The coder shift is gated on m's
|
||||
go/no-go for v3 — not on any unanswered design question from picasso.
|
||||
|
||||
---
|
||||
|
||||
## 10. Deployment on mDock (raw docker)
|
||||
|
||||
Inspected mDock's live services on 2026-05-15 to lock the conventions
|
||||
before writing this:
|
||||
|
||||
- All m-built services on mDock live under `/home/m/stacks/<project>/`
|
||||
with a single `docker-compose.yml`. Older services in `/home/m/<project>/`
|
||||
use the same pattern; the canonical-new path is `stacks/`.
|
||||
- Compose v2 (`docker compose`), images built from Gitea container registry
|
||||
(`mgit.msbls.de/m/<project>:latest`), `restart: unless-stopped` on every
|
||||
service, `container_name: <project>` explicit.
|
||||
- Host port mappings: deliberately collision-free across the host. Existing
|
||||
high ports in use include 3300 (mgreen), 3077 (paperless-ai), 7878
|
||||
(radarr), 8082 (mgeo-tileserver), 8989 (sonarr), 9696 (prowlarr).
|
||||
**Port 7777 is free** — taking it for mCables.
|
||||
- Bind-mount volumes: `/home/m/<project>-data:/app/data` is the canonical
|
||||
pattern (mgreen). For project-local data we put `data/` *next to* the
|
||||
compose file so a `git pull && docker compose up -d` is the whole deploy:
|
||||
`/home/m/stacks/mcables/data:/app/data`.
|
||||
- Secrets via `env_file: /home/m/secrets/<project>/.env` (msports-garmin
|
||||
pattern). mCables only needs `MEXDRAW_TOKEN` for export.
|
||||
- No reverse proxy on mDock. Services expose ports directly on the LAN
|
||||
(mDock = `192.168.178.131` / Tailscale `mdock`). Public exposure goes via
|
||||
mlake/Dokploy + Caddy when needed — out of scope for mCables (LAN-only).
|
||||
- Auto-deploy via the Gitea Actions self-hosted runner already installed
|
||||
on mDock (`/home/m/act-runner/`, label `self-hosted:host`). Push to
|
||||
`main` → workflow on mDock → `docker compose up --build -d`.
|
||||
|
||||
### Repo layout for mCables
|
||||
|
||||
```
|
||||
mCables/
|
||||
├── cmd/mcables/main.go # Go binary
|
||||
├── internal/
|
||||
│ ├── db/ # migrations + store
|
||||
│ ├── importer/ # post-MVP only (not in MVP)
|
||||
│ ├── exporter/ # DB → .excalidraw
|
||||
│ └── server/ # net/http handlers
|
||||
├── web/ # embedded static frontend
|
||||
│ ├── index.html
|
||||
│ ├── main.js # ES module entry
|
||||
│ ├── style.css
|
||||
│ └── lib/... # SVG helpers, store, components
|
||||
├── data/ # mCables runtime DB lives here (gitignored)
|
||||
│ └── .gitkeep
|
||||
├── docs/design.md # this file
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── .gitea/workflows/deploy.yml
|
||||
├── .gitignore # data/, *.db, *.db-wal, *.db-shm
|
||||
├── Makefile # build, typecheck, test, run
|
||||
├── go.mod / go.sum
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### Dockerfile sketch
|
||||
|
||||
Multi-stage; the final image is `scratch` because `modernc.org/sqlite` is
|
||||
pure Go.
|
||||
|
||||
```dockerfile
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM golang:1.23-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \
|
||||
-o /out/mcables ./cmd/mcables
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/mcables /app/mcables
|
||||
ENV MCABLES_ADDR=0.0.0.0:7777
|
||||
ENV MCABLES_DB=/app/data/mcables.db
|
||||
USER nonroot:nonroot
|
||||
EXPOSE 7777
|
||||
ENTRYPOINT ["/app/mcables"]
|
||||
```
|
||||
|
||||
### docker-compose.yml (on mDock at `/home/m/stacks/mcables/`)
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mcables:
|
||||
image: mgit.msbls.de/m/mcables:latest
|
||||
container_name: mcables
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "7777:7777"
|
||||
environment:
|
||||
- TZ=Europe/Berlin
|
||||
- MCABLES_ADDR=0.0.0.0:7777
|
||||
- MCABLES_DB=/app/data/mcables.db
|
||||
- MEXDRAW_BASE_URL=https://mxdrw.msbls.de
|
||||
env_file:
|
||||
- /home/m/secrets/mcables/.env # contains MEXDRAW_TOKEN
|
||||
volumes:
|
||||
- /home/m/stacks/mcables/data:/app/data
|
||||
```
|
||||
|
||||
LAN URL: `http://mdock:7777` (or `http://192.168.178.131:7777`).
|
||||
|
||||
### Gitea Actions deploy workflow
|
||||
|
||||
`.gitea/workflows/deploy.yml`:
|
||||
|
||||
```yaml
|
||||
name: deploy
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build image
|
||||
run: docker build -t mgit.msbls.de/m/mcables:latest .
|
||||
- name: Push image
|
||||
run: |
|
||||
echo "${{ secrets.GITEA_TOKEN }}" | \
|
||||
docker login mgit.msbls.de -u mAi --password-stdin
|
||||
docker push mgit.msbls.de/m/mcables:latest
|
||||
- name: Up
|
||||
run: |
|
||||
cd /home/m/stacks/mcables
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Local-development run (no Docker)
|
||||
|
||||
```
|
||||
make run # go run ./cmd/mcables → :7777 against ./data/mcables.db
|
||||
make typecheck # tsc --noEmit on web/
|
||||
make test # go test ./...
|
||||
```
|
||||
|
||||
The repo has `data/` checked-in-empty (with `.gitkeep`); `data/*.db*` is
|
||||
gitignored.
|
||||
|
||||
---
|
||||
|
||||
DESIGN v3 READY — coder shift gated
|
||||
Reference in New Issue
Block a user