Compare commits

...

24 Commits

Author SHA1 Message Date
mAi
e53f4b1a5e docs(debug): sherlock frame-select — not a regression, render-order clutter
Playwright drives the deployed image four ways. The frame <rect> has its
own pointerdown handler that calls startDrag; startDrag calls
e.stopPropagation() before the pan-start handler runs; the selector at
main.js:2087 already includes [data-frame-id]. Click that lands on the
rect → frame selected, inspector switches to the Frame panel. Verified.

m's complaint is real but cause is render order: frames paint first;
devices, ports, cables, clamps, IO markers all paint on top. Any click
on the frame interior that happens to land on one of those elements
hits that element, not the frame. For LOFT's big 'Entertainment' frame
the visible canvas portion is ~180×424 px and partly covered by yellow
cable polylines.

Not a one-line bug. Three UX options ordered by effort in the doc:
make the frame label the selection grip (drop pointer-events:none + add
pointerdown), parent-frame breadcrumb on the device inspector, modifier
key to escape to the enclosing frame. None applied — picasso/perseus
call.
2026-05-16 19:30:08 +02:00
mAi
1f246c0047 docs(debug): sherlock +Port "does nothing" root cause + fix
Playwright drives the live deployment through three repro scenarios
(empty canvas / on device / on existing port). POST /api/.../ports
returns 201 every time and a new circle appears in the DOM — +Port is
not a no-op. But snapToDeviceEdge collapses the click region down to a
handful of edge-midpoint coordinates with no de-dup, so new ports stack
pixel-perfect on existing ones. Plus placePortAt is the only "place"
function that doesn't set state.selection on the new entity, so the
inspector stays on the device panel and the new port gets no .selected
halo — the canvas + panel both look unchanged from m's view.

Primary fix (one line): set state.selection = {kind: "port", id} in
placePortAt before render(). Verified live by intercepting the POST and
dispatching a pointerdown on the new circle — inspector switches to the
PORT panel with edge picker and the port gets its halo.

Secondary: snapToDeviceEdge could walk along the chosen edge when the
calculated position is already occupied (~8px tolerance). Optional.

Unrelated bug surfaced: startDrag.onUp does e.currentTarget.classList...
on a recycled async event. Spams "Cannot read properties of null" in
the console on every click-only device select. Trivial closure-capture
fix included in the doc.
2026-05-16 11:09:09 +02:00
mAi
3a43762e8c docs(debug): sherlock +Dev click root cause + one-line fix
Playwright repro confirms +Dev → click inside frame or empty canvas drops
silently. Root cause: input.focus() inside promptInline runs during the
pointerdown handler, then the browser's default mousedown action blurs
the input because the SVG click target isn't focusable. The input's blur
listener calls done(null) → fo.remove() before m can type.

Fix verified live by monkey-patching the deployed page: e.preventDefault()
on the canvas pointerdown when a tool is armed keeps the FO focused.

Diff sketch + trace + secondary cleanup tip in the doc. Picasso to apply.
2026-05-15 23:14:52 +02:00
mAi
e12b449169 merge: cursor + cache fixes
- CSS: .canvas-wrap.tool-{frame,device} #canvas, #canvas * { cursor:
  crosshair !important } so frame/device rects don't display grab while
  a tool is armed
- Server: Cache-Control: no-cache on embedded static handler so browsers
  revalidate via ETag instead of serving stale main.js after redeploy
2026-05-15 20:40:07 +02:00
mAi
28a376a7f3 fix(ui+server): tool cursor wins on canvas children; no-cache static assets
Issue 1 — cursor lies about armed tool. .svg-draggable { cursor: grab }
on frame/device rects beat the .canvas-wrap.tool-device #canvas {
cursor: crosshair } rule because element-level wins over descendant.
m saw "grab" hovering a frame with +Dev armed and thought the tool was
broken even though clicks routed correctly after the previous fix. Add
a descendant rule with !important so tool-armed wraps any child cursor:
  .canvas-wrap.tool-frame  #canvas *,
  .canvas-wrap.tool-device #canvas * { cursor: crosshair !important; }

Issue 2 — stale browser cache after each redeploy. http.FileServerFS
served embedded assets with no Cache-Control header, so browsers held
on to the previous main.js/style.css until hard-reload. New noCache
middleware on the static handler emits Cache-Control: no-cache. Note:
embedded FS files have zero ModTime, so http.FileServer suppresses
Last-Modified — every fetch is a fresh 200 rather than a 304. Fine at
~30KB of JS+CSS, and fixes the staleness problem completely.

Middleware is wrapped only around the static handler. /api/* responses
write their own headers and aren't touched.

Verified locally:
  curl -I /main.js   → Cache-Control: no-cache
  curl -I /style.css → Cache-Control: no-cache + contains the new rule
  curl -I /api/healthz → unaffected (no Cache-Control from us)
go test -race ./... still green.
2026-05-15 20:38:48 +02:00
mAi
6d637e1fac merge: fix +Dev inside frame silently dropped
Move the [data-frame-id]/[data-device-id] early-return below the
tool-armed branches in onCanvasPointerDown. With a tool armed,
the canvas-level handler always wins; without a tool, the original
behaviour (frame/device pointerdown handlers capture for drag/select)
is restored.
2026-05-15 20:34:39 +02:00
mAi
94869f342e fix(ui): +Dev inside a frame was silently dropped
onCanvasPointerDown returned early whenever the click landed on a
[data-device-id] or [data-frame-id] element so the per-element drag
handlers wouldn't get hijacked. Problem: this early-return fired BEFORE
the tool check, so clicking +Dev inside an existing frame never reached
placeDeviceAt().

Reordered: tool-armed branches run first and short-circuit. Only when
no tool is armed does the "click started on a child element — leave it
alone" guard kick in. End behaviour:
- +Dev anywhere (incl. inside a frame) drops a device. frame_id
  auto-resolves via the existing frameAt() point-in-rect.
- +Frm anywhere (incl. inside an existing frame) starts a rubber-band;
  rare but not harmful.
- No tool armed: clicking a device/frame still goes to its own handler
  (drag / select). Clicking empty canvas still clears selection.

Hand-tested via the served /main.js + the equivalent backend POST/PATCH
sequence: device-in-frame, device-outside, device-drag, frame-drag with
cascaded device patches — all work.
2026-05-15 20:33:17 +02:00
mAi
a9e6d7aa62 merge: slice 2 — frames + devices + drag-to-position
picasso shipped (3 commits @ b159131):
- internal/db/frames_devices.go: project-scoped CRUD, cross-project FK
  rejection, sentinel errors (duplicate name -> 409, invalid input -> 400)
- internal/server/frames_devices.go: handlers under /api/projects/:pid/
  {frames,devices}, full CRUD
- web/static: SVG rendering + tools (+ Frm rubber-band, + Dev click-place),
  drag with frame-children-follow, inspector with debounced edits

30 store tests green with -race. Hand-test: cross-frame device drag,
frame-drag-with-children, server restart all preserve state.
2026-05-15 18:23:37 +02:00
mAi
b15913124a feat: frontend — frames + devices on SVG, tools, drag, inspector
Renders the slice-2 backend on the empty canvas from slice 1.

Canvas:
- Frames render as dashed-stroke rects with top-left label, slightly
  tinted fill. Devices render as solid-stroke rects with centred label
  in device.color.
- Selection halo via .selected class (stroke-width bump).
- Empty-state hint disappears once any geometry exists.

Tools (left sidebar + keyboard):
- F / + Frame  — rubber-band rect on the canvas. <80×60 cancels. On
  release, inline foreignObject namer → POST /api/projects/:pid/frames.
- D / + Device — single click places a 100×35 device centred at the
  click. Inline namer → POST devices. Drop-point determines initial
  frame_id via point-in-rect against all frames (smallest bbox wins).
- Esc cancels active tool / inline namer / clears selection.

Drag (pointer events + svg getScreenCTM):
- Devices: drag updates x/y live via transform, persists via
  PATCH .../devices/:id on pointerup. Also recomputes frame_id from
  drop point and includes "frame_id": null|<id> if it changed.
- Frames: dragging a frame moves its contained devices visually too;
  on pointerup, single PATCH for the frame + one PATCH per moved device.
  Children-batch is computed at pointerdown and only sent on release —
  no per-pointermove network traffic.

Inspector:
- Frame selection: name (debounced rename), x/y/w/h, device count,
  Delete button (confirm prompt — devices keep existing, frame_id → NULL
  via the schema's ON DELETE SET NULL).
- Device selection: name (debounced rename), colour picker
  (change-event PATCH, no debounce), x/y/w/h, current frame, Delete.
- Background click clears selection.

devicePatch wire format uses tri-state frame_id: key absent = leave,
key:null = clear, key:<int> = move. Frontend uses `null` explicitly
when a device drops outside all frames.
2026-05-15 18:22:49 +02:00
mAi
21bf00566c feat: http handlers — frames + devices CRUD under /api/projects/:pid/
All 8 endpoints (list, create, patch, delete) for both resources. Path
params parsed via Go 1.22 ServeMux PathValue.

devicePatch uses json.RawMessage for frame_id so the wire format
distinguishes:
  - key absent       → leave as-is
  - "frame_id": null → clear (device leaves all frames)
  - "frame_id": 42   → move to that frame
parseFrameRef translates that into the store's db.FrameRef tri-state.

Sentinel-error mapping unchanged (writeError covers ErrInvalidInput,
ErrConflict, ErrNotFound, etc.). Cross-project frame_id refs surface as
400.
2026-05-15 18:17:43 +02:00
mAi
cf1671e8c1 feat: db store — frames + devices CRUD, project-scoped
Snapshot now populates frames + devices from the DB (slice 1 left them as
empty arrays).

Frame store:
- CreateFrame requires positive width/height; rejects empty name; UNIQUE
  (project_id, name) collisions surface as ErrConflict via mapWriteErr.
- GetFrame is project-scoped — wrong-project read returns ErrNotFound.
- UpdateFrame applies a partial; project_id is not exposed (moving a
  frame across projects would orphan its devices).
- DeleteFrame relies on the schema's ON DELETE SET NULL to drop
  devices' frame_id refs cleanly; verified by test.

Device store:
- CreateDevice defaults color to #1e1e1e if blank; rejects empty name,
  non-positive size; validates frame_id is in the same project (returns
  ErrInvalidInput on cross-project ref).
- UpdateDevice uses a FrameRef tri-state for frame_id so callers can
  distinguish "leave alone" from "clear to NULL" from "move to frame X".
- Cross-project frame_id on PATCH is rejected with ErrInvalidInput.
- ListDevices supports an optional frame_id filter.

13 new table-driven tests, all green with -race.
2026-05-15 18:16:33 +02:00
mAi
d3b660d140 merge: image moved to m/mcables namespace
mAi got admin on m/mCables but Gitea container packages are
user-namespace-scoped — repo-collab perm is insufficient. Pushed
once using m's ~/.netrc token, deleted the mAi/mcables stub.

Compose now references mgit.msbls.de/m/mcables:latest.
2026-05-15 18:12:37 +02:00
mAi
dc5fafeaa8 deploy: image now under m/ namespace on mgit.msbls.de
m granted mAi admin on m/mCables, but Gitea's container registry is
user-namespace-scoped (not repo-collab-scoped) so the push had to go
through m's own credentials for this one administrative move:

    docker login mgit.msbls.de -u m -p <m's token>
    docker push mgit.msbls.de/m/mcables:latest

Image digest sha256:76624f17… is identical to the one previously living
at mgit.msbls.de/mai/mcables:latest — same build, just retagged.

Drops the workaround comment from the compose file. The mai/mcables
package will be deleted via API after the deploy verifies.
2026-05-15 18:11:31 +02:00
mAi
017a77e187 merge: deploy infra to mDock (pulled forward from §10)
picasso shipped (commit 8a31f0a on mai/picasso/deploy-mdock):
- Dockerfile: multi-stage golang:1.23-alpine -> distroless/static
- docker-compose.yml at repo root (raw-docker pattern, not Dokploy)
- .dockerignore
- README deploy section

Live: http://mdock:7777 (image sha256:76624f17, 12.2MB).
Persistence verified across compose restart.

Note: mAi lacks write on m/ in Gitea, so image lives at
mgit.msbls.de/mAi/mcables:latest. m can retag once mAi gets write
on m/mCables (see docker-compose.yml comment).
2026-05-15 18:02:09 +02:00
mAi
8a31f0af60 deploy: Dockerfile + docker-compose.yml for mDock, manual first roll
Pulls the deploy infra forward from §10 so m can see slice 1 on his LAN.

- Dockerfile: multi-stage golang:1.25-alpine → distroless/static-debian12.
  CGO_ENABLED=0 (modernc.org/sqlite is pure Go). USER 1000:1000 so the
  bind-mount on mDock (owned by m:m) is writable without chowning the
  host dir. -trimpath + -s -w; 12.2MB final image.
- docker-compose.yml: matches the mDock convention surveyed earlier
  (container_name explicit, restart: unless-stopped, env_file in
  /home/m/secrets/mcables/.env, bind-mount /home/m/stacks/mcables/data,
  port 7777 exposed on LAN). Image temporarily under the mai/ namespace
  on mgit.msbls.de because mAi doesn't have write access to m/* today —
  documented in a comment so retagging is one line when permissions land.
- .dockerignore: keeps .git, .worktrees, .m, data/, docs/, *.md,
  editor cruft out of the build context.

Manual deploy verified end-to-end:
- docker build → image sha256:76624f17 (12.2MB)
- mAi-authenticated push to mgit.msbls.de/mai/mcables:latest
- ssh mdock anonymous pull works (registry allows public reads on this
  namespace)
- POST /api/projects {"name":"LOFT"} returns the row, GET /api/projects
  shows it; docker compose restart preserves it on disk; second GET
  still shows LOFT.

Gitea Actions auto-deploy left for a follow-up task per the head's
instruction — gets us the moving parts right first.
2026-05-15 18:01:30 +02:00
mAi
98f30306a1 merge: slice 1 — bootstrap + project CRUD + global cable_types
picasso shipped (7 commits @ 905c75c):
- Go module + cmd/mcables binary
- internal/db: migrations runner + 001_init.sql (full v3 schema, 5 cable_types seeded)
- internal/db/store.go: projects + cable_types CRUD with sentinel errors
- internal/server: net/http handlers (Go 1.22 ServeMux)
- web/static: project picker, legend, modals (new project / cable type / delete), ?project= URL state
- 17 store tests green, end-to-end smoke verified

Endpoints live: /api/healthz, /api/projects {GET POST}, /api/projects/:id
{GET PATCH DELETE?confirm=<name>}, /api/cable-types {GET POST}, /api/cable-types/:id {PATCH DELETE}.

Next: slice 2 (frames + devices + drag-to-position) on m's go.
2026-05-15 16:50:02 +02:00
mAi
905c75c6db test+docs: store coverage + README for slice 1
Adds table-driven store tests:
- projects: drawing_name auto-default, explicit-name accept, empty-name
  reject, duplicate-name conflict, ordered list, GetProject not-found,
  partial PATCH semantics, blank-drawing-name re-default on PATCH,
  ?confirm=<name> guardrail (wrong / empty / correct), snapshot returns
  the 5 globally-seeded cable_types
- cable_types: 5 seeded with the legend colours, global UNIQUE(name),
  rename + recolour, RESTRICT-blocked delete when a cable references the
  type (with count surfaced via CountCablesUsingType), unused delete
  succeeds, project deletion does NOT cascade into cable_types

go test -race ./... passes. Updates README.md with run instructions,
env vars, the slice-1 API surface, and the slice roadmap.
2026-05-15 16:48:29 +02:00
mAi
c13000ee7e feat: frontend shell — project picker, legend, modals (new project / cable type / delete), URL ?project= state 2026-05-15 16:45:29 +02:00
mAi
1e3988161b feat: http server — net/http (Go 1.22 ServeMux), /api/healthz + projects + cable-types, JSON errors 2026-05-15 16:45:29 +02:00
mAi
255d52e7c4 feat: db store — projects + cable_types CRUD with sentinel errors and confirm-name guardrail 2026-05-15 16:45:29 +02:00
mAi
cd34dde133 feat: db migrations runner + 001_init.sql (full v3 schema, 5 cable_types seeded) 2026-05-15 16:45:29 +02:00
mAi
b6eb29a103 chore: untrack .m/ worker-local mai event log 2026-05-15 16:40:41 +02:00
mAi
e55993ca53 bootstrap: go module, skeleton dirs, Makefile, main.go entrypoint 2026-05-15 16:40:14 +02:00
mAi
14f0d74e44 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.
2026-05-15 16:38:02 +02:00
27 changed files with 4164 additions and 25 deletions

32
.dockerignore Normal file
View File

@@ -0,0 +1,32 @@
# Source-control + worktree noise
.git
.gitignore
.gitea
.worktrees
# mai worker-local logs
.m
# Local runtime state (mounted as a volume in production)
data
*.db
*.db-wal
*.db-shm
# Build artefacts
bin
mcables
# Editor cruft
.vscode
.idea
*.swp
# Documentation (lives in git, not in the image)
docs
CLAUDE.md
README.md
# Test files (build still respects them via go.mod, this only strips
# the test fixtures we might check in later)
**/testdata

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
# Local DB
data/*.db
data/*.db-wal
data/*.db-shm
# mai worker-local logs (per-worktree, not source)
.m/
# Build artefacts
bin/
mcables
# Editor
.vscode/
.idea/
*.swp

36
Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
# syntax=docker/dockerfile:1.7
#
# mCables — single-stage build → distroless runtime image.
# go.mod requires go 1.25; modernc.org/sqlite is pure Go so CGO_ENABLED=0
# and a distroless/static runtime is all we need.
FROM golang:1.25-alpine AS build
WORKDIR /src
# Cache deps before copying the rest of the source.
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# -trimpath strips local paths from the binary; -s -w drops debug info.
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 \
MCABLES_DB=/app/data/mcables.db
EXPOSE 7777
# Run as UID:GID 1000:1000 to match m on mDock — the bind-mounted
# /home/m/stacks/mcables/data is owned by m:m, so the container can write
# to it without chowning the host dir. distroless/static-debian12 accepts
# arbitrary numeric UIDs; the Go binary doesn't need a /etc/passwd entry.
USER 1000:1000
ENTRYPOINT ["/app/mcables"]

27
Makefile Normal file
View File

@@ -0,0 +1,27 @@
.PHONY: build run test typecheck fmt clean
BIN := bin/mcables
PKG := ./...
build:
@mkdir -p bin
go build -trimpath -ldflags="-s -w" -o $(BIN) ./cmd/mcables
run:
go run ./cmd/mcables
test:
go test -race $(PKG)
typecheck:
@if [ -f web/tsconfig.json ]; then \
cd web && tsc --noEmit; \
else \
echo "web/tsconfig.json not present yet — typecheck skipped"; \
fi
fmt:
gofmt -s -w .
clean:
rm -rf bin

168
README.md
View File

@@ -1,37 +1,155 @@
# mCables
Cable management for m's setup — visual interface + SQLite inventory, generating + updating Excalidraw diagrams via mExDraw.
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
Bootstrap. Architecture sketch below; implementation pending.
Slice 1 — bootstrap shipped. Projects + global cable types are
end-to-end; the SVG canvas is intentionally empty until slice 2.
## Goal
Track devices, ports, and cables across m's setups (server rack, office, living room). Generate / update Excalidraw diagrams from the inventory. Detect bundles of parallel cables. Visualise cable types by colour (RJ45, DP, HDMI, USB, Power, …).
m's existing drawing is the seed: https://mxdrw.msbls.de/draw/Cable-Management.excalidraw — devices are rectangles, ports are ellipses positioned on the device, cables are arrows from port to port, cable type is encoded via colour with a legend.
## Architecture sketch
| Layer | Tech | Role |
| Slice | What's in it | Status |
|---|---|---|
| Storage | SQLite (`~/.m/mcables.db`) | `devices`, `ports`, `cables`, `cable_types`, `bundles`, `frames` |
| Backend | Go | HTTP API serving the visual frontend, mExDraw integration for diagram I/O |
| Frontend | Visual web UI | Browser-based editor (no CLI). Add/edit devices and cables, see live preview |
| Output | mExDraw via MCP | Render + update Excalidraw drawings |
| Project tracking | mBrian `topic-mcables` | Decisions, status, links to drawings — not the data itself |
| 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 |
## Tech decisions (open)
## Run it
- Frontend stack — vanilla TS + small UI lib, or a framework (Svelte / Preact)?
- Diagram import from the existing `Cable-Management.excalidraw` — one-shot migration script that parses bindings → DB rows.
- Layout algorithm for bundle suggestions — parallel cables along the same path get bundled visually.
```sh
go run ./cmd/mcables
# open http://localhost:7777
```
These get resolved in the first design pass.
Or built:
## Refs
```sh
make build
./bin/mcables
```
- m's seed drawing: https://mxdrw.msbls.de/draw/Cable-Management.excalidraw
- mExDraw MCP: `mcp__mexdraw__*`
- Related: mBrian `topic-msbls` (infrastructure inventory)
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
```sh
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/`):
```sh
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):
```sh
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**:
```sh
ssh mdock 'cd /home/m/stacks/mcables && docker compose pull && docker compose up -d'
```
4. **Verify** from any LAN host:
```sh
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.

0
data/.gitkeep Normal file
View File

20
docker-compose.yml Normal file
View File

@@ -0,0 +1,20 @@
# mCables — production compose for mDock.
# Lives at /home/m/stacks/mcables/docker-compose.yml on mDock.
# Matches the existing mDock service patterns (mgreen, mgeo, …).
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
env_file:
# Empty for slice 1. MEXDRAW_TOKEN lands here when slice 5 ships.
- /home/m/secrets/mcables/.env
volumes:
- /home/m/stacks/mcables/data:/app/data

98
docs/sherlock-+dev-bug.md Normal file
View File

@@ -0,0 +1,98 @@
# +Dev click silently drops — root cause & fix
**Reporter:** sherlock (Playwright debug shift, 2026-05-15 ~23:10)
**Target:** http://mdock:7777 (deployed at e12b449)
**Verdict:** Bug reproduced; root cause identified; one-line fix verified live.
## Repro (Playwright, headless Chromium 1217)
1. GET `http://mdock:7777/?project=1` (LOFT). Snapshot returns one frame (`Entertainment`).
2. Click `#tool-device` — armed correctly (`.canvas-wrap.tool-device`, button `.armed`, cursor `crosshair` both over the empty SVG and over frame rects).
3. Click inside the frame (rect centre).
4. Expected: inline-namer `<foreignObject><input>` appears focused, m types name, Enter, `POST /api/projects/1/devices` fires, device renders.
5. Observed: no FO visible, no POST, tool disarms silently. Same failure when clicking on **empty** canvas (outside any frame).
## Trace (instrumented JS)
```
[782ms] svg.append foreignObject ← placeDeviceAt → promptInline appended it
[782ms] focusout BUTTON #tool-device
[783ms] focusin INPUT ← input.focus() succeeded
[783ms] svg-bubble-pd rect ← pointerdown still bubbling
[783ms] mutation.remove foreignObject ← FO ripped out
```
Patched `Element.prototype.remove` traces the offender:
```
at Element.remove (anon)
at done (http://mdock:7777/main.js:585:36)
at HTMLInputElement.<anonymous> (http://mdock:7777/main.js:592:42)
```
That's `input.addEventListener("blur", () => done(input.value.trim() || null))` in `promptInline`. The input was focused at 783 ms and blurred ~6 ms later, so `done(null)` removed the FO before m could type.
## Root cause
`placeDeviceAt` calls `promptInline` synchronously from inside the canvas `pointerdown` handler. `promptInline` does `input.focus()` synchronously. After the pointerdown handler returns, the browser performs the default mousedown action — focus the nearest focusable ancestor of the click target, else **blur the active element**. The click target is an SVG `<rect>` (or the `<svg>` root) which is not focusable, so the freshly-focused input gets blurred. The input's own `blur` listener fires `done(null)``fo.remove()`.
This is independent of the previous fix at 94869f3 (which corrected the ordering of `onCanvasPointerDown` so the tool branches run before the "click on existing element" early-return). Routing is now correct, which is what unmasks the focus-blur bug. `+ Frame` is not affected because there `promptInline` runs from the pointer**up** callback, after pointer events have finished, so no mousedown is pending to steal focus.
The cursor + Cache-Control fix at 28a376a addressed only the visual cursor lie and the redeploy staleness. It does not touch the click handler.
## Fix (one line)
`web/static/main.js` around line 472, in `onCanvasPointerDown`:
```diff
if (state.tool === "frame") {
+ e.preventDefault();
startFrameRubberBand(e, p);
return;
}
if (state.tool === "device") {
+ e.preventDefault();
placeDeviceAt(p);
return;
}
```
`e.preventDefault()` on the pointerdown suppresses the compatibility mousedown's default focus-shift, so the input keeps focus and m can type. The `+ Frame` branch gets the same treatment for symmetry and to prevent a subtle text-selection side effect during rubber-band drag (it's not strictly required for the focus issue there because `+ Frame` focuses on pointerup).
Per-call site rationale, not a blanket `if (state.tool) e.preventDefault()` at the top, because future tools that intentionally rely on default click behavior (e.g. a cable-drawing tool that needs link-clicks on ports) might want different semantics.
### Verified live
Monkey-patched the deployed page with a capture-phase listener that calls `e.preventDefault()` while `#tool-device.armed` exists. Result: device created, named `pc-sherlock`, persisted (POST `/api/projects/1/devices` 200). Screenshot: `/tmp/sherlock/fix_verified.png`.
## Secondary observation (not blocking)
After the fix, pressing `Enter` on the inline-namer logs a `pageerror`:
```
Failed to execute 'remove' on 'Element':
The node to be removed is no longer a child of this node.
Perhaps it was moved in a 'blur' event handler?
```
`done()` is called twice (once from Enter keydown, once from the resulting blur). The `if (activeNamer === fo)` guard on the second entry is recursive-safe because the first `done()` runs `fo.remove()` *before* setting `activeNamer = null`, so the synchronous blur fires inside the first remove() and re-enters with the guard still true. Cheap fix: reorder the two lines so the flag clears first:
```diff
const done = (val) => {
- if (activeNamer === fo) { fo.remove(); activeNamer = null; }
+ if (activeNamer === fo) { activeNamer = null; fo.remove(); }
resolve(val);
};
```
Functional impact: none — the device is still created. Cosmetic console error only. Worth folding into the same patch.
## Artifacts (sherlock side, /tmp)
- `repro.py` — initial reproduction, screenshots before/after click.
- `repro2.py` — event-trace instrumentation (capture+bubble pointerdowns, focus, mutations).
- `repro3.py``remove()` stack-trace capture that named `done() <- blur listener`.
- `verify_fix.py` — applied the proposed fix in-page; device created end-to-end.
- `test_empty.py` — confirmed the bug bites on empty canvas too, not just inside frames.
- `run.log`, `run2.log`, `run3.log`, `verify.log`, `empty.log` — full transcripts.
- `fix_verified.png` — post-fix screenshot with `pc-sherlock` device visible inside the frame.

144
docs/sherlock-+port-bug.md Normal file
View File

@@ -0,0 +1,144 @@
# +Port "still does nothing" — root cause & fix
**Reporter:** sherlock (Playwright debug shift 2, 2026-05-16 ~11:00)
**Target:** http://mdock:7777 (deployed image c361bf38, picasso fix 3276cfe)
**Verdict:** +Port is **not actually a no-op** — it successfully creates the port server-side AND client-side every time. The user-visible failure is **invisible stacking**: the new port renders at the exact same pixel as a pre-existing port on the same edge midpoint. m sees no canvas change and no panel switch, so the click feels dead.
## What I ran
Five Playwright drives against the live deployment, real Chromium (1217), full console + network + DOM mutation capture. Scripts and transcripts: `/tmp/sherlock/port_*`.
| Scenario | Click target | POST result | DOM change | What m sees |
|---|---|---|---|---|
| (a) empty canvas inside frame | (443, 626) screen | 201, port id 40 created | new circle at TV bottom-left | maybe a new dot, but device list still selected, no halo |
| (b) on the device body (TV center) | (563, 454) screen | 201, port id 41 | new circle at **(686.4, 643)** — same cx,cy as ports 37, 38, 39 already at that point | nothing — new dot stacks pixel-perfect under existing ones |
| (c) directly on an existing port circle | (538, 445) screen | 201, port id 42 | new circle at **(636.4, 643)** — same cx,cy as port 27 | nothing — stacked |
In every scenario:
- `state.tool === "port"` after the +Port click (inferred from `.canvas-wrap.tool-port` + cursor `crosshair`)
- `POST /api/projects/1/devices/2/ports` returns 201 with the new port row
- `state.ports.push(port)` happens, `render()` redraws, the new circle exists in the DOM
- The inspector port list grows by one row
- **The canvas does not visibly change.**
After 7 +Port placements on the TV, the inspector lists 7 ports, but only ~3 dots are visible on the device — the rest are stacked at identical (cx, cy):
```
ports on TV after the test run:
port 27 fill=#e03131 cx=636.4 cy=643.0 ← original
port 37 fill=#e03131 cx=686.4 cy=643.0
port 38 fill=#e03131 cx=686.4 cy=643.0 ← stacks on 37
port 39 fill=#1971c2 cx=686.4 cy=643.0 ← stacks on 37,38
port 40 fill=#e03131 cx=636.4 cy=678.0
port 41 fill=#e03131 cx=686.4 cy=643.0 ← stacks on 37,38,39
port 42 fill=#e03131 cx=636.4 cy=643.0 ← stacks on 27
```
## Why it stacks
`snapToDeviceEdge` (main.js:15601574) projects the click to the nearest of the four device edges and clamps the parallel coordinate to `[0, device.width]` (or `device.height`). For the dominant click region — anywhere over the device body — `snapToDeviceEdge` collapses a wide band of clicks down to a handful of discrete `(xOff, yOff)` tuples. For a 100×35 device, clicking anywhere in the bottom half + roughly the horizontal middle resolves to `(xOff=50, yOff=35)`. Two clicks in that band produce two ports at identical coordinates. There is no de-dup against existing ports.
This is geometry, not a regression — but it means the +Port tool has the same visible signature as a no-op tool for the most natural click region.
## Why m's panel feedback is also weak
`placePortAt` (main.js:16851706) finishes with `state.ports.push(port); armTool(null); render();`. It does **not** set `state.selection = { kind: "port", id: port.id }`. So:
- `state.selection` stays on the device (set in the +Port-arming click).
- The inspector continues to render the device panel; the port list grows by a row, but the panel doesn't switch.
- The new port circle does not get the `.selected` halo (drop-shadow) defined in style.css.
`placeDeviceAt`, `placeIOMarkerAt`, `startFrameRubberBand`, and `finishCableDrawAt` all set `state.selection` to the new entity. `placePortAt` is the odd one out.
## Proposed fix (verified live)
Two changes in `web/static/main.js`, one for stacking, one for feedback. The second is sufficient on its own to make the tool feel responsive.
### A. Select the newly-placed port (primary)
```diff
async function placePortAt(p) {
...
try {
const port = await createPort(state.active.id, did, {
type_id: tid,
x_offset: snap.xOff,
y_offset: snap.yOff,
});
state.ports.push(port);
+ state.selection = { kind: "port", id: port.id };
armTool(null);
render();
```
Effect verified live by intercepting the POST response and dispatching a pointerdown on the new port circle (simulates the selection that the fix would set internally). Result:
- Inspector immediately switches from the **DEVICE** panel to the **PORT** panel showing cable-type swatch, label input, edge dropdown (Top/Right/Bottom/Left), Delete button.
- The new port circle gets the `.selected` class → drop-shadow halo, visually distinct even when stacked.
- Screenshot: `/tmp/sherlock/port_fix_verified.png`.
m clicking +Port now produces unambiguous feedback (panel switch + halo), AND he can immediately move the new port to the right edge with the dropdown — exactly the workflow when you wanted "another port over there".
### B. De-dup snap position (secondary, optional)
If two ports on the same device land within ~8 px of each other on the same edge, walk along that edge in 12 px increments until a free slot is found (or distribute the existing ones evenly — pick the lighter change). This eliminates pixel-perfect stacks for power users; (A) alone covers the perception bug.
Sketch:
```js
function snapToDeviceEdge(device, x, y, existingPortsOnDevice) {
const raw = snapRaw(device, x, y); // current logic
if (!existingPortsOnDevice?.length) return raw;
const isOnEdge = (port, edge) =>
(edge === "left" && port.x_offset === 0) ||
(edge === "right" && port.x_offset === device.width) ||
(edge === "top" && port.y_offset === 0) ||
(edge === "bottom" && port.y_offset === device.height);
const sibs = existingPortsOnDevice.filter((p) => isOnEdge(p, raw.edge));
// Walk along the parallel axis until no sibling within 8px tolerance.
...
}
```
Then `placePortAt` passes `state.ports.filter((p) => p.device_id === did)`.
Not blocking — m's complaint is fixed by (A). File (B) as a polish ticket.
## Unrelated bug surfaced during repro (separate fix, separate scope)
Every click-only device selection throws this pageerror:
```
TypeError: Cannot read properties of null (reading 'classList')
at SVGSVGElement.onUp (http://mdock:7777/main.js:1846:21)
```
Source: `startDrag.onUp` does `e.currentTarget.classList.remove("dragging")` (main.js:1846). `e` is the closure-captured pointerdown event; by the time `onUp` runs (async, after pointerup), the browser has nulled out `e.currentTarget`. Click-only path aborts at line 1846 before the trailing `render()` (which is redundant here because `startDrag` already calls render at line 1794, so the user-visible state is fine — but the console error spams every click).
Trivial fix: capture the target into a closure before defining the async handlers.
```diff
function startDrag(e, kind, id) {
...
+ const dragTarget = /** @type {Element} */ (e.currentTarget);
...
- e.currentTarget.classList.add("dragging");
+ dragTarget.classList.add("dragging");
...
const onUp = async (ev) => {
...
- e.currentTarget.classList.remove("dragging");
+ dragTarget.classList.remove("dragging");
```
Not directly responsible for the +Port complaint, but it's noise in every console session and the fix is one line. Worth bundling with the port-select patch.
## Artifacts
Under `/tmp/sherlock/`:
- `port_repro.py`, `port_repro2.py`, `port_repro3.py` — staged reproductions
- `trace_pe.py` — pageerror stack capture
- `verify_port_fix.py` — applied the proposed selection-after-placement fix in-page
- `port_run.log`, `port_run2.log`, `port_run3.log`, `pe.log`, `verify_port.log` — full transcripts
- `port_after_redo.png`, `port_scenario_*.png` — pre-fix screenshots showing the inspector-list-grows-but-canvas-doesn't symptom
- `port_fix_verified.png` — post-fix screenshot, inspector switched to **PORT** panel with edge picker

View File

@@ -0,0 +1,99 @@
# "I cannot change frames" — repro & verdict
**Reporter:** sherlock (Playwright debug shift 3, 2026-05-16 ~19:30)
**Target:** http://mdock:7777 (CableGUI, deployed at 79e17a5)
**Hypothesis under test:** kandinsky's pan-on-left-drag (2933bb8) regressed frame select because the "isEmpty" selector missed `[data-frame-id]`.
**Verdict:** **Hypothesis is wrong.** The selector at main.js:2087 already lists `[data-frame-id]`, the frame `<rect>` has its own pointerdown handler that calls `startDrag`, and `startDrag` calls `e.stopPropagation()` synchronously before the pan-start handler could fire. Frame selection works mechanically on the deployed image — verified four ways. m's complaint is real, but it's a **render-order / clutter** problem, not a click-handler regression.
## What I ran (4 Playwright drives against the deployed image)
### Selection works on a click that lands on the frame rect
`frame_repro2.py` TEST 2 — Esc to deselect, then click 2 px inside frame 1's left edge:
```
before: selected_frames=[] inspector=None
after: selected_frames=['1'] inspector='Frame'
pointerdowns: svg-capture target=rect.frame-rect frame=1
pan changes: []
```
Frame 1's inspector panel appears with name="Entertainment", x/y/w/h editable, "Delete frame" button. No pan-start fired.
`frame_clean_test.py` — Esc, then click frame 3 (Network) bottom-left corner where nothing else is rendered:
```
top element at click point: rect.frame-rect (frame=3)
after click: selected_frames=['3'] inspector='Frame'
```
### Selection works on a click + small drag too
`frame_repro2.py` TEST 3 — 5 px drag (past the 3 px pan threshold) on frame interior:
- `startDrag(e, "frame", f.id)` fires at the frame rect's `pointerdown`. It is the **first** pointerdown handler in the chain.
- `startDrag` (main.js:2839) calls `e.stopPropagation()` synchronously when no tool is armed → pan-start handler never gets the event.
- The 5 px movement is handled by `startDrag`'s own `onMove` (frame drag), not by the empty-canvas pan path.
- Result: frame stays selected, frame nudges 5 px to the right. Inspector panel stays correct.
50 px drag (TEST 4): same — frame stays selected, moves further.
## Why m experiences "doesn't select"
The frame `<rect>` paints **first**; everything else paints on top of it in this order (from index.html):
```
canvas-frames ← frame rects (BOTTOM)
canvas-devices ← device rects + port circles inside each device <g>
canvas-ports
canvas-cables ← cable polylines (often crossing through frame interior)
canvas-clamps
canvas-io ← IO marker diamonds
```
So any click on the frame's visible interior that happens to land on a device, port, port circle, cable polyline, clamp, or IO diamond hits THAT element, not the frame. The frame rect only "wins" the hit-test on pixels that are not covered by anything else.
Two concrete observations from the LOFT snapshot:
- Frame 1 "Entertainment" is huge (SVG 1340×848 at viewBox 2000×1500). Most of it renders past the canvas-wrap's right edge (x > 1220 px on a 1500 px viewport) and is occluded by the inspector `<aside>`. Only a ~180 × 424 px strip on the visible canvas right side is selectable. That strip is partly covered by devices and yellow cable polylines crossing diagonally.
- Frame 3 "Network": grid hit-test, 9 sample points → 5 hit the frame, 2 hit cable polylines that cross through its centre. `frame_clean_test.py` confirmed: bottom-left corner click selects; centre click selects the cable.
For m, "I click on the frame and it doesn't select" is **literally true at the click point he's choosing** — but the click is going to the cable/device on top, not the frame underneath. There is no regression in the dispatch.
## Recommendation (no code change applied)
Since the click-handler chain is correct, this is a UX call for picasso/perseus to make, not a one-line patch sherlock should apply. Three options ordered by effort:
1. **Frame label is the selection grip.** Drop `pointer-events: none` from `.frame-label` (style.css:183) and add a pointerdown handler on the label that calls `startDrag(e, "frame", id)`. The label sits in the top-left of the frame, never overlaps a device, and is always visible — m gets a deterministic spot to click. This is a 2-line patch.
2. **Selection breadcrumb on the device inspector.** When a selected device has `frame_id`, render `parent frame: <name>` as a clickable chip that calls `state.selection = {kind: "frame", id: frame.id}; render()`. Lets m drill from "I selected the wrong thing" → frame in one click.
3. **Modifier-click to escape to frame.** Alt-click (or Ctrl-click) on any element promotes the hit-test to "select the enclosing frame, if any". Discoverable via tooltip on the inspector header.
Option (1) is the cheapest and matches user mental model ("click the label to select that thing"). Probably worth pairing with the empty-canvas pan UX so m can hold Shift to disable pan when he wants to clear selection — but that's a separate refinement.
## What the head's hypothesis *would* look like if it were the bug
For completeness — if `[data-frame-id]` were missing from the selector in `onCanvasPointerDown`:
```js
// (hypothetical broken version)
if (e.target instanceof Element && e.target.closest("[data-device-id], [data-io-id]")) return;
// ^^ no frame-id
if (e.button === 0 && state.cableDrawFromPortID == null) {
startEmptyCanvasGesture(e);
}
```
…then frame clicks would fall into `startEmptyCanvasGesture`. Sub-3 px clicks would still deselect (no good for m), and any tiny hand-jitter would promote to a pan. m would see "click frame → view shifts, no selection." That symptom matches m's wording — but the actual code is correct, so this isn't what's happening.
The current code (main.js:2087):
```js
if (e.target instanceof Element && e.target.closest("[data-device-id], [data-frame-id], [data-io-id], [data-clamp-id], [data-port-id], [data-cable-id]")) return;
```
All six known target types are listed. No regression here.
## Artifacts
Under `/tmp/sherlock/`:
- `frame_repro.py`, `frame_repro2.py`, `frame_repro3.py`, `frame_clean_test.py` — staged repros
- `canvas_layout.py` — layout dump confirming canvas-wrap vs inspector aside geometry
- `frame_run.log`, `frame_run2.log`, `frame_run3.log`, `clean.log`, `layout.log` — full transcripts
- `frame2_01_interior.png`, `frame2_02_edge.png`, `frame_clean_result.png` — pre/post-click screenshots showing inspector switching to **Frame** panel on a clean rect-hit click

16
go.mod Normal file
View File

@@ -0,0 +1,16 @@
module mgit.msbls.de/m/mcables
go 1.25.5
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.42.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.50.1 // indirect
)

21
go.sum Normal file
View File

@@ -0,0 +1,21 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=

47
internal/db/db.go Normal file
View File

@@ -0,0 +1,47 @@
// Package db owns SQLite access for mCables: migrations runner + the
// query layer (store.go). The Store wraps a *sql.DB with helpers; tests
// and the HTTP layer take a *Store, never a raw *sql.DB.
package db
import (
"database/sql"
"fmt"
_ "modernc.org/sqlite"
)
// Open opens (or creates) the SQLite file at path and returns a Store
// with WAL + foreign keys + busy_timeout configured.
func Open(path string) (*Store, error) {
// `_pragma` query params are honoured by modernc.org/sqlite for
// connection-time PRAGMA setup. journal_mode WAL is persistent
// across opens; the others apply per-connection.
dsn := fmt.Sprintf(
"file:%s?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(5000)",
path,
)
d, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
if err := d.Ping(); err != nil {
_ = d.Close()
return nil, fmt.Errorf("ping sqlite: %w", err)
}
// Single writer keeps things deterministic for a local-LAN tool;
// reads scale fine in WAL.
d.SetMaxOpenConns(1)
return &Store{db: d}, nil
}
// Store is the application's handle on the SQLite database.
type Store struct {
db *sql.DB
}
// DB returns the underlying *sql.DB. Used by Migrate and (sparingly) by
// callers that need a raw query escape hatch.
func (s *Store) DB() *sql.DB { return s.db }
// Close releases the database.
func (s *Store) Close() error { return s.db.Close() }

View File

@@ -0,0 +1,397 @@
package db
import (
"database/sql"
"errors"
"fmt"
"strings"
)
// -----------------------------------------------------------------------------
// Frames
// -----------------------------------------------------------------------------
// FrameCreate is the create-shape; x/y/width/height carry full positions.
type FrameCreate struct {
Name string
X float64
Y float64
Width float64
Height float64
}
// FrameUpdate is the partial-update shape for PATCH. project_id is
// deliberately absent — moving a frame across projects would orphan its
// devices' frame_id refs, so the API refuses to do it.
type FrameUpdate struct {
Name *string
X *float64
Y *float64
Width *float64
Height *float64
}
// CreateFrame inserts a new frame inside a project.
func (s *Store) CreateFrame(projectID int64, f FrameCreate) (*Frame, error) {
name := strings.TrimSpace(f.Name)
if name == "" {
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
}
if f.Width <= 0 || f.Height <= 0 {
return nil, fmt.Errorf("%w: width and height must be positive", ErrInvalidInput)
}
if _, err := s.GetProject(projectID); err != nil {
return nil, err
}
res, err := s.db.Exec(
`INSERT INTO frames (project_id, name, x, y, width, height)
VALUES (?, ?, ?, ?, ?, ?)`,
projectID, name, f.X, f.Y, f.Width, f.Height,
)
if err != nil {
return nil, mapWriteErr(err)
}
id, _ := res.LastInsertId()
return s.GetFrame(projectID, id)
}
// GetFrame loads a frame, enforcing project_id scoping.
func (s *Store) GetFrame(projectID, id int64) (*Frame, error) {
var f Frame
var ex sql.NullString
err := s.db.QueryRow(
`SELECT id, project_id, name, x, y, width, height, excalidraw_id, created_at, updated_at
FROM frames WHERE id = ? AND project_id = ?`, id, projectID,
).Scan(&f.ID, &f.ProjectID, &f.Name, &f.X, &f.Y, &f.Width, &f.Height,
&ex, &f.CreatedAt, &f.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if ex.Valid {
f.ExcalidrawID = &ex.String
}
return &f, nil
}
// ListFrames returns every frame in a project, ordered by created_at so
// the on-screen z-order is stable.
func (s *Store) ListFrames(projectID int64) ([]Frame, error) {
rows, err := s.db.Query(
`SELECT id, project_id, name, x, y, width, height, excalidraw_id, created_at, updated_at
FROM frames WHERE project_id = ? ORDER BY created_at, id`, projectID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Frame{}
for rows.Next() {
var f Frame
var ex sql.NullString
if err := rows.Scan(&f.ID, &f.ProjectID, &f.Name, &f.X, &f.Y, &f.Width, &f.Height,
&ex, &f.CreatedAt, &f.UpdatedAt); err != nil {
return nil, err
}
if ex.Valid {
f.ExcalidrawID = &ex.String
}
out = append(out, f)
}
return out, rows.Err()
}
// UpdateFrame applies a partial update. project_id stays the same — we
// don't expose moving a frame across projects.
func (s *Store) UpdateFrame(projectID, id int64, u FrameUpdate) (*Frame, error) {
cur, err := s.GetFrame(projectID, id)
if err != nil {
return nil, err
}
if u.Name != nil {
v := strings.TrimSpace(*u.Name)
if v == "" {
return nil, fmt.Errorf("%w: name cannot be empty", ErrInvalidInput)
}
cur.Name = v
}
if u.X != nil {
cur.X = *u.X
}
if u.Y != nil {
cur.Y = *u.Y
}
if u.Width != nil {
if *u.Width <= 0 {
return nil, fmt.Errorf("%w: width must be positive", ErrInvalidInput)
}
cur.Width = *u.Width
}
if u.Height != nil {
if *u.Height <= 0 {
return nil, fmt.Errorf("%w: height must be positive", ErrInvalidInput)
}
cur.Height = *u.Height
}
if _, err := s.db.Exec(
`UPDATE frames
SET name = ?, x = ?, y = ?, width = ?, height = ?, updated_at = datetime('now')
WHERE id = ? AND project_id = ?`,
cur.Name, cur.X, cur.Y, cur.Width, cur.Height, id, projectID,
); err != nil {
return nil, mapWriteErr(err)
}
return s.GetFrame(projectID, id)
}
// DeleteFrame removes a frame. Devices with `frame_id = id` keep existing
// — the schema's ON DELETE SET NULL drops their frame_id to NULL so they
// stay in the project as "outside a frame".
func (s *Store) DeleteFrame(projectID, id int64) error {
if _, err := s.GetFrame(projectID, id); err != nil {
return err
}
if _, err := s.db.Exec(
`DELETE FROM frames WHERE id = ? AND project_id = ?`, id, projectID,
); err != nil {
return err
}
return nil
}
// -----------------------------------------------------------------------------
// Devices
// -----------------------------------------------------------------------------
// DeviceCreate is the create-shape. FrameID may be nil ("outside any frame").
type DeviceCreate struct {
Name string
FrameID *int64
Color string
X float64
Y float64
Width float64
Height float64
}
// DeviceUpdate is the partial-update shape. project_id deliberately not
// settable. FrameID is *(*int64) so callers can distinguish "leave as-is"
// (nil) from "set to NULL" (&nil) — Go syntax: pass a *(*int64) where the
// inner pointer is nil to clear.
type DeviceUpdate struct {
Name *string
FrameID FrameRef // see FrameRef below
Color *string
X *float64
Y *float64
Width *float64
Height *float64
}
// FrameRef encodes a tri-state for the FrameID PATCH:
//
// Set=false → leave the field untouched
// Set=true, ID=nil → set to NULL (device leaves all frames)
// Set=true, ID=&someInt → set to that frame id (must be in same project)
type FrameRef struct {
Set bool
ID *int64
}
// CreateDevice inserts a new device. FrameID, if provided, must reference
// a frame in the same project.
func (s *Store) CreateDevice(projectID int64, d DeviceCreate) (*Device, error) {
name := strings.TrimSpace(d.Name)
if name == "" {
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
}
if d.Width <= 0 || d.Height <= 0 {
return nil, fmt.Errorf("%w: width and height must be positive", ErrInvalidInput)
}
if _, err := s.GetProject(projectID); err != nil {
return nil, err
}
if d.FrameID != nil {
if _, err := s.GetFrame(projectID, *d.FrameID); err != nil {
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("%w: frame_id %d not in project %d", ErrInvalidInput, *d.FrameID, projectID)
}
return nil, err
}
}
color := strings.TrimSpace(d.Color)
if color == "" {
color = "#1e1e1e"
}
res, err := s.db.Exec(
`INSERT INTO devices (project_id, frame_id, name, color, x, y, width, height)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
projectID, nullableInt64(d.FrameID), name, color, d.X, d.Y, d.Width, d.Height,
)
if err != nil {
return nil, mapWriteErr(err)
}
id, _ := res.LastInsertId()
return s.GetDevice(projectID, id)
}
// GetDevice loads a device, project-scoped.
func (s *Store) GetDevice(projectID, id int64) (*Device, error) {
var d Device
var frame sql.NullInt64
var ex sql.NullString
err := s.db.QueryRow(
`SELECT id, project_id, frame_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
FROM devices WHERE id = ? AND project_id = ?`, id, projectID,
).Scan(&d.ID, &d.ProjectID, &frame, &d.Name, &d.Color, &d.X, &d.Y, &d.Width, &d.Height,
&ex, &d.CreatedAt, &d.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if frame.Valid {
v := frame.Int64
d.FrameID = &v
}
if ex.Valid {
d.ExcalidrawID = &ex.String
}
return &d, nil
}
// ListDevices returns devices in a project. If frameID is non-nil and
// dereferences to a value, only devices with that frame_id are returned;
// if frameID dereferences to nil (i.e. caller passed &FrameRef{Set:true,ID:nil})
// — actually this signature uses *int64 directly: pass nil for "all
// devices", or pass &someInt for "devices in that frame". The empty-
// "outside-any-frame" filter isn't exposed yet — slice 2 doesn't need it.
func (s *Store) ListDevices(projectID int64, frameID *int64) ([]Device, error) {
var (
rows *sql.Rows
err error
)
if frameID != nil {
rows, err = s.db.Query(
`SELECT id, project_id, frame_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
FROM devices WHERE project_id = ? AND frame_id = ? ORDER BY created_at, id`,
projectID, *frameID,
)
} else {
rows, err = s.db.Query(
`SELECT id, project_id, frame_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
FROM devices WHERE project_id = ? ORDER BY created_at, id`,
projectID,
)
}
if err != nil {
return nil, err
}
defer rows.Close()
out := []Device{}
for rows.Next() {
var d Device
var frame sql.NullInt64
var ex sql.NullString
if err := rows.Scan(&d.ID, &d.ProjectID, &frame, &d.Name, &d.Color, &d.X, &d.Y, &d.Width, &d.Height,
&ex, &d.CreatedAt, &d.UpdatedAt); err != nil {
return nil, err
}
if frame.Valid {
v := frame.Int64
d.FrameID = &v
}
if ex.Valid {
d.ExcalidrawID = &ex.String
}
out = append(out, d)
}
return out, rows.Err()
}
// UpdateDevice applies a partial update. FrameID is tri-state — see FrameRef.
// A FrameID set to a non-nil ID must reference a frame in the same project.
func (s *Store) UpdateDevice(projectID, id int64, u DeviceUpdate) (*Device, error) {
cur, err := s.GetDevice(projectID, id)
if err != nil {
return nil, err
}
if u.Name != nil {
v := strings.TrimSpace(*u.Name)
if v == "" {
return nil, fmt.Errorf("%w: name cannot be empty", ErrInvalidInput)
}
cur.Name = v
}
if u.Color != nil {
v := strings.TrimSpace(*u.Color)
if v == "" {
return nil, fmt.Errorf("%w: color cannot be empty", ErrInvalidInput)
}
cur.Color = v
}
if u.X != nil {
cur.X = *u.X
}
if u.Y != nil {
cur.Y = *u.Y
}
if u.Width != nil {
if *u.Width <= 0 {
return nil, fmt.Errorf("%w: width must be positive", ErrInvalidInput)
}
cur.Width = *u.Width
}
if u.Height != nil {
if *u.Height <= 0 {
return nil, fmt.Errorf("%w: height must be positive", ErrInvalidInput)
}
cur.Height = *u.Height
}
if u.FrameID.Set {
if u.FrameID.ID != nil {
if _, err := s.GetFrame(projectID, *u.FrameID.ID); err != nil {
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("%w: frame_id %d not in project %d", ErrInvalidInput, *u.FrameID.ID, projectID)
}
return nil, err
}
}
cur.FrameID = u.FrameID.ID
}
if _, err := s.db.Exec(
`UPDATE devices
SET frame_id = ?, name = ?, color = ?, x = ?, y = ?, width = ?, height = ?, updated_at = datetime('now')
WHERE id = ? AND project_id = ?`,
nullableInt64(cur.FrameID), cur.Name, cur.Color, cur.X, cur.Y, cur.Width, cur.Height, id, projectID,
); err != nil {
return nil, mapWriteErr(err)
}
return s.GetDevice(projectID, id)
}
// DeleteDevice removes a device from a project.
func (s *Store) DeleteDevice(projectID, id int64) error {
if _, err := s.GetDevice(projectID, id); err != nil {
return err
}
if _, err := s.db.Exec(
`DELETE FROM devices WHERE id = ? AND project_id = ?`, id, projectID,
); err != nil {
return err
}
return nil
}
// nullableInt64 converts a *int64 into a sql.NullInt64 so we can pass it
// straight into a parameterised query.
func nullableInt64(p *int64) any {
if p == nil {
return nil
}
return *p
}

View File

@@ -0,0 +1,235 @@
package db
import (
"errors"
"testing"
)
// ----------------------------------------------------------------------- frames
func TestCreateFrame_Basics(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
f, err := s.CreateFrame(p.ID, FrameCreate{Name: "desk", X: 10, Y: 20, Width: 800, Height: 600})
if err != nil {
t.Fatalf("create: %v", err)
}
if f.ProjectID != p.ID || f.Name != "desk" || f.Width != 800 {
t.Errorf("unexpected frame: %+v", f)
}
}
func TestCreateFrame_RejectsZeroSize(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
if _, err := s.CreateFrame(p.ID, FrameCreate{Name: "x", Width: 0, Height: 50}); !errors.Is(err, ErrInvalidInput) {
t.Errorf("zero width should be ErrInvalidInput; got %v", err)
}
if _, err := s.CreateFrame(p.ID, FrameCreate{Name: "y", Width: 50, Height: 0}); !errors.Is(err, ErrInvalidInput) {
t.Errorf("zero height should be ErrInvalidInput; got %v", err)
}
}
func TestCreateFrame_DuplicateNameInSameProject(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
if _, err := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 100, Height: 50}); err != nil {
t.Fatalf("first: %v", err)
}
if _, err := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 200, Height: 70}); !errors.Is(err, ErrConflict) {
t.Errorf("duplicate frame name should ErrConflict; got %v", err)
}
}
func TestCreateFrame_SameNameAcrossProjectsOK(t *testing.T) {
s := newTestStore(t)
p1, _ := s.CreateProject("LOFT", "", "")
p2, _ := s.CreateProject("OFFICE", "", "")
if _, err := s.CreateFrame(p1.ID, FrameCreate{Name: "desk", Width: 100, Height: 50}); err != nil {
t.Fatalf("p1: %v", err)
}
if _, err := s.CreateFrame(p2.ID, FrameCreate{Name: "desk", Width: 100, Height: 50}); err != nil {
t.Fatalf("p2: %v", err)
}
}
func TestGetFrame_WrongProjectIsNotFound(t *testing.T) {
s := newTestStore(t)
p1, _ := s.CreateProject("LOFT", "", "")
p2, _ := s.CreateProject("OFFICE", "", "")
f, _ := s.CreateFrame(p1.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
if _, err := s.GetFrame(p2.ID, f.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("cross-project GetFrame should be ErrNotFound; got %v", err)
}
}
func TestListFrames_OrderedByCreation(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
for _, n := range []string{"rack", "desk", "media"} {
if _, err := s.CreateFrame(p.ID, FrameCreate{Name: n, Width: 100, Height: 50}); err != nil {
t.Fatalf("create %s: %v", n, err)
}
}
got, _ := s.ListFrames(p.ID)
if len(got) != 3 {
t.Fatalf("len = %d", len(got))
}
if got[0].Name != "rack" || got[2].Name != "media" {
t.Errorf("order = %v", []string{got[0].Name, got[1].Name, got[2].Name})
}
}
func TestUpdateFrame_PartialFields(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
f, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", X: 0, Y: 0, Width: 100, Height: 50})
nx := 42.0
updated, err := s.UpdateFrame(p.ID, f.ID, FrameUpdate{X: &nx})
if err != nil {
t.Fatalf("update: %v", err)
}
if updated.X != 42 || updated.Name != "desk" || updated.Width != 100 {
t.Errorf("got %+v", updated)
}
}
func TestDeleteFrame_SetsDeviceFrameIDToNull(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
f, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 800, Height: 600})
d, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Mac", FrameID: &f.ID, X: 10, Y: 20, Width: 100, Height: 35})
if d.FrameID == nil || *d.FrameID != f.ID {
t.Fatalf("device frame_id pre-delete = %v, want %d", d.FrameID, f.ID)
}
if err := s.DeleteFrame(p.ID, f.ID); err != nil {
t.Fatalf("delete frame: %v", err)
}
d2, _ := s.GetDevice(p.ID, d.ID)
if d2.FrameID != nil {
t.Errorf("device frame_id post-delete = %v, want nil (SET NULL)", d2.FrameID)
}
}
// ---------------------------------------------------------------------- devices
func TestCreateDevice_DefaultsColor(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
d, err := s.CreateDevice(p.ID, DeviceCreate{Name: "Mac", X: 10, Y: 20, Width: 100, Height: 35})
if err != nil {
t.Fatalf("create: %v", err)
}
if d.Color != "#1e1e1e" {
t.Errorf("default color = %q, want #1e1e1e", d.Color)
}
if d.FrameID != nil {
t.Errorf("frame_id = %v, want nil for unframed device", d.FrameID)
}
}
func TestCreateDevice_DuplicateNameInProject(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
if _, err := s.CreateDevice(p.ID, DeviceCreate{Name: "Mac", X: 0, Y: 0, Width: 100, Height: 35}); err != nil {
t.Fatalf("first: %v", err)
}
if _, err := s.CreateDevice(p.ID, DeviceCreate{Name: "Mac", X: 10, Y: 10, Width: 100, Height: 35}); !errors.Is(err, ErrConflict) {
t.Errorf("dup device name should ErrConflict; got %v", err)
}
}
func TestCreateDevice_CrossProjectFrameRejected(t *testing.T) {
s := newTestStore(t)
p1, _ := s.CreateProject("LOFT", "", "")
p2, _ := s.CreateProject("OFFICE", "", "")
f2, _ := s.CreateFrame(p2.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
// Try to put a LOFT device into an OFFICE frame.
_, err := s.CreateDevice(p1.ID, DeviceCreate{Name: "Mac", FrameID: &f2.ID, X: 0, Y: 0, Width: 100, Height: 35})
if !errors.Is(err, ErrInvalidInput) {
t.Errorf("cross-project frame_id should ErrInvalidInput; got %v", err)
}
}
func TestUpdateDevice_FrameIDTriState(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
f1, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
f2, _ := s.CreateFrame(p.ID, FrameCreate{Name: "rack", Width: 100, Height: 50})
d, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Mac", FrameID: &f1.ID, X: 0, Y: 0, Width: 100, Height: 35})
// Leave alone (FrameID.Set=false) — even passing a different X.
nx := 99.0
u1, _ := s.UpdateDevice(p.ID, d.ID, DeviceUpdate{X: &nx})
if u1.FrameID == nil || *u1.FrameID != f1.ID {
t.Errorf("frame_id should be unchanged (f1); got %v", u1.FrameID)
}
// Move to f2.
u2, _ := s.UpdateDevice(p.ID, d.ID, DeviceUpdate{FrameID: FrameRef{Set: true, ID: &f2.ID}})
if u2.FrameID == nil || *u2.FrameID != f2.ID {
t.Errorf("frame_id should be f2; got %v", u2.FrameID)
}
// Clear (move outside any frame).
u3, _ := s.UpdateDevice(p.ID, d.ID, DeviceUpdate{FrameID: FrameRef{Set: true, ID: nil}})
if u3.FrameID != nil {
t.Errorf("frame_id should be nil after Set:true,ID:nil; got %v", *u3.FrameID)
}
}
func TestUpdateDevice_RejectsCrossProjectFrame(t *testing.T) {
s := newTestStore(t)
p1, _ := s.CreateProject("LOFT", "", "")
p2, _ := s.CreateProject("OFFICE", "", "")
d, _ := s.CreateDevice(p1.ID, DeviceCreate{Name: "Mac", X: 0, Y: 0, Width: 100, Height: 35})
f2, _ := s.CreateFrame(p2.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
_, err := s.UpdateDevice(p1.ID, d.ID, DeviceUpdate{FrameID: FrameRef{Set: true, ID: &f2.ID}})
if !errors.Is(err, ErrInvalidInput) {
t.Errorf("cross-project frame_id should ErrInvalidInput; got %v", err)
}
}
func TestListDevices_FilterByFrame(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
f1, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
f2, _ := s.CreateFrame(p.ID, FrameCreate{Name: "rack", Width: 100, Height: 50})
_, _ = s.CreateDevice(p.ID, DeviceCreate{Name: "A", FrameID: &f1.ID, Width: 100, Height: 35})
_, _ = s.CreateDevice(p.ID, DeviceCreate{Name: "B", FrameID: &f2.ID, Width: 100, Height: 35})
_, _ = s.CreateDevice(p.ID, DeviceCreate{Name: "C", Width: 100, Height: 35}) // outside
all, _ := s.ListDevices(p.ID, nil)
if len(all) != 3 {
t.Errorf("all len = %d, want 3", len(all))
}
inF1, _ := s.ListDevices(p.ID, &f1.ID)
if len(inF1) != 1 || inF1[0].Name != "A" {
t.Errorf("inF1 = %+v", inF1)
}
}
func TestSnapshot_PopulatesFramesAndDevices(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
f, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
_, _ = s.CreateDevice(p.ID, DeviceCreate{Name: "Mac", FrameID: &f.ID, Width: 100, Height: 35})
snap, err := s.Snapshot(p.ID)
if err != nil {
t.Fatalf("snapshot: %v", err)
}
if len(snap.Frames) != 1 || len(snap.Devices) != 1 {
t.Errorf("snapshot frames=%d devices=%d", len(snap.Frames), len(snap.Devices))
}
if len(snap.CableTypes) != 5 {
t.Errorf("cable_types = %d, want 5", len(snap.CableTypes))
}
}
func TestDeleteDevice_NotFoundIsNotFound(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
if err := s.DeleteDevice(p.ID, 999); !errors.Is(err, ErrNotFound) {
t.Errorf("got %v, want ErrNotFound", err)
}
}

94
internal/db/migrate.go Normal file
View File

@@ -0,0 +1,94 @@
package db
import (
"database/sql"
"embed"
"fmt"
"sort"
"strings"
)
//go:embed migrations/*.sql
var migrationFS embed.FS
// Migrate applies any pending SQL files from migrations/*.sql in
// lexicographic order against the given *sql.DB. Applied filenames are
// tracked in schema_migrations so each runs at most once. Idempotent.
func Migrate(d *sql.DB) error {
if _, err := d.Exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
applied, err := loadApplied(d)
if err != nil {
return err
}
entries, err := migrationFS.ReadDir("migrations")
if err != nil {
return fmt.Errorf("read migrations dir: %w", err)
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
continue
}
names = append(names, e.Name())
}
sort.Strings(names)
for _, name := range names {
if applied[name] {
continue
}
body, err := migrationFS.ReadFile("migrations/" + name)
if err != nil {
return fmt.Errorf("read %s: %w", name, err)
}
if err := runMigration(d, name, string(body)); err != nil {
return err
}
}
return nil
}
func loadApplied(d *sql.DB) (map[string]bool, error) {
rows, err := d.Query("SELECT name FROM schema_migrations")
if err != nil {
return nil, fmt.Errorf("load applied: %w", err)
}
defer rows.Close()
out := map[string]bool{}
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
return nil, err
}
out[n] = true
}
return out, rows.Err()
}
func runMigration(d *sql.DB, name, body string) error {
tx, err := d.Begin()
if err != nil {
return fmt.Errorf("begin %s: %w", name, err)
}
if _, err := tx.Exec(body); err != nil {
_ = tx.Rollback()
return fmt.Errorf("apply %s: %w", name, err)
}
if _, err := tx.Exec("INSERT INTO schema_migrations (name) VALUES (?)", name); err != nil {
_ = tx.Rollback()
return fmt.Errorf("record %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit %s: %w", name, err)
}
return nil
}

View File

@@ -0,0 +1,144 @@
-- mCables v3 initial schema. See docs/design.md §2.
-- 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,
drawing_name TEXT NOT NULL,
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, shared across all projects.
-- Seeded once below with the 5 defaults.
CREATE TABLE cable_types (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
color TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
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,
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);
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),
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX devices_project_idx ON devices(project_id);
CREATE INDEX devices_frame_idx ON devices(frame_id);
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,
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);
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',
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);
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);
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);
-- Seed the 5 default cable types, once.
INSERT INTO cable_types (name, color) VALUES
('Power', '#e03131'),
('USB', '#2f9e44'),
('HDMI', '#1971c2'),
('DP', '#9c36b5'),
('RJ45', '#ffd500');

64
internal/db/models.go Normal file
View File

@@ -0,0 +1,64 @@
package db
// Project is the top-level entity. One project ↔ one .excalidraw drawing.
type Project struct {
ID int64 `json:"id"`
Name string `json:"name"`
DrawingName string `json:"drawing_name"`
Description string `json:"description"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// CableType is global. Renaming/recolouring affects every project.
type CableType struct {
ID int64 `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Frame is a sub-zone inside a project (`desk`, `rack`, …).
type Frame struct {
ID int64 `json:"id"`
ProjectID int64 `json:"project_id"`
Name string `json:"name"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
ExcalidrawID *string `json:"excalidraw_id,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Device is a hardware item inside a project, optionally inside a frame.
type Device struct {
ID int64 `json:"id"`
ProjectID int64 `json:"project_id"`
FrameID *int64 `json:"frame_id"` // nullable: device "outside" any frame
Name string `json:"name"`
Color string `json:"color"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
ExcalidrawID *string `json:"excalidraw_id,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Snapshot is the editor's one-shot loader payload for a single project.
// Arrays for collections still gated by future slices stay non-nil [] so
// JSON encodes as [] not null.
type Snapshot struct {
Project Project `json:"project"`
Frames []Frame `json:"frames"`
Devices []Device `json:"devices"`
Ports []any `json:"ports"`
Cables []any `json:"cables"`
IOMarkers []any `json:"io_markers"`
Bundles []any `json:"bundles"`
CableTypes []CableType `json:"cable_types"`
}

330
internal/db/store.go Normal file
View File

@@ -0,0 +1,330 @@
package db
import (
"database/sql"
"errors"
"fmt"
"strings"
)
// Sentinel errors callers can match against. The server layer maps these
// to HTTP status codes.
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict") // UNIQUE violation
ErrInUse = errors.New("in use") // cable_type referenced by a cable
ErrConfirmName = errors.New("confirm name missing or mismatched")
ErrInvalidInput = errors.New("invalid input")
)
// -----------------------------------------------------------------------------
// Projects
// -----------------------------------------------------------------------------
// CreateProject inserts a new project. drawingName, if empty, defaults to
// "<name>.excalidraw". name and drawingName are trimmed; an empty name
// after trimming is rejected.
func (s *Store) CreateProject(name, drawingName, description string) (*Project, error) {
name = strings.TrimSpace(name)
drawingName = strings.TrimSpace(drawingName)
if name == "" {
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
}
if drawingName == "" {
drawingName = name + ".excalidraw"
}
res, err := s.db.Exec(
`INSERT INTO projects (name, drawing_name, description) VALUES (?, ?, ?)`,
name, drawingName, description,
)
if err != nil {
return nil, mapWriteErr(err)
}
id, _ := res.LastInsertId()
return s.GetProject(id)
}
// GetProject loads a project by ID.
func (s *Store) GetProject(id int64) (*Project, error) {
var p Project
err := s.db.QueryRow(
`SELECT id, name, drawing_name, description, created_at, updated_at
FROM projects WHERE id = ?`, id,
).Scan(&p.ID, &p.Name, &p.DrawingName, &p.Description, &p.CreatedAt, &p.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &p, nil
}
// ListProjects returns every project ordered by name.
func (s *Store) ListProjects() ([]Project, error) {
rows, err := s.db.Query(
`SELECT id, name, drawing_name, description, created_at, updated_at
FROM projects ORDER BY name`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Project
for rows.Next() {
var p Project
if err := rows.Scan(&p.ID, &p.Name, &p.DrawingName, &p.Description, &p.CreatedAt, &p.UpdatedAt); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// ProjectUpdate carries partial fields for PATCH. A nil pointer means
// "leave this field untouched".
type ProjectUpdate struct {
Name *string
DrawingName *string
Description *string
}
// UpdateProject applies the partial update. Empty struct = no-op (just
// bumps updated_at). Empty Name (after trim) is rejected; whitespace-only
// DrawingName is treated as "use <name>.excalidraw" — same default as
// CreateProject.
func (s *Store) UpdateProject(id int64, u ProjectUpdate) (*Project, error) {
cur, err := s.GetProject(id)
if err != nil {
return nil, err
}
if u.Name != nil {
v := strings.TrimSpace(*u.Name)
if v == "" {
return nil, fmt.Errorf("%w: name cannot be empty", ErrInvalidInput)
}
cur.Name = v
}
if u.DrawingName != nil {
v := strings.TrimSpace(*u.DrawingName)
if v == "" {
v = cur.Name + ".excalidraw"
}
cur.DrawingName = v
}
if u.Description != nil {
cur.Description = *u.Description
}
if _, err := s.db.Exec(
`UPDATE projects
SET name = ?, drawing_name = ?, description = ?, updated_at = datetime('now')
WHERE id = ?`,
cur.Name, cur.DrawingName, cur.Description, id,
); err != nil {
return nil, mapWriteErr(err)
}
return s.GetProject(id)
}
// DeleteProject removes the project (cascading frames, devices, ports,
// cables, io_markers, bundles, bundle_cables). confirmName must match the
// project's current name; otherwise ErrConfirmName is returned and nothing
// is deleted.
func (s *Store) DeleteProject(id int64, confirmName string) error {
p, err := s.GetProject(id)
if err != nil {
return err
}
if confirmName != p.Name {
return ErrConfirmName
}
if _, err := s.db.Exec(`DELETE FROM projects WHERE id = ?`, id); err != nil {
return err
}
return nil
}
// Snapshot loads the full editor-init payload for one project. Slice 2
// populates frames + devices; ports / cables / io_markers / bundles
// still ship empty until their slices land.
func (s *Store) Snapshot(id int64) (*Snapshot, error) {
p, err := s.GetProject(id)
if err != nil {
return nil, err
}
types, err := s.ListCableTypes()
if err != nil {
return nil, err
}
frames, err := s.ListFrames(id)
if err != nil {
return nil, err
}
devices, err := s.ListDevices(id, nil)
if err != nil {
return nil, err
}
return &Snapshot{
Project: *p,
Frames: frames,
Devices: devices,
Ports: []any{},
Cables: []any{},
IOMarkers: []any{},
Bundles: []any{},
CableTypes: types,
}, nil
}
// -----------------------------------------------------------------------------
// Cable types (global)
// -----------------------------------------------------------------------------
// CreateCableType inserts a global cable type. name must be globally unique.
func (s *Store) CreateCableType(name, color string) (*CableType, error) {
name = strings.TrimSpace(name)
color = strings.TrimSpace(color)
if name == "" {
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
}
if color == "" {
return nil, fmt.Errorf("%w: color is required", ErrInvalidInput)
}
res, err := s.db.Exec(
`INSERT INTO cable_types (name, color) VALUES (?, ?)`, name, color,
)
if err != nil {
return nil, mapWriteErr(err)
}
id, _ := res.LastInsertId()
return s.GetCableType(id)
}
// GetCableType loads a cable type by ID.
func (s *Store) GetCableType(id int64) (*CableType, error) {
var t CableType
err := s.db.QueryRow(
`SELECT id, name, color, created_at, updated_at
FROM cable_types WHERE id = ?`, id,
).Scan(&t.ID, &t.Name, &t.Color, &t.CreatedAt, &t.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &t, nil
}
// ListCableTypes returns every cable type ordered by id (insertion order,
// so the legend renders in the same order across reloads).
func (s *Store) ListCableTypes() ([]CableType, error) {
rows, err := s.db.Query(
`SELECT id, name, color, created_at, updated_at
FROM cable_types ORDER BY id`,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []CableType{}
for rows.Next() {
var t CableType
if err := rows.Scan(&t.ID, &t.Name, &t.Color, &t.CreatedAt, &t.UpdatedAt); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// CableTypeUpdate is the partial-update shape for PATCH.
type CableTypeUpdate struct {
Name *string
Color *string
}
// UpdateCableType applies a partial update.
func (s *Store) UpdateCableType(id int64, u CableTypeUpdate) (*CableType, error) {
cur, err := s.GetCableType(id)
if err != nil {
return nil, err
}
if u.Name != nil {
v := strings.TrimSpace(*u.Name)
if v == "" {
return nil, fmt.Errorf("%w: name cannot be empty", ErrInvalidInput)
}
cur.Name = v
}
if u.Color != nil {
v := strings.TrimSpace(*u.Color)
if v == "" {
return nil, fmt.Errorf("%w: color cannot be empty", ErrInvalidInput)
}
cur.Color = v
}
if _, err := s.db.Exec(
`UPDATE cable_types
SET name = ?, color = ?, updated_at = datetime('now')
WHERE id = ?`,
cur.Name, cur.Color, id,
); err != nil {
return nil, mapWriteErr(err)
}
return s.GetCableType(id)
}
// DeleteCableType removes a cable type. SQLite enforces ON DELETE RESTRICT
// from cables.type_id and ports.type_id; we surface that as ErrInUse plus
// the count of referencing cables (so the UI can show "blocked by N cables").
func (s *Store) DeleteCableType(id int64) error {
if _, err := s.GetCableType(id); err != nil {
return err
}
if _, err := s.db.Exec(`DELETE FROM cable_types WHERE id = ?`, id); err != nil {
if isForeignKeyConstraint(err) {
return ErrInUse
}
return err
}
return nil
}
// CountCablesUsingType returns how many cables reference this cable_type.
// Used by the server to enrich a 409 InUse response with a helpful number.
func (s *Store) CountCablesUsingType(id int64) (int, error) {
var n int
err := s.db.QueryRow(`SELECT COUNT(*) FROM cables WHERE type_id = ?`, id).Scan(&n)
return n, err
}
// -----------------------------------------------------------------------------
// Error mapping
// -----------------------------------------------------------------------------
// mapWriteErr classifies SQLite write errors into our sentinel errors so
// the handler layer can pick the right HTTP status. Falls through to the
// raw error for anything we don't recognise.
func mapWriteErr(err error) error {
if err == nil {
return nil
}
msg := err.Error()
switch {
case strings.Contains(msg, "UNIQUE constraint failed"):
return fmt.Errorf("%w: %s", ErrConflict, msg)
case strings.Contains(msg, "FOREIGN KEY constraint failed"):
return fmt.Errorf("%w: %s", ErrInUse, msg)
case strings.Contains(msg, "CHECK constraint failed"):
return fmt.Errorf("%w: %s", ErrInvalidInput, msg)
}
return err
}
func isForeignKeyConstraint(err error) bool {
return err != nil && strings.Contains(err.Error(), "FOREIGN KEY constraint failed")
}

281
internal/db/store_test.go Normal file
View File

@@ -0,0 +1,281 @@
package db
import (
"errors"
"path/filepath"
"testing"
)
func newTestStore(t *testing.T) *Store {
t.Helper()
path := filepath.Join(t.TempDir(), "test.db")
s, err := Open(path)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
if err := Migrate(s.DB()); err != nil {
t.Fatalf("migrate: %v", err)
}
return s
}
// --------------------------------------------------------------------- projects
func TestCreateProject_DefaultsDrawingName(t *testing.T) {
s := newTestStore(t)
p, err := s.CreateProject("LOFT", "", "")
if err != nil {
t.Fatalf("create: %v", err)
}
if p.Name != "LOFT" {
t.Errorf("name = %q, want LOFT", p.Name)
}
if p.DrawingName != "LOFT.excalidraw" {
t.Errorf("drawing_name = %q, want LOFT.excalidraw", p.DrawingName)
}
}
func TestCreateProject_AcceptsExplicitDrawingName(t *testing.T) {
s := newTestStore(t)
p, err := s.CreateProject("OFFICE", "office-rack.excalidraw", "rack only")
if err != nil {
t.Fatalf("create: %v", err)
}
if p.DrawingName != "office-rack.excalidraw" {
t.Errorf("drawing_name = %q, want office-rack.excalidraw", p.DrawingName)
}
if p.Description != "rack only" {
t.Errorf("description = %q", p.Description)
}
}
func TestCreateProject_EmptyNameRejected(t *testing.T) {
s := newTestStore(t)
if _, err := s.CreateProject(" ", "", ""); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("err = %v, want ErrInvalidInput", err)
}
}
func TestCreateProject_DuplicateNameRejected(t *testing.T) {
s := newTestStore(t)
if _, err := s.CreateProject("LOFT", "", ""); err != nil {
t.Fatalf("first create: %v", err)
}
if _, err := s.CreateProject("LOFT", "", ""); !errors.Is(err, ErrConflict) {
t.Fatalf("second create err = %v, want ErrConflict", err)
}
}
func TestListProjects_OrderedByName(t *testing.T) {
s := newTestStore(t)
for _, name := range []string{"OFFICE", "LOFT", "GARAGE"} {
if _, err := s.CreateProject(name, "", ""); err != nil {
t.Fatalf("create %s: %v", name, err)
}
}
got, err := s.ListProjects()
if err != nil {
t.Fatalf("list: %v", err)
}
want := []string{"GARAGE", "LOFT", "OFFICE"}
for i, p := range got {
if p.Name != want[i] {
t.Errorf("[%d] = %q, want %q", i, p.Name, want[i])
}
}
}
func TestGetProject_NotFound(t *testing.T) {
s := newTestStore(t)
if _, err := s.GetProject(999); !errors.Is(err, ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
}
func TestUpdateProject_PartialFields(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
newName := "LOFT-2"
updated, err := s.UpdateProject(p.ID, ProjectUpdate{Name: &newName})
if err != nil {
t.Fatalf("update: %v", err)
}
if updated.Name != "LOFT-2" {
t.Errorf("name = %q, want LOFT-2", updated.Name)
}
// drawing_name should not auto-change from a Name update — it's only
// auto-defaulted when drawing_name is explicitly set to empty.
if updated.DrawingName != "LOFT.excalidraw" {
t.Errorf("drawing_name = %q, want LOFT.excalidraw (unchanged)", updated.DrawingName)
}
}
func TestUpdateProject_BlankDrawingNameDefaults(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "old.excalidraw", "")
blank := " "
updated, err := s.UpdateProject(p.ID, ProjectUpdate{DrawingName: &blank})
if err != nil {
t.Fatalf("update: %v", err)
}
if updated.DrawingName != "LOFT.excalidraw" {
t.Errorf("drawing_name = %q, want LOFT.excalidraw", updated.DrawingName)
}
}
func TestDeleteProject_ConfirmGuardrail(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
// Wrong name → no delete.
if err := s.DeleteProject(p.ID, "OFFICE"); !errors.Is(err, ErrConfirmName) {
t.Fatalf("wrong-name err = %v, want ErrConfirmName", err)
}
if _, err := s.GetProject(p.ID); err != nil {
t.Fatalf("project should still exist: %v", err)
}
// Empty confirm → no delete.
if err := s.DeleteProject(p.ID, ""); !errors.Is(err, ErrConfirmName) {
t.Fatalf("empty-confirm err = %v, want ErrConfirmName", err)
}
// Correct name → delete.
if err := s.DeleteProject(p.ID, "LOFT"); err != nil {
t.Fatalf("correct-name delete: %v", err)
}
if _, err := s.GetProject(p.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("project should be gone: %v", err)
}
}
func TestSnapshot_IncludesGlobalCableTypes(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
snap, err := s.Snapshot(p.ID)
if err != nil {
t.Fatalf("snapshot: %v", err)
}
if snap.Project.ID != p.ID {
t.Errorf("project.id = %d, want %d", snap.Project.ID, p.ID)
}
if len(snap.CableTypes) != 5 {
t.Errorf("cable_types len = %d, want 5 (the seeded defaults)", len(snap.CableTypes))
}
if snap.Frames == nil || snap.Devices == nil || snap.Ports == nil ||
snap.Cables == nil || snap.IOMarkers == nil || snap.Bundles == nil {
t.Errorf("snapshot collections must be non-nil arrays, not null, for slice-1 JSON output")
}
}
// ------------------------------------------------------------------ cable_types
func TestListCableTypes_SeededFive(t *testing.T) {
s := newTestStore(t)
ts, err := s.ListCableTypes()
if err != nil {
t.Fatalf("list: %v", err)
}
wantNames := []string{"Power", "USB", "HDMI", "DP", "RJ45"}
if len(ts) != 5 {
t.Fatalf("len = %d, want 5", len(ts))
}
for i, want := range wantNames {
if ts[i].Name != want {
t.Errorf("[%d].Name = %q, want %q", i, ts[i].Name, want)
}
if ts[i].Color == "" {
t.Errorf("[%d].Color empty", i)
}
}
}
func TestCreateCableType_GlobalUnique(t *testing.T) {
s := newTestStore(t)
if _, err := s.CreateCableType("Audio", "#ff0000"); err != nil {
t.Fatalf("create: %v", err)
}
if _, err := s.CreateCableType("Audio", "#00ff00"); !errors.Is(err, ErrConflict) {
t.Fatalf("dup err = %v, want ErrConflict", err)
}
}
func TestUpdateCableType_RenameAndRecolour(t *testing.T) {
s := newTestStore(t)
ts, _ := s.ListCableTypes()
hdmi := ts[2] // seed order: Power, USB, HDMI, DP, RJ45
newName := "HDMI-2.1"
newColor := "#000000"
updated, err := s.UpdateCableType(hdmi.ID, CableTypeUpdate{Name: &newName, Color: &newColor})
if err != nil {
t.Fatalf("update: %v", err)
}
if updated.Name != "HDMI-2.1" || updated.Color != "#000000" {
t.Errorf("got %+v", updated)
}
}
func TestDeleteCableType_BlockedByCable(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
// Reach the seeded Power cable type.
ts, _ := s.ListCableTypes()
power := ts[0]
// Wire up a minimal cable referencing the Power type via the raw DB
// (the typed device/port API ships in slice 2+). The schema CHECK
// requires exactly one endpoint each side — use device-level binding
// against placeholder rows.
d := s.DB()
res, err := d.Exec(`INSERT INTO devices (project_id, name, x, y, width, height)
VALUES (?, ?, 0, 0, 100, 30)`, p.ID, "PlaceholderA")
if err != nil {
t.Fatalf("insert device A: %v", err)
}
deviceA, _ := res.LastInsertId()
res, err = d.Exec(`INSERT INTO devices (project_id, name, x, y, width, height)
VALUES (?, ?, 0, 0, 100, 30)`, p.ID, "PlaceholderB")
if err != nil {
t.Fatalf("insert device B: %v", err)
}
deviceB, _ := res.LastInsertId()
if _, err := d.Exec(`INSERT INTO cables
(project_id, type_id, from_device_id, to_device_id)
VALUES (?, ?, ?, ?)`, p.ID, power.ID, deviceA, deviceB); err != nil {
t.Fatalf("insert cable: %v", err)
}
// Now delete → must be blocked.
if err := s.DeleteCableType(power.ID); !errors.Is(err, ErrInUse) {
t.Fatalf("delete err = %v, want ErrInUse", err)
}
n, err := s.CountCablesUsingType(power.ID)
if err != nil {
t.Fatalf("count: %v", err)
}
if n != 1 {
t.Errorf("count = %d, want 1", n)
}
}
func TestDeleteCableType_UnusedSucceeds(t *testing.T) {
s := newTestStore(t)
t2, _ := s.CreateCableType("Audio", "#000000")
if err := s.DeleteCableType(t2.ID); err != nil {
t.Fatalf("delete: %v", err)
}
}
func TestDeleteProject_DoesNotTouchCableTypes(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
if err := s.DeleteProject(p.ID, "LOFT"); err != nil {
t.Fatalf("delete: %v", err)
}
ts, _ := s.ListCableTypes()
if len(ts) != 5 {
t.Errorf("cable_types should survive project deletion; got %d, want 5", len(ts))
}
}

View File

@@ -0,0 +1,234 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/mcables/internal/db"
)
// ---------------------------------------------------------------- frames
type frameCreate struct {
Name string `json:"name"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
}
type framePatch struct {
Name *string `json:"name,omitempty"`
X *float64 `json:"x,omitempty"`
Y *float64 `json:"y,omitempty"`
Width *float64 `json:"width,omitempty"`
Height *float64 `json:"height,omitempty"`
}
func (h *handlers) listFrames(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
fs, err := h.store.ListFrames(pid)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, fs)
}
func (h *handlers) createFrame(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
var body frameCreate
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
f, err := h.store.CreateFrame(pid, db.FrameCreate{
Name: body.Name, X: body.X, Y: body.Y, Width: body.Width, Height: body.Height,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusCreated, f)
}
func (h *handlers) patchFrame(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
var body framePatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
f, err := h.store.UpdateFrame(pid, id, db.FrameUpdate{
Name: body.Name, X: body.X, Y: body.Y, Width: body.Width, Height: body.Height,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, f)
}
func (h *handlers) deleteFrame(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
if err := h.store.DeleteFrame(pid, id); err != nil {
writeError(w, err, nil)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------- devices
type deviceCreate struct {
Name string `json:"name"`
FrameID *int64 `json:"frame_id,omitempty"`
Color string `json:"color,omitempty"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
}
// devicePatch uses a raw `json.RawMessage` for frame_id so we can tell
// "key absent" (leave alone) from "key present and null" (set to NULL)
// from "key present with an int" (move to that frame). Standard encoding
// of nullable fields in JSON PATCH.
type devicePatch struct {
Name *string `json:"name,omitempty"`
FrameID json.RawMessage `json:"frame_id,omitempty"`
Color *string `json:"color,omitempty"`
X *float64 `json:"x,omitempty"`
Y *float64 `json:"y,omitempty"`
Width *float64 `json:"width,omitempty"`
Height *float64 `json:"height,omitempty"`
}
// parseFrameRef decodes the raw frame_id field into a tri-state.
func parseFrameRef(raw json.RawMessage) (db.FrameRef, error) {
if len(raw) == 0 {
return db.FrameRef{Set: false}, nil
}
// "null" → clear; otherwise expect an integer.
if string(raw) == "null" {
return db.FrameRef{Set: true, ID: nil}, nil
}
var id int64
if err := json.Unmarshal(raw, &id); err != nil {
return db.FrameRef{}, err
}
return db.FrameRef{Set: true, ID: &id}, nil
}
func (h *handlers) listDevices(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
ds, err := h.store.ListDevices(pid, nil)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, ds)
}
func (h *handlers) createDevice(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
var body deviceCreate
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
d, err := h.store.CreateDevice(pid, db.DeviceCreate{
Name: body.Name, FrameID: body.FrameID, Color: body.Color,
X: body.X, Y: body.Y, Width: body.Width, Height: body.Height,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusCreated, d)
}
func (h *handlers) patchDevice(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
var body devicePatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
ref, err := parseFrameRef(body.FrameID)
if err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), "frame_id must be an integer or null")
return
}
d, err := h.store.UpdateDevice(pid, id, db.DeviceUpdate{
Name: body.Name, FrameID: ref, Color: body.Color,
X: body.X, Y: body.Y, Width: body.Width, Height: body.Height,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, d)
}
func (h *handlers) deleteDevice(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
if err := h.store.DeleteDevice(pid, id); err != nil {
writeError(w, err, nil)
return
}
w.WriteHeader(http.StatusNoContent)
}

236
internal/server/handlers.go Normal file
View File

@@ -0,0 +1,236 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"mgit.msbls.de/m/mcables/internal/db"
)
type handlers struct {
store *db.Store
}
// ---------------------------------------------------------------- utility
// writeJSON encodes v as JSON at the given status. Errors during encoding
// are logged-silent (the response has already started) — this is the
// last-resort path; callers should validate inputs early.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
type errorBody struct {
Error string `json:"error"`
Details any `json:"details,omitempty"`
}
// writeError maps a Store sentinel to an HTTP status + JSON body.
func writeError(w http.ResponseWriter, err error, details any) {
switch {
case errors.Is(err, db.ErrNotFound):
writeJSON(w, http.StatusNotFound, errorBody{Error: err.Error(), Details: details})
case errors.Is(err, db.ErrConflict):
writeJSON(w, http.StatusConflict, errorBody{Error: err.Error(), Details: details})
case errors.Is(err, db.ErrInUse):
writeJSON(w, http.StatusConflict, errorBody{Error: err.Error(), Details: details})
case errors.Is(err, db.ErrConfirmName):
writeJSON(w, http.StatusBadRequest, errorBody{Error: err.Error(), Details: details})
case errors.Is(err, db.ErrInvalidInput):
writeJSON(w, http.StatusBadRequest, errorBody{Error: err.Error(), Details: details})
default:
writeJSON(w, http.StatusInternalServerError, errorBody{Error: err.Error(), Details: details})
}
}
func parseInt64Path(r *http.Request, key string) (int64, bool) {
raw := r.PathValue(key)
v, err := strconv.ParseInt(raw, 10, 64)
if err != nil || v <= 0 {
return 0, false
}
return v, true
}
// ---------------------------------------------------------------- health
func (h *handlers) healthz(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// ---------------------------------------------------------------- projects
type projectCreate struct {
Name string `json:"name"`
DrawingName string `json:"drawing_name"`
Description string `json:"description"`
}
type projectPatch struct {
Name *string `json:"name,omitempty"`
DrawingName *string `json:"drawing_name,omitempty"`
Description *string `json:"description,omitempty"`
}
func (h *handlers) listProjects(w http.ResponseWriter, _ *http.Request) {
ps, err := h.store.ListProjects()
if err != nil {
writeError(w, err, nil)
return
}
if ps == nil {
ps = []db.Project{}
}
writeJSON(w, http.StatusOK, ps)
}
func (h *handlers) createProject(w http.ResponseWriter, r *http.Request) {
var body projectCreate
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
p, err := h.store.CreateProject(body.Name, body.DrawingName, body.Description)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusCreated, p)
}
func (h *handlers) getProject(w http.ResponseWriter, r *http.Request) {
id, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
snap, err := h.store.Snapshot(id)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, snap)
}
func (h *handlers) patchProject(w http.ResponseWriter, r *http.Request) {
id, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
var body projectPatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
p, err := h.store.UpdateProject(id, db.ProjectUpdate{
Name: body.Name,
DrawingName: body.DrawingName,
Description: body.Description,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, p)
}
func (h *handlers) deleteProject(w http.ResponseWriter, r *http.Request) {
id, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
confirm := r.URL.Query().Get("confirm")
if confirm == "" {
writeError(w, db.ErrConfirmName,
"DELETE requires ?confirm=<project name> matching the project's current name")
return
}
if err := h.store.DeleteProject(id, confirm); err != nil {
writeError(w, err, nil)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------- cable_types
type cableTypeCreate struct {
Name string `json:"name"`
Color string `json:"color"`
}
type cableTypePatch struct {
Name *string `json:"name,omitempty"`
Color *string `json:"color,omitempty"`
}
func (h *handlers) listCableTypes(w http.ResponseWriter, _ *http.Request) {
ts, err := h.store.ListCableTypes()
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, ts)
}
func (h *handlers) createCableType(w http.ResponseWriter, r *http.Request) {
var body cableTypeCreate
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
t, err := h.store.CreateCableType(body.Name, body.Color)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusCreated, t)
}
func (h *handlers) patchCableType(w http.ResponseWriter, r *http.Request) {
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
var body cableTypePatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
t, err := h.store.UpdateCableType(id, db.CableTypeUpdate{
Name: body.Name,
Color: body.Color,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, t)
}
func (h *handlers) deleteCableType(w http.ResponseWriter, r *http.Request) {
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
if err := h.store.DeleteCableType(id); err != nil {
// On ErrInUse, count referencing cables so the client can show
// "blocked by N cables".
if errors.Is(err, db.ErrInUse) {
n, _ := h.store.CountCablesUsingType(id)
writeError(w, err, map[string]int{"in_use_by_cables": n})
return
}
writeError(w, err, nil)
return
}
w.WriteHeader(http.StatusNoContent)
}

71
internal/server/server.go Normal file
View File

@@ -0,0 +1,71 @@
// Package server wires the HTTP API + the embedded frontend onto a
// single net/http handler. Routes use Go 1.22 ServeMux pattern matching
// (no router framework).
package server
import (
"io/fs"
"net/http"
"mgit.msbls.de/m/mcables/internal/db"
)
// New returns an http.Handler serving the mCables API at /api/ and the
// embedded frontend at /. The frontend FS should be rooted such that
// "index.html" is at its root.
func New(store *db.Store, frontend fs.FS) http.Handler {
mux := http.NewServeMux()
h := &handlers{store: store}
// Health
mux.HandleFunc("GET /api/healthz", h.healthz)
// Projects
mux.HandleFunc("GET /api/projects", h.listProjects)
mux.HandleFunc("POST /api/projects", h.createProject)
mux.HandleFunc("GET /api/projects/{pid}", h.getProject)
mux.HandleFunc("PATCH /api/projects/{pid}", h.patchProject)
mux.HandleFunc("DELETE /api/projects/{pid}", h.deleteProject)
// Cable types (global)
mux.HandleFunc("GET /api/cable-types", h.listCableTypes)
mux.HandleFunc("POST /api/cable-types", h.createCableType)
mux.HandleFunc("PATCH /api/cable-types/{id}", h.patchCableType)
mux.HandleFunc("DELETE /api/cable-types/{id}", h.deleteCableType)
// Frames (project-scoped)
mux.HandleFunc("GET /api/projects/{pid}/frames", h.listFrames)
mux.HandleFunc("POST /api/projects/{pid}/frames", h.createFrame)
mux.HandleFunc("PATCH /api/projects/{pid}/frames/{id}", h.patchFrame)
mux.HandleFunc("DELETE /api/projects/{pid}/frames/{id}", h.deleteFrame)
// Devices (project-scoped)
mux.HandleFunc("GET /api/projects/{pid}/devices", h.listDevices)
mux.HandleFunc("POST /api/projects/{pid}/devices", h.createDevice)
mux.HandleFunc("PATCH /api/projects/{pid}/devices/{id}", h.patchDevice)
mux.HandleFunc("DELETE /api/projects/{pid}/devices/{id}", h.deleteDevice)
// Frontend (embedded). Serve "/" → index.html via http.FileServerFS.
// Wrap in noCache so the browser revalidates with the ETag/Last-Modified
// the file server already emits — without this, browsers cache aggressively
// and m sees the old main.js after every redeploy until hard-reload.
mux.Handle("/", noCache(http.FileServerFS(frontend)))
return mux
}
// noCache wraps a static handler so each response carries
// Cache-Control: no-cache. Combined with the ETag/Last-Modified headers
// http.FileServer(FS) already emits, this turns every fetch into a
// cheap revalidation request — the browser uses its cached body when
// the ETag matches but always asks first, so freshly-built assets show
// up on the next page load without a hard-reload.
//
// Applied to the static-asset handler only — API responses write their
// own headers and aren't routed through this.
func noCache(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
h.ServeHTTP(w, r)
})
}

137
web/static/index.html Normal file
View File

@@ -0,0 +1,137 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>mCables</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<header class="topbar">
<span class="brand">mCables</span>
<div class="project-picker">
<label for="project-select" class="sr-only">Project</label>
<select id="project-select" aria-label="Active project">
<option value="">— no project —</option>
</select>
<button type="button" id="btn-new-project" class="btn">+ Project</button>
<button type="button" id="btn-delete-project" class="btn btn-danger" hidden>
Delete
</button>
</div>
<div class="topbar-spacer"></div>
<button type="button" id="btn-export" class="btn" disabled title="Slice 5">
Export
</button>
</header>
<main class="layout">
<aside class="sidebar" aria-label="Tools">
<section class="legend">
<h2 class="sidebar-heading">Cable types</h2>
<ul id="legend-list" class="legend-list"></ul>
<button type="button" id="btn-add-type" class="btn btn-tiny">+ Type</button>
</section>
<section class="tools">
<h2 class="sidebar-heading">Tools</h2>
<ul class="tool-list">
<li><button type="button" id="tool-frame" class="btn btn-tiny" data-tool="frame">+ Frame</button></li>
<li><button type="button" id="tool-device" class="btn btn-tiny" data-tool="device">+ Device</button></li>
<li><button type="button" class="btn btn-tiny" disabled title="Slice 4">+ IO</button></li>
<li><button type="button" class="btn btn-tiny" disabled title="Slice 3">Draw cable</button></li>
</ul>
</section>
</aside>
<section class="canvas-wrap" aria-label="Diagram">
<svg id="canvas" viewBox="0 0 2000 1500" preserveAspectRatio="xMidYMid meet">
<g id="canvas-frames"></g>
<g id="canvas-devices"></g>
<g id="canvas-ports"></g>
<g id="canvas-cables"></g>
<g id="canvas-io"></g>
</svg>
<p id="empty-hint" class="empty-hint">
Pick or create a project to start drawing.
</p>
</section>
<aside class="inspector" aria-label="Inspector">
<h2 class="sidebar-heading">Inspector</h2>
<div id="inspector-body">
<p class="muted">Nothing selected.</p>
</div>
</aside>
</main>
<!-- New Project modal -->
<dialog id="modal-new-project" class="modal" aria-labelledby="np-title">
<form method="dialog" id="form-new-project">
<h2 id="np-title">New project</h2>
<label class="field">
<span>Name</span>
<input type="text" name="name" required autocomplete="off" />
</label>
<label class="field">
<span>Drawing name</span>
<input type="text" name="drawing_name" autocomplete="off"
placeholder="auto: <name>.excalidraw" />
</label>
<label class="field">
<span>Description</span>
<textarea name="description" rows="2"></textarea>
</label>
<p class="form-error" id="np-error" hidden></p>
<div class="actions">
<button type="submit" class="btn btn-primary">Create</button>
<button type="button" class="btn" data-close>Cancel</button>
</div>
</form>
</dialog>
<!-- New/Edit Cable Type modal -->
<dialog id="modal-cable-type" class="modal" aria-labelledby="ct-title">
<form method="dialog" id="form-cable-type">
<h2 id="ct-title">Cable type</h2>
<p class="banner">
Cable types are shared across all projects. Renaming or recolouring
affects every project.
</p>
<label class="field">
<span>Name</span>
<input type="text" name="name" required autocomplete="off" />
</label>
<label class="field">
<span>Colour</span>
<input type="color" name="color" value="#1971c2" />
</label>
<p class="form-error" id="ct-error" hidden></p>
<div class="actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn" data-close>Cancel</button>
</div>
</form>
</dialog>
<!-- Delete Project confirm -->
<dialog id="modal-delete-project" class="modal" aria-labelledby="dp-title">
<form method="dialog" id="form-delete-project">
<h2 id="dp-title">Delete project</h2>
<p>
This will cascade-delete every frame, device, port, cable, IO marker
and bundle in the project. <strong>Cable types are global and are not affected.</strong>
</p>
<p>Type the project name to confirm:</p>
<input type="text" name="confirm" required autocomplete="off"
id="dp-confirm-input" />
<p class="form-error" id="dp-error" hidden></p>
<div class="actions">
<button type="submit" class="btn btn-danger">Delete</button>
<button type="button" class="btn" data-close>Cancel</button>
</div>
</form>
</dialog>
<script type="module" src="/main.js"></script>
</body>
</html>

849
web/static/main.js Normal file
View File

@@ -0,0 +1,849 @@
// mCables frontend entry — vanilla ES module, no build step.
//
// Slice 2 adds: frame + device rendering, +Frm/+Dev tools, drag-to-position,
// inline naming, inspector for selection. State stays minimal: one
// snapshot from the server, then individual PATCHes on each mutation.
/**
* @typedef {{ id: number, name: string, drawing_name: string,
* description: string, created_at: string, updated_at: string }} Project
* @typedef {{ id: number, name: string, color: string,
* created_at: string, updated_at: string }} CableType
* @typedef {{ id: number, project_id: number, name: string,
* x: number, y: number, width: number, height: number }} Frame
* @typedef {{ id: number, project_id: number, frame_id: number|null,
* name: string, color: string,
* x: number, y: number, width: number, height: number }} Device
*/
const API = "/api";
const SVG_NS = "http://www.w3.org/2000/svg";
const state = {
/** @type {Project[]} */ projects: [],
/** @type {CableType[]} */ cableTypes: [],
/** @type {Project | null} */ active: null,
/** @type {Frame[]} */ frames: [],
/** @type {Device[]} */ devices: [],
activeTypeId: /** @type {number|null} */ (null),
/** "frame" | "device" | null */
tool: /** @type {string|null} */ (null),
/** @type {{kind: "frame"|"device", id: number} | null} */ selection: null,
};
// ---------- API client ---------- //
async function api(method, path, body) {
const res = await fetch(API + path, {
method,
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 204) return null;
const text = await res.text();
const json = text ? JSON.parse(text) : null;
if (!res.ok) {
const err = new Error(json?.error || res.statusText);
err.status = res.status;
err.details = json?.details;
throw err;
}
return json;
}
const listProjects = () => api("GET", "/projects");
const createProject = (body) => api("POST", "/projects", body);
const deleteProject = (id, confirm) =>
api("DELETE", `/projects/${id}?confirm=${encodeURIComponent(confirm)}`);
const getSnapshot = (id) => api("GET", `/projects/${id}`);
const listCableTypes = () => api("GET", "/cable-types");
const createCableType = (body) => api("POST", "/cable-types", body);
const patchCableType = (id, body) => api("PATCH", `/cable-types/${id}`, body);
const deleteCableType = (id) => api("DELETE", `/cable-types/${id}`);
const createFrame = (pid, body) => api("POST", `/projects/${pid}/frames`, body);
const patchFrame = (pid, id, body) => api("PATCH", `/projects/${pid}/frames/${id}`, body);
const deleteFrame = (pid, id) => api("DELETE", `/projects/${pid}/frames/${id}`);
const createDevice = (pid, body) => api("POST", `/projects/${pid}/devices`, body);
const patchDevice = (pid, id, body) => api("PATCH", `/projects/${pid}/devices/${id}`, body);
const deleteDevice = (pid, id) => api("DELETE", `/projects/${pid}/devices/${id}`);
// ---------- DOM helpers ---------- //
const $ = (sel) => /** @type {HTMLElement} */ (document.querySelector(sel));
function setHidden(el, hidden) {
if (hidden) el.setAttribute("hidden", "");
else el.removeAttribute("hidden");
}
function svgEl(name, attrs = {}) {
const el = document.createElementNS(SVG_NS, name);
for (const [k, v] of Object.entries(attrs)) {
if (v == null) continue;
el.setAttribute(k, String(v));
}
return el;
}
// ---------- URL state ---------- //
function activeProjectIdFromURL() {
const raw = new URLSearchParams(location.search).get("project");
const id = raw && Number.parseInt(raw, 10);
return Number.isFinite(id) && id > 0 ? id : null;
}
function setActiveInURL(id) {
const url = new URL(location.href);
if (id == null) url.searchParams.delete("project");
else url.searchParams.set("project", String(id));
history.replaceState(null, "", url.toString());
}
// ---------- geometry ---------- //
/** Returns the smallest frame whose bbox contains (x, y), or null. */
function frameAt(x, y) {
/** @type {Frame|null} */ let best = null;
let bestArea = Infinity;
for (const f of state.frames) {
if (x < f.x || x > f.x + f.width || y < f.y || y > f.y + f.height) continue;
const a = f.width * f.height;
if (a < bestArea) { best = f; bestArea = a; }
}
return best;
}
/** Convert a pointer event to SVG-canvas coordinates. */
function svgPoint(evt) {
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
const pt = svg.createSVGPoint();
pt.x = evt.clientX;
pt.y = evt.clientY;
const ctm = svg.getScreenCTM();
if (!ctm) return { x: 0, y: 0 };
const local = pt.matrixTransform(ctm.inverse());
return { x: local.x, y: local.y };
}
// ---------- render ---------- //
function renderProjectPicker() {
const sel = /** @type {HTMLSelectElement} */ ($("#project-select"));
const current = state.active?.id ?? "";
sel.innerHTML = "";
sel.append(new Option("— pick a project —", ""));
for (const p of state.projects) {
const opt = new Option(p.name, String(p.id));
if (p.id === current) opt.selected = true;
sel.append(opt);
}
setHidden($("#btn-delete-project"), !state.active);
}
function renderLegend() {
const ul = $("#legend-list");
ul.innerHTML = "";
for (const t of state.cableTypes) {
const li = document.createElement("li");
li.className = "legend-row";
li.dataset.id = String(t.id);
if (state.activeTypeId === t.id) li.setAttribute("aria-current", "true");
li.innerHTML = `
<span class="legend-swatch" style="background:${t.color}"></span>
<span class="legend-name"></span>
<button type="button" class="legend-edit" aria-label="Edit cable type">edit</button>
`;
li.querySelector(".legend-name").textContent = t.name;
li.addEventListener("click", (e) => {
if (e.target instanceof HTMLElement && e.target.classList.contains("legend-edit")) {
openCableTypeModal(t);
e.stopPropagation();
return;
}
state.activeTypeId = state.activeTypeId === t.id ? null : t.id;
renderLegend();
});
ul.append(li);
}
}
function renderEmptyHint() {
const hint = $("#empty-hint");
if (!state.active) {
hint.textContent = state.projects.length
? "Pick a project from the dropdown to start drawing."
: "Create your first project to get started.";
setHidden(hint, false);
return;
}
if (state.frames.length === 0 && state.devices.length === 0) {
hint.textContent = `${state.active.name} — empty. Use + Frame / + Device to start (press F or D).`;
setHidden(hint, false);
} else {
setHidden(hint, true);
}
}
function renderCanvas() {
const gFrames = $("#canvas-frames");
const gDevices = $("#canvas-devices");
gFrames.innerHTML = "";
gDevices.innerHTML = "";
for (const f of state.frames) {
const g = svgEl("g", { "data-frame-id": f.id });
const rect = svgEl("rect", {
x: f.x, y: f.y, width: f.width, height: f.height,
class: "frame-rect svg-draggable",
rx: 6, ry: 6,
});
if (state.selection?.kind === "frame" && state.selection.id === f.id) {
rect.classList.add("selected");
}
const label = svgEl("text", {
x: f.x + 8, y: f.y + 18,
class: "frame-label",
});
label.textContent = f.name;
g.append(rect, label);
gFrames.append(g);
rect.addEventListener("pointerdown", (e) => startDrag(e, "frame", f.id));
}
for (const d of state.devices) {
const g = svgEl("g", { "data-device-id": d.id });
const rect = svgEl("rect", {
x: d.x, y: d.y, width: d.width, height: d.height,
class: "device-rect svg-draggable",
stroke: d.color,
rx: 3, ry: 3,
});
if (state.selection?.kind === "device" && state.selection.id === d.id) {
rect.classList.add("selected");
}
const label = svgEl("text", {
x: d.x + d.width / 2, y: d.y + d.height / 2,
class: "device-label",
});
label.textContent = d.name;
g.append(rect, label);
gDevices.append(g);
rect.addEventListener("pointerdown", (e) => startDrag(e, "device", d.id));
}
}
function renderInspector() {
const body = $("#inspector-body");
if (!state.selection) {
body.innerHTML = `<p class="muted">Nothing selected.</p>`;
return;
}
if (state.selection.kind === "frame") {
renderInspectorFrame(body, state.selection.id);
} else {
renderInspectorDevice(body, state.selection.id);
}
}
function renderInspectorFrame(body, id) {
const f = state.frames.find((x) => x.id === id);
if (!f) { body.innerHTML = ""; return; }
const deviceCount = state.devices.filter((d) => d.frame_id === f.id).length;
body.innerHTML = `
<p class="section-title">Frame</p>
<label class="field">
<span>Name</span>
<input class="inline-input" id="frm-name" value="" />
</label>
<dl>
<dt>x</dt><dd id="frm-x"></dd>
<dt>y</dt><dd id="frm-y"></dd>
<dt>w</dt><dd id="frm-w"></dd>
<dt>h</dt><dd id="frm-h"></dd>
<dt>devices</dt><dd id="frm-count"></dd>
</dl>
<div class="inspector-actions">
<button type="button" class="btn btn-danger btn-tiny" id="frm-delete">Delete frame</button>
</div>
`;
body.querySelector("#frm-name").value = f.name;
body.querySelector("#frm-x").textContent = f.x.toFixed(0);
body.querySelector("#frm-y").textContent = f.y.toFixed(0);
body.querySelector("#frm-w").textContent = f.width.toFixed(0);
body.querySelector("#frm-h").textContent = f.height.toFixed(0);
body.querySelector("#frm-count").textContent = String(deviceCount);
bindDebouncedRename(body.querySelector("#frm-name"), async (name) => {
if (!state.active) return;
const updated = await patchFrame(state.active.id, f.id, { name });
Object.assign(f, updated);
renderCanvas();
});
body.querySelector("#frm-delete").addEventListener("click", () => {
if (!state.active) return;
if (!confirm(`Delete frame "${f.name}"? Its devices stay but lose their frame.`)) return;
deleteFrame(state.active.id, f.id).then(() => {
state.frames = state.frames.filter((x) => x.id !== f.id);
for (const d of state.devices) if (d.frame_id === f.id) d.frame_id = null;
state.selection = null;
render();
}).catch((e) => alert(`Delete failed: ${e.message}`));
});
}
function renderInspectorDevice(body, id) {
const d = state.devices.find((x) => x.id === id);
if (!d) { body.innerHTML = ""; return; }
const frame = d.frame_id ? state.frames.find((f) => f.id === d.frame_id) : null;
body.innerHTML = `
<p class="section-title">Device</p>
<label class="field">
<span>Name</span>
<input class="inline-input" id="dev-name" value="" />
</label>
<label class="field">
<span>Colour</span>
<input type="color" class="inline-input" id="dev-color" />
</label>
<dl>
<dt>x</dt><dd id="dev-x"></dd>
<dt>y</dt><dd id="dev-y"></dd>
<dt>w</dt><dd id="dev-w"></dd>
<dt>h</dt><dd id="dev-h"></dd>
<dt>frame</dt><dd id="dev-frame"></dd>
</dl>
<div class="inspector-actions">
<button type="button" class="btn btn-danger btn-tiny" id="dev-delete">Delete device</button>
</div>
`;
body.querySelector("#dev-name").value = d.name;
body.querySelector("#dev-color").value = d.color;
body.querySelector("#dev-x").textContent = d.x.toFixed(0);
body.querySelector("#dev-y").textContent = d.y.toFixed(0);
body.querySelector("#dev-w").textContent = d.width.toFixed(0);
body.querySelector("#dev-h").textContent = d.height.toFixed(0);
body.querySelector("#dev-frame").textContent = frame ? frame.name : "—";
bindDebouncedRename(body.querySelector("#dev-name"), async (name) => {
if (!state.active) return;
const updated = await patchDevice(state.active.id, d.id, { name });
Object.assign(d, updated);
renderCanvas();
});
// Colour changes need no debounce — the native colour picker only fires
// `change` on commit.
body.querySelector("#dev-color").addEventListener("change", async (e) => {
if (!state.active) return;
const color = /** @type {HTMLInputElement} */ (e.target).value;
try {
const updated = await patchDevice(state.active.id, d.id, { color });
Object.assign(d, updated);
renderCanvas();
} catch (err) {
alert(`Colour update failed: ${err.message}`);
}
});
body.querySelector("#dev-delete").addEventListener("click", () => {
if (!state.active) return;
if (!confirm(`Delete device "${d.name}"?`)) return;
deleteDevice(state.active.id, d.id).then(() => {
state.devices = state.devices.filter((x) => x.id !== d.id);
state.selection = null;
render();
}).catch((e) => alert(`Delete failed: ${e.message}`));
});
}
function bindDebouncedRename(input, persist) {
let timer = null;
input.addEventListener("input", () => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
const v = input.value.trim();
if (v) persist(v).catch((e) => alert(`Save failed: ${e.message}`));
}, 400);
});
input.addEventListener("blur", () => {
if (timer) { clearTimeout(timer); timer = null; }
const v = input.value.trim();
if (v && v !== input.dataset.last) {
persist(v).catch((e) => alert(`Save failed: ${e.message}`));
input.dataset.last = v;
}
});
}
function render() {
renderProjectPicker();
renderLegend();
renderCanvas();
renderEmptyHint();
renderInspector();
}
// ---------- active project ---------- //
async function activateProject(id) {
if (id == null) {
state.active = null;
state.frames = [];
state.devices = [];
state.selection = null;
setActiveInURL(null);
render();
return;
}
try {
const snap = await getSnapshot(id);
state.active = snap.project;
state.frames = snap.frames || [];
state.devices = snap.devices || [];
state.cableTypes = snap.cable_types || [];
state.selection = null;
setActiveInURL(id);
render();
} catch (err) {
if (err.status === 404) {
state.active = null;
state.frames = [];
state.devices = [];
setActiveInURL(null);
render();
} else {
alert(`Failed to load project: ${err.message}`);
}
}
}
// ---------- tools ---------- //
function armTool(tool) {
if (state.tool === tool) tool = null; // toggle off
state.tool = tool;
const wrap = $(".canvas-wrap");
wrap.classList.toggle("tool-frame", tool === "frame");
wrap.classList.toggle("tool-device", tool === "device");
for (const btn of document.querySelectorAll("[data-tool]")) {
btn.classList.toggle("armed", btn.getAttribute("data-tool") === tool);
}
}
function bindTools() {
for (const btn of document.querySelectorAll("[data-tool]")) {
btn.addEventListener("click", () => armTool(btn.getAttribute("data-tool")));
}
document.addEventListener("keydown", (e) => {
// Avoid stealing keys while user is typing into an input.
const tag = (e.target instanceof HTMLElement) ? e.target.tagName : "";
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
if (e.key === "Escape") { armTool(null); cancelInlineNamer(); state.selection = null; render(); }
else if (e.key === "f" || e.key === "F") armTool("frame");
else if (e.key === "d" || e.key === "D") armTool("device");
});
// Canvas-level pointerdown handles tool activation + selection clearing.
$("#canvas").addEventListener("pointerdown", onCanvasPointerDown);
}
let rubberBand = /** @type {SVGRectElement|null} */ (null);
let rubberStart = /** @type {{x:number,y:number}|null} */ (null);
function onCanvasPointerDown(e) {
if (!state.active) return;
const p = svgPoint(e);
// Armed tool wins: a click anywhere on the canvas — including on top
// of an existing frame or device — fires the tool. The +Dev tool needs
// this so m can drop a device inside a frame; without it the frame's
// own pointerdown handler would steal the click and start a drag.
if (state.tool === "frame") {
startFrameRubberBand(e, p);
return;
}
if (state.tool === "device") {
placeDeviceAt(p);
return;
}
// No tool armed: clicks that started on a device/frame go to their
// own handlers (drag / select). Leave them alone.
if (e.target instanceof Element && e.target.closest("[data-device-id], [data-frame-id]")) return;
// Plain canvas click = clear selection.
if (state.selection) { state.selection = null; render(); }
}
function startFrameRubberBand(e, p0) {
if (!state.active) return;
rubberStart = p0;
rubberBand = svgEl("rect", {
x: p0.x, y: p0.y, width: 0, height: 0,
class: "rubber-band", rx: 6, ry: 6,
});
$("#canvas").append(rubberBand);
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
svg.setPointerCapture(e.pointerId);
const onMove = (ev) => {
if (!rubberBand || !rubberStart) return;
const p = svgPoint(ev);
const x = Math.min(rubberStart.x, p.x);
const y = Math.min(rubberStart.y, p.y);
rubberBand.setAttribute("x", String(x));
rubberBand.setAttribute("y", String(y));
rubberBand.setAttribute("width", String(Math.abs(p.x - rubberStart.x)));
rubberBand.setAttribute("height", String(Math.abs(p.y - rubberStart.y)));
};
const onUp = async (ev) => {
svg.removeEventListener("pointermove", onMove);
svg.removeEventListener("pointerup", onUp);
svg.releasePointerCapture(e.pointerId);
const rect = rubberBand;
const start = rubberStart;
rubberBand = null;
rubberStart = null;
if (!rect || !start) return;
const w = Number(rect.getAttribute("width"));
const h = Number(rect.getAttribute("height"));
const x = Number(rect.getAttribute("x"));
const y = Number(rect.getAttribute("y"));
rect.remove();
if (w < 80 || h < 60) { armTool(null); return; }
armTool(null);
const name = await promptInline("Frame name", x + w / 2, y + 16);
if (!name || !state.active) return;
try {
const f = await createFrame(state.active.id, { name, x, y, width: w, height: h });
state.frames.push(f);
state.selection = { kind: "frame", id: f.id };
render();
} catch (err) {
alert(`Create frame failed: ${err.message}`);
}
};
svg.addEventListener("pointermove", onMove);
svg.addEventListener("pointerup", onUp);
}
async function placeDeviceAt(p) {
if (!state.active) return;
armTool(null);
const W = 100, H = 35;
const x = p.x - W / 2;
const y = p.y - H / 2;
const name = await promptInline("Device name", p.x, p.y);
if (!name || !state.active) return;
const frame = frameAt(p.x, p.y);
try {
const d = await createDevice(state.active.id, {
name, x, y, width: W, height: H,
frame_id: frame ? frame.id : undefined,
});
state.devices.push(d);
state.selection = { kind: "device", id: d.id };
render();
} catch (err) {
alert(`Create device failed: ${err.message}`);
}
}
// ---------- inline namer (foreignObject overlay) ---------- //
let activeNamer = /** @type {SVGForeignObjectElement|null} */ (null);
function cancelInlineNamer() {
if (activeNamer) { activeNamer.remove(); activeNamer = null; }
}
function promptInline(placeholder, cx, cy) {
cancelInlineNamer();
return new Promise((resolve) => {
const fo = document.createElementNS(SVG_NS, "foreignObject");
fo.setAttribute("x", String(cx - 110));
fo.setAttribute("y", String(cy - 14));
fo.setAttribute("width", "220");
fo.setAttribute("height", "28");
fo.innerHTML = `
<div class="inline-namer" xmlns="http://www.w3.org/1999/xhtml">
<input type="text" placeholder="${placeholder}" />
</div>
`;
$("#canvas").append(fo);
activeNamer = fo;
const input = fo.querySelector("input");
input.focus();
const done = (val) => {
if (activeNamer === fo) { fo.remove(); activeNamer = null; }
resolve(val);
};
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") done(input.value.trim());
else if (e.key === "Escape") done(null);
});
input.addEventListener("blur", () => done(input.value.trim() || null));
});
}
// ---------- drag ---------- //
function startDrag(e, kind, id) {
if (!state.active) return;
if (state.tool) return; // a tool is armed; don't hijack
e.stopPropagation();
state.selection = { kind, id };
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
const start = svgPoint(e);
/** @type {Frame|Device|undefined} */
const obj = kind === "frame"
? state.frames.find((f) => f.id === id)
: state.devices.find((d) => d.id === id);
if (!obj) return;
const startX = obj.x;
const startY = obj.y;
// For frame drags, remember the contained devices + their offsets so
// they follow the frame visually + persist on release.
let trackedDevices = /** @type {{d: Device, sx: number, sy: number}[]} */ ([]);
if (kind === "frame") {
for (const d of state.devices) {
if (d.frame_id === obj.id) {
trackedDevices.push({ d, sx: d.x, sy: d.y });
}
}
}
e.currentTarget.classList.add("dragging");
svg.setPointerCapture(e.pointerId);
let dragged = false;
const onMove = (ev) => {
const p = svgPoint(ev);
const dx = p.x - start.x;
const dy = p.y - start.y;
if (!dragged && (Math.abs(dx) + Math.abs(dy) > 1)) dragged = true;
obj.x = startX + dx;
obj.y = startY + dy;
if (kind === "frame") {
for (const t of trackedDevices) { t.d.x = t.sx + dx; t.d.y = t.sy + dy; }
}
renderCanvas();
};
const onUp = async (ev) => {
svg.removeEventListener("pointermove", onMove);
svg.removeEventListener("pointerup", onUp);
svg.releasePointerCapture(e.pointerId);
e.currentTarget.classList.remove("dragging");
if (!dragged) { render(); return; } // click only — re-render to apply selection halo
if (!state.active) return;
try {
if (kind === "frame") {
const f = /** @type {Frame} */ (obj);
await patchFrame(state.active.id, f.id, { x: f.x, y: f.y });
// Persist contained devices too.
await Promise.all(
trackedDevices.map((t) =>
patchDevice(state.active.id, t.d.id, { x: t.d.x, y: t.d.y })),
);
} else {
const d = /** @type {Device} */ (obj);
// Recompute frame_id from drop point (centre of device).
const cx = d.x + d.width / 2;
const cy = d.y + d.height / 2;
const targetFrame = frameAt(cx, cy);
const newFrameID = targetFrame ? targetFrame.id : null;
const patchBody = { x: d.x, y: d.y };
if ((d.frame_id ?? null) !== newFrameID) {
patchBody.frame_id = newFrameID; // explicit null = clear
d.frame_id = newFrameID;
}
await patchDevice(state.active.id, d.id, patchBody);
}
} catch (err) {
alert(`Save failed: ${err.message}`);
}
render();
};
svg.addEventListener("pointermove", onMove);
svg.addEventListener("pointerup", onUp);
}
// ---------- modals (project / cable type) ---------- //
function bindCloseButtons(dialog) {
dialog.querySelectorAll("[data-close]").forEach((btn) =>
btn.addEventListener("click", () => dialog.close()),
);
}
function showError(el, msg) {
if (!msg) { setHidden(el, true); el.textContent = ""; return; }
el.textContent = msg;
setHidden(el, false);
}
function openNewProjectModal() {
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-new-project"));
const form = /** @type {HTMLFormElement} */ ($("#form-new-project"));
const err = $("#np-error");
form.reset();
showError(err, "");
dlg.showModal();
form.elements.namedItem("name").focus();
form.onsubmit = async (e) => {
e.preventDefault();
const fd = new FormData(form);
const body = {
name: String(fd.get("name") || "").trim(),
drawing_name: String(fd.get("drawing_name") || "").trim(),
description: String(fd.get("description") || ""),
};
if (!body.drawing_name) delete body.drawing_name;
try {
const p = await createProject(body);
state.projects = await listProjects();
dlg.close();
await activateProject(p.id);
} catch (e) {
showError(err, e.message || "Failed to create project");
}
};
}
function openCableTypeModal(existing) {
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-cable-type"));
const form = /** @type {HTMLFormElement} */ ($("#form-cable-type"));
const err = $("#ct-error");
const title = $("#ct-title");
form.reset();
showError(err, "");
title.textContent = existing ? `Edit "${existing.name}"` : "New cable type";
if (existing) {
form.elements.namedItem("name").value = existing.name;
form.elements.namedItem("color").value = existing.color;
} else {
form.elements.namedItem("color").value = "#1971c2";
}
const actions = form.querySelector(".actions");
actions.querySelector(".btn-delete-type")?.remove();
if (existing) {
const del = document.createElement("button");
del.type = "button";
del.className = "btn btn-danger btn-delete-type";
del.style.marginRight = "auto";
del.textContent = "Delete";
del.addEventListener("click", async () => {
try {
await deleteCableType(existing.id);
state.cableTypes = await listCableTypes();
if (state.activeTypeId === existing.id) state.activeTypeId = null;
dlg.close();
render();
} catch (e) {
const n = e.details?.in_use_by_cables;
showError(err, n ? `In use by ${n} cable${n === 1 ? "" : "s"}` : (e.message || "Delete failed"));
}
});
actions.prepend(del);
}
dlg.showModal();
form.elements.namedItem("name").focus();
form.onsubmit = async (e) => {
e.preventDefault();
const fd = new FormData(form);
const body = {
name: String(fd.get("name") || "").trim(),
color: String(fd.get("color") || "").trim(),
};
try {
if (existing) await patchCableType(existing.id, body);
else await createCableType(body);
state.cableTypes = await listCableTypes();
dlg.close();
render();
} catch (e) {
showError(err, e.message || "Save failed");
}
};
}
function openDeleteProjectModal() {
if (!state.active) return;
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-delete-project"));
const form = /** @type {HTMLFormElement} */ ($("#form-delete-project"));
const err = $("#dp-error");
const input = /** @type {HTMLInputElement} */ ($("#dp-confirm-input"));
form.reset();
showError(err, "");
input.placeholder = state.active.name;
dlg.showModal();
input.focus();
form.onsubmit = async (e) => {
e.preventDefault();
const confirm = String(new FormData(form).get("confirm") || "");
try {
await deleteProject(state.active.id, confirm);
state.projects = await listProjects();
dlg.close();
await activateProject(null);
} catch (e) {
showError(err, e.message || "Delete failed");
}
};
}
// ---------- boot ---------- //
async function boot() {
bindCloseButtons($("#modal-new-project"));
bindCloseButtons($("#modal-cable-type"));
bindCloseButtons($("#modal-delete-project"));
$("#btn-new-project").addEventListener("click", openNewProjectModal);
$("#btn-add-type").addEventListener("click", () => openCableTypeModal(null));
$("#btn-delete-project").addEventListener("click", openDeleteProjectModal);
$("#project-select").addEventListener("change", (e) => {
const v = /** @type {HTMLSelectElement} */ (e.target).value;
activateProject(v ? Number(v) : null);
});
bindTools();
try {
[state.projects, state.cableTypes] = await Promise.all([
listProjects(),
listCableTypes(),
]);
} catch (e) {
alert(`Failed to load: ${e.message}`);
return;
}
const wanted = activeProjectIdFromURL();
if (wanted && state.projects.some((p) => p.id === wanted)) {
await activateProject(wanted);
} else {
render();
}
}
boot();

370
web/static/style.css Normal file
View File

@@ -0,0 +1,370 @@
:root {
--bg: #fafafa;
--surface: #ffffff;
--surface-2: #f4f4f5;
--border: #d4d4d8;
--text: #18181b;
--text-muted: #71717a;
--accent: #1971c2;
--danger: #e03131;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 2px 8px rgba(0, 0, 0, 0.04);
--radius: 4px;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
height: 100%;
background: var(--bg);
color: var(--text);
font: 14px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
}
body {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
/* ---------- topbar ---------- */
.topbar {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.brand {
font-weight: 600;
font-size: 15px;
}
.project-picker {
display: flex;
align-items: center;
gap: 6px;
}
.topbar-spacer { flex: 1; }
/* ---------- layout ---------- */
.layout {
display: grid;
grid-template-columns: 220px 1fr 280px;
flex: 1;
min-height: 0;
}
.sidebar,
.inspector {
background: var(--surface);
padding: 12px;
overflow-y: auto;
}
.sidebar { border-right: 1px solid var(--border); }
.inspector { border-left: 1px solid var(--border); }
.sidebar-heading {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin: 0 0 8px 0;
}
.tool-list,
.legend-list {
list-style: none;
padding: 0;
margin: 0 0 8px 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.legend-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
border-radius: var(--radius);
cursor: pointer;
}
.legend-row:hover { background: var(--surface-2); }
.legend-row[aria-current="true"] {
background: var(--surface-2);
outline: 1px solid var(--accent);
}
.legend-swatch {
width: 14px;
height: 14px;
border-radius: 3px;
border: 1px solid rgba(0, 0, 0, 0.15);
flex-shrink: 0;
}
.legend-name { flex: 1; }
.legend-edit {
background: transparent;
border: 0;
cursor: pointer;
color: var(--text-muted);
padding: 2px 4px;
border-radius: 2px;
font-size: 12px;
}
.legend-edit:hover { color: var(--text); background: var(--surface-2); }
/* ---------- canvas ---------- */
.canvas-wrap {
position: relative;
overflow: hidden;
background: #f7f7f7;
background-image:
linear-gradient(to right, rgba(0,0,0,0.04) 1px, transparent 1px),
linear-gradient(to bottom, rgba(0,0,0,0.04) 1px, transparent 1px);
background-size: 50px 50px;
}
#canvas {
width: 100%;
height: 100%;
display: block;
}
.empty-hint {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--text-muted);
font-size: 14px;
pointer-events: none;
background: rgba(255, 255, 255, 0.85);
padding: 8px 14px;
border-radius: var(--radius);
}
.muted { color: var(--text-muted); }
/* ---------- canvas elements ---------- */
.frame-rect {
fill: rgba(25, 113, 194, 0.04);
stroke: var(--accent);
stroke-width: 1.5;
stroke-dasharray: 6 4;
}
.frame-rect.selected,
.frame-rect:hover { stroke-width: 2.5; }
.frame-label {
fill: var(--accent);
font-size: 13px;
font-weight: 600;
pointer-events: none;
}
.device-rect {
fill: #fff;
stroke: var(--text);
stroke-width: 1.5;
}
.device-rect.selected { stroke-width: 3; }
.device-rect:hover { filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.15)); }
.device-label {
fill: var(--text);
font-size: 12px;
text-anchor: middle;
dominant-baseline: central;
pointer-events: none;
user-select: none;
}
.svg-draggable { cursor: grab; }
.svg-draggable.dragging { cursor: grabbing; }
/* Tool cursor while a tool is armed. The `* { ... !important }` descendant
rule is the load-bearing part: without it, the `.svg-draggable` rules
on individual frame/device rects win by element specificity and
override the SVG-root cursor — so hovering a frame with +Dev armed
shows `grab`, which lies about what a click will do. */
.canvas-wrap.tool-frame #canvas,
.canvas-wrap.tool-frame #canvas *,
.canvas-wrap.tool-device #canvas,
.canvas-wrap.tool-device #canvas * { cursor: crosshair !important; }
.rubber-band {
fill: rgba(25, 113, 194, 0.08);
stroke: var(--accent);
stroke-width: 1;
stroke-dasharray: 4 4;
pointer-events: none;
}
/* tool buttons toggle armed-state */
.btn[data-tool].armed {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
/* ---------- inspector ---------- */
.inspector dl {
margin: 0;
display: grid;
grid-template-columns: 80px 1fr;
gap: 4px 8px;
font-size: 12px;
}
.inspector dt { color: var(--text-muted); }
.inspector dd { margin: 0; }
.inspector .inline-input {
font: inherit;
width: 100%;
padding: 4px 6px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
}
.inspector .inline-input:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
border-color: var(--accent);
}
.inspector .section-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin: 12px 0 6px 0;
}
.inspector .inspector-actions {
display: flex;
gap: 6px;
margin-top: 12px;
}
/* foreignObject used to inline-name a freshly-placed frame/device */
.inline-namer {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.inline-namer input {
font: inherit;
font-size: 12px;
padding: 2px 4px;
border: 2px solid var(--accent);
border-radius: var(--radius);
background: #fff;
width: calc(100% - 8px);
max-width: 200px;
text-align: center;
}
/* ---------- buttons ---------- */
.btn {
font: inherit;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
padding: 4px 10px;
border-radius: var(--radius);
cursor: pointer;
box-shadow: var(--shadow);
}
.btn:hover { background: var(--surface-2); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; box-shadow: none; }
.btn-tiny { padding: 2px 8px; font-size: 12px; }
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn-primary:hover { background: #155da3; }
.btn-danger { background: var(--danger); color: #fff; border-color: var(--danger); }
.btn-danger:hover { background: #b02828; }
/* ---------- dialog ---------- */
.modal {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0;
width: 380px;
max-width: calc(100vw - 32px);
background: var(--surface);
box-shadow: 0 10px 30px rgba(0,0,0,0.18);
}
.modal::backdrop { background: rgba(0,0,0,0.3); }
.modal form { padding: 16px; }
.modal h2 { margin: 0 0 12px 0; font-size: 16px; }
.modal .banner {
background: #fff8e1;
border: 1px solid #f5d76e;
color: #5b4500;
padding: 8px 10px;
border-radius: var(--radius);
font-size: 13px;
margin: 0 0 12px 0;
}
.modal .actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.modal .form-error {
color: var(--danger);
font-size: 13px;
margin: 6px 0 0 0;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
margin: 0 0 10px 0;
}
.field span {
font-size: 12px;
color: var(--text-muted);
}
.field input,
.field textarea {
font: inherit;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
width: 100%;
}
.field input:focus,
.field textarea:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
border-color: var(--accent);
}

23
web/web.go Normal file
View File

@@ -0,0 +1,23 @@
// Package web bundles the frontend (HTML/JS/CSS) into the Go binary
// via embed.FS so deploying mCables means shipping one file.
package web
import (
"embed"
"io/fs"
)
//go:embed all:static
var assets embed.FS
// Static returns the frontend filesystem rooted at the package's static/
// dir so callers see index.html at "/".
func Static() fs.FS {
sub, err := fs.Sub(assets, "static")
if err != nil {
// embed sub-rooting can only fail if "static" doesn't exist,
// which is a build-time error. Panic is the right shape.
panic(err)
}
return sub
}