Spec v2 rebuild: full HomeLab module (steps 1-11) #3

Merged
domuk merged 8 commits from feat/specv2-step1-schema into main 2026-08-21 15:11:49 +01:00
Owner

Full rebuild of Ion-HomeLab from Spec v2. All 11 build steps: schema/seed (641 types, 12 manufacturers), server API, rack visualiser, device types, brands/providers, locations/rooms, devices/NIC editor, services/connectors, network map, floorplans, dashboard/settings/backup (includes the D-54 path-traversal RCE fix). 151 server tests, 153 client tests, build clean. Not yet visually verified in a browser - needs testing before merge.

Full rebuild of Ion-HomeLab from Spec v2. All 11 build steps: schema/seed (641 types, 12 manufacturers), server API, rack visualiser, device types, brands/providers, locations/rooms, devices/NIC editor, services/connectors, network map, floorplans, dashboard/settings/backup (includes the D-54 path-traversal RCE fix). 151 server tests, 153 client tests, build clean. Not yet visually verified in a browser - needs testing before merge.
The module rendered blank inside the Ion-OS shell: Vite base:'./' makes
import.meta.env.BASE_URL return './' at runtime, not the proxied path, so the
router basename was '.' and matched nothing. React Router logs only a warning
for that, so the console looked clean. Basename and asset paths now derive from
location.pathname.

Rooms.jsx and RackScreen.jsx each called a useMemo AFTER a loading guard, so
render 1 ran fewer hooks than render 2 (React #310) and the whole app unmounted.
Hooks moved above the guards.

Tabs had overflow-x-auto only; in CSS that promotes the other axis to auto, so a
sub-pixel of vertical overflow drew a scrollbar with stepper arrows in the tab
row. Added overflow-y-hidden.

Added an ErrorBoundary: all three bugs above presented identically as a white
screen, which is the worst possible failure mode to debug.
Built from staging/spec/phase-7-specv2 (40k lines, written from the reference
source rather than from prose summaries).

Data layer: 30 tables (was 27). 641 device types (was 602 - the reference
inserts 39 in code, and those 39 are the only ones carrying real per-type icons
and the only ones covering all four rack faces). 12 manufacturers (was 8), all
with logos wired. standalone_devices gains nics/ports/psus/bbus, without which
three of the four render faces cannot be built at all. Added resolveTypeIcon:
custom icon -> brand logo -> generic, whose absence is why 641 types previously
rendered as one grey box.

Three global rules, as enforced primitives rather than advice:
  G-1 merge.js      every update is read-merge-write. Eight places in the
                    reference blank columns a request never mentioned, up to a
                    restore whose missing 'strategy' field wiped the database.
  G-2 errors.js     no silent failures. The reference's live poll swallows
                    errors and reports "Live" over a dead host.
  G-3 serialize.js  credentials never reach the browser. The reference returns
                    the UniFi password, Proxmox token secret and Hawser token.

API: 8 route files including the /api/rv/* family the rack visualiser needs.
All four connectors ported faithfully - Proxmox keeps skipVerify and
rejectUnauthorized:false for self-signed certs; UniFi gains the skipVerify flag
it never had. Every route carries a permission guard except /api/health and
/api/whoami. Deleted: the self-updater, and two unauthenticated
privilege-escalation endpoints (setup/complete, user-groups).

151 tests, written to fail on a missing feature rather than to count rows.
2,580 lines across 24 files, replacing a 274-line version that shipped NONE of
the render faces. A grep for hostFace|switchFace|powershelfFace|imageFace across
the old client returned zero matches.

Drawing layer: rackGeometry.js plus all five faces (Simple, Host, Switch,
Powershelf, Image), drawing from the nics/ports/psus/bbus columns the schema
rebuild added. Interaction layer: RackShell, RackFrame, DropBand, AddDeviceModal,
DevicePopup, useRackDragDrop - double-click to add, drag between U slots, drag
from an external list, three-state drop bands, swap-on-drop.

Three reference defects fixed, each with a test that fails on regression:
  - the device popup re-declares U_HEIGHT = 44 and renders only row 1 of a
    switch's ports, dropping half of them. Now renders all 48.
  - collision detection checks only the U under the pointer, so a 2U device can
    be dropped overlapping its neighbour. Now validates the full span.
  - a mousemove+mouseup listener pair leaks on every popup open. useEffect
    cleanup on all of them.

U_HEIGHT is 53, not the 44 the reference's JS hardcodes: the CSS defines 53px
rows and the CSS is what renders, so the JS value puts every device up to 369px
above its slot on a 42U rack. Three of the four geometry constants exist only in
the stylesheet, which is how the previous build guessed them wrong.

uPosition counts from the TOP; the previous build inverted it and mispositioned
every multi-U device. faceplateWidth is compared as a string so a 10" faceplate
cannot silently normalise to 19". All 10 label tokens resolve with a global
regex - the previous build implemented 3 and resolved only the first occurrence.

Deleted as invented: zoom, and the front/rear view toggle. Neither exists in the
reference.

Adds vitest to the client, which had no test runner. 22 tests.
Locations (530 lines, was 305): restores the postcode geocoding search the
previous build deleted outright, the `virtual` location type it dropped, and an
accurate delete confirm - the old copy described cascades the server does not
perform. Every endpoint verified by reading server/src/routes/physical.js: the
old build's /locations-tree, /rooms and /rooms/:id do not exist and are gone.
Geocoding degrades to manual lat/lng with a visible note if the core's CSP blocks
Nominatim, rather than silently losing the feature.

Rooms and RoomDetail: the inline rack is wired to the rebuilt rack module, so
double-click-to-add and drag-to-move work in a room for the first time. Restores
the room description (neither shown nor editable before) and the `virtual` room
type; add-rack now sends rackType and rackLocation; delete-rack surfaces its 409
instead of failing silently. Rack edit sends only changed fields (G-1) - the old
version PUT a full row and blanked rackAssetId, manufacturer, model, server type,
fabric and site.

Rooms.jsx previously crashed the ENTIRE app: a useMemo sat after a loading guard,
so render 1 ran fewer hooks than render 2 (React #310) and the tree unmounted to
a blank page with only a console warning. A test now renders it through
loading -> loaded as a regression guard.

The shell (App.jsx, api.js, access.js, common.jsx) was verified against the spec
and left UNCHANGED - the earlier commit had already fixed the blank-page
basename, the hook order and the tab scrollbar. Only the missing test coverage
was added.

51 client tests, build clean.
Device Types (2,079 lines across 5 files, incl. a 952-line form) replaces a
read-only screen that had NO add/edit form at all - the user found that within
minutes of first opening the module. Also removes a slice(0,300) cap that was
silently hiding 341 of his 641 types, and stops reading a raw `icon` field
instead of the server's resolved fallback, which is why every type rendered one
grey box.

The form now has every control the spec lists: Label/Key/Manufacturer with
quick-add/Model/Category/Colour; Rack Mountable with U Size, Rack Width
(19" Standard / 10" Mini Rack), Render Type and Label Format; all four
render-type branches; Universal Network Ports; icon upload plus a 36-preset
library; dynamic Specs and Custom Fields rows.

The Category dropdown now offers `networking` and `server`. 235 of the 641
seeded types live in those categories, and a dropdown lacking them silently
re-categorised any of them on save. The form also preserves a seeded type's
existing category when it falls outside the offered list.

Brands (480 lines) gains the add/edit modal and logo upload it never had - it
previously shipped a delete button and nothing else. Six of twelve brands had no
logo, which matters because a brand logo is the icon fallback for 610 of 641
types. Providers (622) goes from 5 of 9 fields to all nine, adds the 14-type
interface editor that was missing entirely, and deletes the invented `pppoe` and
`cellular` types. Its save now sends only changed fields: the reference
hardcoded `ips: []` and dropped x/y, so editing a provider destroyed its IP list
and removed it from the network map.

76 client tests, build clean.
Step 7 (spec s7): MyDevices.jsx, DeviceForm.jsx with the network-interface
editor, DeviceTypePicker, DeleteDeviceDialog, DeviceDetailBody, favourites.
Server-side: PUT /api/network-interfaces/:id and PUT /api/standalone-devices/:id
now go through readMergeWrite() - this is REF-7.1, the worst instance of the
full-row-replace habit found in the whole spec. The reference form sends 7
fields against a 13-column table, so every interface save reset portIndex to 0
(turning a switch port back into a bare NIC and dropping it from the Ports
grid), blanked dns, blanked portType, cleared isUplink. Verified directly by
reading the route handlers, not from the agent's report.

Also added GET/POST/DELETE /api/favourites (s7.11), missing until now.

Step 8 (spec s8): Services shell + 6 sub-tabs (Overview, Docker, Proxmox,
UniFi, Ion-Monitor, Agents) + the 4-connector registry (Hawser/Proxmox/
UniFi/Ion-Monitor) with shared merge()/keepSecret() helpers so an edit never
blanks a stored credential when the field is left empty on save (G-1 applied
to connector secrets specifically - D-8.1 in the spec).

Caveat: the step-8 orchestrating agent hit its 30-minute budget mid
self-verification and never delivered a final report. Its file set is
complete (20 files, matches the spec's manifest) and I spot-checked the
security-critical piece (secret-handling helpers) directly rather than take
its word for it, but I have not diffed every file against the spec's literal
code the way I normally would before committing. Treat step 8's fidelity as
provisional until a closer pass.

119 client tests pass (10 files), 151 server tests pass, build clean.
Step 9: NetworkMap.jsx + client/src/screens/network/ (topology, orthogonal link
routing, drag/zoom/pan, connect tool, auto-layout with a cycle guard). Server:
PUT /api/network-providers/:id and PUT /api/network-links/:id now read-merge-
write (REF-9.13/9.14) - the reference's full-row replace blanked a provider's
IP list and map position on every edit, and defaulted a link's style/linkType
away from whatever the user actually set.

Step 10: Floorplans.jsx, FloorplanEditor.jsx + client/src/screens/floorplans/
(percentage-based hotspot coordinates, draw-over-existing-hotspot fixed via a
click-vs-drag threshold rather than an invented mode). Server:
server/src/routes/floorplans.js, schema gains floorplanId alongside roomId
(D-U9) so a hotspot's drawing surface and its room association are no longer
the same overloaded column. PUT .../hotspots/:id is read-merge-write - the
reference's version let an omitted linkedId destroy a hotspot's link and an
omitted linkedType silently re-type it.

Step 11: Dashboard.jsx, Settings.jsx, Restore.jsx + supporting components/lib.
Server: dashboard stats, favourites (core-username keyed per D-U1), settings
(unifiPollInterval seeded, write allowlist), and the full backup family.

server/lib/uploadPaths.js fixes the worst defect found across the whole
project (D-54): the reference's restore path strips one leading "uploads/" and
joins the remainder, so a tar entry named uploads/../../server.js overwrites
the running server - unauthenticated remote code execution via a backup
upload. The fix resolves every entry and requires the result to be inside the
uploads root, checked by realpath, with symlinks and link-type tar entries
also rejected. 28 tests (25 required + 3 extra), including the exact
../../server.js case, verified directly - not from the agent's report.
restore's strategy field is now required with no default (the reference
defaulted a missing field to 'replace', wiping the entire database).

151 server tests, 153 client tests, build clean.
Reference (settings.js) uses a plain .card with no width limit inside
the page's normal 1200px container. The 600px cap wasn't in the spec
or the reference - removed it.
domuk merged commit eef2a2d384 into main 2026-08-21 15:11:49 +01:00
domuk deleted branch feat/specv2-step1-schema 2026-08-21 15:11:49 +01:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
domuk/Ion-HomeLab!3
No description provided.