mAi b93c42a6e0 feat(db): solver + setup templates + cables/bundles store
Migration 004:
- setup_templates + setup_template_devices + setup_template_requirements
- 3 built-in templates seeded: Living Room (TV+Soundbar+ChromeCast,
  2× HDMI), Home Office (PC+Screen+Keyboard+Mouse, 1× HDMI + 2× USB),
  Server Rack (NAS+Switch+fritz, 2× RJ45).

Cables store (cables.go):
- CRUD with endpoint validation (port|device|io exactly-one, project-
  scoped). Tx-aware: validateEndpointEx + assertCableTypeEx avoid
  deadlocks when the solver Apply tx holds the MaxOpenConns(1) connection.

Bundles store (bundles.go):
- CRUD with cable_ids replacement on PATCH. createBundle(ex, …, ownTx)
  inherits the caller's tx for solver-internal use; returns a locally-
  constructed Bundle when ownTx=false (re-fetching via s.db would
  deadlock).

Solver (solver.go) implements design v4.1 §5b.2 exactly:
- Pre-fetch devices/ports/cables/requirements/bundles.
- Reserve ports used by manual cables (auto=0) so the solver can't
  reuse them.
- For each requirement (must_connect DESC, id ASC):
    * Resolve cable type: preferred, or T = port-types(from) ∩
      port-types(to). |T|==0 → unsatisfied "no compat type"; |T|>1 →
      "ambiguous"; |T|==1 → that one.
    * Pick lowest-id free port on each side. None → unsatisfied with
      WhichSide hint + cable-type name.
- Endpoint-pair bundle: ≥2 staged cables between the same device pair
  → auto bundle.
- Diff against existing auto cables by (type_id, MIN(from,to), MAX(from,to))
  signature. Matched = kept; new = added; orphans = removed.
- Preview returns the diff without writing; Apply runs in a single tx
  that wipes auto bundles, deletes orphan auto cables, inserts new
  ones, and rebuilds bundles.
- PortsAndResolve: combo helper for the inspector quick-fix —
  inserts a port + re-runs Solve.

Setup-templates store (setup_templates.go):
- List/Get with hydrated devices + requirements.
- ApplyTemplate(projectID, templateID, opts) seeds devices + requirements
  in one tx. Per-device name overrides + opt-out. Name collisions skip
  the device (skipped_devices); requirements whose endpoints both fail
  are also skipped (requirements_skipped). UNIQUE-collision on an
  existing requirement is non-fatal; logged in requirements_skipped.

Snapshot: cables + bundles fields tightened to []Cable / []Bundle and
populated from the store.

11 new tests (solver_test.go), all green with -race:
- Basic NAS↔Switch (RJ45) → 1 cable, auto=true
- Ambiguous cable type → unsatisfied
- No free port → unsatisfied with side hint
- Preview doesn't write
- Apply then re-apply → idempotent (kept=N, added=0)
- Manual cable reserves its port → solver can't claim it
- ApplyTemplate Living Room → 3 devices + 2 requirements + 7 ports
  (from the device-type port seeder)
- Home Office template then Solve → 3 cables, 0 unsatisfied
- Name-collision pre-existing device → skipped + req-pair skipped
2026-05-16 01:02:31 +02:00

mCables

Cable-management framework for m's setup — visual web editor backed by a single Go binary + SQLite, generating Excalidraw drawings via mExDraw.

Each cable-managed environment (LOFT, OFFICE, …) is a separate mCables project; each project is backed by exactly one .excalidraw drawing on mxdrw.msbls.de.

Status

Slice 1 — bootstrap shipped. Projects + global cable types are end-to-end; the SVG canvas is intentionally empty until slice 2.

Slice What's in it Status
1 Project CRUD, global cable types, empty SVG canvas, project picker
2 Frames + devices, drag-to-position pending
3 Ports + cables (click-port → click-port) pending
4 IO markers + cable-type editing pending
5 Export to mxdrw.msbls.de pending

Run it

go run ./cmd/mcables
# open http://localhost:7777

Or built:

make build
./bin/mcables

The binary serves the frontend from an embedded web/static/ and the JSON API under /api/. SQLite lives at ./data/mcables.db by default.

Environment

Var Default Notes
MCABLES_ADDR 0.0.0.0:7777 Listen address.
MCABLES_DB ./data/mcables.db SQLite path. Parent dir is created on boot.
MEXDRAW_BASE_URL (unset) Used by slice 5 export — not consumed yet.
MEXDRAW_TOKEN (unset) Bearer for the mExDraw export. Not consumed yet.

Tests

make test           # go test -race ./...

Store-level tests cover projects + cable-types CRUD, the drawing_name auto-default, the ?confirm=<name> guardrail on DELETE /api/projects/:pid, and the ON DELETE RESTRICT on a referenced cable type.

API (slice 1)

GET    /api/healthz                       → 200 {"status":"ok"}

GET    /api/projects                      → [Project, …]
POST   /api/projects                      ← {name, drawing_name?, description?}
                                            drawing_name defaults to "<name>.excalidraw"
GET    /api/projects/:pid                 → {project, cable_types, frames, devices, …}
PATCH  /api/projects/:pid                 ← partial
DELETE /api/projects/:pid?confirm=<name>  ← confirm must equal current name

GET    /api/cable-types                   → [CableType, …]   (global)
POST   /api/cable-types                   ← {name, color}
PATCH  /api/cable-types/:id               ← partial — affects every project
DELETE /api/cable-types/:id               ← 409 in_use if any cable references it

Deploy to mDock

mCables runs on mDock at http://mdock:7777 as a docker-compose service under /home/m/stacks/mcables/. Pattern matches the other mDock services (mgreen-journal, mgeo, msports-garmin, …) — no Dokploy, no reverse proxy, LAN-trusted.

Manual deploy (first roll)

  1. Build + push the image (from any host with docker; today the image lives in mAi's Gitea namespace because mAi doesn't have write access to m/):

    docker build -t mgit.msbls.de/mai/mcables:latest .
    awk '/machine mgit.msbls.de/{getline; getline; print $2}' ~/.netrc-mai \
      | docker login mgit.msbls.de -u mAi --password-stdin
    docker push mgit.msbls.de/mai/mcables:latest
    
  2. Prepare directories on mDock (one-time):

    ssh mdock 'mkdir -p /home/m/stacks/mcables/data /home/m/secrets/mcables \
               && touch /home/m/secrets/mcables/.env \
               && chmod 0600 /home/m/secrets/mcables/.env'
    scp docker-compose.yml mdock:/home/m/stacks/mcables/docker-compose.yml
    
  3. Pull + start:

    ssh mdock 'cd /home/m/stacks/mcables && docker compose pull && docker compose up -d'
    
  4. Verify from any LAN host:

    curl http://mdock:7777/api/healthz       # → {"status":"ok"}
    curl http://mdock:7777/api/cable-types   # → the 5 seeded types
    

To update to a new build: rebuild + push the image, then ssh mdock 'cd /home/m/stacks/mcables && docker compose pull && docker compose up -d'.

Persistence

SQLite lives at /home/m/stacks/mcables/data/mcables.db on the host (bind-mounted into the container at /app/data). Container runs as UID 1000:1000 to align with m:m ownership on mDock — DB files end up owned by m, the host user.

docker compose restart keeps the data intact (tested 2026-05-15).

Automation — follow-up task

This first roll is manual. A Gitea Actions workflow on the self-hosted runner already on mDock (/home/m/act-runner/, label self-hosted:host) — build → push → docker compose up -d on every push to main — is a separate task per the design's §10. Tracking spawned by the head if/when wanted.

Design + project conventions

  • docs/design.md — full v3 design (schema, API, importer/export conventions, slices, mDock deploy notes).
  • CLAUDE.md — project instructions for mai workers.

Architecture

Layer Tech
DB SQLite via modernc.org/sqlite (cgo-free), WAL, FKs on
Backend Go 1.22+ net/http ServeMux pattern routing, single binary
Frontend Vanilla ES modules + SVG, no build step, embedded via embed.FS
Export (slice 5) mExDraw HTTP API on mxdrw.msbls.de

LAN-trusted, no auth.

Description
Cable management — visual interface + SQLite inventory, integrates with mExDraw for diagrams.
Readme 378 KiB
Languages
Go 58.5%
JavaScript 34.7%
CSS 4.1%
HTML 2.4%
Dockerfile 0.2%