Skip to content

Stop installing the dev docker-compose unit on production hosts - #15

Closed
DenDanskeMine wants to merge 105 commits into
mainfrom
fix/installer-links-dev-infra-unit
Closed

Stop installing the dev docker-compose unit on production hosts#15
DenDanskeMine wants to merge 105 commits into
mainfrom
fix/installer-links-dev-infra-unit

Conversation

@DenDanskeMine

Copy link
Copy Markdown
Contributor

Fixes #14.

A production install provisions PostgreSQL and Redis natively — install.sh enables the distro services and creates the role and database with psql. But it also ran make install-services, which links the dev unit set, and that includes danbyte-infra: docker compose up -d postgres redis. Then danbyte-web and danbyte-workers both declared Wants=danbyte-infra.service, so enabling them started the container stack.

On a host that already ran PostgreSQL that left an idle container with an empty database, publishing 5432 against the server Danbyte is actually using.

Worth calling out: harmless as observed, but the two compete for the port and whichever starts first wins it. A reboot could have pointed Danbyte at the empty container, migrated into it, and looked exactly like total data loss — while the real data sat unreachable in the host cluster.

Changes

  • Makefile — split the unit list into DEV_SERVICES (mockups, infra, backend) and SHARED_SERVICES (workers, docs). PROD_SERVICES is now the full production set, and install-prod-services links and enables the timers too, since install.sh no longer calls install-services for them.
  • scripts/install.sh — link PROD_SERVICES only.
  • services/danbyte-web.service, services/danbyte-workers.service — drop Wants=danbyte-infra.service. It's a dev convenience with no business in a unit shipped to production. danbyte-backend (dev-only) keeps it, and make up starts infra explicitly, so the dev workflow is unchanged.
  • docs/getting-started/installation.md — state that production uses native PostgreSQL/Redis and that compose is development only, how to point an install at an existing server, and how to remove the stray unit and container from an install made before this fix.

Notes

Existing installs are unaffected until they re-run the installer; the docs note covers manual cleanup. make docs-build is clean and bash -n scripts/install.sh passes.

Phase 0 of the 3D/planning track — purely additive fields, all defaulted or
nullable so existing data needs no entry:

- FloorPlan.cell_mm (default 600 = a raised-floor tile) + ceiling_mm (3000):
  gives the abstract grid physical meaning for the 3D room view, route-length
  estimation and drawing scale bars.
- FloorPlanTray.level (overhead/underfloor/floor) + elevation_mm (nullable,
  derives from level): where a run physically lives.
- Rack.outer_width_mm / outer_depth_mm (nullable): cabinet footprint.
- OPENING_MM extracted from rack-elevation.tsx into lib/faceplate-geometry.ts
  so 2D, 3D and drawings share one dimension source.
- Forms: plan cell/ceiling, tray level/elevation inspector fields, rack outer
  dims. Docs: racks + floor-plans pages. Tests: round-trips + validator bounds.
Phase 1 of the 3D/planning track.

Backend: GET /api/floor-plans/{id}/scene/ — one fetch with the plan's physical
dims, every tile (racks carrying racked-device geometry + face image URLs) and
trays at their elevations. Static structure only; live status stays on the
sibling /state/ poll so 3D and 2D share one polling channel. Tests: payload
shape + tenant isolation.

Frontend: three + @react-three/fiber + @react-three/drei, confined to the new
components/floorplan3d/ behind a React.lazy boundary — verified: WebGLRenderer
appears only in the async scene chunk. The room view:
- racks as cabinets at tile positions (rail width + outer dims + U height),
  distance LOD: solid frame far, open shell + per-device boxes at true U
  positions near — the U math mirrors the 2D elevation exactly (unit-tested,
  incl. desc_units and starting_unit)
- trays at level/elevation (overhead/underfloor/floor), zones tint the floor,
  blueprint textures it, canvas-sprite name plates (no font CDN — airgap/CSP
  safe), monitoring beacons from the /state/ rollup
- orbit camera, click-select HUD with rack jump-off, WebGL feature-detect
  fallback, frameloop=demand + dpr cap
- 2D/3D toggle on the plan page (?viz=3d deep link; 'view' would collide with
  monitoring's search param)

Docs: floor-plans page. Tests: 9 world-math vitest cases + scene endpoint.
Phase 2 of the 3D/planning track:

- Devices are first-class in the scene: clickable (own HUD card with rack/U
  position/size/face + Open device), hover highlight, and up close each box
  wears its device-type front/rear image — textures load per-URL through an
  LRU-capped cache shared across identical devices, and only for near-LOD
  cabinets so far rooms stay cheap.
- Double-click a rack: the camera eases to frame its front (CameraRig fly-to,
  demand-frameloop-safe).
- ?trace=<cableId> in 3D draws the cable's run as a marching dashed line:
  the SAME routeCable() polyline the 2D overlay computes, lifted to the
  assigned trays' elevation with drops into both endpoint racks, fed by the
  same /cable-paths/ query key.
- Docs updated.
three.js raycasting ignores visibility, so drei <Detailed>'s hidden far-tier
solid cabinet box still caught every click in front of the devices — the rack
always won. Replaced with manual LOD that mounts exactly one tier (unmounted
meshes can't be raycast), with hysteresis so the switch doesn't flicker at the
threshold.
Phase 3 of the 3D/planning track — the 'calculate the best route between
two points' pillar:

- api/pathfinding.py: Python twin of cable-route.ts (same half-cell lattice,
  junction rules — shared vertices, T-splits, mid-segment crossings — and
  Dijkstra), extended with tray attribution for persistence and a max-entry
  bound: an endpoint >6 cells from every tray is unreachable, not connected
  by a fictitious hop. Length = run × cell_mm + per-end vertical drops
  (|tray elevation − rack top|) + 10% slack.
- POST /api/floor-plans/{id}/route/ — pure preview (view perm; explicit
  rbac_action_map since POST would otherwise demand change).
- POST /api/cables/{id}/auto-route/ — computes and persists: replaces the
  cable's tray assignments on that plan only, fills length when blank
  (overwrite flag), atomic.
- Cable page: Auto-route button (edit-gated, hidden when the ends aren't on
  a plan) with a toast naming the trays + estimated length.
- 15 tests: router geometry (T-split, crossing, ring shortest-path,
  disconnected), length/elevation math, API incl. tenant isolation,
  keep-recorded-length, unreachable persistence no-op. Docs + the tray
  docstring's 'later phase' promise updated.
Fixes the reported selection fuzziness and devices popping in/out:

- Name-plate sprites and status beacons no longer raycast — they floated in
  front of cabinets and swallowed clicks meant for the rack/device behind
  them (sprites are hit-testable even when drawn on top). Labels also gain
  depth-testing so they stop bleeding through nearer cabinets.
- LOD distance is now to the cabinet SURFACE (centre minus half diagonal)
  with wide 18/24 hysteresis — centre-distance made edge racks open later
  than they looked and flip at grazing angles.
- Texture cache 64 → 256: rooms with many distinct device images thrashed
  the LRU (evict → reload → face planes flickering).
- Selected-device outline geometry memoized + disposed; outline and beacon
  excluded from raycasting.
Pasting a github.com /tree/ directory URL (what the user naturally copies
from the library's browser view) fetched the HTML page and failed YAML
parsing. Now:

- is_github_dir/expand_github_dir list a folder via the Git trees API
  (one recursive call) and expand it to raw .yaml URLs — a manufacturer
  sub-folder or the whole device-types dir.
- The import endpoint pre-expands tree URLs, caps the synchronous batch at
  200 files with a clear 'narrow the folder' message (the full library is
  thousands — a background path, not this one), and reports a readable error
  when a fetched URL returns HTML instead of YAML.
- Dialog/help text + docs point at folder links. Tests for both helpers.
Cisco alone is ~994 files — past any synchronous cap. A lone GitHub folder
URL now runs as a background job:

- DeviceTypeImportRun model + api/devicetype_import_tasks.py: expands the
  folder, fetches + imports each file on the RQ low queue, writing pollable
  progress ({done,total,created,failed}) and capped per-file failures.
- POST /api/device-types/import-folder/ starts a run; GET
  /api/device-types/import-runs/<id>/ polls it (tenant-scoped). Inline
  fallback when Redis is down. Owning-site resolution shared with the
  synchronous path.
- settings: RQ_REDIS_DB env (default 0) so a second instance can run its own
  worker pool on a spare Redis index without stealing the primary's jobs.
- Import dialog: a folder URL routes to the background run with a live
  progress bar + failure list; small pastes/files still go synchronous.
- Docs + tests (task progress, listing failure, endpoints, tenant isolation,
  HTML-response guard).
GitHub uses /blob/ for files and /tree/ for folders, but people paste
either from the address bar. A /blob/ URL pointing at a directory (no file
extension on the last segment) fetched a non-existent raw file → 404. Now
is_github_dir/expand_github_dir and the dialog's folder detection accept
both, deciding folder-vs-file by whether the trailing path segment has an
extension.
Two overlays on the 3D room, off by default, rendered only for near
(open) cabinets so they never cost anything on far racks:
- U-number ruler as a single canvas texture on the front-left rail (one
  texture per rack, cached by u_height/starting_unit/desc_units; numbering
  matches the 2D elevation incl. desc_units).
- Device-name callouts — a billboarded label per device in the aisle beside
  its U slot. Both are raycast-excluded so they never eat clicks.
Foundation for placing ports precisely on a device type's real front/rear
photo (coexists with the schematic faceplate; the photo map wins when a type
has an image + placed markers).

- DeviceType.image_ports JSONField: {front:[{kind,name,x,y,w,h}], rear:[...]}
  with x/y/w/h normalized 0..1 (center-anchored) so markers scale to any
  render size and to 3D. validate_image_ports mirrors validate_faceplate
  (shape, SlotKind, 0..1 bounds, <=512). Forward migration.
- Builder: device-type 'Photo ports' tab (shown when a front/rear image
  exists) — drag interface/port templates onto the photo, position precisely
  with drag + resize handle + arrow-key nudge + numeric x/y/w/h + fine-grid
  snap; unplaced palette by kind. Saves image_ports.
- Tests: image_ports round-trip + bounds/kind rejection.
- 2D: ImagePortsFaceplate — the device-type photo with port markers matched
  to the device's real interfaces by name ({position}-rendered), each with
  state colour, live SNMP dot, hover card and interface link. The device
  Panel section shows it (useHasImagePorts) whenever the type has an image +
  placed markers; otherwise the schematic faceplate, unchanged.
- 3D: device-mesh overlays the markers as quads on the textured face plane
  (shared transform, front/rear aware), near tier only — 'on top of the
  device' per the reference. image_ports denormalized into the scene payload
  per device (like front_image). Live per-port colour in 3D is a later
  refinement (needs per-device SNMP, impractical at room scale).
- Docs: device-catalog 'Photo ports' section.
…entory health

Photo ports & 3D room:
- Per-port interactivity on the 3D device face: hover/click, port card with
  cable + far-end device, Esc stops a ?trace=; runs stub out of the port and
  rise in front of the cabinet (same-rack patches stay port-to-port).
- Cables layer: every cable drawn port-to-port through its trays (endpoint
  device+port now ride /cable-paths/), Cables/U-numbers/Device-names toggles
  moved into the View popover and persisted per plan.
- Connect-a-cable from 3D: pick both ends on the photos, routing question
  (point-to-point vs ducts with a tray picker), cable creator pre-seeded.
- Speed-tier colour system (FE→400G+) shared by 2D + 3D with a compact
  colorbar legend; live SNMP colours by observed speed; idle ports show a
  faint type-capability outline. Rack facing: front-edge mark on 2D tiles +
  a Front-faces control in the tile inspector.

Hardware inventory (P-H0/H1):
- InventoryItem(+Template): kind/media/capacity_bytes/speed + lifecycle
  status (statuses seeded per tenant: active/planned/failed/spare); templates
  stamp the fields; capacity stored in bytes with a KB…PB unit picker.
- Inventory items placeable on the photo faceplate + 3D face, coloured by
  part status, with a hardware status key in the legend.
- Hardware tab bulk bar: keep/set status/kind/media/capacity/speed + rename/
  clone/delete (shared component bulk bar gains options/status/bytes fields).

Natural name ordering everywhere via a Postgres ICU numeric collation
(disk2 < disk10), applied to name-ordered API lists.

Tests: face-ports resolution (ports + hardware), cable-paths points, bulk
update, natural ordering, image-ports hardware kind; docs updated.
Reword code comments, docstrings, and doc prose to describe behavior
directly instead of leaning on comparisons; feature-identifying references
(importer, device-type library format, interop docs) are unchanged.
- RedfishEndpoint per device (monitoring): BMC host/port/TLS + encrypted
  credentials, plus the last observed hardware; loopback/link-local refused,
  RFC1918 expressly allowed as a scoped, admin-configured exception to the
  outbound guard (redirects disabled, single pinned host).
- Collector (monitoring/redfish.py, plain httpx — no vendor SDK): walks
  Systems → Storage/Drives, Processors, Memory and Chassis → Power/Thermal
  on iDRAC/iLO/XClarity/Supermicro/UCS BMCs.
- Reconcile: match by serial then name (renames stick), create missing
  parts with kind/media/capacity_bytes/model, update facts only; health →
  lifecycle status (OK→active, Critical/Warning→failed) with journal
  entries on flips; parts no longer reported are left alone.
- API: GET/PUT/DELETE /api/monitoring/devices/{id}/redfish/ (secrets
  write-only) + POST …/redfish-poll/; row/site-scoped like the SNMP views.
- UI: "BMC (Redfish)" card on the device SNMP tab — configure, poll now,
  reachability + hardware summary + unhealthy-part callout; polls refresh
  the Hardware tab and faceplate markers.
- Tests: fake Redfish tree end-to-end (create/media/capacity/status),
  serial-match rename survival + flip journaling, loopback refusal, secret
  non-disclosure, tenant isolation. Docs: snmp-discovery gains a Redfish
  section.
SNMP has no standard hardware-health MIB, so SNMP-only BMCs (Supermicro,
Synology, older iDRAC/iLO…) hide disk/PSU/fan status behind vendor OIDs.
This is the definable escape hatch:

- SnmpSensor catalog (monitoring): oid + walk/scalar, item_kind,
  name_template, value_map (raw value → status slug), optional device_type
  binding. Tenant-scoped, RBAC-registered, audited.
- danbyte_checks.fetch_oid: generic scalar-GET / column-WALK over the shared
  pysnmp engine (works on core + Outposts).
- snmp_sensors.poll_device_sensors: runs applicable sensors with the device's
  own SNMP profile, reconciles readings into inventory items (create/match by
  name, flip status; facts untouched), journals flips, stores readings on
  DeviceSnmp.sensors.
- API: /api/monitoring/snmp-sensors/ CRUD (?device_type= filter) + per-device
  POST …/sensor-poll/.
- UI: "Custom SNMP sensors" card on the device SNMP tab — define OID + value
  map with a status picker, poll, see readings; RedfishEndpoint also
  RBAC-registered (missed in H2).
- Tests: walk create/flip/journal, unmapped-value no-op, type-scope skip,
  CRUD + poll view + tenant isolation. Docs: snmp-discovery sensors section.
- poll_hardware management command: refreshes every configured BMC (Redfish)
  and custom SNMP sensor, reconciling inventory + flipping statuses. Bounded
  scope: Redfish endpoints + device-type-scoped sensors (+ all-with-IP when a
  tenant has an all-types sensor).
- danbyte-hardware .service/.timer (every 30 min), added to the Makefile
  TIMERS list so install + in-app upgrade link and enable it automatically.
- Device page: rename the "SNMP" tab to "Monitoring" (it now holds SNMP
  facts, drift, custom sensors and the BMC card) and reorder so SNMP + drift
  come first, the two hardware-health collectors last.
- Docs: snmp-discovery notes the 30-min hardware timer + poll_hardware.
- SNMP card built its own header with a raw h2 + ml-auto flex, so its
  binding picker and Poll button sat out of line with every other card on
  the tab; it now uses the shared Section (title/badge/actions).
- BMC card: hand-rolled colour spans replaced with the shared Badge and the
  destructive text token.
A failed disk was invisible until you drilled into Components → Hardware.
The Hardware card now leads with a "Parts" roll-up — one status-coloured
badge per lifecycle status ("8 Active · 1 Failed"), drawn from the shared
["device-inventory", id] cache so it costs no extra request and updates the
moment a Redfish/SNMP-sensor poll flips a status.
A device with no primary IP fell back to polling its NAME, which pysnmp
reported as "Bad IPv4/UDP transport address DK-TIM-01@161 … Temporary
failure in name resolution" — technically true, useless to the operator.
Target resolution is now:

  1. an explicit per-device override (new SnmpProfileBinding.target),
  2. the device's management / out-of-band IP (previously ignored entirely
     — setting an IP as "management" had no effect on polling),
  3. the primary IP,
  4. the device name, but only when it actually resolves.

Nothing resolvable yields the plain "device has no primary IP (and its name
does not resolve)" message instead of a DNS stack trace. The binding API
reads and writes `target`; tests gain a realistic primary IP.
A device that reports "eth0" for the port silkscreened "Ethernet 1" drifted
twice — once as a new interface, once as not seen — because the matcher only
compared names. Interfaces now carry an optional `snmp_name` ("what the agent
calls this port"), and drift matches on the label OR that link, so the pair
collapses to one row.

- api.Interface.snmp_name + migration; exposed on the interface serializer.
- POST /devices/{id}/snmp/link-interface/ sets or clears the link, enforcing
  one discovered name per interface; device-change gated like accepting drift.
- Drift card: "Link to…" on a discovered-interface row opens a searchable
  list of the device's interfaces (unlinked first, existing links shown) and
  writes the link in one click.
- Test fixtures gain realistic primary IPs now that target resolution no
  longer falls back to unresolvable names.
- device-inventory-pane rendered its own <Table> with hand-rolled header
  checkboxes and inline action buttons. It now uses the shared DataTable with
  selectionColumn + actionsColumn like every sibling pane, so selection,
  sorting, row styling and the bulk bar behave identically.
- DataTable gains `searchable` / `searchPlaceholder`: embedded panes suppress
  the Export/Columns toolbar but still need to find a row in a long list, so
  this renders a filter box bound to the table's global filter. Enabled on
  the inventory, console, power, port, bay, module and services panes.
- The Hardware pane built its column array inline, so every render produced a
  new identity, DataTable's selection effect re-fired, and the pane locked up
  (checkboxes unclickable). Memoised — that was a regression from moving the
  pane onto the shared table.
- An SNMP link was invisible once made: interfaces now carry a "↔ eth0" badge
  wherever they're listed, so you can see which port is mapped and to what.
Inventory tab locked up on load (checkboxes unclickable). DataTable's
selection effect keys on the `data` and `columns` identities; the pane
rebuilt both arrays on every render, so each emit re-rendered the pane,
which rebuilt the arrays, which re-fired the effect. Memoise the ordered
rows and the column list.

A linked SNMP name had no way back out: the drift card could set one but
nothing could clear it. The interface form now shows the SNMP name on
edit — clear the field to unlink.

Speed was a free-text box everywhere, so operators typed "7.2K", "7200
RPM" and "7.2k rpm" into the same column. It now offers the common
industry values per kind (RPM for spinning disks, PCIe/SAS lanes for
flash, DDR grades for memory) as a datalist dropdown, matching how the
interface form offers common_speeds. Still free text — any vendor's
wording fits. Wired into the single-item form, the device-type template
dialog, and bulk edit (which gains `suggestions` on text fields).
The SPA carried ~40 hand-rolled `<input type="checkbox">` controls tagged
`className="ck"`, plus a handful of raw `<select>` and `<radio>`. `ck` and
`ck-sm` were defined nowhere: the stylesheet behind them,
`design/tokens.css`, belonged to the archived htmx/Tailwind pipeline and was
deleted with it. Every one of those controls had been rendering as a raw
browser widget — wrong shape, wrong colours, ignoring the theme.

Converted to `FormCheckbox` where the control has a text label, and the bare
`Checkbox`/`Select` primitives where it sits in a table cell or toolbar row.
Call sites keep their own density classes, so the compact map/rack toolbars
look unchanged.

New `ui/radio-group.tsx` (standard shadcn on the Radix primitive already in
the tree) for the 3D cable-routing choice — two options with descriptions and
a reveal-on-select, which is a radio group, not a segmented toggle.

`FormCheckbox` gained `disabled`, dimming the label with the box; two sites
were only bypassing it for want of that prop.

The drift card's "Link to…" picker was hand-built — raw input, `<button>`
rows, manual filter and sort. It's now Popover + Command like every other
picker, which brings keyboard nav and turns the unlinked-first sort into
proper "Not linked" / "Already linked" groups.

Bulk-edit dialogs also had a raw unit `<select>` next to the byte fields.

Root cause was documentation: docs/design/visual-language.md listed
"Custom checkbox `.ck`" as a component pattern and pointed at a `design/`
directory that no longer exists, so anyone following the design system wrote
dead-class markup. That section is replaced with a "Never hand-roll a
control" table mapping each need to its primitive, plus the Radix API
differences (`onCheckedChange`/`onValueChange`, and `SelectItem` rejecting
`value=""`).
The speed presets shipped as `<datalist>`, which only looks like a feature:
the browser draws that popup itself. It came out light-on-dark, in the wrong
font, at the wrong row height, with a native arrow bolted to the field — the
same unstyled-native-widget bug as the checkboxes, one layer down.

New `ui/suggest-input.tsx`: an Input anchored to a themed Popover listing the
common values. Free text still wins — type anything and it's kept — so the
all-vendor contract holds; the list only stops eight identical disks being
recorded eight different ways.

Behaviour: click, type, or ArrowDown opens it (plain focus doesn't, or
tabbing a form would pop a list at every such field); typing filters, and
once the value IS one of the options the full list returns so changing your
mind is one keystroke. Arrows move, Enter picks a highlighted row and
otherwise falls through to the form, Escape closes. Focus never leaves the
field, which is also what lets this work inside a modal dialog.

`FormText`'s `suggestions` prop now renders this, so every existing call site
— interface speed, part speed, device-type templates — is fixed at once. The
bulk-edit dialog uses it directly.
Two regressions from the shell rework, both owner-caught:

- Photo ports were unclickable in solid mode: the smoked-glass doors were
  raycastable, so every click stopped at the pane and selected the rack.
  Glass is now raycast-inert — purely visual, clicks pass through to
  devices and ports; the cabinet still catches via panels, caps and gear.

- X-ray no longer renders devices as ghosts. That WAS the roadmap spec
  ('ghosted except selected') but the owner's expectation is the right
  design: x-ray removes the TIN, not the equipment. Faceplate photos and
  port markers render and click exactly as in the other modes; the
  see-through story lives in ghosted walls/floor, lifted raised floors
  and the distance outlines. Only Focus ghosts devices now.

Docs + roadmap doc updated (the override is recorded so the spec table
stops being authoritative on this point).
…ling

- Every raised-floor top now draws its 600 mm tile grid aligned to the
  area origin — the grid operators actually address positions by.
- Perforated supply tiles: FloorTileType gains a 'perforated' flag
  (migration 0106, 'Perforated floor (3D)' in the tile-type form). Zones
  of such types render a canvas-texture grate at one repeat per 600 mm
  tile instead of a flat tint — the cold-aisle read without a heat map.
  Zone-level on purpose: the aisle is the unit people think in, and
  zones already exist. Scene payload carries the flag additively.
- Per-area lift: click a raised floor's edge SKIRT to fade that one
  floor out (~0.4 s, animated by invalidating only while the value
  moves) and see into its plenum; click again to close. The skirt, not
  the top — a clickable top would steal every deselect click on the
  pad, the exact trap zone-click isolation fell into. The global 'Lift
  raised floor' toggle and x-ray still lift everything.
- Ceiling (View menu, 3D, default off): a single-sided plane facing
  down — encloses the room from inside, invisible from the bird's-eye,
  and neither casts nor receives shadow since the key light sits above.
- Cables render thicker (1.5→3.5 px, 5 px hover/trace) as an interim fix;
  the full cable-routing redo is now specced as roadmap P8 (in-rack side
  channels, bend radii, tray lanes, loom mode, true tube geometry) and
  pulled forward to run right after this phase.

P4b (lowered floors/pits + floor cut-outs) stays open in the roadmap —
the signed-elevation semantics touch pathfinding, route drops and 3D at
once and deserve their own pass. Backend: scene test for the perforated
flag; makemigrations --check clean.
The owner called the old rendering out twice, and rightly: runs stubbed
0.18 m out of a port and dove straight down IN FRONT of the faceplates,
took hard 90° corners, every cable in a tray rode one centreline, and it
was all constant-pixel screen-space line — unlit, unthickened, unreadable.

- Front-corner channel: a run now leaves its port with a short stub,
  sweeps sideways CLEAR of the faceplates at stub depth, and rises in a
  column hugging the cabinet's nearest front corner — a vertical cable
  manager, not a curtain. (Three generations recorded in the docstring:
  v1 sliced across faceplates, v2 curtained them, v3 dresses the lead.)
- Bends, not corners: new pure filletPath() rounds every interior vertex
  with a quadratic Bézier, pull-back clamped to half the shortest leg,
  endpoints exact — the run still starts ON the port quad.
- Tray lanes: cableLane() hashes the cable id to one of 7 lanes × 28 mm
  across the tray plus a height stagger, applied via offsetPolyline()
  (averaged joint normals) — ten cables in one duct are finally ten
  PARALLEL cables. Deterministic: same cable, same lane, every reload.
- Real geometry: runs render as TubeGeometry with jacket radius by kind
  (power 12 mm > copper 8 mm > fibre 5 mm via cableRadiusM), lit and
  AO'd like everything else; hover glows emissively instead of
  re-widening. Above 200 runs the layer falls back to lines (TUBE_LIMIT)
  — a thousand-cable hall is a loom problem, deferred and recorded in
  the roadmap. The selected/traced run keeps the animated marching line.

All four new functions are pure in world.ts with 10 new tests (131
total). Docs describe the routing contract; the roadmap marks P8 landed
with loom mode as the follow-up.
A 42U vertical PDU is not a shelf appliance — it bolts to a rack rail and
occupies no units. Device grows mount (left/right rail), mount_offset_mm
and mount_span_u (migration 0107), validated hard at the serializer: a
mount needs a rack and a 0U type, excludes position/face/side, and the
span can't outrun the rack. Ten new tests cover the matrix.

This feature owns two long-known rollup bugs, and both fixes CHANGE
VISIBLE NUMBERS (docs call each out):

- used_units stops charging 0U gear a full unit — the old 'or 1' counted
  every positioned 0U appliance as 1U.
- Rack power demand skips devices that HAVE outlets: a PDU's inlet draw
  restates its children's draws, so counting both double-counted every
  rack with a recorded PDU. Demand may drop after upgrading; the new
  number is the honest one.

Build surface (2D first, 3D view-only, per the house rule):
- Device form: Side mount / offset / span appear for 0U types and are
  mutually exclusive with U placement (the form untangles them live).
- Rack elevation: slim rail LANES flank the U grid — that rail's strips
  as vertical chips (click to open), '+' hangs a new one with rack and
  rail pre-picked (/devices/new?rack=…&mount=side_left).
- 3D: side strips on the cabinet flank via pure sideStripBoxM (span
  defaults to ~¾ of the rack, clamped to its top — 3 tests), rendered in
  BOTH LOD tiers so PDUs never pop, clickable to the device HUD, which
  now says 'left/right side rail' instead of a U.
- Scene payload ships mount fields additively; the device filter becomes
  'position is not None or mount'. SceneDevice.position is now
  number|null — the deliberate tsc sweep the old plan predicted — and
  cable runs from strips fall back to tile drops until outlet markers
  land (P5 step 3).

Roadmap note: implementation order deviates from the doc's 'rack type
first' — accessories must stamp devices WITH mount fields, so the mount
had to exist first. Rack type is next.
…PDUs

3D roadmap P5 step 2. A RackType is a reusable cabinet model (manufacturer,
name, rail width, u_height, starting/descending units, outer width/depth,
weight budget; unique per tenant+name) with RackTypeAccessory children: the
factory-fitted 0U strips the model ships with (0U device type + label +
rail + mount offset/span, unique per type+label). Rack gains a SET_NULL
rack_type FK. Migration 0108.

Behavior:
- Picking a type on the rack form copies its dims into the editable fields
  client-side; the rack stays the source of truth.
- Create-only opt-in stamping (create_accessories): one side-mounted device
  per accessory named "{rack}-{label}" (deduped -2/-3 against the tenant's
  device names), components materialised via materialize_device_components
  so a stamped PDU arrives with its real outlets. The device-add site scope
  is pre-checked BEFORE the rack row exists (clean 403, no partial rack)
  and rack + stamps commit atomically.
- Accessories validate hard: 0U device types only; rack_type/device_type
  writes go through the tenant-scoped field; the tray-pattern viewset
  scopes reads through rack_type__tenant (?rack_type= filter).
- RackTypeAccessory has neither tenant nor site column, so it exposes
  tenant_id as a property through its parent — the audit trail stamps
  instance.tenant_id, and without it accessory history would log NULL/NULL
  and fail closed out of its own tenant's view.
- RackTypeViewSet: search, ?manufacturer=, ?picker=1 mini (dims for
  prefill), name-per-tenant 400 (mirrors _check_unique_name), 409 destroy
  guard while racks use the type. RackViewSet gains ?rack_type= and
  select_related for the embedded mini.

Registered in RBAC object types and audit AUDITED_MODELS. 18 tests
(api/tests_rack_types.py): CRUD/tenancy fences, 0U rule, stamping with
outlets, name dedupe, no-stamp default, 403-and-atomic without device.add,
site-scoped denial, audit tenant stamping.

Frontend: DCIM -> Rack types (list via new rack-type column factory,
detail with accessory editor + racks-of-type tab, new/edit forms), rack
form grows the type picker + prefill + stamping checkbox (disabled without
device-add), rack page links its type, audit deep-links resolve.

Docs: rack types section in docs/dcim/racks.md.
…ly see

Owner feedback pass on the room. Ten reports, grouped:

Cues that swallowed the hardware
- Airflow cones were a fixed 50 mm across on a 42 mm-tall 1U box — larger
  than the gear they annotated, and end-on they rendered as blue discs
  sitting over the faceplate, which is why ports were unreadable up close.
  The cone is now sized to the device (airflowGlyphSizeM, ~42% of box
  height, clamped) and its standoff follows, applied per instance through
  the existing InstancedMesh matrix.
- Cable jackets were ~2.5x life size (8 mm radius copper = 16 mm of hose).
  Now power 7 / copper 4.5 / fibre 3 mm radius: still a shade over life
  size so a run reads across the hall, no longer plumbing at a faceplate.

Trays
- Drawn as real baskets — two side rails and a floor of rungs — instead of
  one solid box per segment. The box looked like a painted girder AND hid
  every cable inside it.
- Runs now ride ON the basket floor (trayRideY) rather than at the tray
  datum, which was the middle of the old solid box: that is why cables
  "in" a tray were invisible. Lane spacing 28 -> 26 mm so seven lanes fit
  inside a 200 mm basket's clear width.
- Click a tray to OPEN it: near rail drops, basket tints, and a card lists
  every cable routed through it (click one to select that run).

Routing is visible and editable
- New GET/PUT /api/cables/{id}/routing/?floor_plan= — read the mode and the
  trays in run order, or replace this plan's assignment with an ordered
  list. Empty list = point-to-point. Other plans' assignments untouched;
  only trays on the named plan are accepted; PUT needs cable.change.
- Routing card on the cable page: Point-to-point vs Through trays, with a
  multi-tray ordered picker (add, reorder, remove) — the fine control the
  connect flow never exposed after creation. Auto-route stays the automatic
  twin.
- The 3D cable card names what the run follows. A cable ignoring an obvious
  tray was almost always point-to-point, and nothing said so.

Device rendering
- Devices receive shadows as well as cast them; without it the rack
  interior lit flat.
- Every solid device box carries an edge outline, not just the selected
  one. With a bright photo face and the studio key raking the sides, an
  un-edged box lost its silhouette and the faceplate read as a picture
  floating in the rack.
- Fixed draw order for the transparent pass (TRANSPARENT_ORDER: ghosts,
  then glass). three.js re-sorts transparent objects by depth every frame,
  so ghosts and cabinet glass swapped order as the camera moved — the
  flicker when switching solid/cutaway/x-ray.

Interaction
- Double-click a DEVICE to fly to its face (deviceViewpoint), framed for
  its height and from whichever aisle it faces — the rack gesture, one
  level down.
- View popover lays out in two columns when 3D is on, so it fits on screen
  instead of scrolling inside itself.

11 new pure-geometry tests (world.test.ts 46 -> 57) and 6 backend tests for
the routing endpoint. Docs: tray/routing/double-click behaviour in
docs/features/floor-plans.md plus a "Routing a cable" section.
…ark to see

Second owner feedback pass.

Tray corners, tees and crossings
- Every segment drew as a full-length basket, so at a junction two baskets
  simply shot through each other — rails overshooting the corner, rungs
  crossing mid-air. New world.trayJunctions() finds every joint: a
  polyline's own interior vertices, plus wherever two different runs tee or
  cross (segmentCrossing, inclusive at the ends so a tee counts). Rails now
  stop half a basket short of a joint and one junction plate bridges it,
  drawn once at scene level so a crossing between two runs gets one plate
  rather than two fighting for the same millimetre.

Point-to-point runs cut through the cabinets
- A tray-less run rode at two thirds of the ceiling: 1.98 m in a 3 m room
  against a 1.967 m 42U cabinet top. Thirteen millimetres of clearance,
  which the cap lip and the lane stagger eat — so runs grazed and sliced
  diagonally through every rack between their ends. freeAirRideY() clears
  the tallest cabinet in the room by 300 mm and stays under the ceiling.

Traced-cable animation tanked the frame rate
- The dash crawl invalidated the whole demand-frameloop canvas every frame,
  so a selected cable re-rendered the entire room — shadows, AO, the lot —
  at display refresh. Capped to 30 Hz; the offset still advances by real
  elapsed time so the crawl speed is unchanged.

Bayed racks flickered at the caps
- The top cap and plinth overhung all four sides, so two cabinets side by
  side overlapped each other's caps by 12 mm: intersecting geometry that
  z-fights. Real cabinets in a row butt flush — the lip is now depth-only,
  where it actually reads.

Two surfaces that were simply too dark to see
- Walls "disappeared" on High: N8AO ran at intensity 3, triple the default,
  which buries any large dark surface. The zinc walls went solid black
  against a black background. Now 1.1 with a slightly tighter radius —
  contact shading, not a wash.
- The ceiling never appeared to exist. It faces down (so it can't block the
  bird's-eye) which means the key light above it never reaches it — and at
  #1c1c1f it was indistinguishable from the empty background. Lightened to
  a mid-zinc with a trace of emissive so it reads from inside the room.

QoL
- "Add rack" on a rack type's Racks tab → /racks/new?rack_type=<id>, which
  pre-picks the model and fills its dimensions.

9 new pure-geometry tests (world.test.ts 57 -> 66) covering segment
crossings, junction discovery and dedupe, and the free-air ride height.
Docs: tray joints and over-the-racks routing in floor-plans.md.

Not in this pass, both from the same message: accessories rendering on both
rack faces (needs a front/rear channel on the 0U mount — model change), and
faceplates on side-mounted strips (needs the faceplate builder to support
portrait layouts).
…oth faces

Vertical PDUs were drawn on the front AND rear elevation of the same rack.
That was baked in: the device serializer REJECTED `face` alongside `mount`,
so a 0U strip could never say which channel it lived in, and both views had
to assume it might be in either.

`face` now survives a side mount and means the channel the strip bolts into:

- Serializer: mount no longer excludes face (it still excludes a U position,
  and a half-width side is normalised away as it is for any full-width
  type). The model's help text says what face means on a 0U mount.
- Elevation: a strip with a face draws in the rail lane of THAT elevation
  only. Blank still draws on both — which is what everything mounted before
  this field existed is, and honest: we don't know their channel, so neither
  view should claim one.
- 3D: sideStripBoxM seats the strip at rear-channel depth, front-channel
  depth, or mid-depth for blank, instead of one fixed z.
- Device form grows a Channel select under Side mount; the effect that
  cleared `face` whenever a mount was set is gone, which is what made the
  field unreachable from the UI even once the API allowed it.
- RackTypeAccessory gains `face` (migration 0109) and stamping copies it, so
  a factory-fitted PDU lands in the right channel rather than face-blank.

4 tests: face round-trips on a mount, a stray half-width side is normalised
rather than rejected, an accessory's face reaches the stamped device, and
the accessory default stays unspecified. The old
"mount_excludes_face" case is replaced by its inverse.

Docs: the channel rule in the zero-U section, and channel in the accessory
list, in docs/dcim/racks.md.
A rack type only ever applied at creation: pick a model, get its dims and
(optionally) its accessory strips, and that was the last time the two ever
spoke. Add a PDU to the model afterwards and the racks already built from
it were on their own.

POST /api/racks/{id}/sync-from-type/ mirrors the device action:

- Dry run by default — returns the diff so the UI previews before writing.
  ``dims``: every dimension that drifted from the model, both values.
  ``accessories``: {add: labels this rack is missing, extra: stamped-looking
  strips the type no longer defines}.
- ``apply: true`` copies the dims and stamps the missing strips, using the
  same naming, dedupe and component materialisation as the create-time
  stamp — so a synced PDU arrives with its outlets like any other.
- ``dims`` / ``accessories`` narrow what applies; both default true.
- NEVER deletes. An "extra" strip is somebody's real, cabled PDU, so it is
  reported and left alone. Drift in the dims is legitimate too (the rack
  form lets you edit them after picking a type), so this reports rather
  than nags.
- rack.change to run; the accessory half additionally needs device-add
  scope at the rack's site, matching the create-time gate.

diff_rack_from_type / sync_rack_from_type sit next to their device
equivalents in models.py. Matching a strip by its "{rack}-{label}" name
means a hand-added strip, or one on a renamed rack, doesn't read as
missing.

Frontend: "Sync type" on the rack page (only when it has a type, only with
change) opens a preview dialog — dimensions old → new, accessories to add,
and anything not on the type flagged as left alone — then Apply.

6 tests: dry run reports without touching, apply copies dims and stamps,
idempotent on a second run, an extra strip survives, dims-only, and a
typeless rack is refused. Docs: "Syncing a rack with its type".
Reported: change the rack type's PDU to a different device type, hit Sync
type, and it answers "already matches its type — nothing to apply" while
the rack goes on showing the old strip.

The diff only ever asked "is a strip with this label here?". Presence is
not agreement: a matching label passed, no matter that its device type,
rail, channel, offset or span had all moved on.

The diff gains an `update` half — for each accessory whose strip exists,
every field that no longer agrees, with both values. Applying re-points the
EXISTING device (never creates a second one) across device_type, mount,
face, mount_offset_mm and mount_span_u.

A re-pointed device type materialises the new type's components and leaves
the ones already there. Deleting them belongs to the DEVICE's own
sync-from-type, which is the only action that knows what the cabling
depends on — so the dialog says so rather than quietly orphaning outlets.

The preview dialog grows a "Strips to bring in line" section listing each
field old → new, and the success toast reports added / updated / dimensions
separately.

Test reproduces the report exactly: stamp a strip, swap the accessory's
device type and set its channel, then assert the dry run names both
changes and applying re-points the same device without duplicating it.
…cabinet

Two tray-joint bugs, both found by reading the real geometry off DEV-2's
stress-test plan rather than guessing at pixels. That plan holds one closed
rectangular run:

  (2,2.5) → (9,2.5) → (26.5,2.5) → (26.5,5.5) → (2,5.5) → (2,2.5)

1. CLOSED LOOPS had an unjoined corner. Junctions came from interior
   vertices only (1 .. n-2), so the shared first/last vertex — a real
   corner, and the one every rectangular ring has — was neither an interior
   vertex nor a free end. It got no plate and no trim, and its two rails ran
   straight through each other. Detect closure and treat that vertex as the
   corner it is.

2. COLLINEAR POINTS were treated as corners. (9,2.5) sits mid-way along a
   straight leg — click twice while drawing and you get one. Every interior
   vertex was assumed to be a turn, so a straight run was cut back 100 mm
   either side and plated across the gap: a fake joint interrupting
   unbroken tray. New turnsAt() asks whether the run actually turns.

On the real geometry the diff is exact: four joints before and four after,
but before it was three real corners plus a fake one and a missing one.

PDUs rendered OUTSIDE the cabinet — the strip hung off the far side of the
side panel, so every vertical PDU floated in the aisle next to its rack and
would collide with the neighbour in a bayed row. A vertical PDU bolts into
the ZERO-U CHANNEL: the clear space between the mounting rails and the side
panel. The 19" rail opening is a fixed 450 mm, so that channel is precisely
what extra cabinet width buys you — the reason 750 and 800 mm cabinets are
sold at all. zeroUChannelM() derives it from the recorded outer width, and
the strip now sits in it, clamped to stay inside the cabinet's own
footprint. fitsInChannel() reports whether there is room at all: a 600 mm
cabinet takes a slim 50 mm strip and nothing wider; a genuinely narrow one
takes none.

8 new tests (world.test.ts 66 -> 71), including the closed ring and the
collinear point taken verbatim from the plan that showed the bug. The two
older side-strip assertions expected the strip OUTSIDE the panel and are
rewritten — that expectation was the bug.
An opt-in seeder that builds DC-TEST, sized and wired like a real hall so
the 3D view has something honest to be tested against. Re-runnable; --wipe
tears the hall down first. Never touched by bootstrap.

  manage.py seed_dc_test --wipe

What it builds:

- A 0U "Vertical PDU 24xC13" device type — one inlet, 24 switched outlets.
- Rack type "DC-TEST 42U 800mm", 800 mm wide precisely so both rear
  channels have real zero-U space, carrying PDU-A (left rail) and PDU-B
  (right rail) accessories in the rear channel.
- 100 cabinets, rows A-J x 10, every one built from that type and stamped
  with both strips. Rows sit in facing PAIRS: fronts look at each other
  across a cold aisle (perforated floor), backs vent into the hot aisle
  between pairs — five cold, four hot.
- Per rack: a PA-3420 at U42 and two x3650 M5 servers low down, both types
  chosen because they carry photo faceplates.
- Cabling, 1090 runs: firewall down to each server in its own cabinet
  (200 copper), each row daisy-chained rack to rack so runs cross the hall
  and have to follow tray rather than hop through cabinets (90 fibre), and
  800 power runs — every device's PSUs split across the A and B strips,
  each strip's inlet back to its own feed.
- Two power panels, one feed per rack per panel, so the rack power rollup
  has real supply against real demand.
- Overhead tray: a spine down the west side and a branch over every row.

Endpoints carry it: scene 186 ms / 1.1 MB, cable-paths 1.0 s for the 1090
runs. Above the 200-run tube limit the cables layer drops to lines, which
is the intended behaviour at this scale.

Documented in the dev-workflow seeding block next to seed_demo.
…stops

Three fixes from walking the hall.

NO AISLES. The layout put rack rows one 600 mm cell apart, which read fine
on the flat plan and was nonsense in the room: the cabinets are 1200 mm
DEEP, so each one overhung its 600 mm tile by 300 mm a side and adjacent
rows physically touched. There was nowhere to stand.

Everything is now sized in real millimetres:
- A rack tile is two cells deep, matching the 1200 mm cabinet, so the plan
  and the room agree about where a rack ends.
- Cold aisles are 1800 mm — people install gear from the front there.
- Hot aisles are 1200 mm, the tighter standard minimum for access only.
- A 1200 mm perimeter walkway all round.
The hall comes out 8.4 x 28.2 m for 100 racks, ~2.4 m² each, which is
about right for the real thing. The layout is computed by one _layout()
function rather than a hand-maintained list of row coordinates, so the
aisles cannot drift out of step with the rows again.

HALF-EMPTY CABINETS told you nothing about the room. Every rack is now
full: a redundant pair of 1U firewalls on top and twenty 2U servers below,
42U exactly accounted for, 24 devices per rack counting both PDU strips.
2400 devices.

A full cabinet holds 22 cabled devices, so wiring every port would mint
~4500 cables — past the point the room draws tubes at all, and slow to
resolve. Cabling is therefore a representative set by default (both
firewalls and two servers on A+B cords, four data drops, both feeds, plus
the row chains) at 1490 cables, with --full-cabling to wire everything when
the point IS to stress the cables layer.

CAMERA SLID after the mouse stopped. Drei enables OrbitControls damping by
default, which keeps easing the orbit after you let go; on a hall-sized
plan that means overshooting whatever you were trying to look at.
enableDamping={false} — the camera stops when you stop.
COLLISION, and the same mistake as the depth one on the other axis. The test
cabinets were 800 mm wide on 600 mm tiles, so each overhung its tile by
100 mm a side and every neighbour in a row intersected by 200 mm — devices
visibly running through the rack next door. They are 600 mm now, exactly one
cell, so a row bays flush the way a real one does. A 600 mm cabinet still
leaves ~63 mm of zero-U channel per side, enough for the 50 mm PDU strips,
so nothing about the PDU placement changes.

LAG, and part of it was mine. The edge outline added last round allocated a
fresh EdgesGeometry per device, and r3f's <boxGeometry> allocates per mesh
too — with 2400 devices that is ~5000 buffers for about four distinct
shapes, since nearly every device is 1U or 2U at full width. Both are now
keyed to the millimetre and SHARED: three.js keeps transforms per mesh, so
one geometry serves any number of devices.

The near-LOD threshold went 18/24 m -> 12/16 m. It was tuned on a handful of
half-empty cabinets; in a hall of FULL racks it admitted most of the room to
the near tier at once, and a near rack is 24 boxes plus 24 outlines plus 24
textured faceplates. Twelve metres still covers two or three rows, which is
as much detail as anyone reads at once.

Not the lighting: Low already drops shadows and AO (castShadow={rq !== "low"}
and the composer is High-only), so there was no "legacy no-lighting" tier
left to add. At this scale the cost is geometry count and draw calls, which
is what the two changes above attack.
Two owner reports from the DC-TEST hall.

"extremely laggy": the near tier gives every device its own meshes — body,
edge outline, photo plane, port quads. Sharing the geometry last round cut the
allocation, not the draw calls, and 12 m of a full hall is still ~80 cabinets
and ~2000 devices. Split the near tier in two: `detail` (under 7 m) keeps the
full treatment, and a new `mid` tier out to 26 m draws every device in ONE
instanced draw call per cabinet — no photo, no outline, no ports, no picking,
none of which survives that distance anyway. Beyond that the frame stands
alone as before. `tierFor` in world.ts owns the thresholds and hysteresis so
the two boundaries can't drift apart.

"front images is shown but not rear": the photo plane keyed off the face the
device is BOLTED to, so a front-mounted server showed its front panel from
the hot aisle too, and rear_image never rendered at all. RackMesh now tracks
which side of the cabinet the eye is on (dot with the rotated front normal,
dead band so sliding along a row doesn't thrash it) and DeviceMesh picks the
panel, the photo, and the marker set from the side you can actually see.

75 world tests (4 new on tierFor), typecheck and build clean, no new lint.
The seeder's own comment claimed the row chains "have to follow the tray",
but it built the cables BEFORE the trays existed and never pinned them to
anything — so all 1490 runs were point-to-point and the 3D room drew them
looping through free air above the racks with the tray sitting unused.

Trays are now laid first, and each cross-hall row run goes through
`route_through_trays` — the same Dijkstra behind POST /cables/{id}/auto-route/
— so a seeded hall is routed the way an operator would have routed it. The
command reports how many runs were pinned; a run the tray graph can't reach
stays point-to-point rather than being silently dropped.
Owner's console had the answer: ERR_INSUFFICIENT_RESOURCES on hundreds of
/face-ports/ and /snmp/ calls. Every device in the detail tier fired TWO
fetches to resolve its port markers and poll live SNMP. That was fine for the
rack-or-two the code was written against; in a hall it is ~1000 concurrent
XHRs, which exhausts Chrome's resource pool and buries the main thread in
fetch/JSON/query churn — so the frame budget goes to bookkeeping, not drawing.
No amount of GPU work explains it, and I was wrong to keep looking there.

Live resolution is now granted only to the cabinet the operator has engaged
with (selected, or holding the focused device), which caps the traffic at ~24
devices no matter how big the room is. Markers still DRAW everywhere — they
ride in the scene payload — they just show record colour instead of live
status until you click the cabinet. A per-rack batch endpoint would restore
always-on live colour in one request; that is the follow-up, not this fix.

Also, on the owner's point that one image shared by many devices should only
cost once: the texture was already shared, but the photo PLANE allocated a
geometry and a MeshBasicMaterial per device, so twenty identical servers meant
twenty materials around one texture — and the renderer sorts by material, so
each one re-bound the program. Both are now shared, keyed by size and by
texture.

And the quality tier the owner asked for three times: "Flat" — one ambient
light, no key light, no shadow pass, no environment probe, capped dpr. I still
don't think lighting was the drain, but it is now a menu item instead of an
argument, and Flat is a genuinely useful floor for weak hardware.

163 tests, typecheck and build clean.
Owner's INP trace named the culprit: 408 ms INP, and the interaction list is
almost entirely `keyboard` at 130–800 ms each. That is WASD navigation, and the
cost was React, not the GPU.

Each cabinet swaps LOD tier through useState, so crossing a threshold
re-renders that rack — and a detail-tier rack re-renders two dozen DeviceMesh
subtrees, each with a query and several memos. Walking an aisle crosses
thresholds continuously, so a held key produced a rolling reconciliation
cascade. The three-tier split I added last round made it worse: more
boundaries, plus a second piece of state (viewRear) that also flips mid-walk.

New camera-motion.ts holds one module-level "moving until" stamp (module-level
so reading it from a hundred useFrames can never itself render). The rig marks
it on every frame it moves the camera — keys, wheel dolly-through, fly-to — and
racks skip both swaps while it is set. Motion now costs draw calls only; the
room re-tiers ~180 ms after you stop. Key release schedules one trailing
invalidate, because the demand loop would otherwise stop with swaps pending and
leave the room coarse.

163 tests, typecheck and build clean.
rackFootprintM made the cabinet exactly as tall as its rail space
(u_height × pitch + plinth), and the shell then drew its 30 mm top panel at
the very top — so the cap occupied two thirds of the highest U and whatever
was installed there was almost invisible. A real 42U cabinet is taller than
42U of rail precisely so the lid has somewhere to go.

Height now carries the cap as its own term, and the thickness lives in one
exported constant that both the footprint and the shell read, so the cabinet
and the panel that closes it can't drift apart. Side panels come out spanning
exactly the rail space, which is also correct.

163 tests, typecheck and build clean.
Two null-returns in portEndRun were doing all the damage.

1. A termination whose port name matched no photo marker returned null, and the
   caller's fallback is a drop at the TILE centre — inside the cabinet. That is
   why in-rack runs looked like they dived into the middle of the rack and
   could not be traced: not a routing bug, a missing anchor. A port with no
   matching marker still has a known unit and a known face, so it now anchors
   on the middle of that device's exposed face and takes the same stub-out and
   corner-riser treatment as a matched port. Landing on the right U beats
   landing in a hole.

2. Side-mounted 0U strips returned null unconditionally, so every power cable
   in the room — PDU outlet to server inlet — resolved to nothing and was
   never drawn. Outlets carry a trailing index (C13-01 …), so they now spread
   down the strip at roughly a real C13 pitch, clamped inside it, and route out
   to the cabinet's corner channel. Per-outlet markers in the scene payload
   will replace the pitch estimate; until then the run terminates on the right
   strip at about the right height, which is what tracing needs.

The rack→world transform both branches need is now one helper instead of an
inline copy.

163 tests, typecheck and build clean.
The blue discs burying every faceplate in the owner's screenshots are the
airflow cones seen head-on. They were centred on the device face, straight
across the port field, at 42% of the unit's height — so a rack of them made
faceplates unreadable. Reported twice; I shrank them the first time and left
them in the same place, which was the actual problem.

They now ride the top edge of the unit and are less than half the size (height
fraction 0.42 → 0.2, cap 50 → 24 mm). A cue annotates a unit; it must never be
the thing you see instead of it. The placement test asserted the old centred
behaviour, so it now asserts the intent: inside the unit, in its top slice.

163 tests, typecheck and build clean.
…ed to

Owner: "the blue cable should route to the port in the server's rear but it
just routes to the front." Correct, and it is the same error I fixed for
faceplate textures two commits ago, repeated in the cable anchor — I decided
the panel from dev.face, the MOUNT side. A front-mounted server has its NICs
and PSU inlets on its REAR; that is the entire reason hot aisles are where the
cabling lives. So every run went to the cold aisle regardless of where its port
physically sits.

The marker lookup now searches both panels and the one that matches decides
where the cable lands, which panel the stub leaves through, and which side
portLocalM measures from (it takes the panel explicitly now, defaulting to the
mount face so the faceplate-drawing callers are unchanged). With no marker at
all the panel is genuinely unknowable from the scene payload, so full-depth
gear falls back to the rear — right for rack servers, which is what is
unmarked here.

That fallback is a convention, not data. The real fix is carrying each port's
side in the scene payload so this is derived rather than assumed; it is the
next thing I would do here.

163 tests, typecheck and build clean.
Trace visibility: a run between two rows spends most of its length behind a
cabinet, so depth-testing the trace meant the answer to "where does this cable
go" was occluded by the very gear you were asking about. The traced line now
draws last and ignores depth — above the glass and ghosts TRANSPARENT_ORDER
sequences — so it reads as an overlay on the room instead of an object inside
it. Only the ACTIVE trace does this; the ordinary cables layer is unchanged.

PSU naming: the imported library types arrived with `Psu 0 / Psu 1 / Psu 2` — a
name range expanded from zero, so a dual-PSU server claimed three inlets and
the first was called PSU zero. The seeder now renumbers them to the 1-based
pair, before devices are stamped so their ports come out right first time. It
only touches a type whose names actually show the 0-based pattern, so a
hand-curated type is never clobbered.

163 tests, typecheck and build clean.
Fixes #14.

A production install provisions PostgreSQL and Redis natively — install.sh
enables the distro services and creates the role and database with psql. But it
also ran `make install-services`, which links the DEV unit set, and that
includes `danbyte-infra`: `docker compose up -d postgres redis`. Then
danbyte-web and danbyte-workers both declared `Wants=danbyte-infra.service`, so
enabling them started the container stack.

The result on a host that already ran PostgreSQL: an idle container with an
empty database, publishing 5432 against the server Danbyte is actually using.
Harmless as observed, but the two compete for the port and whichever starts
first wins it — so a reboot could have pointed Danbyte at the empty container,
migrated into it, and looked exactly like total data loss.

- Makefile: split the unit list into DEV_SERVICES (mockups, infra, backend) and
  SHARED_SERVICES (workers, docs). PROD_SERVICES is now the full production set,
  and install-prod-services links and enables the timers too, since install.sh
  no longer calls install-services for them.
- install.sh: link PROD_SERVICES only.
- danbyte-web, danbyte-workers: drop `Wants=danbyte-infra.service`. It is a dev
  convenience and has no business in a unit shipped to production;
  danbyte-backend (dev-only) keeps it, and `make up` starts infra explicitly.
- Docs: state that production uses native PostgreSQL/Redis and that compose is
  development only, how to point an install at an existing server, and how to
  remove the stray unit and container from an install made before this fix.

Existing installs are unaffected until re-run; the docs note covers cleanup.
@DenDanskeMine

Copy link
Copy Markdown
Contributor Author

Wrong base — this branch was cut from a feature branch and carried 105 unrelated commits. Reopening from main.

@DenDanskeMine
DenDanskeMine deleted the fix/installer-links-dev-infra-unit branch July 26, 2026 15:24
@github-project-automation github-project-automation Bot moved this from Todo to Done in Roadmap Jul 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Question about danbyte-infra.service when using an existing PostgreSQL installation

1 participant