Compare commits

..
28 Commits
Author SHA1 Message Date
Pouzor ecc3cb8364 chore: bump version to 3.2.0
ha-relevant: no
2026-08-10 00:40:24 +02:00
Pouzor 9954fccf75 feat(rack): link a mount to any Device Inventory entry
The rack's link picker read `/nodes`, so it only ever offered devices
someone had already approved onto a logical canvas — two rows on a homelab
holding 74 inventory entries. A device on no canvas is still the record of
a real box, and is exactly what a rack is built out of.

`DevicePickerModal` replaces `NodePickerModal` and lists the Device
Inventory itself. Picking an entry calls the new `relinkDevice`, which
repoints the mount's `deviceId`, adopts that entry's node, status and —
unless the user renamed the plate — its label. One entry, one mount: a row
another plate stands for is not offered, and the store refuses it anyway.
The placeholder a rack-created plate left behind is dropped through the new
`DELETE /api/v1/scan/pending/{id}`, which refuses a device a rack still
mounts (409): foreign keys are off at runtime, so the mount would be left
naming a row that no longer exists.

`LinkedDevicePanel` becomes "Linked device" and now prints what discovery
found even when nothing on a canvas answers for the device; only the
canvas-side rows go missing, under a "Not on a logical canvas." note.

Also renames `pending_devices` to `device_inventory` (and
`pending_device_links` to `device_inventory_links`), with the Python and
TypeScript names that followed it. "Pending devices" was the scanner's word
for a queue of finds awaiting approval; the rows outlive approval, are
edited by hand and are what a rack mounts. Routes, payload keys and MCP
tool names are a published contract and are unchanged — `/scan/pending/*`
and the `pending_devices` key in `/stats` stay as they are.

The rename migration runs before `create_all`, or an empty new table would
be created beside the populated old one and every scanned device would read
as gone; it repairs that state too, for anyone whose app already started
mid-upgrade. Foreign keys are switched on for the rename so SQLite rewrites
the `REFERENCES` clause in `rack_devices`.

ha-relevant: maybe
2026-08-10 00:23:10 +02:00
Pouzor c263f839a8 fix(rack): stamp the linked node's last-seen with an offset
SQLite has no timezone type: an aware `last_seen` reads back naive, and
Pydantic then put it on the wire as `2026-08-08T10:00:00`. To the browser
that is *local* time, so the Logical view panel printed a timestamp
shifted by the viewer's UTC offset.

ha-relevant: no
2026-08-10 00:23:10 +02:00
Pouzor e22152d2d3 feat(rack): show the logical view of a mounted device
The Edit Device modal left the column under the port list empty, while
the logical canvas already held every technical fact about the same box.

A mount that stands for a Device Inventory entry now prints them there:
canvas name, type, hostname, IP, MAC, OS, the status check the node runs,
the canvas it is drawn on, when it was last seen, and the services
discovery fingerprinted on it. Read-only — the logical view owns them.

`/racks/inventory` ships the node half as `node_*` alongside the
inventory row's own mac/hostname/os/services, and the panel prefers the
node value: the node is what the user curates, the inventory row is what
discovery last saw and goes stale after a rename or a DHCP move. Rows
neither side can fill are dropped, so a device on no canvas still shows
what was reported and an accessory shows no panel at all.

The new API fields are optional client-side, so an older backend reads
back as "not on a canvas" rather than an empty node.

ha-relevant: maybe
2026-08-10 00:23:10 +02:00
Pouzor 57df3b9198 fix(rack): stop a blanked height field from shrinking the rack
Three from the third review pass, all in code this branch added.

Number('') is 0, so `Number(draft) || MIN_RACK_U` turned a select-all +
Delete into `uHeight: 1` on blur. A rack with mounts refused it and
showed a misleading toast; an empty one — the just-added case, 24U by
default — took it silently, with no undo. An empty or unparseable field
is now "no edit".

CableLayer returned null on `visibility === 'hidden'` before the
per-cable filter that deliberately keeps the selected run drawn ever
ran, so switching the header select to Hidden left RackCablePanel open
over a cable nothing showed. Hidden now still draws the selection —
and nothing else, not even the hover reveal.

PropertyList tracked the open edit form by array position while the
array belongs to the caller. Removing or reordering an earlier row
slid the form onto its neighbour and Save overwrote the wrong
property. The form closes on any mutation from outside it. This one
predates the branch — it moved verbatim out of DetailPanel — but the
extraction doubled its reach to the cable panel, and the component had
no tests. It has ten now.

ha-relevant: maybe
2026-08-09 20:35:23 +02:00
Pouzor 5617f0800f fix(rack): commit height and colour edits on blur, not per keystroke
The rack height field called updateRack on every character. Since that
relocates the mounts a shrink pushes past the top rail, and the rack
canvas has no undo, typing rearranged the rack behind the user:
backspacing "24" leaves "2", every mount is relocated to the bottom,
and typing "4" back restores the height but not the layout. On a full
rack it fired the refusal toast twice per edit instead.

The height and the cable colour hex now keep a local draft and commit
on blur or Enter. A colour that does not parse falls back to the cable
type's default, rather than handing the SVG stroke a half-typed "#39d"
and leaving the run invisible.

Three more from the same review:

- Delete rack asks first, naming how many mounts it takes with it. It
  drops every mount and every cable touching them, with no undo.
- save() takes the design id the caller means to write. The
  design-switch flow saves the old design while the store may already
  hold the new one; it now refuses instead of persisting the wrong one
  and dropping the other's edits.
- fromRackDevice clamps col_span against col_start rather than against
  the grid alone. 11 + 12 is two legal fields spanning to column 23 of
  12, which the new backend validator rejects — one device 422'd the
  whole save.

ha-relevant: maybe
2026-08-09 20:35:23 +02:00
Pouzor cd6402a680 fix(rack): keep a rack height edit from orphaning its mounts
The number input's min/max are hints the browser does not enforce on a
typed value, and updateRack wrote the patch straight through. Two ways
out of a usable canvas:

- Shrinking below a mounted device left the plate drawn above the
  chassis, outside the React Flow node, with nothing in the UI able to
  drag it back. freeUnits only counts 1..uHeight, so the modal's "used
  of" label under-counted it in silence.
- A height over 100 made RackSave reject every save with a 422, so both
  the explicit Save and each autosave tick reported "Save failed" with
  no cause.

updateRack now clamps to [MIN_RACK_U, MAX_RACK_U] and relocates the
mounts a shrink pushes past the top rail, one at a time so two never
land on the same slot. It returns false — changing nothing — when one
has nowhere to go, and the modal says so.

Two server-side guards that would have contained it: RackSaveRequest
cross-checks every mount against the rack it names, and RackDeviceSave
checks col_start + col_span against the grid, which each field passing
its own bounds never caught.

Also normalizes the MAC on POST /scan/pending. Every other write path
canonicalizes it and dedup compares by equality, so a hand-typed
AA-BB-CC-11-22-33 never matched the scanned aa:bb:cc:11:22:33 and
approve built a duplicate node.

ha-relevant: maybe
2026-08-09 20:35:23 +02:00
Pouzor 4d2128c494 feat(rack): document a cable and print it on the canvas
Clicking a cable — in patch mode or out of it — selects it and opens a
right rail. It carries the physical facts (type, colour, label) and
`properties`: the same NodeProperty records the logical canvas already
uses for nodes, each with its own "show on canvas" eye. What is ticked
visible is drawn on a small plate at the midpoint of the run, so an
export carries its lengths and VLANs.

The property editor moves out of DetailPanel into
components/common/PropertyList so both canvases share one implementation
rather than growing a second.

Persistence: rack_cables gains label_visible and properties, added by an
idempotent migration in _try_migrate; the response schema coerces the
NULLs legacy rows read back as.

Also drops the 40 % plate fade that came with cables-on. It let the rail
strips and the U grid show through the mounted gear, which read as a
rendering bug — cables are drawn above the plates anyway.

ha-relevant: maybe
2026-08-09 20:35:23 +02:00
Pouzor 84360ffc50 chore(deps): bump js-yaml to 4.3.1 for CVE-2026-59870
js-yaml 4.0.0 through 4.3.0 resolve !!omap in quadratic time
(GHSA-5p4m-2wfm-xmqj, high). The advisory landed after this branch's last
green run and fails `npm audit --omit=dev --audit-level=high` on main too.

4.3.1 is the patched 4.x, so the existing caret range covers it and the YAML
import/export code is untouched. Staying on 4.x avoids the 5.x API change,
which belongs in its own bump.

ha-relevant: no
2026-08-09 20:35:23 +02:00
Pouzor 56df462a8f ci: stop the secret scan reporting pytest names as Lob keys
The push that carried the whole branch through TruffleHog failed on a
"verified" Lob credential: `test_updates_an_existing_device_in_place`, a test
function in backend/tests/test_racks.py. Lob's detector matches any
`test_`-prefixed identifier of that length and reports it verified, because
Lob test-mode keys authenticate unconditionally — so verification proves
nothing here.

Lob is a direct-mail API this project does not use, and any pytest function
named that long trips it again, so the detector is excluded rather than the
test renamed.

ha-relevant: no
2026-08-09 20:35:23 +02:00
Pouzor 06ccd354c5 fix(rack): make Import links idempotent instead of one-shot
networkImportDone lived in memory only. loadDesign starts from emptyState and
the flag was in neither the save payload nor standaloneStorage, so a reload
re-armed the import: the second run found the original ports taken, walked on
to the next free pair, and drew a duplicate cable for every logical link — once
per reload.

The device pair is the guard now, and it survives a reload because the cables
do: a pair already patched is skipped whatever ports carry it. The flag and the
disabled button go with it, which also makes the feature better — run it again
after racking more gear and it adds only what is missing.

While in there, the import prefers a port whose type matches the link: a fibre
edge landing on two RJ45 jacks drew an amber run across copper. Any free port
is still used when the plate has no matching one.

Sidebar's rack branch gains the tests it shipped without: the view/action swap,
the hidden discovery actions, + Device opening the editor, the capacity footer,
and the unsaved badge reading the rack store rather than the canvas one.

ha-relevant: no
2026-08-09 20:35:23 +02:00
Pouzor c63bfd3859 fix(rack): keep Import links usable after a run that matched nothing
importCablesFromNetwork latched networkImportDone whatever the outcome. Click
Import links before racking anything and the toast says "No matching link found
— rack the devices first", then the button is disabled for the rest of the
session: it asks for a retry and forbids it. Only a design switch or a reload
cleared the flag.

It now latches on success only.

ha-relevant: no
2026-08-09 20:35:23 +02:00
Pouzor 2af6f728d7 docs(rack): document the rack canvas for users
The rack canvas shipped with a developer reference under frontend/src/rack and
nothing a user would find: the README did not contain the word rack, and
FEATURES.md jumped from Multiple Canvases to Customize Style.

docs/rack-canvas.md covers it end to end — creating the canvas, rack settings,
the three ways to mount something, the placement rules, the faceplate catalog,
ports and patching, the Check device status, saving, and the known limits.
FEATURES.md gains it as section 5, beside the other canvas features rather than
appended after Authentication, so sections 5 to 18 shift by one; the README gets
a nav entry and a short section pointing at the full page.

Marked as working without a backend, since a rack canvas persists to
localStorage in standalone; the inventory picker, the status check and the link
import stay flagged as full mode.

ha-relevant: no
2026-08-09 20:35:23 +02:00
Pouzor b1186bd7dd fix(rack): drop the cable type filter, and dress the visibility select like the header
Two native <select> elements sat in a header made of ghost buttons, and they
looked it: their own border and background, an OS chevron, an unthemed popup.

The type filter goes entirely — state, action and the CableLayer branch that
read it, plus CABLE_TYPE_LABELS which had no other consumer. Filtering the
cabling down to copper or fibre answered a question nobody asks of a rack
they are looking at; the type still follows the port a patch starts from,
which is where it matters.

The visibility control becomes the shared ui/select, with a trigger sized and
coloured like the buttons beside it — transparent until hovered — and an icon
per option: pointer for on-hover, eye for always, crossed eye for hidden.

Tests cover the trigger reflecting the active visibility, a pick writing it to
the store, and CableLayer drawing ethernet and fibre alike. base-ui commits a
selection on a real pointer sequence rather than a bare click, so that one goes
through userEvent.

ha-relevant: no
2026-08-09 20:35:23 +02:00
Pouzor a70fc248bf feat(walkthrough): introduce the rack canvas, and keep Next inside the card
The Getting Started tour stopped at the logical canvas, so the third design
type was invisible to anyone who had not gone looking for New Canvas → Kind.
A step between grouping and styling now points at the canvas switcher and
says what a rack canvas is for. It is `mode: 'all'` — a rack needs no backend,
it persists to localStorage in standalone like any other canvas — and it
anchors on the switcher rather than a rack control, since the tour runs on a
network canvas where none of the rack sidebar items exist.

That twelfth step also broke the card footer. The progress strip grows with
the step count and neither footer child could shrink (`min-width: auto`), so
at twelve dots the strip pushed the counter and both buttons past the right
edge of the 320px card — Next ended up outside it. The strip is now the part
that gives: it shrinks and clips, the controls hold their size, and Next stays
anchored whatever the step count.

ha-relevant: maybe
The overlay fix applies to any build of the frontend; the rack step itself is
pointless until the rack canvas is ported.
2026-08-09 20:35:23 +02:00
Pouzor ff740f7466 feat(rack): patch by dragging port to port, and select a cable before deleting it
Cabling was a two-click flow with no way back: the only way to remove a patch
was to click it, which deleted it outright, and clicking a port was the only way
to start one.

- Drag from a port to another to patch, with a dashed rubber band following the
  pointer. Clicking one port then another still works; Escape drops a
  half-drawn patch.
- A click on a cable now selects it (accent halo, fat invisible hit area);
  Delete/Backspace or the new header Unplug button removes it.
- Leaving patch mode restores the cable visibility it was entered with, instead
  of stranding the canvas on "always" with every plate faded until a reload.
- Draw every port at one fixed size — the 1U switch/patch-panel socket — rather
  than scaling it with plate height. Drops the now-unused template portSize.

ha-relevant: no
2026-08-09 20:35:23 +02:00
Pouzor 3138cda378 feat(rack): follow a node's status check, and type rack-created gear
A mount's Status select gains "Check device": instead of a colour frozen at
mount time, it follows the status check already configured on the matching
logical-canvas node (ping, http, ssh…). The rack runs no checker of its own,
so `auto` resolves through the Device Inventory's `node_status`, falls back to
`unknown` when nothing is behind it, and is only offered when the mount — or
the entry being picked — resolves to a node. Losing that link drops the mount
back to `unknown` rather than leaving it on a status nothing can answer.
`useAutoStatusRefresh` polls the inventory every 60s while at least one mount
follows a node, and only then; it writes inventory only, so autosave stays
quiet.

A device created from the rack canvas also lands in the Device Inventory with
a type now. Nothing discovers those rows, so the plate they wear is the only
thing that says what they are — without it they had no icon, no role badge and
no place in the type filter. Passive gear uses the rack-only kinds
`patch_panel` and `pdu`; the latter is deliberately not `socket`, which is
excluded from the rack inventory and would make a PDU vanish on creation.

ha-relevant: maybe
2026-08-09 20:35:23 +02:00
Pouzor 0640704678 fix(rack): draw desktop NAS towers at a size a disk actually is
The drive stack stretched to fill the plate, so the 2-bay box wore two doors
twice as wide as the 5-bay one's — a 3.5" disk is the same disk in every
chassis. The stack now grows with the bay count instead: `nasBays` sizes it
from a fixed per-door width, and scales that back through `colSpan`, since unit
coordinates are fractions of the plate and a narrower plate needs bigger
fractions to draw the same door.

Height went to 5U for all three. A U draws at 24px against a 456px inner width,
so the canvas squashes the vertical axis about 1.8x: a tower that is 3U in
metal renders as a squat box, and its doors read as squares. The picker looked
right all along because it draws a plate at its own aspect, not the rack's.

The 2-bay is a sixth of the rack rather than a quarter — 76x120px at 19", close
to the proportions of the real thing. That is a new plate width, so the modal's
Width select and the picker's width labels learned it; a colSpan with no
matching option leaves the select blank. A test now walks the catalog and
asserts every plate width is offered.

ha-relevant: maybe
2026-08-09 20:35:23 +02:00
Pouzor b129200929 feat(rack): pick a mount from the Device Inventory, filtered to rackable gear
The rack device modal listed inventory entries in a flat <select>: one line per
device, no search, no filters, nothing to tell two look-alike hosts apart. It
now opens the Device Inventory modal itself in a picker mode — a card click
returns the device instead of approving it, and the bulk/clear controls (plus
the s/a/Enter shortcuts) are hidden, since a picker has no business deleting
anything.

That list also carried VMs, containers and mesh devices, which no rack can
hold. A new "Rackable" filter drops them, armed by default when the rack opens
the modal. It is an exclusion list, not an allow list, so new hardware types
show up by default and an unclassified device (a bare ARP hit) stays visible.
`utils/rackable.ts` mirrors `_UNRACKABLE_TYPES` in racks.py — the list the
inventory endpoint already filtered on — and test_rackable_sync.py fails the
backend suite on drift.

The rack side keeps the last word: a device missing from its own inventory, or
already mounted in this design, is refused by name rather than mounted as a
ghost. Nothing is preselected anymore either, so submitting without picking
says so instead of quietly mounting whichever entry happened to be first.

Standalone keeps the <select>: the inventory modal reads pending_devices over
REST and there is no backend there.

The modal itself was max-w-md and one column, which pushed the port list below
the fold. It now matches NodeModal's width with the fields on the left and the
ports on the right.

ha-relevant: maybe
2026-08-09 20:35:23 +02:00
Pouzor e1f8850d1f fix(rack): guard rack rows against cross-design saves, and half-applied plates
Four more defects from the branch review.

`POST /racks/save` fetched every row by primary key alone and then wrote
`design_id` from the payload, so a save on design B carrying an id that belongs
to design A moved A's rack — devices and cables with it — into B, with no error
and no way to notice. Ids come from the client, so a copied design or a stale
tab is enough. `_owned` now refuses a row owned by another design with a 409.

`commitEdit` dropped `applyFaceplate`'s return. Picking a plate the rack has no
room for, then shrinking the height by hand, let `updateDevice` succeed on its
own and `setPorts` write the new plate's ports onto the old faceplate — the
device ended up wearing a plate it did not have, silently. The failure is now
reported and nothing is committed.

Two smaller ones. The inventory select held a dead id after the list was
refetched, so submitting reported "No free slot in this rack" when the real
cause was an entry that had been racked elsewhere or deleted; it now falls back
to its placeholder and says so. And both create paths patched the form's own
geometry over the slot `findSlot` had just chosen, undoing a relocation the user
was never shown — the mount is handed the whole geometry up front, and only the
overrides are patched afterwards.

ha-relevant: maybe
2026-08-09 20:35:23 +02:00
Pouzor 9bf90cbbed fix(rack): draw the desktop NAS plates like the boxes they are
The first cut put the drive bays in a squat block across the top half and left
the name across the middle, which looked nothing like a UGREEN DXP or a
Synology DS: their front is two to five tall tray doors filling the height, over
a thin strip carrying the badge, the LED and the sockets.

The name band was the blocker — it was nailed to mid-height for every plate, so
anything tall wore its name across its own artwork. `labelBox.y` (default 0.5,
so rack gear is untouched) now places the band, and the status LED rides it.
The NAS plates put it at 0.86 with the trays above and the ports on the same
strip.

Bay corners also scaled badly: a flat 1.5px radius reads as a square hole once
the cells are as large as a 3U tray, so the radius now follows the cell size.

ha-relevant: yes
2026-08-09 20:35:23 +02:00
Pouzor 7067bd76d4 feat(rack): add desktop NAS faceplates in 2, 4 and 5 bays
A UGREEN DXP or a Synology DS is not rack gear: it sits on a shelf, taking
about a third of the width and standing some 3U tall, with its drive trays side
by side rather than in a grid. The catalog had no shape for that — a homelab
NAS had to borrow the 2U rack-mount plate.

Trays fill the upper half, one column per disk. The name band and the ports
share the lower half side by side, since the name is always drawn at mid-height
whatever the U count. Ports follow the class: one RJ45 on the 2-bay, two on the
4-bay, two plus an SFP+ on the 5-bay.

`suggestFaceplate` still proposes the rack-mount `nas-2u` for a discovered NAS;
which of the two a scan should assume is a separate question.

ha-relevant: yes
2026-08-09 20:35:23 +02:00
Pouzor 8cafeee743 fix(rack): stop the device modal losing cables, status and inventory rows
Four defects found reviewing the last two commits.

Reverting a faceplate choice took every cable on the device down with it.
Browsing to another plate and back left `plateChanged` set with freshly seeded
port ids, while `commitEdit` skipped `applyFaceplate` because the id matched —
so `setPorts` received ids the device never had and dropped every cable
attached to the old ones, with no warning, since from the user's side the plate
had not changed. Coming back to the device's own plate now restores its ports,
ids included.

The inventory select preselects the first unracked entry, but only
`pickInventory` seeded the status from it, so submitting without touching the
select wrote `unknown` over the status the mount had just copied. Status now
seeds from that same entry.

Creating a device POSTs to the Device Inventory before the mount is attempted,
so a rack with no free slot stranded the row — and stranded another on every
retry. The rack is checked for a slot before anything is created.

And `_service_name` accepted a service with no port, where the inventory
requires one (`None not in _COMMON_PORTS` is True): a portless service named a
device in the rack picker but not in the Device Inventory, the exact mismatch
the naming fix set out to remove.

ha-relevant: maybe
2026-08-09 20:35:23 +02:00
Pouzor cfa296ef87 fix(rack): name inventory entries the way the Device Inventory does
The rack picker listed "192.168.1.63 · 192.168.1.63" where the inventory
showed "jellyfin", so a device the user picked out by one name turned up here
under another.

Two causes. The inventory route labelled an entry
`friendly_name or hostname or ip or id`, skipping the app-name step: `_device_label`
now mirrors `deviceLabel` in PendingDevicesModal — friendly name, host, the app
its ports say it runs (ssh/http/https ignored, generic web deprioritized), IP,
IEEE, id. And the option line appended the IP unconditionally, doubling it
whenever the label already was the IP; it now adds the IP only when it says
something new, plus the type as the tiebreaker between look-alikes.

Also fixes the port row: the name field and the type select both carried
`w-full` from the shared input class, so the select took the row and the name
field collapsed into an unlabelled box.

ha-relevant: maybe
2026-08-09 20:35:23 +02:00
Pouzor c6c7624084 feat(rack): pick faceplates from a visual catalog
A plate is a drawing, not a name — "Switch 24 ports + 2 SFP" in a <select>
tells you far less than seeing the plate. The Faceplate field now previews the
current plate and opens FaceplatePicker: every template drawn with the canvas
renderer, grouped by category, searchable by name or category, and filtered by
kind so accessories are offered only to accessories. Switching the device
source between device and accessory swaps the plate to a default of the other
kind.

Each tile scales the plate to the tile width and keeps its fraction of the rack
(a half-width plate draws half as wide), so sizes compare honestly; the meta
line states U height, width and port count.

Two layout notes worth keeping: DialogContent carries `sm:max-w-sm`, which is
media-scoped and beats a plain `max-w-*`, so the override has to be prefixed;
and an SVG is a flex item that shrinks but never grows, so the plate is drawn
at a nominal size and scaled up by CSS instead of sized in pixels.

ha-relevant: no
2026-08-09 20:35:23 +02:00
Pouzor e2da642554 feat(rack): edit devices in a modal, file rack gear in the Device Inventory
The rack canvas loses its right rail: a mount is now edited in its own dialog,
like a network node, and the canvas keeps the full width.

Devices:
- `RackDeviceModal` adds and edits a mount — label, faceplate, U position,
  height, column, width, status, colour, ports, unmount. Adding offers three
  sources: an existing Device Inventory entry, a new device created here, or a
  rack-only accessory. Opened by `+ Device` in the left rail or a double-click
  on a plate.
- `RackSettingsModal` takes over rack chrome (name, location, capacity, 19"/10",
  numbering, colours, delete), on a double-click of the chassis.
- The left rail's `+ Rack` becomes `+ Device`; `+ Rack` stays in the header.

Inventory:
- Gear created from a rack canvas lands in the Device Inventory with
  `discovery_source: "rack"`, so it inherits the existing search, filters, hide
  and delete instead of a parallel list in the sidebar. New "Rack devices"
  source filter and badge.
- It is never placed on a logical canvas: approve returns 409, bulk-approve
  skips it with `match: "rack"`, and the UI drops the Approve button and reports
  the skip.
- `InventoryTray` becomes `AccessoryTray` — blanks, shelves and cable managers
  only, still a drag source.

Fixes:
- A taller faceplate (or a hand-typed height) no longer silently keeps the old
  geometry when it collides: the device relocates to the nearest slot that takes
  the new size, and only a rack with no such slot refuses the edit. This is why
  a 1U and a 2U plate rendered at the same height and the field looked locked.
- The empty-state buttons sat under `.react-flow__renderer` (z-index 4), so the
  pane swallowed their clicks as a canvas drag.

Tests: rack device/settings modals, empty-state stacking, relocate-vs-refuse in
the store, rack source bucketing, inventory approve gating (frontend + backend).

ha-relevant: maybe
The modal-based editors and the rack store are portable; the inventory tagging
rides on the standalone FastAPI scan routes. Discuss before porting.
2026-08-09 20:35:23 +02:00
Pouzor a0d16e1454 feat(rack): integrate the rack canvas as a design type
The rack prototype lived behind /racklab with no backend. It now becomes a
third design_type alongside network and electrical, sharing the app shell:
same header, same left rail, same right panel, same design switcher. App
branches the renderer on design_type.

Backend
- racks / rack_devices / rack_cables, all design_id-scoped.
- rack_devices.device_id -> pending_devices is the primary link. Those rows
  survive approval and node deletion, so unracking never removes the device
  from the Device Inventory. node_id -> nodes is a second, optional link used
  only for live status and for matching network-link imports. Both are
  ON DELETE SET NULL and label is denormalized, so a rack keeps rendering
  after an inventory purge.
- GET /racks, POST /racks/save (full-state upsert + prune, same contract as
  canvas/save), GET /racks/inventory.
- POST /scan/pending adds an inventory entry by hand, for hardware no scan
  can discover.
- design_type is now validated and actually branched on; the schema comment
  calling it vestigial is gone.
- Design delete unwinds rack rows explicitly (SQLite does not always honour
  the cascade); design copy duplicates racks, mounts and patches with fresh
  ids and re-points the cables at the copies.

Frontend
- /racklab and RackLab are removed; no dual maintenance.
- Rack types lifted into @/types; rackSerializer maps the snake_case wire
  shapes to them, narrowing every enum on the way in.
- The store gains load/save, dirty tracking and an editSeq, so racks get the
  same explicit Save and the same opt-in autosave as the logical canvas.
- Header, sidebar and inspector adapt in rack mode: canvas-only actions are
  hidden, Add Rack / Patch / cable visibility / type filter / Import links
  take their place, the sidebar body becomes the inventory tray, and the
  footer counts racks, mounts, cables and free U.
- rackTheme derives the rack palette from the active app theme rather than
  declaring one per theme, so a new theme works here for free. Per-rack
  chrome stays user-editable; only its default comes from the theme.
- New Canvas gains a Kind picker (hidden when copying, since a copy inherits
  the source kind).

Tests: 24 backend, 5 frontend files (store, persistence, networkLinks,
rackTheme, rackSerializer) plus DesignModal coverage for the Kind picker.

ha-relevant: maybe
Frontend rack renderer is portable; the FastAPI routes and SQLAlchemy models
are standalone-only by the persistence/transport split. Discuss before porting.
2026-08-09 20:35:23 +02:00
Pouzor 33df37b691 feat(rack): front-only rack canvas prototype at /racklab
Physical counterpart to the logical canvas: racks hold hardware at a U
position, with drawn front panels and port-to-port patching.

- 12-column grid so 2 or 3 machines can share one U; collision and
  snap-to-free-slot handled in layout.ts
- 19 faceplate templates drawn as SVG in unit coordinates, so a plate
  scales with U height and rack width
- RJ45 / SFP port artwork only; ports stay hidden on non-patch gear
  until hover, selection or patch mode. Outlets are artwork, never cabled
- cables are their own relation, not React Flow edges, rendered in a
  ViewportPortal so they cross racks and follow pan/zoom
- unmounting a device never removes its inventory entry
- rack settings: size, 19"/10", numbering direction, colours, enclosure

Isolated behind its own entry point (like /view) — no API, no
persistence, no design_type change until the UX is settled.

ha-relevant: yes
2026-08-09 20:35:23 +02:00
118 changed files with 14218 additions and 825 deletions
+6
View File
@@ -22,6 +22,12 @@ jobs:
uses: trufflesecurity/trufflehog@main
with:
path: ./
# Lob (a direct-mail API this project does not use) matches any
# `test_`-prefixed identifier of ~35 chars and reports it verified,
# because Lob test keys authenticate unconditionally. Every pytest
# function named at that length trips it — the first was
# `test_updates_an_existing_device_in_place`.
extra_args: --exclude-detectors=Lob
dependency-audit:
runs-on: ubuntu-latest
+27
View File
@@ -5,6 +5,33 @@ All notable changes to **Homelable** are documented here.
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/),
and this project adheres to [Semantic Versioning](https://semver.org/).
## [3.2.0] - 2026-08-10
### Features
- Rack canvas: a third design type, next to network and electrical, with racks, mounted devices and port-to-port patching. (#315)
- Declarative SVG faceplates picked from a visual catalog, including desktop NAS enclosures in 2, 4 and 5 bays. (#315)
- Rack gear is filed in the Device Inventory, and a mount can be picked from the rackable entries. (#315)
- Cables carry a type, colour, label and properties, printed on the canvas at the midpoint of the run. (#315)
- Mounted devices follow the status check of the logical node they match, and links can be imported from the logical canvas. (#315)
- Getting Started walkthrough covers the rack canvas. (#315)
- Show the logical view of a mounted device, and link a mount to any Device Inventory entry. (#323)
### Fixes
- Guard rack rows against cross-design saves and half-applied faceplates. (#315)
- A rack height edit no longer orphans its mounts; height and colour commit on blur, and a blanked field no longer shrinks the rack. (#315)
- Import links is idempotent, and stays usable after a run that matched nothing. (#315)
- Stop the device modal losing cables, status and inventory rows. (#315)
- Name rack inventory entries the way the Device Inventory does. (#315)
- Stamp the linked node's last-seen with an offset. (#323)
- Bump js-yaml to 4.3.1 for CVE-2026-59870. (#315)
- CI: stop the secret scan reporting pytest names as Lob keys. (#315)
### Docs
- Document the rack canvas for users. (#315)
## [3.1.2] - 2026-08-01
### Features
+48 -28
View File
@@ -16,20 +16,21 @@ Here's what Homelable can do. One line on what each feature is, then how to swit
2. [Groups & Nesting](#2-groups--nesting)
3. [Text Annotations](#3-text-annotations)
4. [Multiple Canvases](#4-multiple-canvases)
5. [Customize Style](#5-customize-style)
6. [Floor Plan](#6-floor-plan-)
7. [Network Scanner (IP import)](#7-network-scanner-ip-import-)
8. [Zigbee Import](#8-zigbee-import-)
9. [Z-Wave Import](#9-z-wave-import-)
10. [Proxmox VE Import](#10-proxmox-ve-import-)
11. [Device Inventory](#11-device-inventory-)
12. [Live Status Monitoring](#12-live-status-monitoring-)
13. [Export (PNG / SVG / YAML / Markdown)](#13-export)
14. [Live View (read-only public canvas)](#14-live-view-)
15. [Gethomepage Widget](#15-gethomepage-widget-)
16. [MCP Server (AI integration)](#16-mcp-server-)
17. [Settings & Shortcuts](#17-settings--shortcuts)
18. [Authentication (Local / OpenID Connect)](#18-authentication-local--openid-connect-)
5. [Rack Canvas](#5-rack-canvas)
6. [Customize Style](#6-customize-style)
7. [Floor Plan](#7-floor-plan-)
8. [Network Scanner (IP import)](#8-network-scanner-ip-import-)
9. [Zigbee Import](#9-zigbee-import-)
10. [Z-Wave Import](#10-z-wave-import-)
11. [Proxmox VE Import](#11-proxmox-ve-import-)
12. [Device Inventory](#12-device-inventory-)
13. [Live Status Monitoring](#13-live-status-monitoring-)
14. [Export (PNG / SVG / YAML / Markdown)](#14-export)
15. [Live View (read-only public canvas)](#15-live-view-)
16. [Gethomepage Widget](#16-gethomepage-widget-)
17. [MCP Server (AI integration)](#17-mcp-server-)
18. [Settings & Shortcuts](#18-settings--shortcuts)
19. [Authentication (Local / OpenID Connect)](#19-authentication-local--openid-connect-)
---
@@ -74,7 +75,26 @@ Here's what Homelable can do. One line on what each feature is, then how to swit
---
## 5. Customize Style
## 5. Rack Canvas
**What:** The physical side of the lab, as its own kind of canvas: racks, the gear mounted in them, and the patch cables between their ports. Where a diagram says what talks to what, a rack says what sits in which U, and which switch port the uplink lands on.
**Use:**
- **Canvas switcher** → **New Canvas** → Kind **Rack**. A canvas cannot change kind afterwards.
- **Add Rack** in the header drops a rack; double-click its frame for U height, 19"/10" width, numbering direction, colours and the enclosed/open look.
- **+ Device** in the sidebar mounts something: an entry from your **Device Inventory** 🔒, a **new device** (which joins the inventory tagged *Rack devices*), or a rack-only **accessory** (blank, shelf, cable manager). Double-click a plate to edit it, and **Unmount** to take it out — the device itself stays in the inventory.
- Gear occupies a U range and part of a 12-column width grid, so half- and third-width machines share a U. A drop snaps to the nearest free slot, an impossible one previews red, and growing a device relocates it to the nearest slot that fits.
- The **Faceplate** field opens a visual catalog — servers, switches, routers, patch panels, UPS and PDUs, desktop NAS towers, shelves and blanks — drawn as vector artwork that scales with the rack and follows your theme.
- **Patch**: drag from one port to another (or click both in turn) to cable them, across racks if you need to. Click a cable to select it, then **Delete** or **Unplug**. A header select controls whether cables show on hover, always, or not at all.
- **Status**: pin a mount's colour, or set **Check device** 🔒 so it follows the status check already configured on the matching diagram node.
- **Import links** 🔒 derives patches from the physical links already drawn on your diagrams. Run it again after racking more gear — pairs already cabled are left alone.
- Saving is explicit — **Save Rack** — like any other canvas.
> **Full documentation:** [docs/rack-canvas.md](./docs/rack-canvas.md)
---
## 6. Customize Style
**What:** Repaint the whole thing with a preset theme, or roll your own node and edge colors.
@@ -85,7 +105,7 @@ Here's what Homelable can do. One line on what each feature is, then how to swit
---
## 6. Floor Plan 🔒
## 7. Floor Plan 🔒
**What:** Put a background image (a house plan, an office layout, a rack diagram) behind a canvas and lay your devices out on top of it.
@@ -96,7 +116,7 @@ Here's what Homelable can do. One line on what each feature is, then how to swit
---
## 7. Network Scanner (IP import) 🔒
## 8. Network Scanner (IP import) 🔒
**What:** Point `nmap` at your network, fingerprint the services it finds, and turn hosts into nodes.
@@ -116,7 +136,7 @@ SCANNER_HTTP_VERIFY_TLS=false
---
## 8. Zigbee Import 🔒
## 9. Zigbee Import 🔒
**What:** Pull your **Zigbee2MQTT** topology in over MQTT and drop every device on the canvas as a typed node.
@@ -129,7 +149,7 @@ Nodes come in as `zigbee_coordinator` / `zigbee_router` / `zigbee_enddevice`. Th
---
## 9. Z-Wave Import 🔒
## 10. Z-Wave Import 🔒
**What:** Same idea for **Z-Wave JS UI**, over the same MQTT broker.
@@ -142,7 +162,7 @@ Nodes: `zwave_coordinator` / `zwave_router` / `zwave_enddevice`. The hierarchy c
---
## 10. Proxmox VE Import 🔒
## 11. Proxmox VE Import 🔒
**What:** Pull your **Proxmox VE** inventory (hosts, VMs, LXC) in over the Proxmox REST API — typed, named nodes with run state and hardware specs. Optional scheduled **auto-sync**; guest IPs already found by a scan are merged, not duplicated.
@@ -156,7 +176,7 @@ Nodes: `proxmox` (host) / `vm` / `lxc`, linked host→guest by a `virtual` edge.
---
## 11. Device Inventory 🔒
## 12. Device Inventory 🔒
**What:** The holding pen for everything found by a scan or import that isn't on the canvas yet, plus a separate **Hidden Devices** list.
@@ -167,7 +187,7 @@ Nodes: `proxmox` (host) / `vm` / `lxc`, linked host→guest by a `virtual` edge.
---
## 12. Live Status Monitoring 🔒
## 13. Live Status Monitoring 🔒
**What:** Keeps checking each node and shows its status (🟢 online / 🔴 offline / ⚫ unknown) right on the canvas.
@@ -188,7 +208,7 @@ Nodes: `proxmox` (host) / `vm` / `lxc`, linked host→guest by a `virtual` edge.
---
## 13. Export
## 14. Export
**What:** Get your canvas out as a picture or as structured data.
@@ -200,7 +220,7 @@ Nodes: `proxmox` (host) / `vm` / `lxc`, linked host→guest by a `virtual` edge.
---
## 14. Live View 🔒
## 15. Live View 🔒
**What:** A read-only, no-login snapshot of a canvas you can share on your LAN. Off by default.
@@ -212,7 +232,7 @@ Pan and zoom only, no editing. Click a node with an IP and it opens in a new tab
---
## 15. Gethomepage Widget 🔒
## 16. Gethomepage Widget 🔒
**What:** A tiny JSON stats endpoint for [gethomepage](https://gethomepage.dev)'s `customapi` widget. Off by default.
@@ -224,7 +244,7 @@ Widget snippet lives in the [README](./README.md#gethomepage-widget-read-only-st
---
## 16. MCP Server 🔒
## 17. MCP Server 🔒
**What:** A [Model Context Protocol](https://modelcontextprotocol.io) server so an MCP client (Claude Code, Claude Desktop, Open WebUI…) can read and change your topology. Optional, runs as its own service.
@@ -242,7 +262,7 @@ The AI can list nodes/edges/canvas/pending/scans, add/update/delete nodes and ed
---
## 17. Settings & Shortcuts
## 18. Settings & Shortcuts
**What:** App config and keyboard shortcuts.
@@ -253,7 +273,7 @@ The AI can list nodes/edges/canvas/pending/scans, add/update/delete nodes and ed
---
## 18. Authentication (Local / OpenID Connect) 🔒
## 19. Authentication (Local / OpenID Connect) 🔒
**What:** Homelable protects the app behind a login. Two exclusive modes, set once in `.env` with `AUTH_MODE`:
- **`local`** (default) — a single username + bcrypt-hashed password. Nothing changes for existing installs.
+20 -1
View File
@@ -21,6 +21,7 @@
<a href="#screenshots">Screenshots</a> ·
<a href="#features">Features</a> ·
<a href="#installation">Installation</a> ·
<a href="#rack-canvas">Rack Canvas</a> ·
<a href="#network-scanner">Network Scanner</a> ·
<a href="#zigbee2mqtt-import">Zigbee / Z-Wave</a> ·
<a href="#proxmox-ve-import">Proxmox</a> ·
@@ -56,7 +57,7 @@ If you are running <img width="22" height="22" align="top" alt="New_Home_Assist
## Features
From one-click **network scans** and **Proxmox / Zigbee / Z-Wave** imports to **live status monitoring**, floor plans, multi-canvas layouts and an **MCP server** for AI assistants — Homelable maps and watches your whole homelab.
From one-click **network scans** and **Proxmox / Zigbee / Z-Wave** imports to **live status monitoring**, floor plans, **rack canvases** with port-to-port patching, multi-canvas layouts and an **MCP server** for AI assistants — Homelable maps and watches your whole homelab.
Every feature, with how to turn it on and use it, is described in **[FEATURES.md](./FEATURES.md)**.
@@ -68,6 +69,24 @@ Docker, Proxmox LXC, build from source, configuration, and development setup are
---
## Rack Canvas
Next to the network diagram, Homelable draws the **physical** side of your lab: racks, the gear mounted in them, and the patch cables between their ports. It is a canvas kind of its own — create it from the canvas switcher, **New Canvas → Kind → Rack**.
### Usage
1. **Add Rack** in the header drops a rack; double-click its frame to set U height, 19"/10" width, numbering direction and colours
2. **+ Device** in the sidebar mounts something — an entry from your Device Inventory, a new device (which joins the inventory), or an accessory (blank, shelf, cable manager)
3. Pick a **faceplate** from the visual catalog: servers, switches, routers, patch panels, UPS and PDUs, desktop NAS towers, shelves and blanks
4. Click **Patch**, then drag from one port to another to cable them — across racks if you need to. Click a cable and press Delete to unplug it
5. **Save Rack** when you're happy; nothing is written behind your back
Gear sits in a U range and part of a 12-column width grid, so half- and third-width machines share a U; a drop snaps to the nearest free slot. A mount can follow the status check of its matching diagram node, and **Import links** derives patches from the links already drawn on your diagrams.
> **Full documentation:** [docs/rack-canvas.md](./docs/rack-canvas.md)
---
## Network Scanner
The scanner runs `nmap -sV --open` on your configured CIDR ranges and populates a **Pending Devices** queue. From the sidebar you can then approve (adds a node to the canvas), hide, or ignore each discovered device.
+1 -1
View File
@@ -1 +1 @@
3.1.2
3.2.0
+62 -2
View File
@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import CanvasState, Design, Edge, Node
from app.db.models import CanvasState, Design, Edge, Node, Rack, RackCable, RackDevice
from app.schemas.designs import DesignCopy, DesignCreate, DesignResponse, DesignUpdate
router = APIRouter()
@@ -118,6 +118,56 @@ async def copy_design(
)
)
# Copy the rack canvas, if the source has one. Racks, mounted gear and patches
# all get new ids; the inventory / node links are carried over untouched, since
# a copy documents the same hardware.
src_racks = (await db.execute(select(Rack).where(Rack.design_id == source_id))).scalars().all()
rack_id_map = {r.id: str(uuid.uuid4()) for r in src_racks}
rack_skip = {"id", "design_id", "created_at", "updated_at"}
for r in src_racks:
cols = {c.name: getattr(r, c.name) for c in Rack.__table__.columns if c.name not in rack_skip}
db.add(Rack(id=rack_id_map[r.id], design_id=new_design.id, **cols))
src_rack_devices = (
await db.execute(select(RackDevice).where(RackDevice.design_id == source_id))
).scalars().all()
rack_device_id_map = {d.id: str(uuid.uuid4()) for d in src_rack_devices}
rack_device_skip = {"id", "design_id", "rack_id", "created_at", "updated_at"}
for d in src_rack_devices:
if d.rack_id not in rack_id_map:
continue
cols = {
c.name: getattr(d, c.name)
for c in RackDevice.__table__.columns
if c.name not in rack_device_skip
}
db.add(
RackDevice(
id=rack_device_id_map[d.id],
design_id=new_design.id,
rack_id=rack_id_map[d.rack_id],
**cols,
)
)
src_cables = (await db.execute(select(RackCable).where(RackCable.design_id == source_id))).scalars().all()
cable_skip = {"id", "design_id", "from_device_id", "to_device_id", "created_at"}
for c_row in src_cables:
if c_row.from_device_id not in rack_device_id_map or c_row.to_device_id not in rack_device_id_map:
continue
cols = {
c.name: getattr(c_row, c.name) for c in RackCable.__table__.columns if c.name not in cable_skip
}
db.add(
RackCable(
id=str(uuid.uuid4()),
design_id=new_design.id,
from_device_id=rack_device_id_map[c_row.from_device_id],
to_device_id=rack_device_id_map[c_row.to_device_id],
**cols,
)
)
# Copy canvas state (viewport, custom style, and the floor plan carried in viewport).
src_state = await db.get(CanvasState, source_id)
db.add(
@@ -165,10 +215,20 @@ async def delete_design(
count = (await db.execute(select(Design))).scalars().all()
if len(count) <= 1:
raise HTTPException(400, "Cannot delete the only design")
# Delete associated canvas state, edges, nodes
# Delete associated canvas state, rack rows, edges, nodes. SQLite does not
# always enforce the ON DELETE CASCADE, so unwind by hand — cables before
# devices before racks.
cs = await db.get(CanvasState, design_id)
if cs:
await db.delete(cs)
for cable in (await db.execute(select(RackCable).where(RackCable.design_id == design_id))).scalars().all():
await db.delete(cable)
for rack_device in (
await db.execute(select(RackDevice).where(RackDevice.design_id == design_id))
).scalars().all():
await db.delete(rack_device)
for rack in (await db.execute(select(Rack).where(Rack.design_id == design_id))).scalars().all():
await db.delete(rack)
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
for e in edges:
await db.delete(e)
+14 -14
View File
@@ -21,7 +21,7 @@ from app.api.deps import get_current_user
from app.core.config import settings
from app.core.scheduler import reschedule_proxmox_sync, set_proxmox_sync_enabled
from app.db.database import AsyncSessionLocal, get_db
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
from app.db.models import InventoryDevice, InventoryDeviceLink, Node, ScanRun
from app.schemas.proxmox import (
ProxmoxConfig,
ProxmoxConnectionRequest,
@@ -245,7 +245,7 @@ async def _persist_pending_import(
nodes_raw: list[dict[str, Any]],
edges_raw: list[dict[str, Any]],
) -> ProxmoxImportPendingResponse:
"""Upsert Proxmox nodes/edges into pending_devices + pending_device_links.
"""Upsert Proxmox nodes/edges into device_inventory + device_inventory_links.
Two-tier identity (order matters):
1. Match an existing canvas Node or pending row by **IP** (merge into a
@@ -328,14 +328,14 @@ async def _persist_pending_import(
async def _find_pending(
db: AsyncSession, ieee: str, ip: str | None, mac: str | None
) -> PendingDevice | None:
filters = [PendingDevice.ieee_address == ieee]
) -> InventoryDevice | None:
filters = [InventoryDevice.ieee_address == ieee]
if ip:
filters.append(PendingDevice.ip == ip)
filters.append(InventoryDevice.ip == ip)
if mac:
filters.append(PendingDevice.mac == mac)
filters.append(InventoryDevice.mac == mac)
return (
await db.execute(select(PendingDevice).where(or_(*filters)))
await db.execute(select(InventoryDevice).where(or_(*filters)))
).scalars().first()
@@ -346,8 +346,8 @@ def _new_pending(
n: dict[str, Any],
props: list[dict[str, Any]],
status: str,
) -> PendingDevice:
return PendingDevice(
) -> InventoryDevice:
return InventoryDevice(
ieee_address=ieee,
ip=ip,
mac=mac,
@@ -363,7 +363,7 @@ def _new_pending(
)
def _sources_after_merge(row: PendingDevice) -> list[str]:
def _sources_after_merge(row: InventoryDevice) -> list[str]:
"""Discovery sources for an inventory row after a Proxmox import merges in.
Must run BEFORE the ``pve-`` ieee is adopted onto the row, so it can tell
@@ -381,7 +381,7 @@ def _sources_after_merge(row: PendingDevice) -> list[str]:
def _refresh_pending(
pending: PendingDevice,
pending: InventoryDevice,
ieee: str,
ip: str | None,
mac: str | None,
@@ -441,8 +441,8 @@ async def _replace_links(
(``proxmox_cluster`` → 'cluster' edges).
"""
await db.execute(
sa_delete(PendingDeviceLink).where(
PendingDeviceLink.discovery_source.in_([_PROXMOX_GUEST_SOURCE, _PROXMOX_CLUSTER_SOURCE])
sa_delete(InventoryDeviceLink).where(
InventoryDeviceLink.discovery_source.in_([_PROXMOX_GUEST_SOURCE, _PROXMOX_CLUSTER_SOURCE])
)
)
recorded = 0
@@ -453,7 +453,7 @@ async def _replace_links(
if not src or not tgt or (src, tgt) in seen:
return
seen.add((src, tgt))
db.add(PendingDeviceLink(source_ieee=src, target_ieee=tgt, discovery_source=source))
db.add(InventoryDeviceLink(source_ieee=src, target_ieee=tgt, discovery_source=source))
recorded += 1
for e in edges_raw:
+349
View File
@@ -0,0 +1,349 @@
from datetime import datetime, timezone
from typing import Any, TypeVar
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import CanvasState, Design, InventoryDevice, Node, Rack, RackCable, RackDevice
from app.schemas.racks import (
RackCableResponse,
RackDeviceResponse,
RackInventoryItem,
RackInventoryResponse,
RackResponse,
RackSaveRequest,
RackServiceInfo,
RackStateResponse,
)
router = APIRouter()
# Device kinds that cannot be racked. An exclusion list rather than an allow list:
# new hardware types should show up in the tray by default, and only the obviously
# virtual / mesh / annotation kinds need to opt out.
_UNRACKABLE_TYPES = {
"vm",
"lxc",
"docker_container",
"mobile",
"laptop",
"light",
"socket",
"load",
"groupRect",
"group",
"text",
"zigbee_coordinator",
"zigbee_router",
"zigbee_enddevice",
"zwave_coordinator",
"zwave_router",
"zwave_enddevice",
}
_COMMON_PORTS = {22, 80, 443}
def _service_name(device: InventoryDevice) -> str | None:
"""Name a device after the app it runs, the way the Device Inventory does.
Ports everyone exposes (ssh, http, https) say nothing, and the generic web
category loses to a real match, so "jellyfin" wins over "http".
"""
candidates = [
s
for s in (device.services or [])
if isinstance(s, dict)
and s.get("category")
and s.get("service_name")
# A service with no port is not a fingerprint the inventory names a
# device after either — and `None not in _COMMON_PORTS` is True.
and s.get("port") is not None
and s.get("port") not in _COMMON_PORTS
]
if not candidates:
return None
named = next((s for s in candidates if str(s["category"]).lower() != "web"), candidates[0])
name = named.get("service_name")
return str(name) if name else None
def _device_label(device: InventoryDevice) -> str:
"""Inventory naming, mirrored: friendly name, host, app, IP, IEEE, id.
Must stay in step with `deviceLabel` in `InventoryDevicesModal.tsx` — a device
the user picked out of the inventory by one name should not turn up in the
rack picker under another.
"""
return (
device.friendly_name
or device.hostname
or _service_name(device)
or device.ip
or device.ieee_address
or device.id
)
# A mount prints its services as a short list, not a port scan dump.
_MAX_SERVICES = 12
def _services(raw: Any) -> list[RackServiceInfo]:
"""Fingerprinted services, deduped and trimmed for the rack's info panel."""
out: list[RackServiceInfo] = []
seen: set[tuple[int | None, str | None]] = set()
for entry in raw or []:
if not isinstance(entry, dict):
continue
raw_port = entry.get("port")
port = raw_port if isinstance(raw_port, int) else None
raw_name = entry.get("service_name") or entry.get("name")
name = str(raw_name) if raw_name else None
if port is None and name is None:
continue
if (port, name) in seen:
continue
seen.add((port, name))
out.append(RackServiceInfo(port=port, name=name))
if len(out) == _MAX_SERVICES:
break
return out
def _ip_tokens(ip: str | None) -> list[str]:
"""Split a device ``ip`` field into individual addresses.
Mirrors the scan routes: the canvas stores multiple addresses in one
comma-separated string, so matching must compare per-address.
"""
return [t.strip() for t in ip.split(",") if t.strip()] if ip else []
_Row = TypeVar("_Row", Rack, RackDevice, RackCable)
async def _owned(
db: AsyncSession, model: type[_Row], row_id: str, design_id: str
) -> _Row | None:
"""Fetch a rack row by id, refusing one that belongs to another design.
The upsert below sets `design_id` from the payload, so without this a save
carrying an id from design A would quietly move A's rack — devices and
cables included — into design B, and A would lose it with no error. Ids come
from the client, so a copied design or a stale tab is enough to collide.
"""
row = await db.get(model, row_id)
if row is not None and row.design_id != design_id:
raise HTTPException(409, f"{model.__name__} {row_id} belongs to another design")
return row
async def _require_design(db: AsyncSession, design_id: str) -> Design:
design = await db.get(Design, design_id)
if not design:
raise HTTPException(404, "Design not found")
return design
@router.get("", response_model=RackStateResponse)
async def load_racks(
design_id: str = Query(..., description="Rack design to load"),
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> RackStateResponse:
await _require_design(db, design_id)
racks = (await db.execute(select(Rack).where(Rack.design_id == design_id))).scalars().all()
devices = (
await db.execute(select(RackDevice).where(RackDevice.design_id == design_id))
).scalars().all()
cables = (await db.execute(select(RackCable).where(RackCable.design_id == design_id))).scalars().all()
state = await db.get(CanvasState, design_id)
return RackStateResponse(
racks=[RackResponse.model_validate(r) for r in racks],
devices=[RackDeviceResponse.model_validate(d) for d in devices],
cables=[RackCableResponse.model_validate(c) for c in cables],
viewport=state.viewport if state else {"x": 0, "y": 0, "zoom": 1},
)
@router.post("/save")
async def save_racks(
body: RackSaveRequest,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, bool]:
"""Persist the full rack state of one design: upsert what is sent, prune the rest."""
await _require_design(db, body.design_id)
rack_ids = {r.id for r in body.racks}
device_ids = {d.id for d in body.devices}
# A device must land in a rack that is part of the same payload, and a cable
# must join two devices in it — otherwise the prune below would orphan them.
for device in body.devices:
if device.rack_id not in rack_ids:
raise HTTPException(400, f"Device {device.id} references unknown rack {device.rack_id}")
for cable in body.cables:
if cable.from_device_id not in device_ids or cable.to_device_id not in device_ids:
raise HTTPException(400, f"Cable {cable.id} references a device outside this payload")
# Prune first (cables, then devices, then racks) so freed rows never collide
# with an upsert that reuses their geometry.
for existing_cable in (
await db.execute(select(RackCable).where(RackCable.design_id == body.design_id))
).scalars().all():
if existing_cable.id not in {c.id for c in body.cables}:
await db.delete(existing_cable)
for existing_device in (
await db.execute(select(RackDevice).where(RackDevice.design_id == body.design_id))
).scalars().all():
if existing_device.id not in device_ids:
await db.delete(existing_device)
for existing_rack in (
await db.execute(select(Rack).where(Rack.design_id == body.design_id))
).scalars().all():
if existing_rack.id not in rack_ids:
await db.delete(existing_rack)
await db.flush()
for rack_data in body.racks:
payload: dict[str, Any] = rack_data.model_dump()
payload["design_id"] = body.design_id
db_rack = await _owned(db, Rack, rack_data.id, body.design_id)
if db_rack:
for field, value in payload.items():
setattr(db_rack, field, value)
else:
db.add(Rack(**payload))
await db.flush() # racks must exist before devices point at them
for device_data in body.devices:
payload = device_data.model_dump()
payload["design_id"] = body.design_id
db_device = await _owned(db, RackDevice, device_data.id, body.design_id)
if db_device:
for field, value in payload.items():
setattr(db_device, field, value)
else:
db.add(RackDevice(**payload))
await db.flush() # devices must exist before cables point at them
for cable_data in body.cables:
payload = cable_data.model_dump()
payload["design_id"] = body.design_id
db_cable = await _owned(db, RackCable, cable_data.id, body.design_id)
if db_cable:
for field, value in payload.items():
setattr(db_cable, field, value)
else:
db.add(RackCable(**payload))
# Viewport lives on the design's shared CanvasState row, like the logical canvas.
state = await db.get(CanvasState, body.design_id)
if state:
state.viewport = body.viewport
state.saved_at = datetime.now(timezone.utc)
else:
db.add(CanvasState(design_id=body.design_id, viewport=body.viewport))
await db.commit()
return {"saved": True}
@router.get("/inventory", response_model=RackInventoryResponse)
async def rack_inventory(
design_id: str = Query(..., description="Rack design the tray is for"),
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> RackInventoryResponse:
"""Device Inventory entries that can be racked, flagged with what is already mounted.
Reads `device_inventory` — the inventory survives approval and node deletion,
so unracking or deleting a canvas node never removes the entry here.
"""
await _require_design(db, design_id)
devices = (
await db.execute(select(InventoryDevice).where(InventoryDevice.status != "hidden"))
).scalars().all()
mounts = (
await db.execute(
select(RackDevice.device_id, RackDevice.node_id).where(
RackDevice.design_id == design_id
)
)
).all()
mounted = {device_id for device_id, _ in mounts if device_id}
# A mount can name its canvas node itself, when the user linked one by hand
# in the rack. That beats the IEEE/IP guess below: the guess is what failed
# to find anything in the first place.
pinned = {device_id: node_id for device_id, node_id in mounts if device_id and node_id}
# Correlate against canvas nodes for live status: IEEE first (stable), then IP.
nodes = (await db.execute(select(Node))).scalars().all()
by_id = {n.id: n for n in nodes}
by_ieee = {n.ieee_address: n for n in nodes if n.ieee_address}
by_ip: dict[str, Node] = {}
for node in nodes:
for token in _ip_tokens(node.ip):
by_ip.setdefault(token, node)
# Which canvas the matched node lives on — the rack prints it so the user
# knows where the logical twin of a mount is drawn.
design_names = {
d.id: d.name for d in (await db.execute(select(Design))).scalars().all()
}
items = []
for device in devices:
if device.suggested_type in _UNRACKABLE_TYPES:
continue
# A node the user linked by hand wins; a deleted one falls back to the
# guess rather than reporting a node that no longer exists.
linked: Node | None = by_id.get(pinned.get(device.id) or "")
if linked is None and device.ieee_address:
linked = by_ieee.get(device.ieee_address)
if linked is None:
linked = next((by_ip[t] for t in _ip_tokens(device.ip) if t in by_ip), None)
# The node is the richer record once a device has been approved onto a
# canvas; the inventory row is what discovery last saw. Report both and
# let the panel prefer the node.
services = _services(device.services) or (
_services(linked.services) if linked else []
)
items.append(
RackInventoryItem(
id=device.id,
label=_device_label(device),
suggested_type=device.suggested_type,
ip=device.ip,
status=device.status,
discovery_source=device.discovery_source,
mac=device.mac or device.ieee_address,
hostname=device.hostname,
os=device.os,
services=services,
node_id=linked.id if linked else None,
node_status=linked.status if linked else None,
node_label=linked.label if linked else None,
node_type=linked.type if linked else None,
node_ip=linked.ip if linked else None,
node_mac=(linked.mac or linked.ieee_address) if linked else None,
node_hostname=linked.hostname if linked else None,
node_os=linked.os if linked else None,
node_check_method=linked.check_method if linked else None,
node_design_id=linked.design_id if linked else None,
node_design_name=(
design_names.get(linked.design_id) if linked and linked.design_id else None
),
node_last_seen=linked.last_seen if linked else None,
racked=device.id in mounted,
)
)
return RackInventoryResponse(items=items)
+127 -33
View File
@@ -12,9 +12,18 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.core.config import settings
from app.db.database import AsyncSessionLocal, get_db
from app.db.models import Design, Edge, Node, PendingDevice, PendingDeviceLink, ScanRun
from app.db.models import (
Design,
Edge,
InventoryDevice,
InventoryDeviceLink,
Node,
RackDevice,
ScanRun,
)
from app.schemas.nodes import NodeCreate
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
from app.schemas.scan import InventoryDeviceCreate, InventoryDeviceResponse, ScanRunResponse
from app.services.mac_utils import normalize_mac
from app.services.node_dedupe import dedupe_nodes_by_ieee, find_duplicate_node
from app.services.scanner import DeepScanOptions, _valid_port_range, request_cancel, run_scan
from app.services.zigbee_service import (
@@ -38,6 +47,15 @@ def _ip_tokens(ip: str | None) -> list[str]:
return [t.strip() for t in ip.split(",") if t.strip()] if ip else []
def _is_rack_only(device: InventoryDevice) -> bool:
"""True for inventory entries created from a rack canvas.
They describe a mount — a patch panel, a shelf, a chassis — not a host to
document on a logical canvas, so they are never approved onto one.
"""
return device.discovery_source == "rack" or "rack" in (device.discovery_sources or [])
def _is_wireless(node_type: str | None) -> bool:
"""Zigbee + Z-Wave mesh devices share online status / no ICMP check."""
return node_type in _ZIGBEE_TYPES or node_type in _ZWAVE_TYPES
@@ -224,7 +242,7 @@ def _agg(values: list[datetime], *, newest: bool) -> datetime | None:
async def _canvas_correlation(
db: AsyncSession, devices: list[PendingDevice]
db: AsyncSession, devices: list[InventoryDevice]
) -> dict[str, dict[str, Any]]:
"""Correlate each device to existing canvas nodes by ``ieee_address``, ``mac``
or ``ip``.
@@ -290,8 +308,8 @@ async def _canvas_correlation(
async def _with_canvas_counts(
db: AsyncSession, devices: list[PendingDevice]
) -> list[PendingDevice]:
db: AsyncSession, devices: list[InventoryDevice]
) -> list[InventoryDevice]:
"""Attach transient canvas count + linked-node timestamps for the response."""
info = await _canvas_correlation(db, devices)
for d in devices:
@@ -304,28 +322,91 @@ async def _with_canvas_counts(
return devices
@router.get("/pending", response_model=list[PendingDeviceResponse])
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
@router.get("/pending", response_model=list[InventoryDeviceResponse])
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[InventoryDevice]:
# Inventory: every scanned device except the user-hidden ones. Approved devices
# stay listed so they keep showing with a canvas-presence badge.
result = await db.execute(select(PendingDevice).where(PendingDevice.status != "hidden"))
result = await db.execute(select(InventoryDevice).where(InventoryDevice.status != "hidden"))
return await _with_canvas_counts(db, list(result.scalars().all()))
@router.post("/pending", response_model=InventoryDeviceResponse, status_code=201)
async def create_pending(
body: InventoryDeviceCreate,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> InventoryDevice:
"""Add an inventory entry by hand, for hardware no scan can discover.
Lands as ``status="pending"`` like a discovery would, so the existing approve
/ hide / restore flows apply unchanged.
"""
device = InventoryDevice(
hostname=body.hostname,
ip=body.ip,
# Canonical form, like every other write path: dedup compares MACs by
# equality, so a hand-typed "AA-BB-CC-11-22-33" would never match the
# scanned "aa:bb:cc:11:22:33" and approve would build a duplicate node.
mac=normalize_mac(body.mac),
suggested_type=body.suggested_type,
model=body.model,
vendor=body.vendor,
properties=body.properties,
status="pending",
discovery_source=body.discovery_source,
discovery_sources=[body.discovery_source],
)
db.add(device)
await db.commit()
await db.refresh(device)
return (await _with_canvas_counts(db, [device]))[0]
@router.delete("/pending", response_model=dict)
async def clear_pending(
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, int]:
from sqlalchemy import delete as sa_delete
result = await db.execute(sa_delete(PendingDevice).where(PendingDevice.status == "pending"))
result = await db.execute(sa_delete(InventoryDevice).where(InventoryDevice.status == "pending"))
await db.commit()
return {"deleted": result.rowcount}
@router.get("/hidden", response_model=list[PendingDeviceResponse])
async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "hidden"))
@router.delete("/pending/{device_id}", response_model=dict)
async def delete_pending(
device_id: str,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, bool]:
"""Remove one inventory entry.
Used by the rack canvas to drop the placeholder it created for a plate once
that plate is pointed at a real inventory row. Refuses while a rack still
mounts the device: SQLite runs with foreign keys off here, so the mount's
``device_id`` would be left naming a row that no longer exists rather than
being cleared by the ``ON DELETE SET NULL`` the schema declares.
"""
device = (
await db.execute(select(InventoryDevice).where(InventoryDevice.id == device_id))
).scalar_one_or_none()
if not device:
raise HTTPException(status_code=404, detail="Device not found")
mounted = (
await db.execute(select(RackDevice.id).where(RackDevice.device_id == device_id).limit(1))
).scalar_one_or_none()
if mounted:
raise HTTPException(status_code=409, detail="Device is mounted in a rack")
await db.delete(device)
await db.commit()
return {"deleted": True}
@router.get("/hidden", response_model=list[InventoryDeviceResponse])
async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[InventoryDevice]:
result = await db.execute(select(InventoryDevice).where(InventoryDevice.status == "hidden"))
return await _with_canvas_counts(db, list(result.scalars().all()))
@@ -350,9 +431,9 @@ async def bulk_approve_devices(
# node was later deleted) must still be placeable on THIS design. Duplicates
# are guarded per-design below, not by the global status flag.
result = await db.execute(
select(PendingDevice).where(
PendingDevice.id.in_(payload.device_ids),
PendingDevice.status != "hidden",
select(InventoryDevice).where(
InventoryDevice.id.in_(payload.device_ids),
InventoryDevice.status != "hidden",
)
)
devices = result.scalars().all()
@@ -378,9 +459,17 @@ async def bulk_approve_devices(
placed_ieee: dict[str, Any] = {ieee: nid for nid, _, _, ieee in existing if ieee}
created_nodes: list[Node] = []
approved_devices: list[PendingDevice] = []
approved_devices: list[InventoryDevice] = []
skipped_devices: list[dict[str, Any]] = []
for device in devices:
# Rack-only gear belongs to a rack canvas, never to a logical one.
if _is_rack_only(device):
skipped_devices.append({
"device_id": device.id,
"label": device.hostname or device.friendly_name or "device",
"match": "rack", "value": "rack device", "_ref": None,
})
continue
# Record which identifier collided so the caller can explain each skip
# (and, for existing on-canvas nodes, link to the node already there).
ip_hit = next((t for t in _ip_tokens(device.ip) if t in placed_ips), None)
@@ -477,9 +566,9 @@ async def bulk_hide_devices(
_: str = Depends(get_current_user),
) -> dict[str, Any]:
result = await db.execute(
select(PendingDevice).where(
PendingDevice.id.in_(payload.device_ids),
PendingDevice.status == "pending",
select(InventoryDevice).where(
InventoryDevice.id.in_(payload.device_ids),
InventoryDevice.status == "pending",
)
)
devices = result.scalars().all()
@@ -495,7 +584,7 @@ async def restore_device(
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, Any]:
device = await db.get(PendingDevice, device_id)
device = await db.get(InventoryDevice, device_id)
if not device:
raise HTTPException(status_code=404, detail="Device not found")
if device.status != "hidden":
@@ -512,9 +601,9 @@ async def bulk_restore_devices(
_: str = Depends(get_current_user),
) -> dict[str, Any]:
result = await db.execute(
select(PendingDevice).where(
PendingDevice.id.in_(payload.device_ids),
PendingDevice.status == "hidden",
select(InventoryDevice).where(
InventoryDevice.id.in_(payload.device_ids),
InventoryDevice.status == "hidden",
)
)
devices = result.scalars().all()
@@ -537,7 +626,7 @@ async def approve_device(
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
node_design_id = first.id if first else None
device = await db.get(PendingDevice, device_id)
device = await db.get(InventoryDevice, device_id)
if not device:
raise HTTPException(status_code=404, detail="Device not found")
# A device's status is GLOBAL — it flips to "approved" the moment it lands on
@@ -549,6 +638,11 @@ async def approve_device(
# device is off-limits here.
if device.status == "hidden":
raise HTTPException(status_code=409, detail="Device is hidden")
if _is_rack_only(device):
raise HTTPException(
status_code=409,
detail="Rack devices cannot be placed on a logical canvas",
)
wireless = _is_wireless(node_data.type)
# A device already on THIS design (matched by ieee, ip OR mac) is NOT placed
@@ -621,10 +715,10 @@ async def _is_proxmox_cluster_member(db: AsyncSession, ieee: str | None) -> bool
return False
found = (
await db.execute(
select(PendingDeviceLink.id)
select(InventoryDeviceLink.id)
.where(
PendingDeviceLink.discovery_source == "proxmox_cluster",
(PendingDeviceLink.source_ieee == ieee) | (PendingDeviceLink.target_ieee == ieee),
InventoryDeviceLink.discovery_source == "proxmox_cluster",
(InventoryDeviceLink.source_ieee == ieee) | (InventoryDeviceLink.target_ieee == ieee),
)
.limit(1)
)
@@ -635,7 +729,7 @@ async def _is_proxmox_cluster_member(db: AsyncSession, ieee: str | None) -> bool
async def _resolve_pending_links_for_ieee(
db: AsyncSession, ieee: str | None, design_id: str | None
) -> list[dict[str, str]]:
"""Materialize edges for any pending_device_links involving ``ieee`` on the
"""Materialize edges for any device_inventory_links involving ``ieee`` on the
canvas identified by ``design_id``.
For each link where the other endpoint already exists as a Node *on this
@@ -649,9 +743,9 @@ async def _resolve_pending_links_for_ieee(
return []
links_q = await db.execute(
select(PendingDeviceLink).where(
(PendingDeviceLink.source_ieee == ieee)
| (PendingDeviceLink.target_ieee == ieee)
select(InventoryDeviceLink).where(
(InventoryDeviceLink.source_ieee == ieee)
| (InventoryDeviceLink.target_ieee == ieee)
)
)
links = list(links_q.scalars().all())
@@ -751,7 +845,7 @@ async def _resolve_pending_links_for_ieee(
async def hide_device(
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
) -> dict[str, bool]:
device = await db.get(PendingDevice, device_id)
device = await db.get(InventoryDevice, device_id)
if not device:
raise HTTPException(status_code=404, detail="Device not found")
device.status = "hidden"
@@ -763,7 +857,7 @@ async def hide_device(
async def ignore_device(
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
) -> dict[str, bool]:
device = await db.get(PendingDevice, device_id)
device = await db.get(InventoryDevice, device_id)
if not device:
raise HTTPException(status_code=404, detail="Device not found")
await db.delete(device)
+5 -3
View File
@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.db.database import get_db
from app.db.models import Node, PendingDevice, ScanRun
from app.db.models import InventoryDevice, Node, ScanRun
router = APIRouter()
@@ -38,8 +38,8 @@ async def summary(
pending = (
await db.execute(
select(func.count())
.select_from(PendingDevice)
.where(PendingDevice.status == "pending")
.select_from(InventoryDevice)
.where(InventoryDevice.status == "pending")
)
).scalar_one()
@@ -58,6 +58,8 @@ async def summary(
"online": counts.get("online", 0),
"offline": counts.get("offline", 0),
"unknown": counts.get("unknown", 0),
# Wire key kept from before the table was renamed to `device_inventory`:
# the stats payload is a published contract, and the MCP server reads it.
"pending_devices": pending,
"zigbee_devices": zigbee,
"last_scan_at": last_scan_at.isoformat() if last_scan_at else None,
+8 -8
View File
@@ -13,7 +13,7 @@ from app.api.deps import get_current_user
from app.core.config import settings
from app.core.scheduler import reschedule_zigbee_sync, set_zigbee_sync_enabled
from app.db.database import AsyncSessionLocal, get_db
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
from app.db.models import InventoryDevice, InventoryDeviceLink, Node, ScanRun
from app.schemas.scan import ScanRunResponse
from app.schemas.zigbee import (
ZigbeeConfig,
@@ -185,7 +185,7 @@ async def _persist_pending_import(
nodes_raw: list[dict[str, Any]],
edges_raw: list[dict[str, Any]],
) -> ZigbeeImportPendingResponse:
"""Upsert nodes/edges into pending_devices + pending_device_links.
"""Upsert nodes/edges into device_inventory + device_inventory_links.
Coordinator auto-approves to a canvas Node. Other devices upsert by IEEE.
All zigbee-source links are wiped and re-inserted from the new map.
@@ -230,12 +230,12 @@ async def _persist_pending_import(
)
inv = (
await db.execute(
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
select(InventoryDevice).where(InventoryDevice.ieee_address == ieee)
)
).scalar_one_or_none()
if inv is None:
db.add(
PendingDevice(
InventoryDevice(
ieee_address=ieee,
friendly_name=n.get("friendly_name"),
hostname=n.get("friendly_name"),
@@ -263,12 +263,12 @@ async def _persist_pending_import(
continue
result = await db.execute(
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
select(InventoryDevice).where(InventoryDevice.ieee_address == ieee)
)
pending = result.scalar_one_or_none()
if pending is None:
db.add(
PendingDevice(
InventoryDevice(
ieee_address=ieee,
friendly_name=n.get("friendly_name"),
hostname=n.get("friendly_name"),
@@ -303,7 +303,7 @@ async def _persist_pending_import(
# Replace all zigbee-source links with the freshly discovered set.
await db.execute(
sa_delete(PendingDeviceLink).where(PendingDeviceLink.discovery_source == "zigbee")
sa_delete(InventoryDeviceLink).where(InventoryDeviceLink.discovery_source == "zigbee")
)
links_recorded = 0
@@ -315,7 +315,7 @@ async def _persist_pending_import(
continue
seen.add((src, tgt))
db.add(
PendingDeviceLink(
InventoryDeviceLink(
source_ieee=src,
target_ieee=tgt,
discovery_source="zigbee",
+8 -8
View File
@@ -13,7 +13,7 @@ from app.api.deps import get_current_user
from app.core.config import settings
from app.core.scheduler import reschedule_zwave_sync, set_zwave_sync_enabled
from app.db.database import AsyncSessionLocal, get_db
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
from app.db.models import InventoryDevice, InventoryDeviceLink, Node, ScanRun
from app.schemas.scan import ScanRunResponse
from app.schemas.zwave import (
ZwaveConfig,
@@ -182,7 +182,7 @@ async def _persist_pending_import(
nodes_raw: list[dict[str, Any]],
edges_raw: list[dict[str, Any]],
) -> ZwaveImportPendingResponse:
"""Upsert nodes/edges into pending_devices + pending_device_links.
"""Upsert nodes/edges into device_inventory + device_inventory_links.
Coordinator auto-approves to a canvas Node. Other devices upsert by Z-Wave
identity. All zwave-source links are wiped and re-inserted from the new map.
@@ -225,12 +225,12 @@ async def _persist_pending_import(
)
inv = (
await db.execute(
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
select(InventoryDevice).where(InventoryDevice.ieee_address == ieee)
)
).scalar_one_or_none()
if inv is None:
db.add(
PendingDevice(
InventoryDevice(
ieee_address=ieee,
friendly_name=n.get("friendly_name"),
hostname=n.get("friendly_name"),
@@ -257,12 +257,12 @@ async def _persist_pending_import(
continue
result = await db.execute(
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
select(InventoryDevice).where(InventoryDevice.ieee_address == ieee)
)
pending = result.scalar_one_or_none()
if pending is None:
db.add(
PendingDevice(
InventoryDevice(
ieee_address=ieee,
friendly_name=n.get("friendly_name"),
hostname=n.get("friendly_name"),
@@ -292,7 +292,7 @@ async def _persist_pending_import(
# Replace all zwave-source links with the freshly discovered set.
await db.execute(
sa_delete(PendingDeviceLink).where(PendingDeviceLink.discovery_source == "zwave")
sa_delete(InventoryDeviceLink).where(InventoryDeviceLink.discovery_source == "zwave")
)
links_recorded = 0
@@ -304,7 +304,7 @@ async def _persist_pending_import(
continue
seen.add((src, tgt))
db.add(
PendingDeviceLink(
InventoryDeviceLink(
source_ieee=src,
target_ieee=tgt,
discovery_source="zwave",
+102 -28
View File
@@ -60,9 +60,67 @@ def _backup_db() -> None:
logger.warning("Could not create DB backup at %s", backup_path)
# Tables renamed in 3.2.0. "Pending devices" was the scanner's word for a queue
# of finds awaiting approval; the same rows outlive approval, are edited by hand
# and are what a rack mounts, so the product calls them the Device Inventory.
# Only the names moved — every column, every row and every route is unchanged.
_RENAMED_TABLES: list[tuple[str, str]] = [
("pending_devices", "device_inventory"),
("pending_device_links", "device_inventory_links"),
]
async def _rename_legacy_tables(conn: AsyncConnection) -> None:
"""Rename the pre-3.2.0 inventory tables. Must run before `create_all`.
Order matters: `create_all` would otherwise create an empty
`device_inventory` beside the populated `pending_devices`, and every device
the user ever scanned would look lost.
Foreign keys are switched on for the rename so SQLite rewrites the
``REFERENCES pending_devices`` clause in `rack_devices` too; enforcement is
off at runtime, so a missed rewrite costs nothing today, but it would leave
the schema naming a table that no longer exists.
"""
for old, new in _RENAMED_TABLES:
rows = (
await conn.exec_driver_sql(
"SELECT name FROM sqlite_master WHERE type='table' AND name IN (?, ?)",
(old, new),
)
).fetchall()
names = {r[0] for r in rows}
if old not in names:
continue # Fresh install, or already migrated.
if new in names:
# A start that ran `create_all` before this migration existed left an
# empty new table beside the populated old one, and the app read the
# empty one. Clear it out of the way rather than stranding the data;
# a new table with rows in it means the rename already happened and
# something re-created the old one, which is not ours to resolve.
count = (await conn.exec_driver_sql(f"SELECT COUNT(*) FROM {new}")).scalar()
if count:
logger.warning(
"Both %s and %s hold rows; leaving them alone. Merge them by hand.", old, new
)
continue
logger.info("Dropping the empty %s left by an earlier start", new)
await _try_migrate(conn, f"DROP TABLE {new}", label=f"{new}.drop_empty")
logger.info("Migrating table %s -> %s", old, new)
with suppress(OperationalError):
await conn.exec_driver_sql("PRAGMA foreign_keys = ON")
await _try_migrate(conn, f"ALTER TABLE {old} RENAME TO {new}", label=f"{new}.rename")
# The index rides the rename under its old name; the model asks for the new
# one, so drop the stale duplicate rather than carrying both.
await _try_migrate(
conn, "DROP INDEX IF EXISTS ix_pending_devices_ieee_address", label="device_inventory.index",
)
async def init_db() -> None:
_backup_db()
async with engine.begin() as conn:
await _rename_legacy_tables(conn)
await conn.run_sync(Base.metadata.create_all)
# Add columns introduced after initial schema (idempotent)
with suppress(OperationalError):
@@ -114,11 +172,11 @@ async def init_db() -> None:
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN right_handles INTEGER NOT NULL DEFAULT 0")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_source TEXT")
await conn.exec_driver_sql("ALTER TABLE device_inventory ADD COLUMN discovery_source TEXT")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN properties JSON")
await conn.exec_driver_sql("ALTER TABLE device_inventory ADD COLUMN properties JSON")
with suppress(OperationalError):
await conn.exec_driver_sql("UPDATE pending_devices SET properties = '[]' WHERE properties IS NULL")
await conn.exec_driver_sql("UPDATE device_inventory SET properties = '[]' WHERE properties IS NULL")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE scan_runs ADD COLUMN kind TEXT NOT NULL DEFAULT 'ip'")
# --- Zigbee schema migrations (logged variant per CLAUDE.md feedback) ---
@@ -128,32 +186,32 @@ async def init_db() -> None:
"nodes.ieee_address.index",
"CREATE INDEX IF NOT EXISTS ix_nodes_ieee_address ON nodes(ieee_address)",
),
("pending_devices.ieee_address", "ALTER TABLE pending_devices ADD COLUMN ieee_address TEXT"),
("device_inventory.ieee_address", "ALTER TABLE device_inventory ADD COLUMN ieee_address TEXT"),
(
"pending_devices.ieee_address.index",
"CREATE INDEX IF NOT EXISTS ix_pending_devices_ieee_address "
"ON pending_devices(ieee_address)",
"device_inventory.ieee_address.index",
"CREATE INDEX IF NOT EXISTS ix_device_inventory_ieee_address "
"ON device_inventory(ieee_address)",
),
("pending_devices.friendly_name", "ALTER TABLE pending_devices ADD COLUMN friendly_name TEXT"),
("pending_devices.device_subtype", "ALTER TABLE pending_devices ADD COLUMN device_subtype TEXT"),
("pending_devices.model", "ALTER TABLE pending_devices ADD COLUMN model TEXT"),
("pending_devices.vendor", "ALTER TABLE pending_devices ADD COLUMN vendor TEXT"),
("pending_devices.lqi", "ALTER TABLE pending_devices ADD COLUMN lqi INTEGER"),
("device_inventory.friendly_name", "ALTER TABLE device_inventory ADD COLUMN friendly_name TEXT"),
("device_inventory.device_subtype", "ALTER TABLE device_inventory ADD COLUMN device_subtype TEXT"),
("device_inventory.model", "ALTER TABLE device_inventory ADD COLUMN model TEXT"),
("device_inventory.vendor", "ALTER TABLE device_inventory ADD COLUMN vendor TEXT"),
("device_inventory.lqi", "ALTER TABLE device_inventory ADD COLUMN lqi INTEGER"),
]
for label, sql in zigbee_migrations:
await _try_migrate(conn, sql, label=label)
# Drop NOT NULL on pending_devices.ip (Zigbee devices have no IP).
# Drop NOT NULL on device_inventory.ip (Zigbee devices have no IP).
# SQLite can't ALTER column nullability — rebuild the table if needed.
try:
info = await conn.exec_driver_sql("PRAGMA table_info(pending_devices)")
info = await conn.exec_driver_sql("PRAGMA table_info(device_inventory)")
cols = info.fetchall()
ip_col = next((c for c in cols if c[1] == "ip"), None)
# PRAGMA table_info row layout: (cid, name, type, notnull, dflt, pk)
if ip_col and ip_col[3] == 1:
logger.info("Migrating pending_devices: dropping NOT NULL on ip column")
logger.info("Migrating device_inventory: dropping NOT NULL on ip column")
await conn.exec_driver_sql("PRAGMA foreign_keys = OFF")
await conn.exec_driver_sql(
"CREATE TABLE pending_devices_new ("
"CREATE TABLE device_inventory_new ("
"id VARCHAR PRIMARY KEY,"
"ip VARCHAR,"
"mac VARCHAR, hostname VARCHAR, os VARCHAR, services JSON,"
@@ -171,25 +229,25 @@ async def init_db() -> None:
")"
)
await conn.exec_driver_sql(
"INSERT INTO pending_devices_new "
"INSERT INTO device_inventory_new "
"(id, ip, mac, hostname, os, services, suggested_type, status, "
"discovery_source, ieee_address, friendly_name, device_subtype, "
"model, vendor, lqi, discovered_at) "
"SELECT id, ip, mac, hostname, os, services, suggested_type, status, "
"discovery_source, ieee_address, friendly_name, device_subtype, "
"model, vendor, lqi, discovered_at FROM pending_devices"
"model, vendor, lqi, discovered_at FROM device_inventory"
)
await conn.exec_driver_sql("DROP TABLE pending_devices")
await conn.exec_driver_sql("DROP TABLE device_inventory")
await conn.exec_driver_sql(
"ALTER TABLE pending_devices_new RENAME TO pending_devices"
"ALTER TABLE device_inventory_new RENAME TO device_inventory"
)
await conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_pending_devices_ieee_address "
"ON pending_devices(ieee_address)"
"CREATE INDEX IF NOT EXISTS ix_device_inventory_ieee_address "
"ON device_inventory(ieee_address)"
)
await conn.exec_driver_sql("PRAGMA foreign_keys = ON")
except OperationalError as exc:
logger.warning("pending_devices ip-nullable rebuild failed: %s", exc)
logger.warning("device_inventory ip-nullable rebuild failed: %s", exc)
# --- end Zigbee schema migrations -------------------------------------
# --- Electrical designs schema migrations -----------------------------
# Create designs table (idempotent)
@@ -286,6 +344,22 @@ async def init_db() -> None:
logger.warning("canvas_state migration failed: %s", exc)
# --- end Electrical designs schema migrations --------------------------
# Rack cables gained editable annotations: a label the user can print on
# the canvas, plus the same free-form property records nodes carry.
rack_cable_migrations: list[tuple[str, str]] = [
(
"rack_cables.label_visible",
"ALTER TABLE rack_cables ADD COLUMN label_visible BOOLEAN NOT NULL DEFAULT 0",
),
("rack_cables.properties", "ALTER TABLE rack_cables ADD COLUMN properties JSON"),
]
for label, sql in rack_cable_migrations:
await _try_migrate(conn, sql, label=label)
with suppress(OperationalError):
await conn.exec_driver_sql(
"UPDATE rack_cables SET properties = '[]' WHERE properties IS NULL"
)
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
with suppress(OperationalError):
@@ -325,28 +399,28 @@ async def init_db() -> None:
# Proxmox import carries every source. Backfill from the legacy single
# discovery_source so existing rows show under their filter.
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_sources JSON")
await conn.exec_driver_sql("ALTER TABLE device_inventory ADD COLUMN discovery_sources JSON")
with suppress(OperationalError):
await conn.exec_driver_sql(
"UPDATE pending_devices SET discovery_sources = json_array(discovery_source) "
"UPDATE device_inventory SET discovery_sources = json_array(discovery_source) "
"WHERE discovery_sources IS NULL AND discovery_source IS NOT NULL"
)
# Legacy IP-scanned rows predating discovery_source have a NULL scalar
# but a real IP — treat them as an ARP scan so they keep the IP tag.
with suppress(OperationalError):
await conn.exec_driver_sql(
"UPDATE pending_devices SET discovery_sources = json_array('arp') "
"UPDATE device_inventory SET discovery_sources = json_array('arp') "
"WHERE discovery_sources IS NULL AND discovery_source IS NULL AND ip IS NOT NULL"
)
with suppress(OperationalError):
await conn.exec_driver_sql(
"UPDATE pending_devices SET discovery_sources = '[]' WHERE discovery_sources IS NULL"
"UPDATE device_inventory SET discovery_sources = '[]' WHERE discovery_sources IS NULL"
)
# Canonicalize stored MACs (lowercase, ':' separators) so cross-source
# dedup can match a Proxmox NIC MAC against an ARP-scanned one by equality.
with suppress(OperationalError):
await conn.exec_driver_sql(
"UPDATE pending_devices SET mac = lower(replace(mac, '-', ':')) WHERE mac IS NOT NULL"
"UPDATE device_inventory SET mac = lower(replace(mac, '-', ':')) WHERE mac IS NOT NULL"
)
with suppress(OperationalError):
await conn.exec_driver_sql(
+103 -5
View File
@@ -105,8 +105,106 @@ class CanvasState(Base):
saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class PendingDevice(Base):
__tablename__ = "pending_devices"
class Rack(Base):
"""A physical rack on a `design_type='rack'` canvas.
Racks live per design, like nodes and edges. Geometry is expressed in rack
units: `u_height` is the number of mountable U, and a device's `u_start` is
always counted from the bottom rail — `numbering` only changes the printed
labels.
"""
__tablename__ = "racks"
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
design_id: Mapped[str] = mapped_column(String, ForeignKey("designs.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(String, nullable=False)
u_height: Mapped[int] = mapped_column(Integer, default=42)
# "19" or "10" (inches). Drives the drawn inner width.
width_standard: Mapped[str] = mapped_column(String, default="19")
# "bottom-up" or "top-down" — printed U labels only, never storage order.
numbering: Mapped[str] = mapped_column(String, default="bottom-up")
location: Mapped[str | None] = mapped_column(String, nullable=True)
# Frame/rail/interior colours + showNumbers/enclosed flags.
style: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
pos_x: Mapped[float] = mapped_column(Float, default=0)
pos_y: Mapped[float] = mapped_column(Float, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
class RackDevice(Base):
"""A piece of gear mounted in a rack.
Two independent, both-optional links back to the rest of the app:
* `device_id` — the Device Inventory entry (`device_inventory`). This is the
primary link: inventory rows survive approval *and* node deletion, so
unracking or deleting a canvas node never removes the inventory entry.
* `node_id` — the logical-canvas node, when one exists. Only used to resolve
live status and to seed cables from the network design's edges.
Both are ``SET NULL`` on delete and `label` is denormalized, so a rack keeps
rendering after an inventory purge.
"""
__tablename__ = "rack_devices"
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
design_id: Mapped[str] = mapped_column(String, ForeignKey("designs.id", ondelete="CASCADE"), nullable=False)
rack_id: Mapped[str] = mapped_column(String, ForeignKey("racks.id", ondelete="CASCADE"), nullable=False)
device_id: Mapped[str | None] = mapped_column(
String, ForeignKey("device_inventory.id", ondelete="SET NULL"), nullable=True
)
node_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id", ondelete="SET NULL"), nullable=True)
label: Mapped[str] = mapped_column(String, nullable=False)
# 1-based, counted from the bottom rail.
u_start: Mapped[int] = mapped_column(Integer, default=1)
u_height: Mapped[int] = mapped_column(Integer, default=1)
# 12-column horizontal grid: full = 12, half = 6, third = 4, quarter = 3.
col_start: Mapped[int] = mapped_column(Integer, default=0)
col_span: Mapped[int] = mapped_column(Integer, default=12)
faceplate_id: Mapped[str] = mapped_column(String, nullable=False, default="blank-1u")
color: Mapped[str | None] = mapped_column(String, nullable=True)
status: Mapped[str] = mapped_column(String, default="unknown")
# [{id, label, type, x, y}] — positions are unit coordinates on the plate.
ports: Mapped[list[Any]] = mapped_column(JSON, default=list)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
class RackCable(Base):
"""A patch between two rack device ports.
Not an `Edge`: rack cables are port-to-port and may cross racks, so they are
their own relation rather than a canvas edge.
"""
__tablename__ = "rack_cables"
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
design_id: Mapped[str] = mapped_column(String, ForeignKey("designs.id", ondelete="CASCADE"), nullable=False)
from_device_id: Mapped[str] = mapped_column(
String, ForeignKey("rack_devices.id", ondelete="CASCADE"), nullable=False
)
from_port_id: Mapped[str] = mapped_column(String, nullable=False)
to_device_id: Mapped[str] = mapped_column(
String, ForeignKey("rack_devices.id", ondelete="CASCADE"), nullable=False
)
to_port_id: Mapped[str] = mapped_column(String, nullable=False)
type: Mapped[str] = mapped_column(String, default="ethernet")
color: Mapped[str] = mapped_column(String, default="#39d353")
label: Mapped[str | None] = mapped_column(String, nullable=True)
# Print the label next to the run on the canvas.
label_visible: Mapped[bool] = mapped_column(Boolean, default=False)
# [{key, value, icon, visible}] — same records nodes carry; the visible ones
# are drawn beside the cable.
properties: Mapped[list[Any]] = mapped_column(JSON, default=list)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class InventoryDevice(Base):
__tablename__ = "device_inventory"
# Permit the plain (non-Mapped[]) annotations on the transient request-only
# attributes below; without this SQLAlchemy 2.0 tries to map them as columns.
__allow_unmapped__ = True
@@ -149,16 +247,16 @@ class PendingDevice(Base):
node_last_seen: datetime | None = None
class PendingDeviceLink(Base):
class InventoryDeviceLink(Base):
"""Link between two Zigbee endpoints discovered during import.
Endpoints are addressed by IEEE (stable across re-imports). Either side may
already exist as a canvas Node (resolved via Node.ieee_address) or still be
a PendingDevice. On approval, the matching Edge is auto-created when both
a InventoryDevice. On approval, the matching Edge is auto-created when both
endpoints exist as canvas Nodes.
"""
__tablename__ = "pending_device_links"
__tablename__ = "device_inventory_links"
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
source_ieee: Mapped[str] = mapped_column(String, nullable=False, index=True)
+2
View File
@@ -17,6 +17,7 @@ from app.api.routes import (
media,
nodes,
proxmox,
racks,
scan,
stats,
status,
@@ -77,6 +78,7 @@ app.include_router(nodes.router, prefix="/api/v1/nodes", tags=["nodes"])
app.include_router(edges.router, prefix="/api/v1/edges", tags=["edges"])
app.include_router(canvas.router, prefix="/api/v1/canvas", tags=["canvas"])
app.include_router(designs.router, prefix="/api/v1/designs", tags=["designs"])
app.include_router(racks.router, prefix="/api/v1/racks", tags=["racks"])
app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"])
app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
+15 -3
View File
@@ -1,15 +1,27 @@
from datetime import datetime
from pydantic import BaseModel
from pydantic import BaseModel, field_validator
# Kept in sync with DesignType in frontend/src/types.
# "network" and "electrical" render the same React Flow canvas and differ only by
# palette and node set — the icon drives their presentation. "rack" is a genuinely
# different renderer (racks, mounted gear, port-to-port cables), so the frontend
# does branch on this field.
DESIGN_TYPES = {"network", "electrical", "rack"}
class DesignCreate(BaseModel):
name: str
icon: str = "dashboard"
# Vestigial: kept for backward compatibility. The UI no longer branches on it;
# the chosen icon now drives presentation. Defaults to a generic canvas.
design_type: str = "network"
@field_validator("design_type")
@classmethod
def _known_type(cls, v: str) -> str:
if v not in DESIGN_TYPES:
raise ValueError(f"design_type must be one of {sorted(DESIGN_TYPES)}")
return v
class DesignUpdate(BaseModel):
name: str | None = None
+302
View File
@@ -0,0 +1,302 @@
from datetime import datetime, timezone
from typing import Any
from pydantic import BaseModel, field_validator, model_validator
# Kept in sync with the frontend rack model (frontend/src/types).
WIDTH_STANDARDS = {"19", "10"}
NUMBERINGS = {"bottom-up", "top-down"}
CABLE_TYPES = {"ethernet", "fiber"}
# 12-column horizontal grid: full = 12, half = 6, third = 4, quarter = 3.
RACK_COLUMNS = 12
class RackSave(BaseModel):
id: str
name: str
u_height: int = 42
width_standard: str = "19"
numbering: str = "bottom-up"
location: str | None = None
style: dict[str, Any] = {}
pos_x: float = 0
pos_y: float = 0
@field_validator("u_height")
@classmethod
def _positive_height(cls, v: int) -> int:
if not 1 <= v <= 100:
raise ValueError("u_height must be between 1 and 100")
return v
@field_validator("width_standard")
@classmethod
def _known_width(cls, v: str) -> str:
if v not in WIDTH_STANDARDS:
raise ValueError(f"width_standard must be one of {sorted(WIDTH_STANDARDS)}")
return v
@field_validator("numbering")
@classmethod
def _known_numbering(cls, v: str) -> str:
if v not in NUMBERINGS:
raise ValueError(f"numbering must be one of {sorted(NUMBERINGS)}")
return v
class RackDeviceSave(BaseModel):
id: str
rack_id: str
device_id: str | None = None
node_id: str | None = None
label: str
u_start: int = 1
u_height: int = 1
col_start: int = 0
col_span: int = RACK_COLUMNS
faceplate_id: str = "blank-1u"
color: str | None = None
status: str = "unknown"
ports: list[Any] = []
@field_validator("u_start", "u_height")
@classmethod
def _positive(cls, v: int) -> int:
if v < 1:
raise ValueError("u_start and u_height are 1-based and must be >= 1")
return v
@field_validator("col_start")
@classmethod
def _column_in_grid(cls, v: int) -> int:
if not 0 <= v < RACK_COLUMNS:
raise ValueError(f"col_start must be within the {RACK_COLUMNS}-column grid")
return v
@field_validator("col_span")
@classmethod
def _span_in_grid(cls, v: int) -> int:
if not 1 <= v <= RACK_COLUMNS:
raise ValueError(f"col_span must be between 1 and {RACK_COLUMNS}")
return v
@model_validator(mode="after")
def _fits_the_column_grid(self) -> "RackDeviceSave":
"""`col_start` and `col_span` are legal apart and still illegal together.
11 + 12 spans to column 23 of a 12-column grid — each field passes its
own check, and the frontend serializer clamps them independently, so
nothing caught it before this.
"""
if self.col_start + self.col_span > RACK_COLUMNS:
raise ValueError(
f"col_start + col_span must not exceed the {RACK_COLUMNS}-column grid"
)
return self
class RackCableSave(BaseModel):
id: str
from_device_id: str
from_port_id: str
to_device_id: str
to_port_id: str
type: str = "ethernet"
color: str = "#39d353"
label: str | None = None
label_visible: bool = False
# [{key, value, icon, visible}] — free-form, same shape as node properties.
properties: list[dict[str, Any]] = []
@field_validator("type")
@classmethod
def _known_type(cls, v: str) -> str:
if v not in CABLE_TYPES:
raise ValueError(f"type must be one of {sorted(CABLE_TYPES)}")
return v
@field_validator("properties", mode="before")
@classmethod
def _clean_properties(cls, v: Any) -> list[dict[str, Any]]:
"""Keep only usable records — a property with no key draws an empty badge."""
if not isinstance(v, list):
return []
return [p for p in v if isinstance(p, dict) and str(p.get("key", "")).strip()]
class RackSaveRequest(BaseModel):
"""Full rack state for one design — upserted, with anything missing pruned.
Same contract as ``POST /api/v1/canvas/save``: the client owns the state and
sends all of it on an explicit Save.
"""
design_id: str
racks: list[RackSave] = []
devices: list[RackDeviceSave] = []
cables: list[RackCableSave] = []
# Pan/zoom, stored on the design's shared CanvasState row like the logical canvas.
viewport: dict[str, Any] = {}
@model_validator(mode="after")
def _devices_fit_their_rack(self) -> "RackSaveRequest":
"""A mount must fit inside the rack it names.
`RackDeviceSave` validates each field in isolation and never sees the
rack, so `u_start: 40` in a 12U rack passed. A mount above the top rail
draws outside the chassis and nothing in the UI can drag it back, so the
server refuses to store it. Rack membership itself is checked in the
route, which knows what is already persisted.
"""
heights = {r.id: r.u_height for r in self.racks}
for device in self.devices:
u_height = heights.get(device.rack_id)
if u_height is None:
continue # unknown rack — the route reports that with a 400
if device.u_start + device.u_height - 1 > u_height:
raise ValueError(
f"Device {device.id} does not fit in rack {device.rack_id}: "
f"U {device.u_start}–{device.u_start + device.u_height - 1} "
f"of {u_height}U"
)
return self
class RackResponse(BaseModel):
id: str
design_id: str
name: str
u_height: int
width_standard: str
numbering: str
location: str | None = None
style: dict[str, Any] = {}
pos_x: float
pos_y: float
@field_validator("style", mode="before")
@classmethod
def _coerce_style(cls, v: Any) -> dict[str, Any]:
return v if isinstance(v, dict) else {}
model_config = {"from_attributes": True}
class RackDeviceResponse(BaseModel):
id: str
design_id: str
rack_id: str
device_id: str | None = None
node_id: str | None = None
label: str
u_start: int
u_height: int
col_start: int
col_span: int
faceplate_id: str
color: str | None = None
status: str
ports: list[Any] = []
@field_validator("ports", mode="before")
@classmethod
def _coerce_ports(cls, v: Any) -> list[Any]:
return v if isinstance(v, list) else []
model_config = {"from_attributes": True}
class RackCableResponse(BaseModel):
id: str
design_id: str
from_device_id: str
from_port_id: str
to_device_id: str
to_port_id: str
type: str
color: str
label: str | None = None
label_visible: bool = False
properties: list[dict[str, Any]] = []
@field_validator("properties", mode="before")
@classmethod
def _list_properties(cls, v: Any) -> list[dict[str, Any]]:
"""Rows written before the column existed read back as NULL."""
return v if isinstance(v, list) else []
@field_validator("label_visible", mode="before")
@classmethod
def _bool_label_visible(cls, v: Any) -> bool:
return bool(v)
model_config = {"from_attributes": True}
class RackStateResponse(BaseModel):
racks: list[RackResponse]
devices: list[RackDeviceResponse]
cables: list[RackCableResponse]
viewport: dict[str, Any] = {}
class RackServiceInfo(BaseModel):
"""One service fingerprinted on a device, as the rack view prints it."""
port: int | None = None
name: str | None = None
class RackInventoryItem(BaseModel):
"""A Device Inventory entry offered to the rack tray.
Carries the technical facts the logical canvas already knows about the same
hardware (`node_*`), so a mount can print them without the user leaving the
rack. Everything node-side is read-only here: the logical canvas owns it.
"""
id: str
label: str
suggested_type: str | None = None
ip: str | None = None
status: str
discovery_source: str | None = None
# Inventory-side technical detail, from discovery.
mac: str | None = None
hostname: str | None = None
os: str | None = None
services: list[RackServiceInfo] = []
# Live status of the matching canvas node, when the device is on one.
node_id: str | None = None
node_status: str | None = None
# What that node holds — the logical view of the same box.
node_label: str | None = None
node_type: str | None = None
node_ip: str | None = None
node_mac: str | None = None
node_hostname: str | None = None
node_os: str | None = None
node_check_method: str | None = None
node_design_id: str | None = None
node_design_name: str | None = None
node_last_seen: datetime | None = None
# True when this device is already mounted somewhere in this design.
racked: bool = False
@field_validator("node_last_seen")
@classmethod
def _utc(cls, v: datetime | None) -> datetime | None:
"""Stamp UTC on a naive timestamp before it goes on the wire.
SQLite has no timezone type: an aware datetime written to `last_seen`
reads back naive, and Pydantic then serializes it without an offset.
`new Date("2026-08-08T10:00:00")` in the browser is *local* time, so the
rack printed a last-seen shifted by the viewer's offset.
"""
if v is not None and v.tzinfo is None:
return v.replace(tzinfo=timezone.utc)
return v
class RackInventoryResponse(BaseModel):
items: list[RackInventoryItem]
+36 -1
View File
@@ -4,7 +4,7 @@ from typing import Any
from pydantic import BaseModel, field_validator
class PendingDeviceResponse(BaseModel):
class InventoryDeviceResponse(BaseModel):
id: str
ip: str | None
mac: str | None
@@ -47,6 +47,41 @@ class PendingDeviceResponse(BaseModel):
model_config = {"from_attributes": True}
"""Sources a hand-made inventory entry may claim.
``rack`` marks gear created from a rack canvas. Those rows live in the Device
Inventory like any other, but they describe a physical mount rather than a host
on the network, so they are never placed on a logical canvas.
"""
MANUAL_SOURCES = {"manual", "rack"}
class InventoryDeviceCreate(BaseModel):
"""Manually add an entry to the Device Inventory.
Used when the user documents hardware no scan can find — a dumb switch, a
patch panel, a machine that is powered off. Lands with
`discovery_source="manual"` (or `"rack"`) so the inventory filters can tell
it apart.
"""
hostname: str
ip: str | None = None
mac: str | None = None
suggested_type: str | None = None
model: str | None = None
vendor: str | None = None
properties: list[Any] = []
discovery_source: str = "manual"
@field_validator("discovery_source")
@classmethod
def _known_source(cls, v: str) -> str:
if v not in MANUAL_SOURCES:
raise ValueError(f"discovery_source must be one of {sorted(MANUAL_SOURCES)}")
return v
class ScanRunResponse(BaseModel):
id: str
status: str
+1 -1
View File
@@ -1,4 +1,4 @@
"""Helpers for the multi-valued ``PendingDevice.discovery_sources`` set.
"""Helpers for the multi-valued ``InventoryDevice.discovery_sources`` set.
A device discovered by more than one path (e.g. an IP scan *and* a Proxmox
import) accumulates every source that has seen it, so it surfaces under each
+13 -13
View File
@@ -15,7 +15,7 @@ from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.db.models import Node, PendingDevice, ScanRun
from app.db.models import InventoryDevice, Node, ScanRun
from app.services.discovery_sources import add_source
from app.services.fingerprint import fingerprint_ports, suggest_node_type
from app.services.http_probe import probe_open_ports
@@ -476,12 +476,12 @@ async def _dedupe_pending_by_ip(db: AsyncSession) -> int:
otherwise the oldest row, and deletes the rest. Returns the number deleted.
"""
rows = (await db.execute(
select(PendingDevice)
.where(PendingDevice.status != "hidden", PendingDevice.ip.isnot(None))
.order_by(PendingDevice.discovered_at)
select(InventoryDevice)
.where(InventoryDevice.status != "hidden", InventoryDevice.ip.isnot(None))
.order_by(InventoryDevice.discovered_at)
)).scalars().all()
by_ip: dict[str, list[PendingDevice]] = {}
by_ip: dict[str, list[InventoryDevice]] = {}
for row in rows:
if row.ip is None: # guarded by the query, but keeps the type checker happy
continue
@@ -507,7 +507,7 @@ async def run_scan(
run_id: str,
deep_scan: DeepScanOptions | None = None,
) -> None:
"""Execute scan for given CIDR ranges and populate pending_devices."""
"""Execute scan for given CIDR ranges and populate device_inventory."""
from app.api.routes.status import broadcast_scan_update
deep_scan = deep_scan or DeepScanOptions()
@@ -527,7 +527,7 @@ async def run_scan(
# Devices already on a canvas are intentionally NOT suppressed: they stay
# in the inventory and are badged "In N canvas" via per-request correlation.
hidden_ips_result = await db.execute(
select(PendingDevice.ip).where(PendingDevice.status == "hidden")
select(InventoryDevice.ip).where(InventoryDevice.status == "hidden")
)
hidden_ips: set[str] = {row[0] for row in hidden_ips_result.fetchall()}
@@ -570,13 +570,13 @@ async def run_scan(
# a duplicate — and so a device previously imported from Proxmox (which
# may have no IP but a known NIC MAC) reconciles with this scan instead
# of doubling up. Hidden rows are already skipped above.
match_cond = [PendingDevice.ip == ip]
match_cond = [InventoryDevice.ip == ip]
if norm_mac:
match_cond.append(PendingDevice.mac == norm_mac)
match_cond.append(InventoryDevice.mac == norm_mac)
existing_rows = (await db.execute(
select(PendingDevice)
.where(or_(*match_cond), PendingDevice.status != "hidden")
.order_by(PendingDevice.discovered_at)
select(InventoryDevice)
.where(or_(*match_cond), InventoryDevice.status != "hidden")
.order_by(InventoryDevice.discovered_at)
)).scalars().all()
if existing_rows:
@@ -600,7 +600,7 @@ async def run_scan(
keep.discovery_sources = add_source(keep.discovery_sources, discovery_source)
# status preserved — an approved device stays approved.
else:
db.add(PendingDevice(
db.add(InventoryDevice(
ip=ip,
mac=norm_mac,
hostname=host.get("hostname"),
+5 -5
View File
@@ -3,13 +3,13 @@ import uuid
import pytest
from app.db.models import PendingDevice
from app.db.models import InventoryDevice
@pytest.fixture
async def pending_device(db_session):
import uuid
device = PendingDevice(
device = InventoryDevice(
id=str(uuid.uuid4()),
ip="192.168.1.100",
mac="aa:bb:cc:dd:ee:ff",
@@ -39,10 +39,10 @@ async def mem_db():
@pytest.fixture
async def two_pending_devices(db_session):
async def two_device_inventory(db_session):
devices = []
for i in range(2):
d = PendingDevice(
d = InventoryDevice(
id=str(uuid.uuid4()),
ip=f"192.168.1.{10 + i}",
mac=None,
@@ -62,7 +62,7 @@ async def two_pending_devices(db_session):
@pytest.fixture
async def zigbee_pending_device(db_session):
device = PendingDevice(
device = InventoryDevice(
id=str(uuid.uuid4()),
ip=None,
mac=None,
+4 -4
View File
@@ -1,7 +1,7 @@
"""Shared builders for scan test suite (pure helpers, no fixtures)."""
import uuid
from app.db.models import Design, Node, PendingDevice
from app.db.models import Design, InventoryDevice, Node
async def _add_design(db_session, name: str) -> str:
@@ -21,7 +21,7 @@ def _node(design_id: str, *, ip=None, ieee=None, mac=None) -> Node:
async def _seed_zigbee_pending_pair(db_session):
"""Create a coordinator Node + a pending device + a link between them."""
from app.db.models import Node, PendingDeviceLink
from app.db.models import InventoryDeviceLink, Node
coord = Node(
label="Coordinator",
@@ -31,7 +31,7 @@ async def _seed_zigbee_pending_pair(db_session):
)
db_session.add(coord)
pending = PendingDevice(
pending = InventoryDevice(
ieee_address="0xR1",
friendly_name="router_1",
suggested_type="zigbee_router",
@@ -42,7 +42,7 @@ async def _seed_zigbee_pending_pair(db_session):
db_session.add(pending)
db_session.add(
PendingDeviceLink(
InventoryDeviceLink(
source_ieee="0xCOORD",
target_ieee="0xR1",
discovery_source="zigbee",
+28 -28
View File
@@ -5,7 +5,7 @@ import pytest
from httpx import AsyncClient
from sqlalchemy import select
from app.db.models import Design, Node, PendingDevice
from app.db.models import Design, InventoryDevice, Node
from tests.scan.helpers import _add_design, _node
@@ -62,7 +62,7 @@ async def test_approve_device_conflicts_on_existing_ieee_same_design(
services=[], design_id=design.id,
)
db_session.add(existing)
device = PendingDevice(
device = InventoryDevice(
id=str(uuid.uuid4()), ieee_address="0xZZZ", suggested_type="zigbee_enddevice",
status="pending", discovery_source="zigbee",
)
@@ -103,7 +103,7 @@ async def test_approve_device_force_creates_duplicate_ieee(
label="sensor", type="zigbee_enddevice", ieee_address="0xZZZ",
services=[], design_id=design.id,
))
device = PendingDevice(
device = InventoryDevice(
id=str(uuid.uuid4()), ieee_address="0xZZZ", suggested_type="zigbee_enddevice",
status="pending", discovery_source="zigbee",
)
@@ -274,7 +274,7 @@ async def test_approve_device_places_already_approved_on_another_design(
target = await _add_design(db_session, "Network Topology")
# Device is on `other` already (its global status is "approved").
db_session.add(_node(other, ieee="0x00158d0005292b83"))
device = PendingDevice(
device = InventoryDevice(
id=str(uuid.uuid4()), ieee_address="0x00158d0005292b83",
suggested_type="zigbee_enddevice", status="approved",
discovery_source="zigbee",
@@ -399,8 +399,8 @@ async def test_ignore_device(client: AsyncClient, headers, pending_device):
@pytest.mark.asyncio
async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_pending_devices):
ids = [d.id for d in two_pending_devices]
async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_device_inventory):
ids = [d.id for d in two_device_inventory]
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
assert res.status_code == 200
data = res.json()
@@ -418,7 +418,7 @@ async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_p
@pytest.mark.asyncio
async def test_bulk_approve_places_already_approved_device_on_another_design(
client: AsyncClient, headers, db_session, two_pending_devices
client: AsyncClient, headers, db_session, two_device_inventory
):
"""Regression: a device already approved (status='approved', e.g. placed on
another canvas) must still get a node on the design being approved onto.
@@ -426,7 +426,7 @@ async def test_bulk_approve_places_already_approved_device_on_another_design(
Previously bulk-approve filtered status=='pending', so selecting an
already-approved device created no node — the user saw fewer nodes than
they selected."""
ids = [d.id for d in two_pending_devices]
ids = [d.id for d in two_device_inventory]
design_a = await _add_design(db_session, "Canvas A")
design_b = await _add_design(db_session, "Canvas B")
@@ -458,10 +458,10 @@ async def test_bulk_approve_places_already_approved_device_on_another_design(
@pytest.mark.asyncio
async def test_bulk_approve_skips_device_already_on_target_design(
client: AsyncClient, headers, db_session, two_pending_devices
client: AsyncClient, headers, db_session, two_device_inventory
):
"""A device already on the target canvas (same ip) is not placed twice."""
ids = [d.id for d in two_pending_devices]
ids = [d.id for d in two_device_inventory]
design = await _add_design(db_session, "Canvas")
# First device already sits on the canvas (matched by ip).
db_session.add(_node(design, ip="192.168.1.10"))
@@ -487,11 +487,11 @@ async def test_bulk_approve_skips_device_already_on_target_design(
@pytest.mark.asyncio
async def test_bulk_approve_skips_device_matching_ip_in_comma_list(
client: AsyncClient, headers, db_session, two_pending_devices
client: AsyncClient, headers, db_session, two_device_inventory
):
"""The on-canvas node's ip holds an IPv6 before the IPv4; the device scanned
as the plain IPv4 is still recognised as already placed (issue #258)."""
ids = [d.id for d in two_pending_devices]
ids = [d.id for d in two_device_inventory]
design = await _add_design(db_session, "Canvas")
db_session.add(_node(design, ip="fe80::1, 192.168.1.10"))
await db_session.commit()
@@ -509,11 +509,11 @@ async def test_bulk_approve_skips_device_matching_ip_in_comma_list(
@pytest.mark.asyncio
async def test_bulk_approve_reports_skipped_devices(
client: AsyncClient, headers, db_session, two_pending_devices
client: AsyncClient, headers, db_session, two_device_inventory
):
"""Bulk can't prompt per-device, so it reports each duplicate it skipped
(with the existing node id) instead of silently dropping it."""
ids = [d.id for d in two_pending_devices]
ids = [d.id for d in two_device_inventory]
design = await _add_design(db_session, "Canvas")
existing = _node(design, ip="192.168.1.10")
db_session.add(existing)
@@ -568,7 +568,7 @@ async def test_bulk_approve_copies_mac_to_node_and_properties(
from sqlalchemy import select
from app.db.models import Node as NodeModel
device = PendingDevice(
device = InventoryDevice(
id=str(uuid.uuid4()),
ip="192.168.1.55",
mac="11:22:33:44:55:66",
@@ -597,12 +597,12 @@ async def test_bulk_approve_copies_mac_to_node_and_properties(
@pytest.mark.asyncio
async def test_bulk_approve_sets_default_check_method(client: AsyncClient, headers, two_pending_devices, db_session):
async def test_bulk_approve_sets_default_check_method(client: AsyncClient, headers, two_device_inventory, db_session):
"""Approved devices with an IP must default to ping; otherwise scheduler skips them."""
from sqlalchemy import select
from app.db.models import Node as NodeModel
ids = [d.id for d in two_pending_devices]
ids = [d.id for d in two_device_inventory]
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
assert res.status_code == 200
nodes = (await db_session.execute(select(NodeModel))).scalars().all()
@@ -628,8 +628,8 @@ async def test_approve_device_sets_default_check_method(client: AsyncClient, hea
@pytest.mark.asyncio
async def test_bulk_approve_skips_already_approved(client: AsyncClient, headers, two_pending_devices):
ids = [d.id for d in two_pending_devices]
async def test_bulk_approve_skips_already_approved(client: AsyncClient, headers, two_device_inventory):
ids = [d.id for d in two_device_inventory]
# Approve first device individually first
await client.post(
f"/api/v1/scan/pending/{ids[0]}/approve",
@@ -645,15 +645,15 @@ async def test_bulk_approve_skips_already_approved(client: AsyncClient, headers,
@pytest.mark.asyncio
async def test_bulk_approve_requires_auth(client: AsyncClient, two_pending_devices):
ids = [d.id for d in two_pending_devices]
async def test_bulk_approve_requires_auth(client: AsyncClient, two_device_inventory):
ids = [d.id for d in two_device_inventory]
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids})
assert res.status_code == 401
@pytest.mark.asyncio
async def test_bulk_hide_hides_devices(client: AsyncClient, headers, two_pending_devices):
ids = [d.id for d in two_pending_devices]
async def test_bulk_hide_hides_devices(client: AsyncClient, headers, two_device_inventory):
ids = [d.id for d in two_device_inventory]
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers)
assert res.status_code == 200
data = res.json()
@@ -665,8 +665,8 @@ async def test_bulk_hide_hides_devices(client: AsyncClient, headers, two_pending
@pytest.mark.asyncio
async def test_bulk_hide_skips_non_pending(client: AsyncClient, headers, two_pending_devices):
ids = [d.id for d in two_pending_devices]
async def test_bulk_hide_skips_non_pending(client: AsyncClient, headers, two_device_inventory):
ids = [d.id for d in two_device_inventory]
# Hide first device individually first
await client.post(f"/api/v1/scan/pending/{ids[0]}/hide", headers=headers)
# Bulk hide both — first is already hidden (not pending anymore)
@@ -678,8 +678,8 @@ async def test_bulk_hide_skips_non_pending(client: AsyncClient, headers, two_pen
@pytest.mark.asyncio
async def test_bulk_hide_requires_auth(client: AsyncClient, two_pending_devices):
ids = [d.id for d in two_pending_devices]
async def test_bulk_hide_requires_auth(client: AsyncClient, two_device_inventory):
ids = [d.id for d in two_device_inventory]
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids})
assert res.status_code == 401
@@ -690,7 +690,7 @@ async def test_bulk_approve_targets_requested_design(client, headers, db_session
first design — otherwise approved devices land on the wrong canvas."""
first = await _add_design(db_session, "Default") # first design (fallback)
active = await _add_design(db_session, "zwave") # the design the user is on
dev = PendingDevice(
dev = InventoryDevice(
id=str(uuid.uuid4()),
ieee_address="zwave-H-2",
friendly_name="Living Room Plug",
+15 -15
View File
@@ -5,7 +5,7 @@ import pytest
from httpx import AsyncClient
from sqlalchemy import select
from app.db.models import Node, PendingDevice
from app.db.models import InventoryDevice, Node
from tests.scan.helpers import _add_design, _seed_zigbee_pending_pair
@@ -184,7 +184,7 @@ async def test_approve_zigbee_skips_duplicate_edge(
"""Re-running the resolution does not create a second edge for the same pair."""
from sqlalchemy import select
from app.db.models import Edge, PendingDevice, PendingDeviceLink
from app.db.models import Edge, InventoryDevice, InventoryDeviceLink
coord, pending = await _seed_zigbee_pending_pair(db_session)
body = {"label": "router_1", "type": "zigbee_router", "ip": None, "status": "unknown", "services": []}
@@ -193,7 +193,7 @@ async def test_approve_zigbee_skips_duplicate_edge(
# Simulate a second pending row + link between same coord and a new device,
# but keep an existing edge in place to verify dedupe also handles
# the swapped-direction case.
new_pending = PendingDevice(
new_pending = InventoryDevice(
ieee_address="0xR1B",
friendly_name="r1b",
suggested_type="zigbee_router",
@@ -202,7 +202,7 @@ async def test_approve_zigbee_skips_duplicate_edge(
)
db_session.add(new_pending)
db_session.add(
PendingDeviceLink(source_ieee="0xCOORD", target_ieee="0xR1B", discovery_source="zigbee")
InventoryDeviceLink(source_ieee="0xCOORD", target_ieee="0xR1B", discovery_source="zigbee")
)
await db_session.commit()
res = await client.post(
@@ -220,16 +220,16 @@ async def test_approve_zigbee_skips_when_other_endpoint_still_pending(
"""Both endpoints pending → no edge yet, link row preserved for later."""
from sqlalchemy import select
from app.db.models import Edge, PendingDevice, PendingDeviceLink
from app.db.models import Edge, InventoryDevice, InventoryDeviceLink
a = PendingDevice(
a = InventoryDevice(
ieee_address="0xA",
friendly_name="a",
suggested_type="zigbee_router",
status="pending",
discovery_source="zigbee",
)
b = PendingDevice(
b = InventoryDevice(
ieee_address="0xB",
friendly_name="b",
suggested_type="zigbee_enddevice",
@@ -238,7 +238,7 @@ async def test_approve_zigbee_skips_when_other_endpoint_still_pending(
)
db_session.add_all([a, b])
db_session.add(
PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
InventoryDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
)
await db_session.commit()
@@ -258,7 +258,7 @@ async def test_approve_zigbee_skips_when_other_endpoint_still_pending(
edges = (await db_session.execute(select(Edge))).scalars().all()
assert edges == []
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
links = (await db_session.execute(select(InventoryDeviceLink))).scalars().all()
assert len(links) == 1 # preserved for later resolution
@@ -271,16 +271,16 @@ async def test_approve_zigbee_resolves_link_after_second_approval(
be re-approved onto another canvas — it's topology, wiped only on reimport."""
from sqlalchemy import select
from app.db.models import Edge, PendingDevice, PendingDeviceLink
from app.db.models import Edge, InventoryDevice, InventoryDeviceLink
a = PendingDevice(
a = InventoryDevice(
ieee_address="0xA",
friendly_name="a",
suggested_type="zigbee_router",
status="pending",
discovery_source="zigbee",
)
b = PendingDevice(
b = InventoryDevice(
ieee_address="0xB",
friendly_name="b",
suggested_type="zigbee_enddevice",
@@ -289,7 +289,7 @@ async def test_approve_zigbee_resolves_link_after_second_approval(
)
db_session.add_all([a, b])
db_session.add(
PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
InventoryDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
)
await db_session.commit()
@@ -300,14 +300,14 @@ async def test_approve_zigbee_resolves_link_after_second_approval(
edges = (await db_session.execute(select(Edge))).scalars().all()
assert len(edges) == 1
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
links = (await db_session.execute(select(InventoryDeviceLink))).scalars().all()
assert len(links) == 1 # retained for re-approval onto other canvases
@pytest.mark.asyncio
async def test_single_approve_zwave_sets_wireless_fields(client, headers, db_session):
active = await _add_design(db_session, "zwave")
dev = PendingDevice(
dev = InventoryDevice(
id=str(uuid.uuid4()),
ieee_address="zwave-H-9",
friendly_name="Door Sensor",
+110 -2
View File
@@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
from app.db.models import PendingDevice
from app.db.models import InventoryDevice
from tests.scan.helpers import _add_design, _node
@@ -91,7 +91,7 @@ async def test_canvas_count_counts_distinct_designs_by_ip(client, headers, db_se
@pytest.mark.asyncio
async def test_canvas_count_correlates_by_ieee(client, headers, db_session):
device = PendingDevice(
device = InventoryDevice(
id=str(uuid.uuid4()), ieee_address="0x00124b001", discovery_source="zigbee",
suggested_type="zigbee_enddevice", services=[], status="pending",
)
@@ -260,3 +260,111 @@ async def test_update_scan_config_persists_deep_scan(client: AsyncClient, header
)
assert res.status_code == 200
assert saved == {"http_ranges": ["9000-9100"], "probe": True}
@pytest.mark.asyncio
async def test_create_pending_normalizes_the_mac(client: AsyncClient, headers):
# Dedup compares MACs by equality, so a hand-typed entry in any other
# notation would never match the scanned row and approve would build a
# duplicate node.
res = await client.post(
"/api/v1/scan/pending",
json={
"hostname": "printer",
"mac": "AA-BB-CC-11-22-33",
"discovery_source": "manual",
},
headers=headers,
)
assert res.status_code == 201, res.text
assert res.json()["mac"] == "aa:bb:cc:11:22:33"
@pytest.mark.asyncio
async def test_create_pending_keeps_a_missing_mac_null(client: AsyncClient, headers):
res = await client.post(
"/api/v1/scan/pending",
json={"hostname": "no-mac", "discovery_source": "manual"},
headers=headers,
)
assert res.status_code == 201, res.text
assert res.json()["mac"] is None
@pytest.mark.asyncio
async def test_delete_pending_removes_one_entry(client: AsyncClient, headers, pending_device):
# The rack canvas drops the placeholder it created for a plate once that
# plate is pointed at a real inventory row.
res = await client.delete(f"/api/v1/scan/pending/{pending_device.id}", headers=headers)
assert res.status_code == 200, res.text
assert res.json() == {"deleted": True}
listed = (await client.get("/api/v1/scan/pending", headers=headers)).json()
assert listed == []
@pytest.mark.asyncio
async def test_delete_pending_unknown_is_404(client: AsyncClient, headers):
res = await client.delete(f"/api/v1/scan/pending/{uuid.uuid4()}", headers=headers)
assert res.status_code == 404
@pytest.mark.asyncio
async def test_delete_pending_requires_auth(client: AsyncClient, pending_device):
res = await client.delete(f"/api/v1/scan/pending/{pending_device.id}")
assert res.status_code == 401
@pytest.mark.asyncio
async def test_delete_pending_refuses_a_mounted_device(client: AsyncClient, headers, pending_device):
# `rack_devices.device_id` is ON DELETE SET NULL: without this guard the
# delete would strip the mount of its device instead of erroring.
design = (
await client.post(
"/api/v1/designs",
json={"name": "Rack Room", "icon": "server", "design_type": "rack"},
headers=headers,
)
).json()["id"]
saved = await client.post(
"/api/v1/racks/save",
json={
"design_id": design,
"racks": [
{
"id": "rack-1",
"name": "Main",
"u_height": 12,
"width_standard": "19",
"numbering": "bottom-up",
"style": {},
"pos_x": 0,
"pos_y": 0,
}
],
"devices": [
{
"id": "dev-1",
"rack_id": "rack-1",
"device_id": pending_device.id,
"label": "my-server",
"u_start": 1,
"u_height": 1,
"col_start": 0,
"col_span": 12,
"faceplate_id": "server-1u",
"status": "unknown",
"ports": [],
}
],
"cables": [],
"viewport": {"x": 0, "y": 0, "zoom": 1},
},
headers=headers,
)
assert saved.status_code == 200, saved.text
res = await client.delete(f"/api/v1/scan/pending/{pending_device.id}", headers=headers)
assert res.status_code == 409
listed = (await client.get("/api/v1/scan/pending", headers=headers)).json()
assert [d["id"] for d in listed] == [pending_device.id]
+15 -15
View File
@@ -7,7 +7,7 @@ from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Node, PendingDevice, ScanRun
from app.db.models import InventoryDevice, Node, ScanRun
from app.services.scanner import _cancelled_runs, request_cancel, run_scan
@@ -118,7 +118,7 @@ async def test_run_scan_creates_new_pending_device(db_session: AsyncSession):
await run_scan(["192.168.1.0/24"], db_session, run_id)
result = await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.50")
)
device = result.scalar_one_or_none()
assert device is not None
@@ -141,7 +141,7 @@ async def test_run_scan_keeps_stale_pending_for_canvas_nodes(db_session: AsyncSe
pos_x=0.0,
pos_y=0.0,
)
stale = PendingDevice(
stale = InventoryDevice(
id=str(uuid.uuid4()),
ip="192.168.1.50",
mac=None,
@@ -167,7 +167,7 @@ async def test_run_scan_keeps_stale_pending_for_canvas_nodes(db_session: AsyncSe
await run_scan(["192.168.1.0/24"], db_session, run_id)
result = await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.50")
)
assert result.scalar_one_or_none() is not None
@@ -201,7 +201,7 @@ async def test_run_scan_records_ip_already_in_canvas(db_session: AsyncSession):
await run_scan(["192.168.1.0/24"], db_session, run_id)
result = await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.50")
)
device = result.scalar_one_or_none()
assert device is not None
@@ -212,7 +212,7 @@ async def test_run_scan_records_ip_already_in_canvas(db_session: AsyncSession):
async def test_run_scan_refreshes_approved_device_without_duplicating(db_session: AsyncSession):
"""Re-scanning an already-approved device updates its row in place instead of
spawning a fresh pending duplicate, and keeps it approved."""
approved = PendingDevice(
approved = InventoryDevice(
id=str(uuid.uuid4()), ip="192.168.1.50", mac=None, hostname="old",
os=None, services=[], suggested_type="server", status="approved",
)
@@ -228,7 +228,7 @@ async def test_run_scan_refreshes_approved_device_without_duplicating(db_session
await run_scan(["192.168.1.0/24"], db_session, run_id)
rows = (await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.50")
)).scalars().all()
assert len(rows) == 1
assert rows[0].status == "approved"
@@ -240,7 +240,7 @@ async def test_run_scan_collapses_existing_duplicate_rows(db_session: AsyncSessi
"""Pre-existing duplicate inventory rows for one IP are collapsed to a single
row at scan start, even if the device is not re-discovered."""
for status in ("approved", "pending", "pending"):
db_session.add(PendingDevice(
db_session.add(InventoryDevice(
id=str(uuid.uuid4()), ip="192.168.1.77", mac=None, hostname=None,
os=None, services=[], suggested_type="server", status=status,
))
@@ -255,7 +255,7 @@ async def test_run_scan_collapses_existing_duplicate_rows(db_session: AsyncSessi
await run_scan(["192.168.1.0/24"], db_session, run_id)
rows = (await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.77")
select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.77")
)).scalars().all()
assert len(rows) == 1
assert rows[0].status == "approved" # approved row is the one kept
@@ -264,7 +264,7 @@ async def test_run_scan_collapses_existing_duplicate_rows(db_session: AsyncSessi
@pytest.mark.asyncio
async def test_run_scan_skips_hidden_device(db_session: AsyncSession):
"""Devices previously hidden by the user must not re-appear in pending on re-scan."""
hidden = PendingDevice(
hidden = InventoryDevice(
id=str(uuid.uuid4()),
ip="192.168.1.50",
mac=None,
@@ -289,9 +289,9 @@ async def test_run_scan_skips_hidden_device(db_session: AsyncSession):
await run_scan(["192.168.1.0/24"], db_session, run_id)
result = await db_session.execute(
select(PendingDevice).where(
PendingDevice.ip == "192.168.1.50",
PendingDevice.status == "pending",
select(InventoryDevice).where(
InventoryDevice.ip == "192.168.1.50",
InventoryDevice.status == "pending",
)
)
assert result.scalar_one_or_none() is None
@@ -395,7 +395,7 @@ async def test_run_scan_cancelled_mid_scan_skips_remaining_cidrs(db_session: Asy
async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession):
"""Re-scanning the same IP updates services instead of creating a duplicate."""
# Pre-existing pending device with no services
existing = PendingDevice(
existing = InventoryDevice(
id=str(uuid.uuid4()),
ip="192.168.1.50",
mac=None,
@@ -421,7 +421,7 @@ async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession
# Should still be only one device
result = await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.50")
)
devices = list(result.scalars().all())
assert len(devices) == 1
+131
View File
@@ -0,0 +1,131 @@
"""The pending_devices -> device_inventory rename, run at startup.
It is the one migration that must happen *before* `create_all`, so it gets its
own suite: get the order wrong and every device a user ever scanned reads as
gone, because the app looks at a freshly created empty table.
"""
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
from app.db.database import _rename_legacy_tables
pytestmark = pytest.mark.asyncio
def _legacy_ddl(table: str) -> str:
return f"CREATE TABLE {table} (id VARCHAR PRIMARY KEY, hostname VARCHAR)"
async def _engine(tmp_path, *, statements: list[str]):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'test.db'}")
async with engine.begin() as conn:
for sql in statements:
await conn.exec_driver_sql(sql)
return engine
async def _tables(engine) -> set[str]:
async with engine.begin() as conn:
rows = (
await conn.exec_driver_sql("SELECT name FROM sqlite_master WHERE type='table'")
).fetchall()
return {r[0] for r in rows}
async def _rename(engine) -> None:
async with engine.begin() as conn:
await _rename_legacy_tables(conn)
async def test_renames_the_legacy_tables_keeping_their_rows(tmp_path):
engine = await _engine(
tmp_path,
statements=[
_legacy_ddl("pending_devices"),
_legacy_ddl("pending_device_links"),
"INSERT INTO pending_devices (id, hostname) VALUES ('d1', 'nas')",
],
)
await _rename(engine)
assert await _tables(engine) == {"device_inventory", "device_inventory_links"}
async with engine.begin() as conn:
rows = (await conn.exec_driver_sql("SELECT hostname FROM device_inventory")).fetchall()
assert [r[0] for r in rows] == ["nas"]
await engine.dispose()
async def test_rewrites_the_reference_from_rack_devices(tmp_path):
# `rack_devices.device_id` names the inventory table. Left pointing at the
# old name, the schema references a table that no longer exists.
engine = await _engine(
tmp_path,
statements=[
_legacy_ddl("pending_devices"),
"CREATE TABLE rack_devices (id VARCHAR PRIMARY KEY, device_id VARCHAR "
"REFERENCES pending_devices(id) ON DELETE SET NULL)",
],
)
await _rename(engine)
async with engine.begin() as conn:
ddl = (
await conn.exec_driver_sql(
"SELECT sql FROM sqlite_master WHERE name='rack_devices'"
)
).scalar()
assert "device_inventory" in ddl
assert "pending_devices" not in ddl
await engine.dispose()
async def test_replaces_an_empty_table_left_by_an_earlier_start(tmp_path):
# A start that ran `create_all` before this migration existed created the new
# table empty, and the app read that one instead of the user's devices.
engine = await _engine(
tmp_path,
statements=[
_legacy_ddl("pending_devices"),
_legacy_ddl("device_inventory"),
"INSERT INTO pending_devices (id, hostname) VALUES ('d1', 'nas')",
],
)
await _rename(engine)
assert await _tables(engine) == {"device_inventory"}
async with engine.begin() as conn:
count = (await conn.exec_driver_sql("SELECT COUNT(*) FROM device_inventory")).scalar()
assert count == 1
await engine.dispose()
async def test_leaves_two_populated_tables_alone(tmp_path):
# Rows on both sides is not a state this migration created, and dropping
# either one would lose data.
engine = await _engine(
tmp_path,
statements=[
_legacy_ddl("pending_devices"),
_legacy_ddl("device_inventory"),
"INSERT INTO pending_devices (id, hostname) VALUES ('d1', 'old')",
"INSERT INTO device_inventory (id, hostname) VALUES ('d2', 'new')",
],
)
await _rename(engine)
assert await _tables(engine) == {"pending_devices", "device_inventory"}
await engine.dispose()
async def test_is_a_no_op_on_a_fresh_database(tmp_path):
engine = await _engine(tmp_path, statements=[_legacy_ddl("device_inventory")])
await _rename(engine)
await _rename(engine) # And still a no-op the second time round.
assert await _tables(engine) == {"device_inventory"}
await engine.dispose()
+20 -20
View File
@@ -17,7 +17,7 @@ from app.api.routes.proxmox import (
)
from app.api.routes.scan import _is_proxmox_cluster_member, _resolve_pending_links_for_ieee
from app.core.config import settings
from app.db.models import Design, Edge, Node, PendingDevice, PendingDeviceLink
from app.db.models import Design, Edge, InventoryDevice, InventoryDeviceLink, Node
@pytest.fixture(autouse=True)
@@ -212,7 +212,7 @@ async def test_persist_creates_pending(db_session) -> None:
result = await _persist_pending_import(db_session, nodes, edges)
assert result.pending_created == 2
assert result.links_recorded == 1
rows = (await db_session.execute(select(PendingDevice))).scalars().all()
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
assert {r.suggested_type for r in rows} == {"proxmox", "vm"}
# Specs carried as properties.
vm = next(r for r in rows if r.suggested_type == "vm")
@@ -239,14 +239,14 @@ async def test_persist_merges_existing_scanned_node_by_ip(db_session) -> None:
assert merged.cpu_count == 2
assert any(p["key"] == "CPU Cores" for p in (merged.properties or []))
# Inventory row exists as approved (already on canvas).
inv = (await db_session.execute(select(PendingDevice).where(PendingDevice.ieee_address == "pve-pve1-101"))).scalar_one()
inv = (await db_session.execute(select(InventoryDevice).where(InventoryDevice.ieee_address == "pve-pve1-101"))).scalar_one()
assert inv.status == "approved"
@pytest.mark.asyncio
async def test_persist_merges_pending_scan_row_by_mac(db_session) -> None:
# Device previously found by an IP scan: arp source, MAC known, no ieee.
db_session.add(PendingDevice(
db_session.add(InventoryDevice(
id=str(uuid.uuid4()), ip="10.0.0.5", mac="bc:24:11:aa:bb:cc",
suggested_type="generic", status="pending",
discovery_source="arp", discovery_sources=["arp"],
@@ -257,7 +257,7 @@ async def test_persist_merges_pending_scan_row_by_mac(db_session) -> None:
# different casing. Must merge, not duplicate.
await _persist_pending_import(db_session, [_guest_node(101, None, mac="BC:24:11:AA:BB:CC")], [])
rows = (await db_session.execute(select(PendingDevice))).scalars().all()
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
assert len(rows) == 1
row = rows[0]
assert row.ieee_address == "pve-pve1-101" # adopted proxmox identity
@@ -269,7 +269,7 @@ async def test_persist_merges_pending_scan_row_by_mac(db_session) -> None:
async def test_persist_preserves_ip_tag_for_legacy_null_source_row(db_session) -> None:
# Legacy inventory row from an old IP scan, before discovery_source(s) were
# recorded: scalar NULL, sources empty — but it has an IP + MAC.
db_session.add(PendingDevice(
db_session.add(InventoryDevice(
id=str(uuid.uuid4()), ip="192.168.1.108", mac="bc:24:11:6c:96:52",
suggested_type="lxc", status="pending",
discovery_source=None, discovery_sources=[],
@@ -280,7 +280,7 @@ async def test_persist_preserves_ip_tag_for_legacy_null_source_row(db_session) -
db_session, [_guest_node(108, "192.168.1.108", mac="BC:24:11:6C:96:52")], []
)
rows = (await db_session.execute(select(PendingDevice))).scalars().all()
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
assert len(rows) == 1
# The IP tag must survive the proxmox merge even with no recorded origin.
assert set(rows[0].discovery_sources) == {"arp", "proxmox"}
@@ -295,7 +295,7 @@ async def test_persist_preserves_ip_tag_on_canvas_merge_legacy_row(db_session) -
ip="192.168.1.108", mac="bc:24:11:6c:96:52",
status="online", pos_x=0, pos_y=0,
)
inv = PendingDevice(
inv = InventoryDevice(
id=str(uuid.uuid4()), ip="192.168.1.108", mac="bc:24:11:6c:96:52",
suggested_type="lxc", status="approved",
discovery_source=None, discovery_sources=[],
@@ -308,7 +308,7 @@ async def test_persist_preserves_ip_tag_on_canvas_merge_legacy_row(db_session) -
)
inv_row = (await db_session.execute(
select(PendingDevice).where(PendingDevice.mac == "bc:24:11:6c:96:52")
select(InventoryDevice).where(InventoryDevice.mac == "bc:24:11:6c:96:52")
)).scalar_one()
assert set(inv_row.discovery_sources) == {"arp", "proxmox"}
@@ -320,7 +320,7 @@ async def test_persist_does_not_add_ip_tag_to_pure_proxmox_guest(db_session) ->
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], [])
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], []) # re-sync
row = (await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "pve-pve1-101")
select(InventoryDevice).where(InventoryDevice.ieee_address == "pve-pve1-101")
)).scalar_one()
assert set(row.discovery_sources) == {"proxmox"}
@@ -351,27 +351,27 @@ async def test_persist_resync_updates_in_place(db_session) -> None:
await _persist_pending_import(db_session, nodes, [])
# Second sync: same device, new IP. Should update, not duplicate.
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.9")], [])
rows = (await db_session.execute(select(PendingDevice).where(PendingDevice.ieee_address == "pve-pve1-101"))).scalars().all()
rows = (await db_session.execute(select(InventoryDevice).where(InventoryDevice.ieee_address == "pve-pve1-101"))).scalars().all()
assert len(rows) == 1
assert rows[0].ip == "10.0.0.9"
@pytest.mark.asyncio
async def test_persist_keeps_hidden_hidden(db_session) -> None:
db_session.add(PendingDevice(
db_session.add(InventoryDevice(
id=str(uuid.uuid4()), ieee_address="pve-pve1-101", ip="10.0.0.5",
suggested_type="vm", status="hidden", discovery_source="proxmox",
))
await db_session.commit()
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], [])
row = (await db_session.execute(select(PendingDevice).where(PendingDevice.ieee_address == "pve-pve1-101"))).scalar_one()
row = (await db_session.execute(select(InventoryDevice).where(InventoryDevice.ieee_address == "pve-pve1-101"))).scalar_one()
assert row.status == "hidden"
@pytest.mark.asyncio
async def test_pending_endpoint_tolerates_legacy_null_properties(client: AsyncClient, headers: dict, db_session) -> None:
# Legacy row: properties column NULL (added by migration on older DBs).
dev = PendingDevice(
dev = InventoryDevice(
id=str(uuid.uuid4()), ip="192.168.1.9", suggested_type="server",
status="pending", discovery_source="arp",
)
@@ -396,7 +396,7 @@ async def test_persist_records_cluster_links_between_hosts(db_session) -> None:
edges = [{"source": "pve-node-pve1", "target": "pve-pve1-101"}]
await _persist_pending_import(db_session, nodes, edges)
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
links = (await db_session.execute(select(InventoryDeviceLink))).scalars().all()
cluster = [ln for ln in links if ln.discovery_source == "proxmox_cluster"]
assert len(cluster) == 1
assert (cluster[0].source_ieee, cluster[0].target_ieee) == ("pve-node-a", "pve-node-b")
@@ -410,7 +410,7 @@ async def test_persist_records_cluster_links_between_hosts(db_session) -> None:
async def test_single_host_records_no_cluster_link(db_session) -> None:
await _persist_pending_import(db_session, [_host_node()], [])
links = (await db_session.execute(
select(PendingDeviceLink).where(PendingDeviceLink.discovery_source == "proxmox_cluster")
select(InventoryDeviceLink).where(InventoryDeviceLink.discovery_source == "proxmox_cluster")
)).scalars().all()
assert links == []
@@ -427,7 +427,7 @@ async def test_cluster_link_resolves_to_cluster_edge(db_session) -> None:
status="online", pos_x=0, pos_y=0, design_id=design.id,
left_handles=1, right_handles=1)
db_session.add_all([a, b])
db_session.add(PendingDeviceLink(
db_session.add(InventoryDeviceLink(
id=str(uuid.uuid4()), source_ieee="pve-node-a", target_ieee="pve-node-b",
discovery_source="proxmox_cluster",
))
@@ -459,7 +459,7 @@ async def test_link_survives_and_resolves_onto_second_design(db_session) -> None
Node(id=str(uuid.uuid4()), type="iot", label="y", ieee_address="0xBBBB",
status="online", pos_x=0, pos_y=0, design_id=d.id),
])
db_session.add(PendingDeviceLink(
db_session.add(InventoryDeviceLink(
id=str(uuid.uuid4()), source_ieee="0xAAAA", target_ieee="0xBBBB",
discovery_source="zigbee",
))
@@ -476,7 +476,7 @@ async def test_link_survives_and_resolves_onto_second_design(db_session) -> None
assert {e.design_id for e in edges} == {da.id, db_.id}
assert all(e.type == "iot" for e in edges)
# The link row is still present for any further design.
assert (await db_session.execute(select(PendingDeviceLink))).scalars().one()
assert (await db_session.execute(select(InventoryDeviceLink))).scalars().one()
@pytest.mark.asyncio
@@ -484,6 +484,6 @@ async def test_persist_never_deletes(db_session) -> None:
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], [])
# A later sync that no longer includes vm101 must not remove it.
await _persist_pending_import(db_session, [_guest_node(202, "10.0.0.6")], [])
rows = (await db_session.execute(select(PendingDevice))).scalars().all()
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
ieees = {r.ieee_address for r in rows}
assert "pve-pve1-101" in ieees and "pve-pve1-202" in ieees
+34
View File
@@ -0,0 +1,34 @@
"""The rackable exclusion list is duplicated in the frontend — keep them equal.
`GET /api/v1/racks/inventory` filters on `_UNRACKABLE_TYPES`, while the Device
Inventory modal's "Rackable" filter (used as the rack picker's default) filters
on `UNRACKABLE_TYPES` in `frontend/src/utils/rackable.ts`. If the two drift, the
picker offers devices the rack side then refuses.
"""
import re
from pathlib import Path
from app.api.routes.racks import _UNRACKABLE_TYPES
FRONTEND_FILE = (
Path(__file__).resolve().parents[2] / "frontend" / "src" / "utils" / "rackable.ts"
)
def _frontend_unrackable() -> set[str]:
src = FRONTEND_FILE.read_text()
match = re.search(r"export const UNRACKABLE_TYPES = new Set\(\[(.*?)\]\)", src, re.S)
assert match, (
"Could not locate UNRACKABLE_TYPES in frontend/src/utils/rackable.ts — "
"update this parser if the file's shape changed."
)
return set(re.findall(r"'([^']+)'", match.group(1)))
def test_unrackable_types_in_sync_with_frontend():
frontend = _frontend_unrackable()
assert frontend == _UNRACKABLE_TYPES, (
"racks.py _UNRACKABLE_TYPES is out of sync with frontend rackable.ts.\n"
f"Missing from the backend: {sorted(frontend - _UNRACKABLE_TYPES)}\n"
f"Missing from the frontend: {sorted(_UNRACKABLE_TYPES - frontend)}"
)
+798
View File
@@ -0,0 +1,798 @@
import pytest
from httpx import AsyncClient
from sqlalchemy import text
from app.db.models import InventoryDevice
pytestmark = pytest.mark.asyncio
async def _design(client: AsyncClient, headers: dict, *, name="Rack Room", design_type="rack") -> str:
res = await client.post(
"/api/v1/designs",
json={"name": name, "icon": "server", "design_type": design_type},
headers=headers,
)
assert res.status_code == 201, res.text
return res.json()["id"]
def _state(design_id: str, **overrides) -> dict:
"""A minimal but complete rack payload: one rack, two devices, one patch."""
payload = {
"design_id": design_id,
"racks": [
{
"id": "rack-1",
"name": "Main",
"u_height": 12,
"width_standard": "19",
"numbering": "bottom-up",
"location": "garage",
"style": {"frame": "#1c2129", "showNumbers": True},
"pos_x": 40,
"pos_y": 60,
}
],
"devices": [
{
"id": "dev-sw",
"rack_id": "rack-1",
"label": "sw-24",
"u_start": 10,
"u_height": 1,
"col_start": 0,
"col_span": 12,
"faceplate_id": "switch-24",
"status": "online",
"ports": [{"id": "p1", "label": "1", "type": "rj45", "x": 0.4, "y": 0.5}],
},
{
"id": "dev-nas",
"rack_id": "rack-1",
"label": "nas",
"u_start": 4,
"u_height": 2,
"col_start": 0,
"col_span": 12,
"faceplate_id": "nas-2u",
"status": "unknown",
"ports": [{"id": "p2", "label": "eth0", "type": "rj45", "x": 0.7, "y": 0.5}],
},
],
"cables": [
{
"id": "cbl-1",
"from_device_id": "dev-sw",
"from_port_id": "p1",
"to_device_id": "dev-nas",
"to_port_id": "p2",
"type": "ethernet",
"color": "#39d353",
}
],
"viewport": {"x": 10, "y": 20, "zoom": 1.5},
}
payload.update(overrides)
return payload
class TestDesignType:
async def test_creates_a_rack_design(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
res = await client.get("/api/v1/designs", headers=headers)
design = next(d for d in res.json() if d["id"] == design_id)
assert design["design_type"] == "rack"
async def test_rejects_an_unknown_design_type(self, client: AsyncClient, headers):
res = await client.post(
"/api/v1/designs",
json={"name": "Nope", "design_type": "spaceship"},
headers=headers,
)
assert res.status_code == 422
class TestSaveAndLoad:
async def test_round_trips_the_full_state(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
res = await client.post("/api/v1/racks/save", json=_state(design_id), headers=headers)
assert res.status_code == 200, res.text
loaded = (await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)).json()
assert [r["name"] for r in loaded["racks"]] == ["Main"]
assert loaded["racks"][0]["u_height"] == 12
assert loaded["racks"][0]["style"]["showNumbers"] is True
assert {d["id"] for d in loaded["devices"]} == {"dev-sw", "dev-nas"}
assert loaded["devices"][0]["ports"][0]["type"] == "rj45"
assert loaded["cables"][0]["from_port_id"] == "p1"
assert loaded["viewport"] == {"x": 10, "y": 20, "zoom": 1.5}
async def test_keeps_a_mount_that_follows_its_node_check(
self, client: AsyncClient, headers
):
# "Check device" is stored as `auto` on the mount: the rack has no
# checker, it reads the linked node's live status at render time. The
# status column is free-form, so this only guards it staying that way.
design_id = await _design(client, headers)
state = _state(design_id)
state["devices"][0]["status"] = "auto"
res = await client.post("/api/v1/racks/save", json=state, headers=headers)
assert res.status_code == 200, res.text
loaded = (await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)).json()
mount = next(d for d in loaded["devices"] if d["id"] == "dev-sw")
assert mount["status"] == "auto"
async def test_prunes_what_the_client_dropped(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
await client.post("/api/v1/racks/save", json=_state(design_id), headers=headers)
trimmed = _state(design_id)
trimmed["devices"] = [d for d in trimmed["devices"] if d["id"] == "dev-sw"]
trimmed["cables"] = []
await client.post("/api/v1/racks/save", json=trimmed, headers=headers)
loaded = (await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)).json()
assert {d["id"] for d in loaded["devices"]} == {"dev-sw"}
assert loaded["cables"] == []
async def test_updates_an_existing_device_in_place(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
await client.post("/api/v1/racks/save", json=_state(design_id), headers=headers)
moved = _state(design_id)
moved["devices"][1]["u_start"] = 1
moved["devices"][1]["label"] = "nas-renamed"
await client.post("/api/v1/racks/save", json=moved, headers=headers)
loaded = (await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)).json()
nas = next(d for d in loaded["devices"] if d["id"] == "dev-nas")
assert (nas["u_start"], nas["label"]) == (1, "nas-renamed")
async def test_keeps_designs_isolated(self, client: AsyncClient, headers):
first = await _design(client, headers, name="Rack A")
second = await _design(client, headers, name="Rack B")
await client.post("/api/v1/racks/save", json=_state(first), headers=headers)
loaded = (await client.get(f"/api/v1/racks?design_id={second}", headers=headers)).json()
assert loaded["racks"] == []
assert loaded["devices"] == []
async def test_refuses_to_steal_a_row_from_another_design(
self, client: AsyncClient, headers
):
"""Ids come from the client, so a stale tab or a copy can collide.
The upsert sets `design_id` from the payload: without a guard, saving
design B with an id owned by design A moved A's rack — devices and
cables with it — into B, and A silently lost it.
"""
first = await _design(client, headers, name="Rack A")
second = await _design(client, headers, name="Rack B")
await client.post("/api/v1/racks/save", json=_state(first), headers=headers)
res = await client.post("/api/v1/racks/save", json=_state(second), headers=headers)
assert res.status_code == 409
kept = (await client.get(f"/api/v1/racks?design_id={first}", headers=headers)).json()
assert [r["id"] for r in kept["racks"]] == ["rack-1"]
assert len(kept["devices"]) == 2
async def test_rejects_an_unknown_design(self, client: AsyncClient, headers):
res = await client.post("/api/v1/racks/save", json=_state("nope"), headers=headers)
assert res.status_code == 404
async def test_rejects_a_device_pointing_outside_the_payload(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
broken = _state(design_id)
broken["devices"][0]["rack_id"] = "rack-ghost"
res = await client.post("/api/v1/racks/save", json=broken, headers=headers)
assert res.status_code == 400
async def test_rejects_a_cable_pointing_outside_the_payload(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
broken = _state(design_id)
broken["cables"][0]["to_device_id"] = "dev-ghost"
res = await client.post("/api/v1/racks/save", json=broken, headers=headers)
assert res.status_code == 400
@pytest.mark.parametrize(
"patch",
[
{"u_height": 0},
{"width_standard": "23"},
{"numbering": "sideways"},
],
)
async def test_rejects_invalid_rack_geometry(self, client: AsyncClient, headers, patch):
design_id = await _design(client, headers)
payload = _state(design_id)
payload["racks"][0].update(patch)
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
assert res.status_code == 422
@pytest.mark.parametrize(
"patch",
[
{"u_start": 0},
{"col_start": 12},
{"col_span": 13},
],
)
async def test_rejects_invalid_device_geometry(self, client: AsyncClient, headers, patch):
design_id = await _design(client, headers)
payload = _state(design_id)
payload["devices"][0].update(patch)
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
assert res.status_code == 422
async def test_rejects_a_device_taller_than_its_rack(self, client: AsyncClient, headers):
# Each field is legal on its own; only the rack says otherwise. A mount
# above the top rail draws outside the chassis with no way to drag it back.
design_id = await _design(client, headers)
payload = _state(design_id)
payload["devices"][0]["u_start"] = 40 # rack is 12U
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
assert res.status_code == 422
async def test_rejects_a_device_whose_height_overruns_the_rack(
self, client: AsyncClient, headers
):
design_id = await _design(client, headers)
payload = _state(design_id)
payload["devices"][0].update({"u_start": 11, "u_height": 4}) # 11..14 of 12U
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
assert res.status_code == 422
async def test_accepts_a_device_that_ends_on_the_top_rail(
self, client: AsyncClient, headers
):
design_id = await _design(client, headers)
payload = _state(design_id)
payload["devices"][0].update({"u_start": 11, "u_height": 2}) # 11..12 of 12U
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
assert res.status_code == 200, res.text
async def test_rejects_a_span_that_overruns_the_column_grid(
self, client: AsyncClient, headers
):
design_id = await _design(client, headers)
payload = _state(design_id)
payload["devices"][0].update({"col_start": 11, "col_span": 12})
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
assert res.status_code == 422
async def test_accepts_a_half_width_pair_sharing_one_u(
self, client: AsyncClient, headers
):
design_id = await _design(client, headers)
payload = _state(design_id)
payload["devices"][0].update({"col_start": 0, "col_span": 6})
payload["devices"][1].update({"col_start": 6, "col_span": 6})
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
assert res.status_code == 200, res.text
async def test_rejects_an_unknown_cable_type(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
payload = _state(design_id)
payload["cables"][0]["type"] = "power"
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
assert res.status_code == 422
async def test_round_trips_cable_annotations(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
payload = _state(design_id)
payload["cables"][0].update(
{
"label": "Uplink to core",
"label_visible": True,
"properties": [
{"key": "Length", "value": "2 m", "icon": None, "visible": True},
{"key": "VLAN", "value": "20", "icon": None, "visible": False},
],
}
)
assert (
await client.post("/api/v1/racks/save", json=payload, headers=headers)
).status_code == 200
cable = (
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
).json()["cables"][0]
assert cable["label"] == "Uplink to core"
assert cable["label_visible"] is True
assert [p["key"] for p in cable["properties"]] == ["Length", "VLAN"]
assert cable["properties"][1]["visible"] is False
async def test_defaults_cable_annotations_when_absent(
self, client: AsyncClient, headers
):
# A client that predates the feature sends neither key; the response must
# still carry usable values rather than nulls.
design_id = await _design(client, headers)
assert (
await client.post("/api/v1/racks/save", json=_state(design_id), headers=headers)
).status_code == 200
cable = (
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
).json()["cables"][0]
assert cable["label_visible"] is False
assert cable["properties"] == []
async def test_drops_cable_properties_with_no_key(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
payload = _state(design_id)
payload["cables"][0]["properties"] = [
{"key": " ", "value": "orphan", "icon": None, "visible": True},
{"key": "Length", "value": "2 m", "icon": None, "visible": True},
]
assert (
await client.post("/api/v1/racks/save", json=payload, headers=headers)
).status_code == 200
cable = (
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
).json()["cables"][0]
assert [p["key"] for p in cable["properties"]] == ["Length"]
async def test_updates_cable_annotations_on_a_second_save(
self, client: AsyncClient, headers
):
design_id = await _design(client, headers)
payload = _state(design_id)
payload["cables"][0]["properties"] = [
{"key": "Length", "value": "2 m", "icon": None, "visible": True}
]
await client.post("/api/v1/racks/save", json=payload, headers=headers)
payload["cables"][0]["properties"] = []
payload["cables"][0]["label_visible"] = True
await client.post("/api/v1/racks/save", json=payload, headers=headers)
cable = (
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
).json()["cables"][0]
assert cable["properties"] == []
assert cable["label_visible"] is True
async def test_requires_auth(self, client: AsyncClient):
assert (await client.get("/api/v1/racks?design_id=any")).status_code == 401
class TestInventory:
async def test_lists_rackable_devices_only(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
await client.post(
"/api/v1/scan/pending",
json={"hostname": "pve-01", "suggested_type": "proxmox"},
headers=headers,
)
await client.post(
"/api/v1/scan/pending",
json={"hostname": "phone", "suggested_type": "mobile"},
headers=headers,
)
res = await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
labels = [i["label"] for i in res.json()["items"]]
assert "pve-01" in labels
assert "phone" not in labels
async def test_names_a_device_the_way_the_inventory_does(
self, client: AsyncClient, headers, db_session
):
"""A scan find with no hostname is named after the app it runs, not its IP.
The rack picker used to show "192.168.1.63 · 192.168.1.63" where the
Device Inventory showed "jellyfin".
"""
design_id = await _design(client, headers)
db_session.add(
InventoryDevice(
id="pd-services",
ip="192.168.1.63",
services=[
{"port": 443, "category": "web", "service_name": "https"},
{"port": 8096, "category": "media", "service_name": "jellyfin"},
],
suggested_type="server",
)
)
db_session.add(InventoryDevice(id="pd-bare", ip="192.168.1.64", suggested_type="server"))
# A portless service is no fingerprint: the inventory skips it, so must
# this — `None not in _COMMON_PORTS` is True and used to let it through.
db_session.add(
InventoryDevice(
id="pd-portless",
ip="192.168.1.65",
services=[{"port": None, "category": "media", "service_name": "jellyfin"}],
suggested_type="server",
)
)
await db_session.commit()
items = (
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
).json()["items"]
assert next(i for i in items if i["id"] == "pd-services")["label"] == "jellyfin"
# Nothing better to say: the IP stays the label, and the UI stops doubling it.
assert next(i for i in items if i["id"] == "pd-bare")["label"] == "192.168.1.64"
assert next(i for i in items if i["id"] == "pd-portless")["label"] == "192.168.1.65"
async def test_flags_devices_already_mounted(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
created = (
await client.post(
"/api/v1/scan/pending",
json={"hostname": "sw-24", "suggested_type": "switch"},
headers=headers,
)
).json()
payload = _state(design_id)
payload["devices"][0]["device_id"] = created["id"]
await client.post("/api/v1/racks/save", json=payload, headers=headers)
items = (
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
).json()["items"]
assert next(i for i in items if i["id"] == created["id"])["racked"] is True
async def test_resolves_the_canvas_node_for_live_status(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
node = (
await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "nas", "ip": "192.168.1.9", "status": "online"},
headers=headers,
)
).json()
await client.post(
"/api/v1/scan/pending",
json={"hostname": "nas", "ip": "192.168.1.9", "suggested_type": "nas"},
headers=headers,
)
items = (
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
).json()["items"]
entry = next(i for i in items if i["label"] == "nas")
assert entry["node_id"] == node["id"]
assert entry["node_status"] == "online"
async def test_reports_what_the_canvas_node_knows(
self, client: AsyncClient, headers, db_session
):
"""The rack prints the logical view's facts, so the endpoint must ship them."""
rack_design = await _design(client, headers)
network = await _design(client, headers, name="Network", design_type="network")
await client.post(
"/api/v1/nodes",
json={
"type": "nas",
"label": "nas-truenas",
"design_id": network,
"ip": "192.168.1.9",
"mac": "aa:bb:cc:dd:ee:ff",
"hostname": "nas.lan",
"os": "TrueNAS SCALE",
"status": "online",
"check_method": "http",
},
headers=headers,
)
# Straight to the table: the manual-create schema carries no os/services,
# and a scan find does.
db_session.add(
InventoryDevice(
id="pd-nas",
hostname="nas",
ip="192.168.1.9",
mac="aa:bb:cc:dd:ee:ff",
os="TrueNAS",
suggested_type="nas",
services=[{"port": 445, "service_name": "smb", "category": "storage"}],
)
)
await db_session.commit()
items = (
await client.get(f"/api/v1/racks/inventory?design_id={rack_design}", headers=headers)
).json()["items"]
entry = next(i for i in items if i["ip"] == "192.168.1.9")
assert entry["mac"] == "aa:bb:cc:dd:ee:ff"
assert entry["os"] == "TrueNAS"
assert entry["services"] == [{"port": 445, "name": "smb"}]
assert entry["node_label"] == "nas-truenas"
assert entry["node_type"] == "nas"
assert entry["node_hostname"] == "nas.lan"
assert entry["node_os"] == "TrueNAS SCALE"
assert entry["node_check_method"] == "http"
assert entry["node_design_id"] == network
assert entry["node_design_name"] == "Network"
async def test_stamps_last_seen_with_an_offset(self, client: AsyncClient, headers, db_session):
"""SQLite reads `last_seen` back naive, and a naive ISO string is *local*
time to `new Date()` — the panel printed it shifted by the viewer's offset.
"""
design_id = await _design(client, headers)
node = (
await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "seen", "ip": "192.168.1.77"},
headers=headers,
)
).json()
db_session.add(InventoryDevice(id="pd-seen", ip="192.168.1.77", suggested_type="server"))
await db_session.execute(
text("UPDATE nodes SET last_seen = :ts WHERE id = :id"),
{"ts": "2026-08-08 10:00:00.000000", "id": node["id"]},
)
await db_session.commit()
items = (
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
).json()["items"]
entry = next(i for i in items if i["id"] == "pd-seen")
assert entry["node_last_seen"].endswith(("Z", "+00:00"))
async def test_prefers_the_node_the_mount_names(self, client: AsyncClient, headers):
"""A link the user picked in the rack beats the IEEE/IP guess.
The guess is what failed on gear with no MAC and no address, and it is
wrong — not merely absent — when two hosts have swapped IPs.
"""
design_id = await _design(client, headers)
picked = (
await client.post(
"/api/v1/nodes",
json={"type": "pdu", "label": "pdu-main", "status": "online"},
headers=headers,
)
).json()
guessed = (
await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "someone-else", "ip": "192.168.1.90"},
headers=headers,
)
).json()
device = (
await client.post(
"/api/v1/scan/pending",
json={"hostname": "pdu", "ip": "192.168.1.90", "suggested_type": "generic"},
headers=headers,
)
).json()
payload = _state(design_id)
payload["devices"][0]["device_id"] = device["id"]
payload["devices"][0]["node_id"] = picked["id"]
await client.post("/api/v1/racks/save", json=payload, headers=headers)
items = (
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
).json()["items"]
entry = next(i for i in items if i["id"] == device["id"])
assert entry["node_id"] == picked["id"] != guessed["id"]
assert entry["node_label"] == "pdu-main"
assert entry["node_status"] == "online"
async def test_falls_back_when_the_named_node_is_gone(self, client: AsyncClient, headers):
"""A mount keeps its `node_id` in the payload the client last sent, so a
node deleted meanwhile must not report a node that no longer exists.
"""
design_id = await _design(client, headers)
guessed = (
await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "nas", "ip": "192.168.1.91"},
headers=headers,
)
).json()
device = (
await client.post(
"/api/v1/scan/pending",
json={"hostname": "nas", "ip": "192.168.1.91", "suggested_type": "nas"},
headers=headers,
)
).json()
payload = _state(design_id)
payload["devices"][0]["device_id"] = device["id"]
payload["devices"][0]["node_id"] = "node-that-was-deleted"
await client.post("/api/v1/racks/save", json=payload, headers=headers)
items = (
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
).json()["items"]
entry = next(i for i in items if i["id"] == device["id"])
assert entry["node_id"] == guessed["id"]
async def test_leaves_the_node_fields_empty_without_a_match(
self, client: AsyncClient, headers
):
design_id = await _design(client, headers)
await client.post(
"/api/v1/scan/pending",
json={"hostname": "pdu-main", "suggested_type": "generic"},
headers=headers,
)
items = (
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
).json()["items"]
entry = next(i for i in items if i["label"] == "pdu-main")
assert entry["node_id"] is None
assert entry["node_label"] is None
assert entry["node_design_name"] is None
assert entry["services"] == []
async def test_falls_back_to_the_node_services(self, client: AsyncClient, headers, db_session):
"""An approved device keeps its fingerprint on the node, not the inventory row."""
design_id = await _design(client, headers)
await client.post(
"/api/v1/nodes",
json={
"type": "server",
"label": "media",
"ip": "192.168.1.63",
"services": [{"port": 8096, "service_name": "jellyfin", "category": "media"}],
},
headers=headers,
)
db_session.add(
InventoryDevice(id="pd-noservices", ip="192.168.1.63", suggested_type="server")
)
await db_session.commit()
items = (
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
).json()["items"]
entry = next(i for i in items if i["id"] == "pd-noservices")
assert entry["services"] == [{"port": 8096, "name": "jellyfin"}]
async def test_manual_entry_is_tagged_as_such(self, client: AsyncClient, headers):
created = (
await client.post(
"/api/v1/scan/pending",
json={"hostname": "patch-panel", "suggested_type": "generic"},
headers=headers,
)
).json()
assert created["discovery_source"] == "manual"
assert created["status"] == "pending"
listed = (await client.get("/api/v1/scan/pending", headers=headers)).json()
assert any(d["id"] == created["id"] for d in listed)
class TestRackSourcedInventory:
"""Gear created from a rack canvas shares the Device Inventory, but never a
logical canvas: it documents a mount, not a host."""
async def _rack_device(self, client: AsyncClient, headers, hostname: str = "patch-house"):
return (
await client.post(
"/api/v1/scan/pending",
json={"hostname": hostname, "discovery_source": "rack"},
headers=headers,
)
).json()
async def test_records_the_rack_source(self, client: AsyncClient, headers):
created = await self._rack_device(client, headers)
assert created["discovery_source"] == "rack"
assert created["discovery_sources"] == ["rack"]
async def test_rejects_an_unknown_source(self, client: AsyncClient, headers):
res = await client.post(
"/api/v1/scan/pending",
json={"hostname": "spoof", "discovery_source": "zigbee"},
headers=headers,
)
assert res.status_code == 422
async def test_approve_refuses_rack_gear(self, client: AsyncClient, headers):
created = await self._rack_device(client, headers)
res = await client.post(
f"/api/v1/scan/pending/{created['id']}/approve",
json={"type": "generic", "label": "patch-house"},
headers=headers,
)
assert res.status_code == 409
assert "rack" in res.json()["detail"].lower()
async def test_bulk_approve_skips_rack_gear(self, client: AsyncClient, headers):
rack_device = await self._rack_device(client, headers)
scanned = (
await client.post(
"/api/v1/scan/pending",
json={"hostname": "nuc", "ip": "192.168.1.77", "suggested_type": "server"},
headers=headers,
)
).json()
res = await client.post(
"/api/v1/scan/pending/bulk-approve",
json={"device_ids": [rack_device["id"], scanned["id"]]},
headers=headers,
)
body = res.json()
assert body["approved"] == 1
assert body["device_ids"] == [scanned["id"]]
skipped = next(s for s in body["skipped_devices"] if s["device_id"] == rack_device["id"])
assert skipped["match"] == "rack"
async def test_rack_gear_is_offered_to_the_rack_inventory(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
created = await self._rack_device(client, headers, hostname="blank-panel")
items = (
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
).json()["items"]
assert any(i["id"] == created["id"] for i in items)
class TestDesignLifecycle:
async def test_deleting_a_design_removes_its_rack_rows(self, client: AsyncClient, headers):
keeper = await _design(client, headers, name="Keeper", design_type="network")
design_id = await _design(client, headers)
await client.post("/api/v1/racks/save", json=_state(design_id), headers=headers)
assert (await client.delete(f"/api/v1/designs/{design_id}", headers=headers)).status_code == 204
assert (await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)).status_code == 404
# The other design is untouched.
assert (await client.get(f"/api/v1/racks?design_id={keeper}", headers=headers)).status_code == 200
async def test_unmounting_keeps_the_inventory_entry(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
created = (
await client.post(
"/api/v1/scan/pending",
json={"hostname": "sw-24", "suggested_type": "switch"},
headers=headers,
)
).json()
payload = _state(design_id)
payload["devices"][0]["device_id"] = created["id"]
await client.post("/api/v1/racks/save", json=payload, headers=headers)
# Unmount: the device disappears from the rack payload entirely.
unmounted = _state(design_id)
unmounted["devices"] = [d for d in unmounted["devices"] if d["id"] != "dev-sw"]
unmounted["cables"] = []
await client.post("/api/v1/racks/save", json=unmounted, headers=headers)
items = (
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
).json()["items"]
entry = next(i for i in items if i["id"] == created["id"])
assert entry["racked"] is False
async def test_copying_a_rack_design_duplicates_its_racks(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
await client.post("/api/v1/racks/save", json=_state(design_id), headers=headers)
copy = (
await client.post(
f"/api/v1/designs/{design_id}/copy",
json={"name": "Rack Room (copy)", "icon": "server"},
headers=headers,
)
).json()
assert copy["design_type"] == "rack"
loaded = (await client.get(f"/api/v1/racks?design_id={copy['id']}", headers=headers)).json()
assert [r["name"] for r in loaded["racks"]] == ["Main"]
assert len(loaded["devices"]) == 2
assert len(loaded["cables"]) == 1
# Fresh ids, so editing the copy cannot touch the original.
assert loaded["racks"][0]["id"] != "rack-1"
assert {d["id"] for d in loaded["devices"]}.isdisjoint({"dev-sw", "dev-nas"})
# Cables still join the copied devices, not the originals.
copied_ids = {d["id"] for d in loaded["devices"]}
assert loaded["cables"][0]["from_device_id"] in copied_ids
assert loaded["cables"][0]["to_device_id"] in copied_ids
+11 -11
View File
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
from sqlalchemy.pool import StaticPool
from app.db.database import Base
from app.db.models import Node, PendingDevice, ScanRun
from app.db.models import InventoryDevice, Node, ScanRun
# ---------------------------------------------------------------------------
# Helpers
@@ -588,7 +588,7 @@ async def test_run_scan_adds_nmap_devices_as_pending(mem_db):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
result = await session.execute(sa_select(PendingDevice))
result = await session.execute(sa_select(InventoryDevice))
devices = result.scalars().all()
assert any(d.ip == "192.168.1.5" for d in devices)
@@ -674,7 +674,7 @@ async def test_run_scan_leaves_last_scan_untouched_on_unmatched_node(mem_db):
@pytest.mark.asyncio
async def test_run_scan_mdns_only_device_added(mem_db):
"""Devices found only by mDNS (not nmap) should appear in pending_devices."""
"""Devices found only by mDNS (not nmap) should appear in device_inventory."""
from app.services.scanner import run_scan
run_id = _make_run_id()
@@ -691,7 +691,7 @@ async def test_run_scan_mdns_only_device_added(mem_db):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.80"))
result = await session.execute(sa_select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.80"))
device = result.scalar_one_or_none()
assert device is not None
@@ -709,7 +709,7 @@ async def test_run_scan_merges_proxmox_row_by_mac(mem_db):
async with mem_db() as session:
session.add(_make_scan_run(run_id))
# Previously imported from Proxmox: no IP, known NIC MAC, vm type.
session.add(PendingDevice(
session.add(InventoryDevice(
id="pve-row", ieee_address="pve-pve1-101", ip=None,
mac="bc:24:11:aa:bb:cc", suggested_type="vm", status="pending",
discovery_source="proxmox", discovery_sources=["proxmox"],
@@ -727,7 +727,7 @@ async def test_run_scan_merges_proxmox_row_by_mac(mem_db):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
rows = (await session.execute(sa_select(PendingDevice))).scalars().all()
rows = (await session.execute(sa_select(InventoryDevice))).scalars().all()
assert len(rows) == 1 # merged, not duplicated
row = rows[0]
@@ -756,7 +756,7 @@ async def test_run_scan_mdns_skipped_if_already_in_nmap(mem_db):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.10"))
result = await session.execute(sa_select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.10"))
devices = result.scalars().all()
assert len(devices) == 1 # not duplicated
@@ -787,7 +787,7 @@ async def test_run_scan_keeps_canvas_nodes(mem_db):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.100"))
result = await session.execute(sa_select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.100"))
device = result.scalar_one_or_none()
assert device is not None
assert device.status == "pending"
@@ -801,7 +801,7 @@ async def test_run_scan_skips_hidden_devices(mem_db):
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
hidden = PendingDevice(ip="192.168.1.55", status="hidden")
hidden = InventoryDevice(ip="192.168.1.55", status="hidden")
session.add(hidden)
await session.commit()
@@ -815,7 +815,7 @@ async def test_run_scan_skips_hidden_devices(mem_db):
async with mem_db() as session:
result = await session.execute(
sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.55", PendingDevice.status == "pending")
sa_select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.55", InventoryDevice.status == "pending")
)
assert result.scalar_one_or_none() is None
@@ -943,7 +943,7 @@ async def test_run_scan_probe_enriches_services(mem_db):
)
async with mem_db() as session:
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.50"))
result = await session.execute(sa_select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.50"))
device = result.scalar_one_or_none()
assert device is not None
+4 -4
View File
@@ -9,7 +9,7 @@ from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.db.models import Node, PendingDevice, ScanRun
from app.db.models import InventoryDevice, Node, ScanRun
@pytest.fixture(autouse=True)
@@ -75,9 +75,9 @@ async def test_summary_aggregates_counts(
Node(type="server", label="D", status="unknown"),
Node(type="iot", label="Z1", status="online", ieee_address="0x1"),
Node(type="iot", label="Z2", status="online", ieee_address="0x2"),
PendingDevice(ip="10.0.0.1", status="pending"),
PendingDevice(ip="10.0.0.2", status="pending"),
PendingDevice(ip="10.0.0.3", status="hidden"), # excluded
InventoryDevice(ip="10.0.0.1", status="pending"),
InventoryDevice(ip="10.0.0.2", status="pending"),
InventoryDevice(ip="10.0.0.3", status="hidden"), # excluded
ScanRun(status="success", finished_at=finished),
ScanRun(status="success",
finished_at=datetime(2026, 5, 13, 10, 0, tzinfo=timezone.utc)),
+24 -24
View File
@@ -338,7 +338,7 @@ async def test_persist_pending_import_coordinator_goes_to_pending(
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import Node, PendingDevice
from app.db.models import InventoryDevice, Node
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
assert result.device_count == 3
@@ -354,7 +354,7 @@ async def test_persist_pending_import_coordinator_goes_to_pending(
# Coordinator sits in the pending inventory.
coord = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xCOORD")
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xCOORD")
)
).scalar_one()
assert coord.status == "pending"
@@ -385,14 +385,14 @@ async def test_persist_pending_import_replaces_links(db_session) -> None:
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import PendingDeviceLink
from app.db.models import InventoryDeviceLink
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
new_edges = [{"source": "0xCOORD", "target": "0xR1"}]
await _persist_pending_import(db_session, _PENDING_NODES[:2], new_edges)
rows = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
rows = (await db_session.execute(select(InventoryDeviceLink))).scalars().all()
assert len(rows) == 1
assert (rows[0].source_ieee, rows[0].target_ieee) == ("0xCOORD", "0xR1")
@@ -403,7 +403,7 @@ async def test_persist_pending_import_sets_coordinator_pending_fields(db_session
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import PendingDevice
from app.db.models import InventoryDevice
nodes_with_meta = [dict(n) for n in _PENDING_NODES]
nodes_with_meta[0]["vendor"] = "TI"
@@ -413,7 +413,7 @@ async def test_persist_pending_import_sets_coordinator_pending_fields(db_session
coord = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xCOORD")
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xCOORD")
)
).scalar_one()
assert coord.vendor == "TI"
@@ -432,10 +432,10 @@ async def test_persist_pending_import_backfills_inventory_for_approved_node(
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import Node, PendingDevice
from app.db.models import InventoryDevice, Node
# Simulate: router was approved earlier → exists as a canvas Node, but with
# no matching pending_devices row (e.g. a legacy auto-placed device).
# no matching device_inventory row (e.g. a legacy auto-placed device).
approved = Node(
label="router_1",
type="zigbee_router",
@@ -455,7 +455,7 @@ async def test_persist_pending_import_backfills_inventory_for_approved_node(
# An inventory row is backfilled as "approved" (it is on a canvas).
inv = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xR1")
)
).scalar_one()
assert inv.status == "approved"
@@ -481,13 +481,13 @@ async def test_persist_pending_import_preserves_hidden_inventory_for_approved_no
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import Node, PendingDevice
from app.db.models import InventoryDevice, Node
db_session.add(Node(
label="router_1", type="zigbee_router", status="online",
check_method="none", ieee_address="0xR1", services=[], properties=[],
))
db_session.add(PendingDevice(
db_session.add(InventoryDevice(
ieee_address="0xR1", friendly_name="router_1", suggested_type="zigbee_router",
device_subtype="Router", status="hidden", discovery_source="zigbee",
))
@@ -497,7 +497,7 @@ async def test_persist_pending_import_preserves_hidden_inventory_for_approved_no
inv = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xR1")
)
).scalar_one()
assert inv.status == "hidden" # stays hidden
@@ -509,7 +509,7 @@ async def test_persist_pending_import_revives_orphaned_approved_device(
) -> None:
"""Regression for #167: approve → delete node → re-import must re-list device.
When a device was approved (PendingDevice.status="approved") and its canvas
When a device was approved (InventoryDevice.status="approved") and its canvas
Node was later deleted, the orphaned "approved" row must be reset to
"pending" on re-import so it shows up in the Pending list again — instead of
being silently swallowed (re-import reports "found" but Pending stays empty).
@@ -517,11 +517,11 @@ async def test_persist_pending_import_revives_orphaned_approved_device(
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import PendingDevice
from app.db.models import InventoryDevice
# Simulate prior approve: a PendingDevice marked approved, but NO matching
# Simulate prior approve: a InventoryDevice marked approved, but NO matching
# Node exists (the user deleted the canvas node afterwards).
orphan = PendingDevice(
orphan = InventoryDevice(
ieee_address="0xR1",
friendly_name="router_1",
hostname="router_1",
@@ -541,7 +541,7 @@ async def test_persist_pending_import_revives_orphaned_approved_device(
# No new row created for 0xR1 — the existing one was updated/revived.
revived = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xR1")
)
).scalar_one()
assert revived.status == "pending"
@@ -552,7 +552,7 @@ async def test_persist_pending_import_revives_orphaned_approved_device(
# It is now visible to the Pending list (status filter == "pending").
listed = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.status == "pending")
select(InventoryDevice).where(InventoryDevice.status == "pending")
)
).scalars().all()
assert {p.ieee_address for p in listed} == {"0xCOORD", "0xR1", "0xE1"}
@@ -566,9 +566,9 @@ async def test_persist_pending_import_keeps_hidden_hidden_on_reimport(
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import PendingDevice
from app.db.models import InventoryDevice
hidden = PendingDevice(
hidden = InventoryDevice(
ieee_address="0xR1",
friendly_name="router_1",
suggested_type="zigbee_router",
@@ -583,7 +583,7 @@ async def test_persist_pending_import_keeps_hidden_hidden_on_reimport(
still_hidden = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xR1")
)
).scalar_one()
assert still_hidden.status == "hidden"
@@ -644,9 +644,9 @@ async def test_persist_pending_import_refreshes_approved_coordinator_node(
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import Node, PendingDevice
from app.db.models import InventoryDevice, Node
# Legacy: coordinator auto-placed as a canvas Node, no pending_devices row.
# Legacy: coordinator auto-placed as a canvas Node, no device_inventory row.
db_session.add(Node(
label="Coordinator", type="zigbee_coordinator", status="online",
check_method="none", ieee_address="0xCOORD", services=[], properties=[],
@@ -669,7 +669,7 @@ async def test_persist_pending_import_refreshes_approved_coordinator_node(
# Inventory row backfilled as approved → now visible in the inventory list.
inv = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xCOORD")
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xCOORD")
)
).scalar_one()
assert inv.status == "approved"
+14 -14
View File
@@ -294,7 +294,7 @@ async def test_persist_coordinator_goes_to_pending(db_session) -> None:
from sqlalchemy import select
from app.api.routes.zwave import _persist_pending_import
from app.db.models import Node, PendingDevice
from app.db.models import InventoryDevice, Node
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
assert result.device_count == 3
@@ -308,7 +308,7 @@ async def test_persist_coordinator_goes_to_pending(db_session) -> None:
assert nodes == []
coord = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-1")
select(InventoryDevice).where(InventoryDevice.ieee_address == "zwave-0xh-1")
)
).scalar_one()
assert coord.status == "pending"
@@ -333,12 +333,12 @@ async def test_persist_replaces_links(db_session) -> None:
from sqlalchemy import select
from app.api.routes.zwave import _persist_pending_import
from app.db.models import PendingDeviceLink
from app.db.models import InventoryDeviceLink
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
new_edges = [{"source": "zwave-0xh-1", "target": "zwave-0xh-2"}]
await _persist_pending_import(db_session, _PENDING_NODES[:2], new_edges)
rows = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
rows = (await db_session.execute(select(InventoryDeviceLink))).scalars().all()
assert len(rows) == 1
assert (rows[0].source_ieee, rows[0].target_ieee) == ("zwave-0xh-1", "zwave-0xh-2")
@@ -349,7 +349,7 @@ async def test_persist_sets_coordinator_pending_fields(db_session) -> None:
from sqlalchemy import select
from app.api.routes.zwave import _persist_pending_import
from app.db.models import PendingDevice
from app.db.models import InventoryDevice
nodes = [dict(n) for n in _PENDING_NODES]
nodes[0]["vendor"] = "Aeotec"
@@ -357,7 +357,7 @@ async def test_persist_sets_coordinator_pending_fields(db_session) -> None:
await _persist_pending_import(db_session, nodes, _PENDING_EDGES)
coord = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-1")
select(InventoryDevice).where(InventoryDevice.ieee_address == "zwave-0xh-1")
)
).scalar_one()
assert coord.vendor == "Aeotec"
@@ -372,7 +372,7 @@ async def test_persist_backfills_inventory_for_approved_node(db_session) -> None
from sqlalchemy import select
from app.api.routes.zwave import _persist_pending_import
from app.db.models import Node, PendingDevice
from app.db.models import InventoryDevice, Node
approved = Node(
label="Wall Plug",
@@ -390,7 +390,7 @@ async def test_persist_backfills_inventory_for_approved_node(db_session) -> None
inv = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
select(InventoryDevice).where(InventoryDevice.ieee_address == "zwave-0xh-2")
)
).scalar_one()
assert inv.status == "approved"
@@ -407,9 +407,9 @@ async def test_persist_revives_orphaned_approved_device(db_session) -> None:
from sqlalchemy import select
from app.api.routes.zwave import _persist_pending_import
from app.db.models import PendingDevice
from app.db.models import InventoryDevice
orphan = PendingDevice(
orphan = InventoryDevice(
ieee_address="zwave-0xh-2",
friendly_name="Wall Plug",
suggested_type="zwave_router",
@@ -423,7 +423,7 @@ async def test_persist_revives_orphaned_approved_device(db_session) -> None:
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
revived = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
select(InventoryDevice).where(InventoryDevice.ieee_address == "zwave-0xh-2")
)
).scalar_one()
assert revived.status == "pending"
@@ -437,9 +437,9 @@ async def test_persist_keeps_hidden_hidden(db_session) -> None:
from sqlalchemy import select
from app.api.routes.zwave import _persist_pending_import
from app.db.models import PendingDevice
from app.db.models import InventoryDevice
hidden = PendingDevice(
hidden = InventoryDevice(
ieee_address="zwave-0xh-2",
friendly_name="Wall Plug",
suggested_type="zwave_router",
@@ -453,7 +453,7 @@ async def test_persist_keeps_hidden_hidden(db_session) -> None:
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
still_hidden = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
select(InventoryDevice).where(InventoryDevice.ieee_address == "zwave-0xh-2")
)
).scalar_one()
assert still_hidden.status == "hidden"
+247
View File
@@ -0,0 +1,247 @@
# Rack Canvas
A rack canvas is the **physical** counterpart to the network diagram: real racks,
the gear mounted in them, and the patch cables between their ports. It is a
canvas kind of its own — you create it like any other canvas, and Homelable swaps
the renderer when you switch to it.
Where the diagram answers *what talks to what*, the rack answers *what is where*:
which U a machine sits in, what plate it wears, which switch port its uplink
lands on.
> Works with **and** without the backend. In the no-backend standalone/demo build
> a rack canvas persists to your browser's local storage; the Device Inventory
> picker and the live status check are backend-only (marked 🔒 below).
---
## Feature Overview
- **Racks** — any U height, 19" or 10" wide, bottom-up or top-down numbering, per-rack frame / rail / interior colours, open or enclosed.
- **Mounted gear** — a device occupies a U range and part of a 12-column width grid, so half- and third-width machines share a U.
- **Faceplates** — a visual catalog of plates (servers, switches, routers, patch panels, UPS, PDUs, desktop NAS towers, shelves, blanks, cable managers) drawn as vector artwork that scales with the rack.
- **Ports & patching** — RJ45 and SFP ports per device, cabled port-to-port by dragging from one to the other, across racks if needed.
- **Device Inventory** 🔒 — a mount points at a real inventory entry, so a rack shows the same devices your scans found, and gear you rack by hand joins that inventory.
- **Live status** 🔒 — a mount can follow the status check already configured on the matching diagram node, and light its plate LED accordingly.
- **Import links** 🔒 — derive patches from the physical links already drawn on your diagrams, as often as you like.
---
## Create a rack canvas
1. Open the **canvas switcher** at the top of the left sidebar.
2. Click **New Canvas**.
3. Under **Kind**, pick **Rack** (the other kind, *Diagram*, is the usual node/link canvas).
4. Name it, pick an icon, and create.
A canvas cannot change kind afterwards — a diagram and a rack store different
things. Deleting a rack canvas deletes its racks, mounts and cables with it;
duplicating a canvas duplicates them too.
---
## Add a rack
**Add Rack** in the header drops a new rack on the canvas. Drag it anywhere; a
canvas can hold as many as you like, and cables can run between them.
**Double-click the rack chrome** (the frame, not a device) to open its settings:
| Setting | What it does |
|---|---|
| Name / Location | Free text — "Baie salon", "Garage", … |
| U height | How many mountable U the rack has (42 by default). |
| Width | 19" or 10" — drives the drawn inner width. |
| Numbering | `bottom-up` or `top-down`. **Labels only** — gear never moves. |
| Show U numbers | Print the U scale on the rails. |
| Enclosed | Draw side panels / a closed chassis. |
| Colours | Frame, rails and interior. Defaults come from your app theme. |
| Delete | Removes the rack and everything mounted in it. |
---
## Mount a device
Click **+ Device** in the left sidebar, or drag an accessory from the sidebar
tray straight onto a rack. The device modal offers three sources:
- **Device Inventory entry** 🔒 — pick something already discovered. The field
opens the real Device Inventory, with its search, source and type filters, and
it opens pre-filtered to **rackable** hardware (no light bulbs, no sockets).
A device already mounted on this canvas is refused.
- **New device** — for hardware no scan can find (a patch panel, a PDU, an
unmanaged switch). It is created as a normal Device Inventory entry tagged
**Rack devices**, so you can search, filter, hide and delete it like any other.
- **Accessory** — rack-only furniture: blanking plate, shelf, cable manager.
These are not devices and never appear in the inventory.
Then set:
| Field | Notes |
|---|---|
| Label | Shown on the plate. |
| Faceplate | Opens the visual catalog — see below. |
| U position / height | `U` is counted **from the bottom rail**, whatever the numbering setting prints. |
| Column / width | Full, half, third, sixth or quarter of the rack width. |
| Status | A fixed colour, or **Check device** 🔒 — see below. |
| Colour | Overrides the plate's default tint. |
| Ports | The list of RJ45 / SFP ports, seeded by the faceplate and editable afterwards. |
**Double-click a plate** to reopen this modal on an existing mount; a single
click only selects it. **Unmount** takes the device out of the rack — it stays in
the Device Inventory.
### Linked device 🔒
Under the port list, a mount that stands for a real device prints what is known
about that box: name, type, hostname, IP, MAC, OS, the status check it runs, the
canvas it is drawn on, when it was last seen, and the services discovery
fingerprinted on it.
It is read-only — edit those facts in the Device Inventory or on the logical
canvas, not in the rack — and an accessory shows nothing at all. A device that is
on no diagram still prints everything discovery found about it; only the
canvas-side lines go missing, and the panel says *Not on a logical canvas.*
**Link to another device…** points the plate at a different Device Inventory
entry — the whole inventory, not just the devices you approved onto a diagram.
Use it when you created the gear from the rack (the entry starts empty, with just
a name) or when the plate ended up on the wrong twin of two look-alike hosts. The
plate takes the entry's IP, MAC, services, status and — unless you renamed it —
its name, **Check device** becomes available if that device is on a diagram, and
the link is saved with the rest of the canvas. A device already mounted elsewhere
in the design is not offered: one entry, one plate. The empty placeholder the
rack created for the plate is removed from the inventory once nothing uses it.
### Placement rules
- A drop **snaps to the nearest free slot**; a placement that cannot work
previews in red.
- Drag a mounted device inside its rack to move it — same snapping.
- **Growing** a device, by hand or by picking a taller plate, relocates it to the
nearest slot that takes the new size. Only a rack with no such slot refuses the
edit, and it tells you so.
---
## Faceplates
The **Faceplate** field opens a catalog, not a dropdown of names: every plate is
drawn with the real renderer, at its real relative width and U height, grouped by
family and searchable.
| Family | Examples |
|---|---|
| Servers | 1U, 1U with drive bays, 2U with bays, 4U storage, half-width SFF, third-width mini PC |
| Network | 8 / 24 / 48-port switches, 1U router |
| Patch | 24-port copper panel, 12-port fibre panel |
| Storage | 2U NAS, desktop NAS towers in 2, 4 and 5 bays |
| Power | 2U UPS, 1U PDU |
| Accessories | Blanking plate, shelf, cable manager |
Picking a plate **reseeds the ports** it comes with; your own port edits are kept
until you change plate again. Plates are drawn as vector artwork in relative
coordinates, so they stay correct at any rack width and U height, and their
colours follow the active app theme.
---
## Ports and patching
Ports are **RJ45** or **SFP/SFP+**, drawn as real jack artwork at a fixed size so
plates of different heights line up. Power outlets are artwork only — they are
never a cable endpoint.
Patch-facing gear (switches, patch panels) shows its ports permanently;
everything else reveals them on hover, on selection, or when cables are shown.
To cable:
1. Click **Patch** in the header.
2. **Drag from one port to another** — a dashed rubber band follows the pointer.
Clicking the two ports in turn works just as well. **Escape** drops a
half-drawn patch.
3. Repeat. One cable per port; copper or fibre follows the port you start from.
4. Click **Exit patching** when done — the canvas returns to the cable visibility
it had before.
To unplug: **click the cable** (it gets an accent halo), then press
**Delete/Backspace** or click **Unplug** in the header. A stray click never
destroys a patch.
A header select controls what you see: cables **on hover**, **always**, or
**hidden**. Cables
pan and zoom with the canvas and may run from one rack to another. A cable you
have selected stays drawn whatever that select says.
### Documenting a cable
**Click a cable** — patching or not — and a panel opens on the right:
| Field | What it does |
|---|---|
| Endpoints | The two devices and ports the run connects. Read-only: move a patch by unplugging it and drawing it again. |
| Type | Ethernet or fibre. A cable still wearing its type's default colour is recoloured to match; a colour you picked yourself is kept. |
| Colour | The sheath colours you actually have on the shelf, plus a free hex field for anything else. |
| Label | A name for the run — "Uplink to core", a patch-panel reference. **Show on canvas** prints it next to the cable. |
| Properties | Anything else worth recording: length, VLAN, speed, category, patch reference. Same editor as a node's properties on a diagram — a label, a value, an optional icon, and an eye that decides whether it shows on the canvas. |
| Unplug cable | Removes the patch. The devices and their ports stay. |
What you tick as visible is drawn on a small plate at the middle of the run, so a
rack photo exports with its cable lengths and VLANs on it. Everything else stays
in the panel, one click away.
### Import links 🔒
**Import links** derives patches from the physical links (ethernet, fibre, vlan,
cluster) already drawn on your diagrams, matching both ends to mounts that point
at those nodes. Run it as often as you like: a pair of devices already cabled
is left alone, so a second run after racking more gear only adds what is
missing.
---
## Status 🔒
A mount's **Status** is either pinned by hand (online / offline / unknown) or set
to **Check device**: the plate then follows the status check already configured
on the matching diagram node (ping, HTTP, SSH, …) and its LED lights accordingly.
The rack runs no checker of its own — it reads the result of the one your diagram
already performs. So:
- **Check device** is only offered when the mount resolves to a diagram node.
- Losing that link drops the mount back to `unknown`, rather than leaving it on a
status nothing answers for.
- The refresh polls every 60 s, and only while at least one mount is on
**Check device**.
---
## Saving
Like the diagram canvas, a rack canvas is **saved explicitly** — **Save Rack** in
the left sidebar, or the header's save button. Nothing is written behind your
back unless you turn on **Autosave** in Settings.
The sidebar footer counts racks, mounts, cables and free U instead of the
diagram's online/offline tally. **PNG export** works here too.
---
## Known limits
- **Front view only** — no rear view, no half-depth pairing.
- No 0U side-mounted PDU.
- No power draw or outlet budgeting.
- No undo/redo on a rack canvas (the diagram canvas keeps its own).
- Rack canvases are not shown by the read-only [Live View](../README.md#live-view-read-only-public-canvas).
- The MCP server exposes diagram nodes and links only, not racks.
---
## See also
- [FEATURES.md](../FEATURES.md) — every feature, one page.
- [Device Inventory](../FEATURES.md#12-device-inventory-) — where racked devices live.
- `frontend/src/rack/README.md` — the developer reference for this canvas.
+6 -6
View File
@@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "3.1.2",
"version": "3.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "3.1.2",
"version": "3.2.0",
"dependencies": {
"@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4",
@@ -21,7 +21,7 @@
"clsx": "^2.1.1",
"dagre": "^0.8.5",
"html-to-image": "^1.11.13",
"js-yaml": "^4.1.1",
"js-yaml": "^4.3.1",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -6884,9 +6884,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"funding": [
{
"type": "github",
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "3.1.2",
"version": "3.2.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -27,7 +27,7 @@
"clsx": "^2.1.1",
"dagre": "^0.8.5",
"html-to-image": "^1.11.13",
"js-yaml": "^4.1.1",
"js-yaml": "^4.3.1",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
+104 -45
View File
@@ -32,7 +32,7 @@ import { TextModal, type TextFormData } from '@/components/modals/TextModal'
import { ThemeModal } from '@/components/modals/ThemeModal'
import { CustomStyleModal } from '@/components/modals/CustomStyleModal'
import { SearchModal } from '@/components/modals/SearchModal'
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
import { DeviceInventoryModal } from '@/components/modals/DeviceInventoryModal'
import { ScanHistoryModal } from '@/components/modals/ScanHistoryModal'
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
import { ConfirmAddToGroupModal } from '@/components/modals/ConfirmAddToGroupModal'
@@ -49,10 +49,13 @@ import { decideCanvasLoad, isNewUserCanvas } from '@/utils/canvasLoadDecision'
import { WalkthroughActionsProvider, type WalkthroughActionApi } from '@/walkthrough/actions'
import { WalkthroughInvite } from '@/walkthrough/WalkthroughInvite'
import { WalkthroughOverlay } from '@/walkthrough/WalkthroughOverlay'
import { DEMO_SCAN_RUNS, DEMO_PENDING_DEVICES } from '@/walkthrough/demoTourData'
import { DEMO_SCAN_RUNS, DEMO_INVENTORY_DEVICES } from '@/walkthrough/demoTourData'
import { useStatusPolling } from '@/hooks/useStatusPolling'
import { bootstrapAuth } from '@/auth/bootstrap'
import type { NodeData, EdgeData, CustomStyleDef, FloorMapConfig, NodeType } from '@/types'
import { RackCanvas } from '@/rack/components/RackCanvas'
import { RackCablePanel } from '@/rack/components/RackCablePanel'
import { useRackStore } from '@/rack/store'
import type { NodeData, EdgeData, CustomStyleDef, DesignType, FloorMapConfig, NodeType } from '@/types'
import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
import type { ZwaveNode, ZwaveEdge } from '@/components/zwave/types'
import type { ProxmoxNode, ProxmoxEdge } from '@/components/proxmox/types'
@@ -66,7 +69,17 @@ export default function App() {
const { isAuthenticated, isInitialized } = useAuthStore()
const authBootstrapStarted = useRef(false)
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
const { activeDesignId, setDesigns, setActiveDesign } = useDesignStore()
const { activeDesignId, activeDesignType, setDesigns, setActiveDesign } = useDesignStore()
const isRackDesign = activeDesignType === 'rack'
const rackDirty = useRackStore((s) => s.hasUnsavedChanges)
const rackEditSeq = useRackStore((s) => s.editSeq)
/** Kind of a design by id, read straight from the store (no stale closure). */
const designTypeOf = useCallback(
(id: string | null | undefined): DesignType =>
useDesignStore.getState().designs.find((d) => d.id === id)?.design_type ?? 'network',
[],
)
useStatusPolling()
@@ -103,14 +116,14 @@ export default function App() {
const [styleEditorType, setStyleEditorType] = useState<NodeType | null>(null)
const [searchOpen, setSearchOpen] = useState(false)
const [scanHistoryOpen, setScanHistoryOpen] = useState(false)
const [pendingModalOpen, setPendingModalOpen] = useState(false)
const [pendingModalStatus, setPendingModalStatus] = useState<'pending' | 'hidden'>('pending')
const [pendingHighlightId, setPendingHighlightId] = useState<string | undefined>(undefined)
const openPendingModal = useCallback((deviceId?: string, status: 'pending' | 'hidden' = 'pending') => {
setPendingHighlightId(undefined)
setPendingModalStatus(status)
setPendingModalOpen(true)
if (deviceId) setTimeout(() => setPendingHighlightId(deviceId), 0)
const [inventoryModalOpen, setInventoryModalOpen] = useState(false)
const [inventoryModalStatus, setInventoryModalStatus] = useState<'pending' | 'hidden'>('pending')
const [inventoryHighlightId, setInventoryHighlightId] = useState<string | undefined>(undefined)
const openInventoryModal = useCallback((deviceId?: string, status: 'pending' | 'hidden' = 'pending') => {
setInventoryHighlightId(undefined)
setInventoryModalStatus(status)
setInventoryModalOpen(true)
if (deviceId) setTimeout(() => setInventoryHighlightId(deviceId), 0)
}, [])
const [shortcutsOpen, setShortcutsOpen] = useState(false)
const [addNodeOpen, setAddNodeOpen] = useState(false)
@@ -134,6 +147,18 @@ export default function App() {
const handleSave = useCallback(async (designIdOverride?: string, options?: { silent?: boolean }): Promise<boolean> => {
try {
const saveDesignId = designIdOverride ?? activeDesignId
// Rack canvases own their own state and persistence path.
if (designTypeOf(saveDesignId) === 'rack') {
// Named explicitly: the design-switch flow saves the *old* design, and
// the store refuses rather than writing under whichever one it holds.
const ok = await useRackStore.getState().save(saveDesignId)
if (ok) {
if (!options?.silent) toast.success('Rack canvas saved')
} else {
toast.error('Save failed')
}
return ok
}
if (STANDALONE) {
if (!saveDesignId) return false
// Floor plans are backend-only (upload/serve), so standalone never persists one.
@@ -154,7 +179,7 @@ export default function App() {
toast.error('Save failed')
return false
}
}, [nodes, edges, markSaved, activeTheme, customStyle, activeDesignId, floorMap])
}, [nodes, edges, markSaved, activeTheme, customStyle, activeDesignId, floorMap, designTypeOf])
// Keep a ref so the keydown handler always calls the latest version
const handleSaveRef = useRef(handleSave)
@@ -165,12 +190,12 @@ export default function App() {
useAutosave({
enabled: autosave.enabled,
delaySeconds: autosave.delay,
hasUnsavedChanges,
hasUnsavedChanges: isRackDesign ? rackDirty : hasUnsavedChanges,
designId: loadedDesignId,
// Debounce resets on each real user edit (editSeq), not on raw nodes/edges
// identity — live status polling churns those arrays without a user edit and
// would otherwise keep re-arming (and starving) the timer during monitoring.
changeSignals: [editSeq],
changeSignals: [isRackDesign ? rackEditSeq : editSeq],
getLiveDesignId: () => loadedDesignIdRef.current,
onSave: (designId) => { void handleSaveRef.current(designId, { silent: true }) },
})
@@ -257,6 +282,24 @@ export default function App() {
setLoadedDesignId(designId)
}, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
/**
* Load whichever canvas the design holds. Rack designs bypass the node/edge
* canvas entirely — different store, different endpoints.
*/
const loadAnyDesign = useCallback(async (designId: string) => {
if (designTypeOf(designId) !== 'rack') {
if (STANDALONE) loadStandaloneCanvas(designId)
else await loadCanvasFromApi(designId)
return
}
// The rack store owns the fetch; App only records provenance afterwards, so
// none of these run synchronously inside the calling effect.
await useRackStore.getState().loadDesign(designId)
setIsNewUser(false)
setLoadError(false)
setLoadedDesignId(designId)
}, [designTypeOf, loadStandaloneCanvas, loadCanvasFromApi])
const loadDesignsAndCanvas = useCallback(async () => {
// Prefer a design id explicitly requested via the URL (?design=<id>), so a
// refresh or shared link opens that design. Ignore it when it doesn't match
@@ -269,7 +312,7 @@ export default function App() {
const targetId = fromUrl ?? activeDesignId ?? designs[0]?.id
if (targetId) {
setActiveDesign(targetId)
loadStandaloneCanvas(targetId)
void loadAnyDesign(targetId)
}
return
}
@@ -281,7 +324,7 @@ export default function App() {
const targetId = fromUrl ?? activeDesignId ?? loadedDesigns[0]?.id
if (targetId) {
setActiveDesign(targetId)
await loadCanvasFromApi(targetId)
await loadAnyDesign(targetId)
}
} catch {
// Backend unreachable/errored — surface it. Do NOT seed the demo: that would
@@ -289,7 +332,7 @@ export default function App() {
setLoadError(true)
toast.error('Could not reach backend — check the server and retry')
}
}, [setDesigns, setActiveDesign, loadCanvasFromApi, loadStandaloneCanvas, activeDesignId])
}, [setDesigns, setActiveDesign, loadAnyDesign, activeDesignId])
// Keep a ref so the auth effect can call the latest loader without listing it
// as a dependency (which would re-fire on every design switch).
@@ -309,7 +352,7 @@ export default function App() {
setScanConfigOpen(false)
setScanHistoryOpen(false)
setTourScanHistoryDemo(false)
setPendingModalOpen(false)
setInventoryModalOpen(false)
setTourInventoryDemo(false)
setEditNodeId(null)
setThemeModalOpen(false)
@@ -328,7 +371,7 @@ export default function App() {
},
openInventoryDemo: () => {
setTourInventoryDemo(true)
openPendingModal(undefined, 'pending')
openInventoryModal(undefined, 'pending')
},
editFirstNode: () => {
const first = useCanvasStore.getState().nodes.find((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
@@ -349,7 +392,7 @@ export default function App() {
},
openStyle: () => setThemeModalOpen(true),
openZigbeeImport: () => setZigbeeImportOpen(true),
}), [openPendingModal])
}), [openInventoryModal])
// Load designs + canvas on auth (or immediately in standalone mode, which has
// no auth gate).
@@ -376,7 +419,7 @@ export default function App() {
}
// Standalone has no auth gate; backed mode requires authentication.
const ready = STANDALONE || isAuthenticated
const loadForDesign = STANDALONE ? loadStandaloneCanvas : loadCanvasFromApi
const loadForDesign = loadAnyDesign
if (ready && activeDesignId && initialLoadDone.current) {
const oldId = prevDesignRef.current
// If the previous design was deleted (no longer in the list), don't try to
@@ -390,7 +433,7 @@ export default function App() {
const targetId = activeDesignId
handleSave(oldId).then((ok) => {
if (ok) {
loadForDesign(targetId)
void loadForDesign(targetId)
} else {
// Save failed: don't load the new design — that would overwrite the
// unsaved in-memory canvas. Revert the selection back to the old
@@ -401,7 +444,12 @@ export default function App() {
}
})
} else {
loadForDesign(activeDesignId)
// Loading a design is exactly the "synchronize with an external system"
// case: it fetches and then publishes the result into React state. The
// rule can't see through the loader, so silence it here rather than
// deferring the fetch by a tick.
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadForDesign(activeDesignId)
}
}
if (activeDesignId) {
@@ -957,7 +1005,7 @@ export default function App() {
onSave={handleSave}
onOpenSettings={() => setSettingsOpen(true)}
onOpenHistory={() => setScanHistoryOpen(true)}
onOpenPending={openPendingModal}
onOpenInventory={openInventoryModal}
/>
<div className="flex flex-col flex-1 min-w-0">
{loadError && (
@@ -990,17 +1038,28 @@ export default function App() {
/>
<div className="flex flex-1 min-h-0">
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
<CanvasContainer
onConnect={handleEdgeConnect}
onEdgeDoubleClick={handleEdgeDoubleClick}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeDragStart={snapshotHistory}
onRequestAddToGroup={setPendingGroupAdd}
onRequestAddToContainer={setPendingContainerAdd}
onOpenPending={(deviceId) => openPendingModal(deviceId)}
/>
{isRackDesign ? (
<RackCanvas />
) : (
<CanvasContainer
onConnect={handleEdgeConnect}
onEdgeDoubleClick={handleEdgeDoubleClick}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeDragStart={snapshotHistory}
onRequestAddToGroup={setPendingGroupAdd}
onRequestAddToContainer={setPendingContainerAdd}
onOpenInventory={(deviceId) => openInventoryModal(deviceId)}
/>
)}
</div>
{(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
{/* Rack designs keep the full width for mounted gear — a mount is
edited in its own modal — but a selected cable has no plate to
double-click, so it gets the rail. */}
{isRackDesign
? <RackCablePanel />
: (selectedNodeId || selectedNodeIds.length > 1) && (
<DetailPanel onEdit={handleEditNode} />
)}
</div>
</div>
</div>
@@ -1078,7 +1137,7 @@ export default function App() {
open={zigbeeImportOpen}
onClose={() => setZigbeeImportOpen(false)}
onAddToCanvas={handleZigbeeAddToCanvas}
onPendingImported={() => {
onInventoryImported={() => {
toast.success('Zigbee import started — check Scan History for results')
}}
/>
@@ -1089,7 +1148,7 @@ export default function App() {
open={zwaveImportOpen}
onClose={() => setZwaveImportOpen(false)}
onAddToCanvas={handleZwaveAddToCanvas}
onPendingImported={() => {
onInventoryImported={() => {
toast.success('Z-Wave import started — check Scan History for results')
}}
/>
@@ -1100,7 +1159,7 @@ export default function App() {
open={proxmoxImportOpen}
onClose={() => setProxmoxImportOpen(false)}
onAddToCanvas={handleProxmoxAddToCanvas}
onPendingImported={() => {
onInventoryImported={() => {
toast.success('Proxmox import started — check Scan History for results')
}}
/>
@@ -1199,7 +1258,7 @@ export default function App() {
<SearchModal
open={searchOpen}
onClose={() => setSearchOpen(false)}
onOpenPending={(deviceId) => openPendingModal(deviceId)}
onOpenInventory={(deviceId) => openInventoryModal(deviceId)}
/>
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
@@ -1230,12 +1289,12 @@ export default function App() {
but canvas prefs (snap, hide-IP) still apply. */}
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
<PendingDevicesModal
open={pendingModalOpen}
onClose={() => setPendingModalOpen(false)}
highlightId={pendingHighlightId}
initialStatus={pendingModalStatus}
demoDevices={tourInventoryDemo ? DEMO_PENDING_DEVICES : undefined}
<DeviceInventoryModal
open={inventoryModalOpen}
onClose={() => setInventoryModalOpen(false)}
highlightId={inventoryHighlightId}
initialStatus={inventoryModalStatus}
demoDevices={tourInventoryDemo ? DEMO_INVENTORY_DEVICES : undefined}
/>
<ExportModal
+54
View File
@@ -61,7 +61,29 @@ export const mediaApi = {
delete: (filename: string) => api.delete(`/media/${filename}`),
}
/**
* A canvas node as `GET /nodes` reports it — the fields a reader needs to tell
* two hosts apart. The canvas itself keeps its own richer React Flow shape; this
* is only for pickers and lookups.
*/
export interface ApiCanvasNode {
id: string
design_id: string | null
label: string
type: string
ip: string | null
mac: string | null
hostname: string | null
os: string | null
status: string
check_method: string | null
last_seen: string | null
}
export const nodesApi = {
/** Every node, across every design. `label` filters case-insensitively. */
list: (label?: string) =>
api.get<ApiCanvasNode[]>('/nodes', { params: label ? { label } : {} }),
create: (data: object) => api.post('/nodes', data),
update: (id: string, data: object) => api.patch(`/nodes/${id}`, data),
delete: (id: string) => api.delete(`/nodes/${id}`),
@@ -106,12 +128,33 @@ export interface DuplicateNodeConflict {
value: string
}
/**
* The Device Inventory rides the scan routes.
*
* `/scan/pending/*` is what the table was called when it was a queue of finds
* awaiting approval; the rows are the Device Inventory now, but the paths are a
* published contract the MCP server also calls, so they stay. The method names
* follow the paths rather than the concept, so a route is easy to find.
*/
export const scanApi = {
trigger: (deepScan?: Partial<DeepScanConfig>) => api.post('/scan/trigger', deepScan ?? {}),
pending: () => api.get('/scan/pending'),
/** Add an inventory entry by hand, for hardware no scan can discover. */
createPending: (data: {
hostname: string
ip?: string | null
mac?: string | null
suggested_type?: string | null
model?: string | null
vendor?: string | null
/** "manual" (default) or "rack" for gear created from a rack canvas. */
discovery_source?: 'manual' | 'rack'
}) => api.post<{ id: string; hostname: string | null }>('/scan/pending', data),
hidden: () => api.get('/scan/hidden'),
runs: () => api.get('/scan/runs'),
clearPending: () => api.delete('/scan/pending'),
/** Remove one inventory entry. 409 when a rack still mounts it. */
deletePending: (id: string) => api.delete<{ deleted: boolean }>(`/scan/pending/${id}`),
approve: (id: string, nodeData: object) =>
api.post<{
approved: boolean
@@ -220,6 +263,17 @@ export const designsApi = {
delete: (id: string) => api.delete(`/designs/${id}`),
}
export const racksApi = {
load: (designId: string) =>
api.get<import('@/utils/rackSerializer').ApiRackState>('/racks', { params: { design_id: designId } }),
save: (payload: import('@/utils/rackSerializer').RackSavePayload) =>
api.post<{ saved: boolean }>('/racks/save', payload),
inventory: (designId: string) =>
api.get<{ items: import('@/utils/rackSerializer').ApiInventoryItem[] }>('/racks/inventory', {
params: { design_id: designId },
}),
}
export interface ZigbeeConfigData {
mqtt_host: string
mqtt_port: number
@@ -34,10 +34,10 @@ interface CanvasContainerProps {
onNodeDragStart?: () => void
onRequestAddToGroup?: (payload: { nodeId: string; groupId: string }) => void
onRequestAddToContainer?: (payload: { nodeId: string; containerId: string }) => void
onOpenPending?: (deviceId: string) => void
onOpenInventory?: (deviceId: string) => void
}
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onRequestAddToGroup, onRequestAddToContainer, onOpenPending }: CanvasContainerProps) {
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onRequestAddToGroup, onRequestAddToContainer, onOpenInventory }: CanvasContainerProps) {
const [lassoMode, setLassoMode] = useState(true)
const {
nodes, edges,
@@ -202,7 +202,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
color={theme.colors.canvasDotColor}
/>
<FloorMapLayer />
<SearchBar onOpenPending={onOpenPending} />
<SearchBar onOpenInventory={onOpenInventory} />
<AlignmentGuides guides={guides} />
<Controls>
<ControlButton
+7 -7
View File
@@ -4,23 +4,23 @@ import { Search, X } from 'lucide-react'
import { useCanvasStore } from '@/stores/canvasStore'
import { scanApi } from '@/api/client'
import { NODE_TYPE_LABELS } from '@/types'
import type { PendingDevice } from '@/components/modals/PendingDeviceModal'
import type { InventoryEntry } from '@/components/modals/InventoryDeviceModal'
interface SearchBarProps {
onOpenPending?: (deviceId: string) => void
onOpenInventory?: (deviceId: string) => void
}
export function SearchBar({ onOpenPending }: SearchBarProps) {
export function SearchBar({ onOpenInventory }: SearchBarProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [pendingDevices, setPendingDevices] = useState<PendingDevice[]>([])
const [inventoryDevices, setInventoryEntrys] = useState<InventoryEntry[]>([])
const inputRef = useRef<HTMLInputElement>(null)
const { nodes, setSelectedNode } = useCanvasStore()
const { setCenter } = useReactFlow()
useEffect(() => {
if (!open) return
scanApi.pending().then((res) => setPendingDevices(res.data)).catch(() => {})
scanApi.pending().then((res) => setInventoryEntrys(res.data)).catch(() => {})
}, [open])
useEffect(() => {
@@ -86,7 +86,7 @@ export function SearchBar({ onOpenPending }: SearchBarProps) {
: []
const pendingResults = q
? pendingDevices.filter((d) =>
? inventoryDevices.filter((d) =>
d.ip?.toLowerCase().includes(q) ||
d.hostname?.toLowerCase().includes(q) ||
d.friendly_name?.toLowerCase().includes(q) ||
@@ -215,7 +215,7 @@ export function SearchBar({ onOpenPending }: SearchBarProps) {
return (
<button
key={d.id}
onClick={() => { onOpenPending?.(d.id); setOpen(false); setQuery('') }}
onClick={() => { onOpenInventory?.(d.id); setOpen(false); setQuery('') }}
style={{
width: '100%',
display: 'flex',
@@ -0,0 +1,329 @@
/**
* Editable list of `NodeProperty` records — key, value, optional icon, and a
* per-property "show it on the canvas" toggle.
*
* Extracted from `DetailPanel` so the rack cable panel gets the same editor
* rather than a second implementation of it. The component owns only its form
* and drag state; the array itself belongs to the caller, which receives every
* mutation as a whole new array through `onChange`.
*/
import { createElement, useState } from 'react'
import { Eye, EyeOff, GripVertical, Pencil, Plus, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
import type { NodeProperty } from '@/types'
export type PropForm = { key: string; value: string; icon: string | null; visible: boolean }
const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true }
interface PropertyListProps {
properties: NodeProperty[]
/** Receives the full next array — the caller decides how to persist it. */
onChange: (next: NodeProperty[]) => void
/** Wording of the per-property visibility toggle ("Show on node" by default). */
visibleLabel?: string
/** Placeholder examples, so a cable does not suggest "CPU Model". */
keyPlaceholder?: string
valuePlaceholder?: string
/** One-click keys pre-filling the add form (e.g. Length, VLAN). */
suggestions?: string[]
/** Section heading. */
title?: string
emptyHint?: string
}
export function PropertyList({
properties,
onChange,
visibleLabel = 'Show on node',
keyPlaceholder = 'Label (e.g. CPU Model)',
valuePlaceholder = 'Value (e.g. i7-12700K)',
suggestions,
title = 'Properties',
emptyHint = 'No properties — click Add to define one.',
}: PropertyListProps) {
const [adding, setAdding] = useState(false)
const [newProp, setNewProp] = useState<PropForm>(EMPTY_PROP)
const [editingIndex, setEditingIndex] = useState<number | null>(null)
const [editProp, setEditProp] = useState<PropForm>(EMPTY_PROP)
const [dragIndex, setDragIndex] = useState<number | null>(null)
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
const handleAdd = () => {
if (!newProp.key.trim() || !newProp.value.trim()) return
onChange([
...properties,
{ key: newProp.key.trim(), value: newProp.value.trim(), icon: newProp.icon, visible: newProp.visible },
])
setNewProp(EMPTY_PROP)
setAdding(false)
}
const handleRemove = (index: number) => {
onChange(properties.filter((_, i) => i !== index))
// `editingIndex` is a position into an array the caller owns, so any
// mutation from outside the form invalidates it: removing an earlier row
// shifted the open form onto its neighbour, and Save then overwrote the
// wrong property. Close the form rather than try to track the move.
setEditingIndex(null)
}
const handleToggleVisible = (index: number) => {
onChange(properties.map((p, i) => (i === index ? { ...p, visible: !p.visible } : p)))
}
const handleStartEdit = (index: number) => {
const p = properties[index]
if (!p) return
setEditProp({ key: p.key, value: p.value, icon: p.icon, visible: p.visible })
setEditingIndex(index)
setAdding(false)
}
const handleSaveEdit = () => {
if (editingIndex === null || !editProp.key.trim() || !editProp.value.trim()) return
onChange(
properties.map((p, i) =>
i === editingIndex
? { key: editProp.key.trim(), value: editProp.value.trim(), icon: editProp.icon, visible: editProp.visible }
: p
)
)
setEditingIndex(null)
}
const handleReorder = (from: number, to: number) => {
if (from === to || from < 0 || to < 0 || from >= properties.length || to >= properties.length) return
const reordered = [...properties]
const [moved] = reordered.splice(from, 1)
reordered.splice(to, 0, moved)
onChange(reordered)
// Same positional hazard as a remove — see handleRemove.
setEditingIndex(null)
}
const startAddWith = (key: string) => {
setEditingIndex(null)
setNewProp({ ...EMPTY_PROP, key })
setAdding(true)
}
return (
<div className="px-4 py-3 border-t border-border">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground">{title}{properties.length > 0 ? ` (${properties.length})` : ''}</span>
<button
onClick={() => { setAdding((v) => !v); setEditingIndex(null) }}
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer"
>
<Plus size={10} /> Add
</button>
</div>
{suggestions && suggestions.length > 0 && !adding && (
<div className="flex flex-wrap gap-1 mb-2">
{suggestions.map((key) => (
<button
key={key}
onClick={() => startAddWith(key)}
title={`Add a ${key} property`}
className="rounded border border-[#30363d] px-1.5 py-0.5 text-[10px] text-muted-foreground hover:border-[#00d4ff] hover:text-[#00d4ff] transition-colors cursor-pointer"
>
+ {key}
</button>
))}
</div>
)}
{adding && (
<PropertyForm
form={newProp}
onChange={setNewProp}
onConfirm={handleAdd}
onCancel={() => { setAdding(false); setNewProp(EMPTY_PROP) }}
confirmLabel="Add"
visibleLabel={visibleLabel}
keyPlaceholder={keyPlaceholder}
valuePlaceholder={valuePlaceholder}
/>
)}
{properties.length > 0 && (
<div className="flex flex-col gap-1.5">
{properties.map((prop, i) =>
editingIndex === i ? (
<PropertyForm
key={`edit-${i}`}
form={editProp}
onChange={setEditProp}
onConfirm={handleSaveEdit}
onCancel={() => setEditingIndex(null)}
confirmLabel="Save"
visibleLabel={visibleLabel}
keyPlaceholder={keyPlaceholder}
valuePlaceholder={valuePlaceholder}
/>
) : (
<PropertyBadge
key={`${prop.key}-${i}`}
prop={prop}
visibleLabel={visibleLabel}
draggable={properties.length > 1}
isDragging={dragIndex === i}
isDragOver={dragOverIndex === i && dragIndex !== i}
onDragStart={() => setDragIndex(i)}
onDragEnter={() => { if (dragIndex !== null) setDragOverIndex(i) }}
onDragEnd={() => { setDragIndex(null); setDragOverIndex(null) }}
onDrop={() => {
if (dragIndex !== null) handleReorder(dragIndex, i)
setDragIndex(null)
setDragOverIndex(null)
}}
onToggleVisible={() => handleToggleVisible(i)}
onEdit={() => handleStartEdit(i)}
onRemove={() => handleRemove(i)}
/>
)
)}
</div>
)}
{properties.length === 0 && !adding && (
<p className="text-[10px] text-muted-foreground/50">{emptyHint}</p>
)}
</div>
)
}
export function PropertyForm({ form, onChange, onConfirm, onCancel, confirmLabel, visibleLabel = 'Show on node', keyPlaceholder = 'Label (e.g. CPU Model)', valuePlaceholder = 'Value (e.g. i7-12700K)' }: {
form: PropForm
onChange: (f: PropForm) => void
onConfirm: () => void
onCancel: () => void
confirmLabel: string
visibleLabel?: string
keyPlaceholder?: string
valuePlaceholder?: string
}) {
return (
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
<Input
value={form.key}
onChange={(e) => onChange({ ...form, key: e.target.value })}
placeholder={keyPlaceholder}
className="bg-[#21262d] border-[#30363d] text-xs h-7"
autoFocus
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
/>
<Input
value={form.value}
onChange={(e) => onChange({ ...form, value: e.target.value })}
placeholder={valuePlaceholder}
className="bg-[#21262d] border-[#30363d] text-xs h-7"
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
/>
{/* Icon picker */}
<div className="flex flex-wrap gap-1 pt-0.5">
<button
onClick={() => onChange({ ...form, icon: null })}
title="No icon"
className={`w-6 h-6 rounded flex items-center justify-center text-[10px] border transition-colors ${
form.icon === null ? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]' : 'border-[#30363d] text-muted-foreground hover:border-[#8b949e]'
}`}
>
–
</button>
{PROPERTY_ICON_NAMES.map((name) => {
const Icon = PROPERTY_ICONS[name]
const active = form.icon === name
return (
<button
key={name}
onClick={() => onChange({ ...form, icon: name })}
title={name}
className={`w-6 h-6 rounded flex items-center justify-center border transition-colors ${
active ? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]' : 'border-[#30363d] text-muted-foreground hover:border-[#8b949e]'
}`}
>
{createElement(Icon, { size: 11 })}
</button>
)
})}
</div>
{/* Visible toggle */}
<label className="flex items-center gap-2 cursor-pointer pt-0.5">
<input
type="checkbox"
checked={form.visible}
onChange={(e) => onChange({ ...form, visible: e.target.checked })}
className="accent-[#00d4ff] w-3 h-3"
/>
<span className="text-[10px] text-muted-foreground">{visibleLabel}</span>
</label>
<div className="flex gap-1.5">
<Button size="sm" className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={onConfirm}>
{confirmLabel}
</Button>
<Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}>Cancel</Button>
</div>
</div>
)
}
export function PropertyBadge({ prop, visibleLabel = 'node', draggable, isDragging, isDragOver, onDragStart, onDragEnter, onDragEnd, onDrop, onToggleVisible, onEdit, onRemove }: {
prop: NodeProperty
/** Used to word the eye tooltip — "Show on node" / "Show on canvas". */
visibleLabel?: string
draggable: boolean
isDragging: boolean
isDragOver: boolean
onDragStart: () => void
onDragEnter: () => void
onDragEnd: () => void
onDrop: () => void
onToggleVisible: () => void
onEdit: () => void
onRemove: () => void
}) {
const Icon = resolvePropertyIcon(prop.icon)
const where = visibleLabel.replace(/^Show on /i, '')
return (
<div
draggable={draggable}
onDragStart={onDragStart}
onDragEnter={onDragEnter}
onDragOver={(e) => e.preventDefault()}
onDragEnd={onDragEnd}
onDrop={(e) => { e.preventDefault(); onDrop() }}
className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors"
style={{
background: '#21262d',
borderColor: isDragOver ? '#00d4ff' : '#30363d',
opacity: isDragging ? 0.4 : 1,
}}
>
<div className="flex items-center gap-1.5 min-w-0">
{draggable && (
<span className="shrink-0 cursor-grab active:cursor-grabbing text-[#8b949e] hover:text-[#00d4ff]" title="Drag to reorder">
{createElement(GripVertical, { size: 11 })}
</span>
)}
{Icon && createElement(Icon, { size: 11, className: 'shrink-0 text-muted-foreground' })}
<span className="font-medium truncate text-foreground" title={prop.key}>{prop.key}</span>
<span className="text-muted-foreground truncate" title={prop.value}>· {prop.value}</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={onToggleVisible}
title={prop.visible ? `Hide on ${where}` : `Show on ${where}`}
className="text-[#8b949e] hover:text-[#00d4ff] transition-colors"
>
{prop.visible ? <Eye size={10} /> : <EyeOff size={10} />}
</button>
<button onClick={onEdit} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff]" title="Edit property">
<Pencil size={10} />
</button>
<button onClick={onRemove} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149]" title="Remove property">
<X size={10} />
</button>
</div>
</div>
)
}
@@ -0,0 +1,123 @@
/**
* Shared property editor — used by the node DetailPanel and the rack cable
* panel, so a positional bug here lands on both canvases.
*/
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { PropertyList } from '../PropertyList'
import type { NodeProperty } from '@/types'
const props = (): NodeProperty[] => [
{ key: 'A', value: '1', icon: null, visible: true },
{ key: 'B', value: '2', icon: null, visible: true },
{ key: 'C', value: '3', icon: null, visible: true },
]
/** The form's confirm button; the section header carries an "Add" too. */
const confirm = (name: string) => {
const buttons = screen.getAllByRole('button', { name })
return buttons[buttons.length - 1]
}
describe('PropertyList', () => {
it('renders each property with its key and value', () => {
render(<PropertyList properties={props()} onChange={vi.fn()} />)
expect(screen.getByTitle('A')).toBeInTheDocument()
expect(screen.getByTitle('3')).toBeInTheDocument()
})
it('adds a property', () => {
const onChange = vi.fn()
render(<PropertyList properties={[]} onChange={onChange} />)
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
fireEvent.change(screen.getByPlaceholderText(/^Label/), { target: { value: 'GPU' } })
fireEvent.change(screen.getByPlaceholderText(/^Value/), { target: { value: 'RTX' } })
fireEvent.click(confirm('Add'))
expect(onChange).toHaveBeenCalledWith([
{ key: 'GPU', value: 'RTX', icon: null, visible: true },
])
})
it('refuses a property with no key or no value', () => {
const onChange = vi.fn()
render(<PropertyList properties={[]} onChange={onChange} />)
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
fireEvent.change(screen.getByPlaceholderText(/^Label/), { target: { value: 'GPU' } })
fireEvent.click(confirm('Add'))
expect(onChange).not.toHaveBeenCalled()
})
it('toggles a property between shown and hidden', () => {
const onChange = vi.fn()
render(<PropertyList properties={props()} onChange={onChange} />)
fireEvent.click(screen.getAllByTitle('Hide on node')[1])
expect(onChange.mock.calls[0][0][1].visible).toBe(false)
})
it('closes the open edit form when another property is removed', () => {
// The form index is a position into an array the caller owns. Removing an
// earlier row shifted the open form onto its neighbour, and Save then
// overwrote the wrong property.
const onChange = vi.fn()
const { rerender } = render(<PropertyList properties={props()} onChange={onChange} />)
fireEvent.click(screen.getAllByTitle('Edit property')[1]) // editing B
expect(screen.getByDisplayValue('B')).toBeInTheDocument()
fireEvent.click(screen.getAllByTitle('Remove property')[0]) // remove A
const next = onChange.mock.calls[0][0]
rerender(<PropertyList properties={next} onChange={onChange} />)
// No form at all — index 1 now points at C, so a surviving form would sit
// on the wrong property and Save would overwrite it.
expect(screen.queryByRole('button', { name: 'Save' })).toBeNull()
expect(next.map((p: NodeProperty) => p.key)).toEqual(['B', 'C'])
})
it('closes the open edit form when the list is reordered', () => {
const onChange = vi.fn()
const { rerender } = render(<PropertyList properties={props()} onChange={onChange} />)
fireEvent.click(screen.getAllByTitle('Edit property')[2]) // editing C
expect(screen.getByDisplayValue('C')).toBeInTheDocument()
const rows = screen.getAllByTitle('Drag to reorder')
fireEvent.dragStart(rows[0])
fireEvent.dragEnter(rows[1])
fireEvent.drop(rows[1])
rerender(<PropertyList properties={onChange.mock.calls[0][0]} onChange={onChange} />)
expect(screen.queryByRole('button', { name: 'Save' })).toBeNull()
})
it('saves an edit onto the property it was opened on', () => {
const onChange = vi.fn()
render(<PropertyList properties={props()} onChange={onChange} />)
fireEvent.click(screen.getAllByTitle('Edit property')[1])
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '22' } })
fireEvent.click(confirm('Save'))
expect(onChange.mock.calls[0][0]).toEqual([
{ key: 'A', value: '1', icon: null, visible: true },
{ key: 'B', value: '22', icon: null, visible: true },
{ key: 'C', value: '3', icon: null, visible: true },
])
})
it('words the visibility toggle for the surface it edits', () => {
render(<PropertyList properties={props()} onChange={vi.fn()} visibleLabel="Show on canvas" />)
expect(screen.getAllByTitle('Hide on canvas')).toHaveLength(3)
})
it('pre-fills the add form from a suggestion', () => {
render(<PropertyList properties={[]} onChange={vi.fn()} suggestions={['Length']} />)
fireEvent.click(screen.getByText('+ Length'))
expect(screen.getByDisplayValue('Length')).toBeInTheDocument()
})
})
+45 -1
View File
@@ -4,11 +4,22 @@ import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { DESIGN_ICONS, DEFAULT_DESIGN_ICON, resolveDesignIcon } from '@/utils/designIcons'
import type { Design, FloorMapConfig } from '@/types'
import type { Design, DesignType, FloorMapConfig } from '@/types'
/** Canvas kinds offered on create. `electrical` shares the network renderer. */
const CANVAS_KINDS: { value: DesignType; label: string; hint: string }[] = [
{ value: 'network', label: 'Diagram', hint: 'Nodes and links on a free canvas' },
{ value: 'rack', label: 'Rack', hint: 'Racks, mounted gear and patching' },
]
export interface DesignFormData {
name: string
icon: string
/**
* Kind of canvas to create. Only offered in create mode — a canvas cannot
* change kind afterwards, since network and rack store different things.
*/
designType?: DesignType
/**
* Floor plan for THIS canvas. Only present when the floor-plan section is
* shown (edit mode on the active canvas). `null` means "remove the floor
@@ -60,9 +71,12 @@ export function DesignModal({
}: DesignModalProps) {
const [name, setName] = useState(initial?.name ?? '')
const [icon, setIcon] = useState(initial?.icon ?? DEFAULT_DESIGN_ICON)
const [designType, setDesignType] = useState<DesignType>(initial?.designType ?? 'network')
// "Copy from existing" is create-mode only (no floor-plan section shown).
const canCopy = !showFloorMap && sourceDesigns.length > 0
// Editing never changes the kind; `initial` is only passed in edit mode.
const canPickKind = !initial && !showFloorMap
const [fromExisting, setFromExisting] = useState(false)
const [sourceId, setSourceId] = useState<string>(sourceDesigns[0]?.id ?? '')
@@ -103,6 +117,7 @@ export function DesignModal({
if (!trimmed) return
if (canCopy && fromExisting && !sourceId) return
const data: DesignFormData = { name: trimmed, icon }
if (canPickKind) data.designType = designType
if (canCopy && fromExisting && sourceId) {
data.sourceId = sourceId
}
@@ -146,6 +161,35 @@ export function DesignModal({
/>
</div>
{/* A copy inherits the source's kind, so the picker would be a lie. */}
{canPickKind && !fromExisting && (
<div className="space-y-1.5">
<Label>Kind</Label>
<div className="grid grid-cols-2 gap-1.5">
{CANVAS_KINDS.map((kind) => {
const selected = kind.value === designType
return (
<button
key={kind.value}
type="button"
aria-pressed={selected}
title={kind.hint}
onClick={() => setDesignType(kind.value)}
className={`rounded-md border px-2 py-2 text-left transition-colors cursor-pointer ${
selected
? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]'
: 'border-border text-muted-foreground hover:text-foreground hover:border-[#30363d]'
}`}
>
<span className="block text-xs font-medium">{kind.label}</span>
<span className="block text-[10px] leading-tight opacity-70">{kind.hint}</span>
</button>
)
})}
</div>
</div>
)}
<div className="space-y-1.5">
<Label>Icon</Label>
<div className="grid grid-cols-8 gap-1.5">
@@ -10,7 +10,7 @@ import { useDesignStore } from '@/stores/designStore'
import { useThemeStore } from '@/stores/themeStore'
import { resolveNodeColors } from '@/utils/nodeColors'
import { toast } from 'sonner'
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
import { InventoryDeviceModal, type InventoryEntry } from '@/components/modals/InventoryDeviceModal'
import type { NodeType, ServiceInfo } from '@/types'
import { applyAutoEdges, type AutoEdge } from '@/utils/autoEdges'
import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties'
@@ -18,15 +18,24 @@ import { buildZwaveProperties, isZwaveType } from '@/utils/zwaveProperties'
import { buildMacProperty } from '@/utils/macProperty'
import { formatRelative, formatTimestamp } from '@/utils/timeFormat'
import { getCenteredPosition } from '@/utils/viewportCenter'
import { sourceBuckets, orderedSources, SOURCE_META, type SourceBucket } from '@/utils/pendingSources'
import { sourceBuckets, orderedSources, isRackDevice, SOURCE_META, type SourceBucket } from '@/utils/deviceSources'
import { isRackable } from '@/utils/rackable'
interface PendingDevicesModalProps {
interface DeviceInventoryModalProps {
open: boolean
onClose: () => void
highlightId?: string
initialStatus?: 'pending' | 'hidden'
/** Getting Started tour: render these canned devices and skip the backend fetch. */
demoDevices?: PendingDevice[]
demoDevices?: InventoryEntry[]
/**
* Picker mode. Clicking a card hands the device back instead of opening its
* detail modal, so another feature (the rack canvas) can reuse this list —
* search, filters, badges and all — rather than reimplementing a `<select>`.
*/
onPick?: (device: InventoryEntry) => void
/** Start with the Rackable filter on (what the rack picker wants). */
initialRackableOnly?: boolean
}
const PORT_COLORS: Record<number, string> = {
@@ -82,7 +91,7 @@ type StatusFilter = 'pending' | 'hidden'
const COMMON_PORTS = new Set([22, 80, 443])
function specialServiceName(d: PendingDevice): string | undefined {
function specialServiceName(d: InventoryEntry): string | undefined {
const candidates = (d.services ?? []).filter(
(s) => s.category != null && s.port != null && !COMMON_PORTS.has(s.port) && s.service_name,
)
@@ -91,7 +100,7 @@ function specialServiceName(d: PendingDevice): string | undefined {
return (nonWeb ?? candidates[0])?.service_name ?? undefined
}
function deviceLabel(d: PendingDevice): string {
function deviceLabel(d: InventoryEntry): string {
return d.friendly_name ?? d.hostname ?? specialServiceName(d) ?? d.ip ?? d.ieee_address ?? 'device'
}
@@ -115,10 +124,10 @@ function injectAutoEdges(edges: AutoEdge[] | undefined) {
}))
}
export function PendingDevicesModal({ open, onClose, highlightId, initialStatus = 'pending', demoDevices }: PendingDevicesModalProps) {
const [devices, setDevices] = useState<PendingDevice[]>([])
export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus = 'pending', demoDevices, onPick, initialRackableOnly = false }: DeviceInventoryModalProps) {
const [devices, setDevices] = useState<InventoryEntry[]>([])
const [loading, setLoading] = useState(false)
const [selected, setSelected] = useState<PendingDevice | null>(null)
const [selected, setSelected] = useState<InventoryEntry | null>(null)
const [selectMode, setSelectMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
@@ -129,13 +138,16 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
const [showOnCanvas, setShowOnCanvas] = useState(true)
// Optionally restrict to devices that have at least one detected service.
const [withServicesOnly, setWithServicesOnly] = useState(false)
// Hardware only — what a rack can hold. On by default when the rack canvas
// opens the inventory to pick a mount.
const [rackableOnly, setRackableOnly] = useState(initialRackableOnly)
const { addNode, scanEventTs } = useCanvasStore()
const setSelectedNode = useCanvasStore((s) => s.setSelectedNode)
const activeDesignId = useDesignStore((s) => s.activeDesignId)
const highlightRef = useRef<HTMLButtonElement>(null)
// Set when a single approve is refused because the same host is already on
// this design — the user decides: link to the existing node or duplicate.
const [dupPrompt, setDupPrompt] = useState<{ device: PendingDevice; conflict: DuplicateNodeConflict } | null>(null)
const [dupPrompt, setDupPrompt] = useState<{ device: InventoryEntry; conflict: DuplicateNodeConflict } | null>(null)
const load = useCallback(async () => {
// Tour/demo mode: show injected devices, never touch the backend.
@@ -162,8 +174,11 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
setSearch('')
} else {
setStatusFilter(initialStatus)
// Reopening from the rack picker must re-arm the filter even if the user
// turned it off during the previous visit.
setRackableOnly(initialRackableOnly)
}
}, [open, initialStatus])
}, [open, initialStatus, initialRackableOnly])
const distinctTypes = useMemo(() => {
const set = new Set<string>()
@@ -179,6 +194,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
// Inventory-only: optionally hide devices already placed on a canvas.
if (statusFilter === 'pending' && !showOnCanvas && (d.canvas_count ?? 0) > 0) return false
if (withServicesOnly && (d.services?.length ?? 0) === 0) return false
if (rackableOnly && !isRackable(d)) return false
if (q) {
const hay = [
d.friendly_name, d.hostname, d.ip, d.mac, d.ieee_address, d.vendor, d.model,
@@ -188,7 +204,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
}
return true
})
}, [devices, search, sourceFilter, typeFilter, statusFilter, showOnCanvas, withServicesOnly])
}, [devices, search, sourceFilter, typeFilter, statusFilter, showOnCanvas, withServicesOnly, rackableOnly])
useEffect(() => {
if (!highlightId || loading || !open) return
@@ -203,13 +219,16 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
})
}
const handleCardClick = (d: PendingDevice) => {
const handleCardClick = (d: InventoryEntry) => {
// Picker mode wins over everything else: the caller opened this list to get
// one device back, not to approve or hide anything.
if (onPick) { onPick(d); return }
if (selectMode) { toggleSelect(d.id); return }
if (statusFilter === 'hidden') { handleRestore(d); return }
setSelected(d)
}
const handleRestore = async (device: PendingDevice) => {
const handleRestore = async (device: InventoryEntry) => {
try {
await scanApi.restore(device.id)
setDevices((prev) => prev.filter((d) => d.id !== device.id))
@@ -275,7 +294,13 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
// force=true is sent only after the user confirms they want a duplicate node
// on this design (the backend otherwise 409s to let us ask).
const approveDevice = async (device: PendingDevice, force = false) => {
const approveDevice = async (device: InventoryEntry, force = false) => {
// Rack gear documents a mount, not a host: it stays out of logical canvases.
// The backend refuses it too — this is the friendly half of the guard.
if (isRackDevice(device)) {
toast.error('Rack devices belong to a rack canvas, not a logical one')
return
}
const fallbackLabel = deviceLabel(device)
const type = (device.suggested_type ?? 'generic') as NodeType
const zwave = isZwaveType(type)
@@ -329,7 +354,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
}
}
const handleApprove = (device: PendingDevice) => approveDevice(device, false)
const handleApprove = (device: InventoryEntry) => approveDevice(device, false)
const goToExistingNode = (nodeId: string) => {
setSelectedNode(nodeId)
@@ -338,7 +363,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
onClose()
}
const handleHide = async (device: PendingDevice) => {
const handleHide = async (device: InventoryEntry) => {
try {
await scanApi.hide(device.id)
setDevices((prev) => prev.filter((d) => d.id !== device.id))
@@ -349,7 +374,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
}
}
const handleIgnore = async (device: PendingDevice) => {
const handleIgnore = async (device: InventoryEntry) => {
try {
await scanApi.ignore(device.id)
setDevices((prev) => prev.filter((d) => d.id !== device.id))
@@ -360,7 +385,14 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
}
const handleBulkApprove = async () => {
const ids = [...selectedIds]
const rackIds = new Set(devices.filter(isRackDevice).map((d) => d.id))
const ids = [...selectedIds].filter((id) => !rackIds.has(id))
const skippedRack = selectedIds.size - ids.length
if (skippedRack > 0) {
toast.error(
`${skippedRack} rack device${skippedRack > 1 ? 's' : ''} skipped — they belong to a rack canvas`,
)
}
if (ids.length === 0) return
try {
const res = await scanApi.bulkApprove(ids, activeDesignId)
@@ -446,6 +478,9 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
}
if (inField) return
if (e.key === '/') { e.preventDefault(); searchRef.current?.focus() }
// Bulk actions are meaningless in picker mode — the card click returns a
// device, so select mode must stay unreachable from the keyboard too.
else if (onPick) return
else if (e.key.toLowerCase() === 's') { e.preventDefault(); if (selectMode) exitSelectMode(); else enterSelectMode() }
else if (e.key.toLowerCase() === 'a' && selectMode) { e.preventDefault(); selectAllVisible() }
else if (e.key === 'Enter' && selectMode && selectedIds.size > 0) {
@@ -461,7 +496,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
// statusFilter is included so Enter dispatches the correct bulk action
// (approve vs restore) even if the device list doesn't change on switch.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, selectMode, selectedIds, filtered, statusFilter])
}, [open, selectMode, selectedIds, filtered, statusFilter, onPick])
return (
<>
@@ -473,7 +508,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
<DialogHeader className="px-4 py-3 border-b border-border shrink-0">
<div className="flex items-center justify-between gap-3">
<DialogTitle className="text-base font-semibold flex items-center gap-2">
{statusFilter === 'pending' ? 'Device Inventory' : 'Hidden Devices'}
{onPick ? 'Pick a Device' : statusFilter === 'pending' ? 'Device Inventory' : 'Hidden Devices'}
<span className="text-muted-foreground font-normal text-xs">
({filtered.length}{filtered.length !== devices.length && ` of ${devices.length}`})
</span>
@@ -482,7 +517,8 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
<button onClick={load} className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors" title="Refresh">
<RefreshCw size={14} />
</button>
{statusFilter === 'pending' && devices.length > 0 && (
{/* Never offer a destructive bulk clear from a picker. */}
{!onPick && statusFilter === 'pending' && devices.length > 0 && (
<button
onClick={handleClearAll}
className="text-muted-foreground hover:text-[#f85149] p-1.5 rounded transition-colors"
@@ -551,6 +587,13 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
>
Proxmox
</button>
<button
onClick={() => setSourceFilter('rack')}
className={`px-2.5 py-1.5 transition-colors border-l border-border ${sourceFilter === 'rack' ? 'bg-[#39d353]/20 text-[#39d353]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
title="Gear created from a rack canvas"
>
Rack devices
</button>
</div>
<select
value={typeFilter}
@@ -595,6 +638,16 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
<ServerCog size={12} />
With services
</button>
<button
onClick={() => setRackableOnly((v) => !v)}
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded border transition-colors ${rackableOnly ? 'bg-[#39d353]/20 text-[#39d353] border-[#39d353]/50' : 'bg-[#0d1117] text-muted-foreground border-border hover:text-foreground'}`}
title="Only show hardware you could mount in a rack (no VMs, containers or mesh devices)"
aria-pressed={rackableOnly}
>
<Server size={12} />
Rackable
</button>
{!onPick && (
<button
onClick={() => selectMode ? exitSelectMode() : enterSelectMode()}
className={`text-xs px-2.5 py-1.5 rounded border transition-colors ${selectMode ? 'bg-[#00d4ff]/20 text-[#00d4ff] border-[#00d4ff]/50' : 'bg-[#0d1117] text-muted-foreground border-border hover:text-foreground'}`}
@@ -602,6 +655,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
>
{selectMode ? 'Exit select' : 'Select mode'}
</button>
)}
</div>
{/* Body */}
@@ -738,7 +792,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
</DialogContent>
</Dialog>
<PendingDeviceModal
<InventoryDeviceModal
device={selected}
onClose={() => setSelected(null)}
onApprove={handleApprove}
@@ -750,7 +804,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
}
interface DeviceCardProps {
device: PendingDevice
device: InventoryEntry
selected: boolean
selectMode: boolean
highlighted: boolean
@@ -1,6 +1,7 @@
import { Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { isRackDevice } from '@/utils/deviceSources'
import type { NodeProperty } from '@/types'
interface Service {
@@ -11,7 +12,7 @@ interface Service {
category?: string | null
}
export interface PendingDevice {
export interface InventoryEntry {
id: string
ip: string | null
mac: string | null
@@ -44,12 +45,12 @@ export interface PendingDevice {
node_last_seen?: string | null
}
interface PendingDeviceModalProps {
device: PendingDevice | null
interface InventoryDeviceModalProps {
device: InventoryEntry | null
onClose: () => void
onApprove: (device: PendingDevice) => void
onHide: (device: PendingDevice) => void
onIgnore: (device: PendingDevice) => void
onApprove: (device: InventoryEntry) => void
onHide: (device: InventoryEntry) => void
onIgnore: (device: InventoryEntry) => void
}
const TYPE_ICONS: Record<string, React.ElementType> = {
@@ -94,11 +95,12 @@ function InfoRow({ label, value }: { label: string; value: string }) {
)
}
export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnore }: PendingDeviceModalProps) {
export function InventoryDeviceModal({ device, onClose, onApprove, onHide, onIgnore }: InventoryDeviceModalProps) {
if (!device) return null
const TypeIcon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
const isZigbee = device.discovery_source === 'zigbee'
const rackOnly = isRackDevice(device)
const titleLabel = device.friendly_name ?? device.hostname ?? device.ip ?? device.ieee_address ?? 'Pending device'
const handleApprove = () => { onApprove(device) }
@@ -178,13 +180,17 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
{/* Actions */}
<div className="flex gap-2 pt-1">
<Button
size="sm"
className="flex-1 bg-[#39d353]/15 text-[#39d353] hover:bg-[#39d353]/25 border border-[#39d353]/30"
onClick={handleApprove}
>
Approve
</Button>
{/* Rack gear is mounted from a rack canvas, never approved onto a
logical one — so it gets no Approve button at all. */}
{!rackOnly && (
<Button
size="sm"
className="flex-1 bg-[#39d353]/15 text-[#39d353] hover:bg-[#39d353]/25 border border-[#39d353]/30"
onClick={handleApprove}
>
Approve
</Button>
)}
<Button
size="sm"
variant="ghost"
+11 -11
View File
@@ -3,24 +3,24 @@ import { useReactFlow } from '@xyflow/react'
import { Search } from 'lucide-react'
import { useCanvasStore } from '@/stores/canvasStore'
import { scanApi } from '@/api/client'
import type { PendingDevice } from '@/components/modals/PendingDeviceModal'
import type { InventoryEntry } from '@/components/modals/InventoryDeviceModal'
interface SearchModalProps {
open: boolean
onClose: () => void
onOpenPending: (deviceId: string) => void
onOpenInventory: (deviceId: string) => void
}
export function SearchModal({ open, onClose, onOpenPending }: SearchModalProps) {
export function SearchModal({ open, onClose, onOpenInventory }: SearchModalProps) {
const [query, setQuery] = useState('')
const [pendingDevices, setPendingDevices] = useState<PendingDevice[]>([])
const [inventoryDevices, setInventoryEntrys] = useState<InventoryEntry[]>([])
const nodes = useCanvasStore((s) => s.nodes)
const setSelectedNode = useCanvasStore((s) => s.setSelectedNode)
const { fitView } = useReactFlow()
useEffect(() => {
if (!open) return
scanApi.pending().then((res) => setPendingDevices(res.data)).catch(() => {})
scanApi.pending().then((res) => setInventoryEntrys(res.data)).catch(() => {})
}, [open])
const searchable = nodes.filter((n) => n.data.type !== 'groupRect')
@@ -32,7 +32,7 @@ export function SearchModal({ open, onClose, onOpenPending }: SearchModalProps)
n.data.hostname?.toLowerCase().includes(q)
).slice(0, 6)
const pendingResults = q.length === 0 ? [] : pendingDevices.filter((d) =>
const pendingResults = q.length === 0 ? [] : inventoryDevices.filter((d) =>
d.ip?.toLowerCase().includes(q) ||
d.hostname?.toLowerCase().includes(q) ||
d.friendly_name?.toLowerCase().includes(q) ||
@@ -52,11 +52,11 @@ export function SearchModal({ open, onClose, onOpenPending }: SearchModalProps)
setQuery('')
}, [fitView, setSelectedNode, onClose])
const handleSelectPending = useCallback((deviceId: string) => {
onOpenPending(deviceId)
const handleSelectInventoryDevice = useCallback((deviceId: string) => {
onOpenInventory(deviceId)
onClose()
setQuery('')
}, [onOpenPending, onClose])
}, [onOpenInventory, onClose])
if (!open) return null
@@ -77,7 +77,7 @@ export function SearchModal({ open, onClose, onOpenPending }: SearchModalProps)
onKeyDown={(e) => {
if (e.key === 'Escape') { onClose(); setQuery('') }
if (e.key === 'Enter' && nodeResults.length > 0) handleSelectNode(nodeResults[0].id)
if (e.key === 'Enter' && nodeResults.length === 0 && pendingResults.length > 0) handleSelectPending(pendingResults[0].id)
if (e.key === 'Enter' && nodeResults.length === 0 && pendingResults.length > 0) handleSelectInventoryDevice(pendingResults[0].id)
}}
/>
<kbd className="text-[10px] text-muted-foreground border border-border rounded px-1">ESC</kbd>
@@ -109,7 +109,7 @@ export function SearchModal({ open, onClose, onOpenPending }: SearchModalProps)
<li
key={device.id}
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
onClick={() => handleSelectPending(device.id)}
onClick={() => handleSelectInventoryDevice(device.id)}
>
<span className="text-xs font-mono text-[#e3b341] w-16 shrink-0">pending</span>
<span className="text-sm text-foreground font-medium flex-1 truncate font-mono">{device.hostname ?? device.ip}</span>
@@ -15,7 +15,7 @@ describe('DesignModal', () => {
const { onSubmit } = renderModal()
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Home Network' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(onSubmit).toHaveBeenCalledWith({ name: 'Home Network', icon: DEFAULT_DESIGN_ICON })
expect(onSubmit).toHaveBeenCalledWith({ name: 'Home Network', icon: DEFAULT_DESIGN_ICON, designType: 'network' })
})
it('submits the selected icon', () => {
@@ -23,7 +23,41 @@ describe('DesignModal', () => {
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Rack Power' } })
fireEvent.click(screen.getByRole('button', { name: 'Electrical' })) // zap icon's aria-label
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(onSubmit).toHaveBeenCalledWith({ name: 'Rack Power', icon: 'zap' })
expect(onSubmit).toHaveBeenCalledWith({ name: 'Rack Power', icon: 'zap', designType: 'network' })
})
describe('canvas kind', () => {
it('defaults to a diagram canvas', () => {
const { onSubmit } = renderModal()
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Net' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(onSubmit.mock.calls[0][0].designType).toBe('network')
})
it('submits the rack kind when picked', () => {
const { onSubmit } = renderModal()
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Rack Room' } })
fireEvent.click(screen.getByRole('button', { name: /^Rack/ }))
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(onSubmit.mock.calls[0][0].designType).toBe('rack')
})
it('is not offered in edit mode — a canvas cannot change kind', () => {
renderModal({ initial: { name: 'Existing', icon: 'server' }, submitLabel: 'Save' })
expect(screen.queryByRole('button', { name: /^Rack/ })).toBeNull()
})
it('is hidden once copying — a copy inherits the source kind', () => {
renderModal({
sourceDesigns: [
{ id: 's1', name: 'Home Net', icon: 'network', design_type: 'network' as const,
created_at: '', updated_at: '', node_count: 1, group_count: 0, text_count: 0 },
],
})
expect(screen.getByRole('button', { name: /^Rack/ })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Copy from existing' }))
expect(screen.queryByRole('button', { name: /^Rack/ })).toBeNull()
})
})
it('trims whitespace and blocks empty names', () => {
@@ -34,7 +68,7 @@ describe('DesignModal', () => {
fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' Spaced ' } })
fireEvent.click(submit)
expect(onSubmit).toHaveBeenCalledWith({ name: 'Spaced', icon: DEFAULT_DESIGN_ICON })
expect(onSubmit).toHaveBeenCalledWith({ name: 'Spaced', icon: DEFAULT_DESIGN_ICON, designType: 'network' })
})
it('prefills name and icon in edit mode', () => {
@@ -55,7 +89,7 @@ describe('DesignModal', () => {
const input = screen.getByLabelText('Name')
fireEvent.change(input, { target: { value: 'Quick' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onSubmit).toHaveBeenCalledWith({ name: 'Quick', icon: DEFAULT_DESIGN_ICON })
expect(onSubmit).toHaveBeenCalledWith({ name: 'Quick', icon: DEFAULT_DESIGN_ICON, designType: 'network' })
})
it('calls onClose from Cancel', () => {
@@ -93,7 +127,7 @@ describe('DesignModal', () => {
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Copy of Home' } })
fireEvent.click(screen.getByRole('button', { name: 'Copy from existing' }))
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(onSubmit).toHaveBeenCalledWith({ name: 'Copy of Home', icon: DEFAULT_DESIGN_ICON, sourceId: 's1' })
expect(onSubmit).toHaveBeenCalledWith({ name: 'Copy of Home', icon: DEFAULT_DESIGN_ICON, designType: 'network', sourceId: 's1' })
})
it('includes the picked sourceId on submit', () => {
@@ -102,14 +136,14 @@ describe('DesignModal', () => {
fireEvent.click(screen.getByRole('button', { name: 'Copy from existing' }))
fireEvent.click(screen.getByRole('radio', { name: /Lab/ }))
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(onSubmit).toHaveBeenCalledWith({ name: 'Copy of Lab', icon: DEFAULT_DESIGN_ICON, sourceId: 's2' })
expect(onSubmit).toHaveBeenCalledWith({ name: 'Copy of Lab', icon: DEFAULT_DESIGN_ICON, designType: 'network', sourceId: 's2' })
})
it('omits sourceId when the blank option is kept', () => {
const { onSubmit } = renderModal({ sourceDesigns })
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Fresh' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(onSubmit).toHaveBeenCalledWith({ name: 'Fresh', icon: DEFAULT_DESIGN_ICON })
expect(onSubmit).toHaveBeenCalledWith({ name: 'Fresh', icon: DEFAULT_DESIGN_ICON, designType: 'network' })
expect('sourceId' in onSubmit.mock.calls[0][0]).toBe(false)
})
@@ -131,7 +165,7 @@ describe('DesignModal', () => {
expect(screen.queryByText('Floor Plan')).toBeNull()
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'X' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(onSubmit).toHaveBeenCalledWith({ name: 'X', icon: DEFAULT_DESIGN_ICON })
expect(onSubmit).toHaveBeenCalledWith({ name: 'X', icon: DEFAULT_DESIGN_ICON, designType: 'network' })
expect('floorMap' in onSubmit.mock.calls[0][0]).toBe(false)
})
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'
import { PendingDevicesModal } from '../PendingDevicesModal'
import { DeviceInventoryModal } from '../DeviceInventoryModal'
import { useCanvasStore } from '@/stores/canvasStore'
vi.mock('@/stores/canvasStore')
@@ -33,8 +33,8 @@ vi.mock('@/api/client', () => ({
vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
vi.mock('@/components/modals/PendingDeviceModal', () => ({
PendingDeviceModal: ({ device, onApprove }: { device: unknown; onApprove: (d: unknown) => void }) =>
vi.mock('@/components/modals/InventoryDeviceModal', () => ({
InventoryDeviceModal: ({ device, onApprove }: { device: unknown; onApprove: (d: unknown) => void }) =>
device ? (
<div data-testid="approval-modal">
<button data-testid="do-approve" onClick={() => onApprove(device)}>approve</button>
@@ -107,6 +107,33 @@ const DEVICE_PROXMOX = {
discovered_at: '2026-01-04T00:00:00Z',
}
const DEVICE_RACK = {
id: 'dev-rack',
ip: null,
hostname: 'patch-house',
mac: null,
os: null,
services: [],
suggested_type: null,
status: 'pending',
discovery_source: 'rack',
discovery_sources: ['rack'],
discovered_at: '2026-01-05T00:00:00Z',
}
const DEVICE_VM = {
id: 'dev-vm',
ip: '192.168.1.55',
hostname: 'web-vm',
mac: null,
os: null,
services: [],
suggested_type: 'vm',
status: 'pending',
discovery_source: 'proxmox',
discovered_at: '2026-01-06T00:00:00Z',
}
beforeEach(() => {
vi.clearAllMocks()
// Apply the selector when one is passed (setSelectedNode is read via a
@@ -134,29 +161,29 @@ const baseProps = {
onClose: vi.fn(),
}
describe('PendingDevicesModal', () => {
describe('DeviceInventoryModal', () => {
it('loads and renders pending devices on open', async () => {
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.getByText('living-room-bulb')).toBeInTheDocument()
})
it('closes via the X button (routes through DialogClose, not a raw onClick)', async () => {
const onClose = vi.fn()
render(<PendingDevicesModal open onClose={onClose} />)
render(<DeviceInventoryModal open onClose={onClose} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('shows source chip ZIGBEE for zigbee device', async () => {
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.getByText('ZIGBEE')).toBeInTheDocument()
})
it('filters by search query', async () => {
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.change(screen.getByPlaceholderText(/Search/), { target: { value: 'living' } })
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
@@ -164,7 +191,7 @@ describe('PendingDevicesModal', () => {
})
it('filters by source (zigbee only)', async () => {
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Zigbee' }))
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
@@ -173,14 +200,14 @@ describe('PendingDevicesModal', () => {
it('shows source chip Z-WAVE for zwave device', async () => {
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZWAVE] })
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-c')).toBeInTheDocument())
expect(screen.getByText('Z-WAVE')).toBeInTheDocument()
})
it('colours the role badge with the node-type accent, not flat grey', async () => {
mockPending.mockResolvedValue({ data: [DEVICE_ZWAVE] })
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
const card = await waitFor(() => screen.getByTestId('pending-card-dev-c'))
// zwave_router accent from the default theme = #e3b341 (amber), applied to
// both the text colour and a translucent background.
@@ -192,7 +219,7 @@ describe('PendingDevicesModal', () => {
it('filters by source (zwave only)', async () => {
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZWAVE] })
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Z-Wave' }))
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
@@ -201,7 +228,7 @@ describe('PendingDevicesModal', () => {
it('shows source chip PROXMOX for a proxmox device (not zigbee despite ieee)', async () => {
mockPending.mockResolvedValue({ data: [DEVICE_PROXMOX] })
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-d')).toBeInTheDocument())
expect(screen.getByText('PROXMOX')).toBeInTheDocument()
expect(screen.queryByText('ZIGBEE')).not.toBeInTheDocument()
@@ -209,7 +236,7 @@ describe('PendingDevicesModal', () => {
it('filters by source (proxmox only)', async () => {
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_PROXMOX] })
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Proxmox' }))
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
@@ -217,7 +244,7 @@ describe('PendingDevicesModal', () => {
})
it('filters by suggested type', async () => {
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.change(screen.getByLabelText('Type filter'), { target: { value: 'server' } })
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
@@ -228,7 +255,7 @@ describe('PendingDevicesModal', () => {
mockHidden.mockResolvedValue({
data: [{ ...DEVICE_IP, id: 'h1', hostname: 'hidden-host', status: 'hidden' }],
})
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Hidden' }))
await waitFor(() => expect(screen.getByTestId('pending-card-h1')).toBeInTheDocument())
@@ -236,7 +263,7 @@ describe('PendingDevicesModal', () => {
})
it('opens approval modal when card is clicked outside select mode', async () => {
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
expect(screen.getByTestId('approval-modal')).toBeInTheDocument()
@@ -259,7 +286,7 @@ describe('PendingDevicesModal', () => {
it('single approve prompts instead of failing when the host is already on the design', async () => {
mockApprove.mockRejectedValueOnce(DUP_409)
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
fireEvent.click(screen.getByTestId('do-approve'))
@@ -273,7 +300,7 @@ describe('PendingDevicesModal', () => {
it('"Add duplicate anyway" retries the approve with force=true', async () => {
mockApprove.mockRejectedValueOnce(DUP_409)
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
fireEvent.click(screen.getByTestId('do-approve'))
@@ -286,7 +313,7 @@ describe('PendingDevicesModal', () => {
it('"Go to existing node" selects the existing node and closes the modal', async () => {
mockApprove.mockRejectedValueOnce(DUP_409)
const onClose = vi.fn()
render(<PendingDevicesModal {...baseProps} onClose={onClose} />)
render(<DeviceInventoryModal {...baseProps} onClose={onClose} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
fireEvent.click(screen.getByTestId('do-approve'))
@@ -297,7 +324,7 @@ describe('PendingDevicesModal', () => {
})
it('toggles selection in select mode instead of opening approval', async () => {
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
@@ -306,7 +333,7 @@ describe('PendingDevicesModal', () => {
})
it('select all visible selects only filtered devices', async () => {
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.change(screen.getByPlaceholderText(/Search/), { target: { value: 'host-a' } })
@@ -315,7 +342,7 @@ describe('PendingDevicesModal', () => {
})
it('bulk approve calls API with selected ids', async () => {
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
@@ -333,7 +360,7 @@ describe('PendingDevicesModal', () => {
skipped_devices: [{ device_id: 'dev-a', label: 'host-a', match: 'ip', value: '192.168.1.10', existing_node_id: 'n-existing' }],
},
})
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
@@ -355,7 +382,7 @@ describe('PendingDevicesModal', () => {
{ ...DEVICE_IP, canvas_count: 1 },
{ ...DEVICE_ZIGBEE, canvas_count: 1 },
] })
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
@@ -369,7 +396,7 @@ describe('PendingDevicesModal', () => {
})
it('bulk approve carries the scanned MAC onto the canvas node (#168)', async () => {
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
@@ -397,7 +424,7 @@ describe('PendingDevicesModal', () => {
})
it('bulk hide calls API with selected ids', async () => {
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
@@ -406,20 +433,20 @@ describe('PendingDevicesModal', () => {
})
it('does not load when closed', () => {
render(<PendingDevicesModal {...baseProps} open={false} />)
render(<DeviceInventoryModal {...baseProps} open={false} />)
expect(mockPending).not.toHaveBeenCalled()
})
it('respects initialStatus=hidden', async () => {
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, hostname: 'hidden-host', status: 'hidden' }] })
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
render(<DeviceInventoryModal {...baseProps} initialStatus="hidden" />)
await waitFor(() => expect(mockHidden).toHaveBeenCalled())
expect(mockPending).not.toHaveBeenCalled()
})
it('clicking a hidden card restores it instead of opening approval', async () => {
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] })
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
render(<DeviceInventoryModal {...baseProps} initialStatus="hidden" />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
await waitFor(() => expect(mockRestore).toHaveBeenCalledWith('dev-a'))
@@ -428,7 +455,7 @@ describe('PendingDevicesModal', () => {
it('bulk restore in hidden mode calls API with selected ids', async () => {
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] })
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
render(<DeviceInventoryModal {...baseProps} initialStatus="hidden" />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
@@ -437,7 +464,7 @@ describe('PendingDevicesModal', () => {
})
it('Enter confirms approve in pending select mode', async () => {
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
@@ -448,7 +475,7 @@ describe('PendingDevicesModal', () => {
it('Enter restores (not approves) in hidden select mode', async () => {
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] })
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
render(<DeviceInventoryModal {...baseProps} initialStatus="hidden" />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
@@ -460,21 +487,21 @@ describe('PendingDevicesModal', () => {
// --- Device Inventory: rename, canvas badge, on-canvas filter ---
it('titles the pending view "Device Inventory"', async () => {
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.getByText('Device Inventory')).toBeInTheDocument()
})
it('shows "Hidden Devices" title in hidden mode', async () => {
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] })
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
render(<DeviceInventoryModal {...baseProps} initialStatus="hidden" />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.getByText('Hidden Devices')).toBeInTheDocument()
})
it('renders a corner canvas-count when canvas_count > 0', async () => {
mockPending.mockResolvedValue({ data: [{ ...DEVICE_IP, canvas_count: 2 }] })
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
const corner = screen.getByLabelText('On 2 canvases')
expect(corner).toHaveTextContent('2')
@@ -482,21 +509,21 @@ describe('PendingDevicesModal', () => {
it('uses singular "canvas" for a single canvas', async () => {
mockPending.mockResolvedValue({ data: [{ ...DEVICE_IP, canvas_count: 1 }] })
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.getByLabelText('On 1 canvas')).toHaveTextContent('1')
})
it('does not render the canvas-count corner when canvas_count is 0', async () => {
mockPending.mockResolvedValue({ data: [{ ...DEVICE_IP, canvas_count: 0 }] })
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.queryByLabelText(/On \d+ canvas/)).not.toBeInTheDocument()
})
it('filters to devices with detected services when "With services" is on', async () => {
// dev-a has an http service; dev-b (zigbee) has none.
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: /With services/ }))
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
@@ -507,7 +534,7 @@ describe('PendingDevicesModal', () => {
mockPending.mockResolvedValue({
data: [DEVICE_IP, { ...DEVICE_ZIGBEE, canvas_count: 1 }],
})
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
// Default: both visible (on-canvas shown).
await waitFor(() => expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument())
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
@@ -529,7 +556,7 @@ describe('PendingDevicesModal', () => {
node_last_seen: '2026-06-25T09:15:00Z',
}],
})
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
const card = await screen.findByTestId('pending-card-dev-a')
const inCard = within(card)
expect(inCard.getByText('Created')).toBeInTheDocument()
@@ -542,11 +569,127 @@ describe('PendingDevicesModal', () => {
it('falls back to Discovered for devices not on any canvas', async () => {
mockPending.mockResolvedValue({ data: [DEVICE_IP] })
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
const card = await screen.findByTestId('pending-card-dev-a')
const inCard = within(card)
expect(inCard.getByText('Discovered')).toBeInTheDocument()
expect(inCard.queryByText('Created')).not.toBeInTheDocument()
})
})
// Gear created from a rack canvas lives in the same inventory (so it gets the
// search / filter / hide / delete tooling) but describes a mount, not a host.
describe('rack devices', () => {
beforeEach(() => {
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_RACK] })
})
it('filters them into their own section', async () => {
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Rack devices' }))
expect(screen.getByTestId('pending-card-dev-rack')).toBeInTheDocument()
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
})
it('refuses to approve one onto a logical canvas', async () => {
const { toast } = await import('sonner')
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-rack')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('pending-card-dev-rack'))
fireEvent.click(screen.getByTestId('do-approve'))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('rack canvas')))
expect(mockApprove).not.toHaveBeenCalled()
})
it('drops them from a bulk approve and says how many', async () => {
const { toast } = await import('sonner')
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
fireEvent.click(screen.getByTestId('pending-card-dev-rack'))
fireEvent.click(screen.getByRole('button', { name: /Approve \(2\)/ }))
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a'], null))
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('1 rack device'))
})
it('keeps rack gear in the rackable list — it is hardware', async () => {
render(<DeviceInventoryModal {...baseProps} initialRackableOnly />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-rack')).toBeInTheDocument())
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
})
it('approves nothing when the whole selection is rack gear', async () => {
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-rack')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.click(screen.getByTestId('pending-card-dev-rack'))
fireEvent.click(screen.getByRole('button', { name: /Approve \(1\)/ }))
await waitFor(() => expect(mockBulkApprove).not.toHaveBeenCalled())
})
})
// The rack canvas opens this modal to pick a mount, so it needs a hardware
// filter and a way to hand one device back instead of approving it.
describe('rackable filter and picker mode', () => {
beforeEach(() => {
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_VM] })
})
it('drops virtual and mesh kinds when the filter is on', async () => {
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-vm')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Rackable' }))
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
expect(screen.queryByTestId('pending-card-dev-vm')).not.toBeInTheDocument()
})
it('starts filtered when the caller asks for it', async () => {
render(<DeviceInventoryModal {...baseProps} initialRackableOnly />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.queryByTestId('pending-card-dev-vm')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Rackable' })).toHaveAttribute(
'aria-pressed',
'true',
)
})
it('re-arms the filter when reopened after the user turned it off', async () => {
const { rerender } = render(<DeviceInventoryModal {...baseProps} initialRackableOnly />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Rackable' }))
expect(screen.getByTestId('pending-card-dev-vm')).toBeInTheDocument()
rerender(<DeviceInventoryModal {...baseProps} open={false} initialRackableOnly />)
rerender(<DeviceInventoryModal {...baseProps} initialRackableOnly />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.queryByTestId('pending-card-dev-vm')).not.toBeInTheDocument()
})
it('hands the card back instead of opening the approval modal', async () => {
const onPick = vi.fn()
render(<DeviceInventoryModal {...baseProps} onPick={onPick} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
expect(onPick).toHaveBeenCalledWith(expect.objectContaining({ id: 'dev-a' }))
expect(screen.queryByTestId('approval-modal')).not.toBeInTheDocument()
})
it('hides the bulk and clear controls a picker has no business offering', async () => {
render(<DeviceInventoryModal {...baseProps} onPick={vi.fn()} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.queryByRole('button', { name: 'Select mode' })).not.toBeInTheDocument()
expect(screen.queryByTitle(/Clear all pending/)).not.toBeInTheDocument()
// …and the keyboard shortcut cannot bring select mode back either.
fireEvent.keyDown(window, { key: 's' })
expect(screen.queryByRole('button', { name: /Approve \(/ })).not.toBeInTheDocument()
})
})
})
@@ -1,8 +1,8 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { PendingDeviceModal, type PendingDevice } from '../PendingDeviceModal'
import { InventoryDeviceModal, type InventoryEntry } from '../InventoryDeviceModal'
function makeDevice(overrides: Partial<PendingDevice> = {}): PendingDevice {
function makeDevice(overrides: Partial<InventoryEntry> = {}): InventoryEntry {
return {
id: 'dev-1',
ip: '192.168.1.100',
@@ -17,19 +17,19 @@ function makeDevice(overrides: Partial<PendingDevice> = {}): PendingDevice {
}
}
describe('PendingDeviceModal', () => {
describe('InventoryDeviceModal', () => {
// ── Visibility ────────────────────────────────────────────────────────────
it('renders nothing when device is null', () => {
const { container } = render(
<PendingDeviceModal device={null} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
<InventoryDeviceModal device={null} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(container.querySelector('[role="dialog"]')).toBeNull()
})
it('renders dialog when device is provided', () => {
render(
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
<InventoryDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByRole('dialog')).toBeDefined()
})
@@ -38,7 +38,7 @@ describe('PendingDeviceModal', () => {
it('shows hostname as title when available', () => {
render(
<PendingDeviceModal device={makeDevice({ hostname: 'myserver.local' })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
<InventoryDeviceModal device={makeDevice({ hostname: 'myserver.local' })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
// hostname appears in both title and info row — check at least one match
expect(screen.getAllByText('myserver.local').length).toBeGreaterThan(0)
@@ -46,7 +46,7 @@ describe('PendingDeviceModal', () => {
it('falls back to IP as title when hostname is null', () => {
render(
<PendingDeviceModal device={makeDevice({ hostname: null })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
<InventoryDeviceModal device={makeDevice({ hostname: null })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
// IP appears in both title and info row when hostname is absent
expect(screen.getAllByText('192.168.1.100').length).toBeGreaterThan(0)
@@ -54,28 +54,28 @@ describe('PendingDeviceModal', () => {
it('shows IP address', () => {
render(
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
<InventoryDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('192.168.1.100')).toBeDefined()
})
it('shows MAC address when present', () => {
render(
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
<InventoryDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('aa:bb:cc:dd:ee:ff')).toBeDefined()
})
it('shows OS when present', () => {
render(
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
<InventoryDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('Linux')).toBeDefined()
})
it('does not show hostname row when hostname is null', () => {
render(
<PendingDeviceModal device={makeDevice({ hostname: null })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
<InventoryDeviceModal device={makeDevice({ hostname: null })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
// "Hostname" label should not appear in the info rows
expect(screen.queryByText('Hostname')).toBeNull()
@@ -83,7 +83,7 @@ describe('PendingDeviceModal', () => {
it('shows suggested type when present', () => {
render(
<PendingDeviceModal device={makeDevice({ suggested_type: 'proxmox' })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
<InventoryDeviceModal device={makeDevice({ suggested_type: 'proxmox' })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('proxmox')).toBeDefined()
})
@@ -92,7 +92,7 @@ describe('PendingDeviceModal', () => {
it('shows "No services detected" when services list is empty', () => {
render(
<PendingDeviceModal device={makeDevice({ services: [] })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
<InventoryDeviceModal device={makeDevice({ services: [] })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('No services detected')).toBeDefined()
})
@@ -105,7 +105,7 @@ describe('PendingDeviceModal', () => {
],
})
render(
<PendingDeviceModal device={device} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
<InventoryDeviceModal device={device} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('Services found (2)')).toBeDefined()
expect(screen.getByText('HTTP')).toBeDefined()
@@ -119,7 +119,7 @@ describe('PendingDeviceModal', () => {
services: [{ port: 8006, protocol: 'tcp', service_name: 'Proxmox Web', category: 'hypervisor' }],
})
render(
<PendingDeviceModal device={device} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
<InventoryDeviceModal device={device} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('hypervisor')).toBeDefined()
})
@@ -131,7 +131,7 @@ describe('PendingDeviceModal', () => {
const onApprove = vi.fn()
const onClose = vi.fn()
render(
<PendingDeviceModal device={device} onClose={onClose} onApprove={onApprove} onHide={vi.fn()} onIgnore={vi.fn()} />
<InventoryDeviceModal device={device} onClose={onClose} onApprove={onApprove} onHide={vi.fn()} onIgnore={vi.fn()} />
)
fireEvent.click(screen.getByRole('button', { name: 'Approve' }))
expect(onApprove).toHaveBeenCalledWith(device)
@@ -143,7 +143,7 @@ describe('PendingDeviceModal', () => {
const onHide = vi.fn()
const onClose = vi.fn()
render(
<PendingDeviceModal device={device} onClose={onClose} onApprove={vi.fn()} onHide={onHide} onIgnore={vi.fn()} />
<InventoryDeviceModal device={device} onClose={onClose} onApprove={vi.fn()} onHide={onHide} onIgnore={vi.fn()} />
)
fireEvent.click(screen.getByRole('button', { name: 'Hide' }))
expect(onHide).toHaveBeenCalledWith(device)
@@ -155,10 +155,37 @@ describe('PendingDeviceModal', () => {
const onIgnore = vi.fn()
const onClose = vi.fn()
render(
<PendingDeviceModal device={device} onClose={onClose} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={onIgnore} />
<InventoryDeviceModal device={device} onClose={onClose} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={onIgnore} />
)
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
expect(onIgnore).toHaveBeenCalledWith(device)
expect(onClose).toHaveBeenCalledOnce()
})
})
describe('InventoryDeviceModal — rack gear', () => {
const rackDevice = makeDevice({
id: 'dev-rack',
hostname: 'patch-house',
ip: null,
discovery_source: 'rack',
discovery_sources: ['rack'],
})
it('offers no Approve button — rack gear never lands on a logical canvas', () => {
render(
<InventoryDeviceModal device={rackDevice} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.queryByRole('button', { name: 'Approve' })).toBeNull()
// Hide and Delete still apply: the entry is inventory like any other.
expect(screen.getByRole('button', { name: 'Hide' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument()
})
it('keeps Approve for a scanned device', () => {
render(
<InventoryDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByRole('button', { name: 'Approve' })).toBeInTheDocument()
})
})
@@ -14,7 +14,7 @@ vi.mock('@/api/client', () => ({
scanApi: { pending: vi.fn().mockResolvedValue({ data: [] }) },
}))
const mockOnOpenPending = vi.fn()
const mockOnOpenInventory = vi.fn()
function makeNode(id: string, overrides: Partial<NodeData> = {}): Node<NodeData> {
return {
@@ -38,23 +38,23 @@ describe('SearchModal', () => {
})
it('renders nothing when closed', () => {
render(<SearchModal open={false} onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
render(<SearchModal open={false} onClose={vi.fn()} onOpenInventory={mockOnOpenInventory} />)
expect(screen.queryByPlaceholderText(/search nodes/i)).toBeNull()
})
it('renders search input when open', () => {
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
render(<SearchModal open onClose={vi.fn()} onOpenInventory={mockOnOpenInventory} />)
expect(screen.getByPlaceholderText(/search nodes/i)).toBeDefined()
})
it('shows "Type to search" hint when query is empty', () => {
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
render(<SearchModal open onClose={vi.fn()} onOpenInventory={mockOnOpenInventory} />)
expect(screen.getByText(/type to search/i)).toBeDefined()
})
it('shows no results message when query has no matches', () => {
useCanvasStore.setState({ nodes: [makeNode('router', { label: 'Router' })] })
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
render(<SearchModal open onClose={vi.fn()} onOpenInventory={mockOnOpenInventory} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zzz' } })
expect(screen.getByText(/no results match/i)).toBeDefined()
})
@@ -63,7 +63,7 @@ describe('SearchModal', () => {
useCanvasStore.setState({
nodes: [makeNode('n1', { label: 'My Router' }), makeNode('n2', { label: 'NAS Server' })],
})
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
render(<SearchModal open onClose={vi.fn()} onOpenInventory={mockOnOpenInventory} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'router' } })
expect(screen.getByText('My Router')).toBeDefined()
expect(screen.queryByText('NAS Server')).toBeNull()
@@ -76,7 +76,7 @@ describe('SearchModal', () => {
makeNode('n2', { label: 'Box B', ip: '10.0.0.1' }),
],
})
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
render(<SearchModal open onClose={vi.fn()} onOpenInventory={mockOnOpenInventory} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: '192.168' } })
expect(screen.getByText('Box A')).toBeDefined()
expect(screen.queryByText('Box B')).toBeNull()
@@ -89,7 +89,7 @@ describe('SearchModal', () => {
makeNode('n2', { label: 'B', hostname: 'nas.local' }),
],
})
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
render(<SearchModal open onClose={vi.fn()} onOpenInventory={mockOnOpenInventory} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'pve' } })
expect(screen.getByText('A')).toBeDefined()
expect(screen.queryByText('B')).toBeNull()
@@ -102,7 +102,7 @@ describe('SearchModal', () => {
makeNode('g1', { label: 'Zone A', type: 'groupRect' }),
],
})
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
render(<SearchModal open onClose={vi.fn()} onOpenInventory={mockOnOpenInventory} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zone' } })
expect(screen.getByText(/no results match/i)).toBeDefined()
})
@@ -111,7 +111,7 @@ describe('SearchModal', () => {
useCanvasStore.setState({
nodes: Array.from({ length: 12 }, (_, i) => makeNode(`n${i}`, { label: `Server ${i}` })),
})
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
render(<SearchModal open onClose={vi.fn()} onOpenInventory={mockOnOpenInventory} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'server' } })
const items = screen.getAllByText(/Server \d/)
expect(items).toHaveLength(6)
@@ -120,7 +120,7 @@ describe('SearchModal', () => {
it('selects node and closes on result click', () => {
const onClose = vi.fn()
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Proxmox' })] })
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
render(<SearchModal open onClose={onClose} onOpenInventory={mockOnOpenInventory} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'prox' } })
fireEvent.click(screen.getByText('Proxmox'))
expect(useCanvasStore.getState().selectedNodeId).toBe('n1')
@@ -131,7 +131,7 @@ describe('SearchModal', () => {
it('selects first result and closes on Enter key', () => {
const onClose = vi.fn()
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Switch' })] })
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
render(<SearchModal open onClose={onClose} onOpenInventory={mockOnOpenInventory} />)
const input = screen.getByPlaceholderText(/search nodes/i)
fireEvent.change(input, { target: { value: 'switch' } })
fireEvent.keyDown(input, { key: 'Enter' })
@@ -141,14 +141,14 @@ describe('SearchModal', () => {
it('closes on Escape key', () => {
const onClose = vi.fn()
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
render(<SearchModal open onClose={onClose} onOpenInventory={mockOnOpenInventory} />)
fireEvent.keyDown(screen.getByPlaceholderText(/search nodes/i), { key: 'Escape' })
expect(onClose).toHaveBeenCalledOnce()
})
it('closes when clicking backdrop', () => {
const onClose = vi.fn()
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
render(<SearchModal open onClose={onClose} onOpenInventory={mockOnOpenInventory} />)
// The backdrop is the fixed inset div — clicking it fires onClose
const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement
fireEvent.click(backdrop)
@@ -157,14 +157,14 @@ describe('SearchModal', () => {
it('does not close when clicking inside the search box', () => {
const onClose = vi.fn()
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
render(<SearchModal open onClose={onClose} onOpenInventory={mockOnOpenInventory} />)
fireEvent.click(screen.getByPlaceholderText(/search nodes/i))
expect(onClose).not.toHaveBeenCalled()
})
it('search is case-insensitive', () => {
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'My NAS' })] })
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
render(<SearchModal open onClose={vi.fn()} onOpenInventory={mockOnOpenInventory} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'MY NAS' } })
expect(screen.getByText('My NAS')).toBeDefined()
})
+4 -250
View File
@@ -7,7 +7,7 @@ import { useCanvasStore, serviceStatusKey } from '@/stores/canvasStore'
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type ServiceStatus, type NodeData, type NodeProperty } from '@/types'
import { getServiceUrl } from '@/utils/serviceUrl'
import { splitIps } from '@/utils/maskIp'
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
import { PropertyList } from '@/components/common/PropertyList'
import { formatTimestamp } from '@/utils/timeFormat'
import { ServiceModal } from '@/components/modals/ServiceModal'
import { serviceToForm, type ServiceFormData, type ServiceSubmitData } from '@/utils/serviceForm'
@@ -21,9 +21,6 @@ interface DetailPanelProps {
/** Open service editor: `index === null` means "add a new service". */
type SvcModalState = { nodeId: string; index: number | null; form?: ServiceFormData }
type PropForm = { key: string; value: string; icon: string | null; visible: boolean }
const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true }
export function DetailPanel({ onEdit }: DetailPanelProps) {
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup, removeFromGroup, setNodeSize } = useCanvasStore()
const serviceStatuses = useCanvasStore((s) => s.serviceStatuses)
@@ -32,13 +29,6 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
const [groupName, setGroupName] = useState('')
const [creatingGroup, setCreatingGroup] = useState(false)
// Properties state
const [addingProp, setAddingProp] = useState(false)
const [newProp, setNewProp] = useState<PropForm>(EMPTY_PROP)
const [editingPropIndex, setEditingPropIndex] = useState<number | null>(null)
const [editProp, setEditProp] = useState<PropForm>(EMPTY_PROP)
const [dragPropIndex, setDragPropIndex] = useState<number | null>(null)
const [dragOverPropIndex, setDragOverPropIndex] = useState<number | null>(null)
const [dragSvcIndex, setDragSvcIndex] = useState<number | null>(null)
const [dragOverSvcIndex, setDragOverSvcIndex] = useState<number | null>(null)
@@ -157,56 +147,9 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
// --- Property handlers ---
const properties: NodeProperty[] = data.properties ?? []
const handleAddProp = () => {
if (!newProp.key.trim() || !newProp.value.trim()) return
const handleChangeProps = (next: NodeProperty[]) => {
snapshotHistory()
const prop: NodeProperty = { key: newProp.key.trim(), value: newProp.value.trim(), icon: newProp.icon, visible: newProp.visible }
updateNode(node.id, { properties: [...properties, prop] })
setNewProp(EMPTY_PROP)
setAddingProp(false)
}
const handleRemoveProp = (index: number) => {
snapshotHistory()
updateNode(node.id, { properties: properties.filter((_, i) => i !== index) })
if (editingPropIndex === index) setEditingPropIndex(null)
}
const handleTogglePropVisible = (index: number) => {
snapshotHistory()
updateNode(node.id, {
properties: properties.map((p, i) => i === index ? { ...p, visible: !p.visible } : p),
})
}
const handleStartEditProp = (index: number) => {
const p = properties[index]
if (!p) return
setEditProp({ key: p.key, value: p.value, icon: p.icon, visible: p.visible })
setEditingPropIndex(index)
setAddingProp(false)
}
const handleSaveEditProp = () => {
if (editingPropIndex === null || !editProp.key.trim() || !editProp.value.trim()) return
snapshotHistory()
updateNode(node.id, {
properties: properties.map((p, i) =>
i === editingPropIndex
? { key: editProp.key.trim(), value: editProp.value.trim(), icon: editProp.icon, visible: editProp.visible }
: p
),
})
setEditingPropIndex(null)
}
const handleReorderProp = (from: number, to: number) => {
if (from === to || from < 0 || to < 0 || from >= properties.length || to >= properties.length) return
snapshotHistory()
const reordered = [...properties]
const [moved] = reordered.splice(from, 1)
reordered.splice(to, 0, moved)
updateNode(node.id, { properties: reordered })
updateNode(node.id, { properties: next })
}
return (
@@ -274,64 +217,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
/>
{/* Properties section */}
<div className="px-4 py-3 border-t border-border">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground">Properties{properties.length > 0 ? ` (${properties.length})` : ''}</span>
<button
onClick={() => { setAddingProp((v) => !v); setEditingPropIndex(null) }}
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer"
>
<Plus size={10} /> Add
</button>
</div>
{addingProp && (
<PropertyForm
form={newProp}
onChange={setNewProp}
onConfirm={handleAddProp}
onCancel={() => { setAddingProp(false); setNewProp(EMPTY_PROP) }}
confirmLabel="Add"
/>
)}
{properties.length > 0 && (
<div className="flex flex-col gap-1.5">
{properties.map((prop, i) =>
editingPropIndex === i ? (
<PropertyForm
key={`edit-${i}`}
form={editProp}
onChange={setEditProp}
onConfirm={handleSaveEditProp}
onCancel={() => setEditingPropIndex(null)}
confirmLabel="Save"
/>
) : (
<PropertyBadge
key={`${prop.key}-${i}`}
prop={prop}
draggable={properties.length > 1}
isDragging={dragPropIndex === i}
isDragOver={dragOverPropIndex === i && dragPropIndex !== i}
onDragStart={() => setDragPropIndex(i)}
onDragEnter={() => { if (dragPropIndex !== null) setDragOverPropIndex(i) }}
onDragEnd={() => { setDragPropIndex(null); setDragOverPropIndex(null) }}
onDrop={() => {
if (dragPropIndex !== null) handleReorderProp(dragPropIndex, i)
setDragPropIndex(null)
setDragOverPropIndex(null)
}}
onToggleVisible={() => handleTogglePropVisible(i)}
onEdit={() => handleStartEditProp(i)}
onRemove={() => handleRemoveProp(i)}
/>
)
)}
</div>
)}
{properties.length === 0 && !addingProp && (
<p className="text-[10px] text-muted-foreground/50">No properties — click Add to define one.</p>
)}
</div>
<PropertyList properties={properties} onChange={handleChangeProps} />
<div className="px-4 py-3 border-t border-border">
<div className="flex items-center justify-between mb-2">
@@ -689,138 +575,6 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono?
)
}
// --- Property components ---
function PropertyForm({ form, onChange, onConfirm, onCancel, confirmLabel }: {
form: PropForm
onChange: (f: PropForm) => void
onConfirm: () => void
onCancel: () => void
confirmLabel: string
}) {
return (
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
<Input
value={form.key}
onChange={(e) => onChange({ ...form, key: e.target.value })}
placeholder="Label (e.g. CPU Model)"
className="bg-[#21262d] border-[#30363d] text-xs h-7"
autoFocus
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
/>
<Input
value={form.value}
onChange={(e) => onChange({ ...form, value: e.target.value })}
placeholder="Value (e.g. i7-12700K)"
className="bg-[#21262d] border-[#30363d] text-xs h-7"
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
/>
{/* Icon picker */}
<div className="flex flex-wrap gap-1 pt-0.5">
<button
onClick={() => onChange({ ...form, icon: null })}
title="No icon"
className={`w-6 h-6 rounded flex items-center justify-center text-[10px] border transition-colors ${
form.icon === null ? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]' : 'border-[#30363d] text-muted-foreground hover:border-[#8b949e]'
}`}
>
–
</button>
{PROPERTY_ICON_NAMES.map((name) => {
const Icon = PROPERTY_ICONS[name]
const active = form.icon === name
return (
<button
key={name}
onClick={() => onChange({ ...form, icon: name })}
title={name}
className={`w-6 h-6 rounded flex items-center justify-center border transition-colors ${
active ? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]' : 'border-[#30363d] text-muted-foreground hover:border-[#8b949e]'
}`}
>
{createElement(Icon, { size: 11 })}
</button>
)
})}
</div>
{/* Visible toggle */}
<label className="flex items-center gap-2 cursor-pointer pt-0.5">
<input
type="checkbox"
checked={form.visible}
onChange={(e) => onChange({ ...form, visible: e.target.checked })}
className="accent-[#00d4ff] w-3 h-3"
/>
<span className="text-[10px] text-muted-foreground">Show on node</span>
</label>
<div className="flex gap-1.5">
<Button size="sm" className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={onConfirm}>
{confirmLabel}
</Button>
<Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}>Cancel</Button>
</div>
</div>
)
}
function PropertyBadge({ prop, draggable, isDragging, isDragOver, onDragStart, onDragEnter, onDragEnd, onDrop, onToggleVisible, onEdit, onRemove }: {
prop: NodeProperty
draggable: boolean
isDragging: boolean
isDragOver: boolean
onDragStart: () => void
onDragEnter: () => void
onDragEnd: () => void
onDrop: () => void
onToggleVisible: () => void
onEdit: () => void
onRemove: () => void
}) {
const Icon = resolvePropertyIcon(prop.icon)
return (
<div
draggable={draggable}
onDragStart={onDragStart}
onDragEnter={onDragEnter}
onDragOver={(e) => e.preventDefault()}
onDragEnd={onDragEnd}
onDrop={(e) => { e.preventDefault(); onDrop() }}
className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors"
style={{
background: '#21262d',
borderColor: isDragOver ? '#00d4ff' : '#30363d',
opacity: isDragging ? 0.4 : 1,
}}
>
<div className="flex items-center gap-1.5 min-w-0">
{draggable && (
<span className="shrink-0 cursor-grab active:cursor-grabbing text-[#8b949e] hover:text-[#00d4ff]" title="Drag to reorder">
{createElement(GripVertical, { size: 11 })}
</span>
)}
{Icon && createElement(Icon, { size: 11, className: 'shrink-0 text-muted-foreground' })}
<span className="font-medium truncate text-foreground" title={prop.key}>{prop.key}</span>
<span className="text-muted-foreground truncate" title={prop.value}>· {prop.value}</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={onToggleVisible}
title={prop.visible ? 'Hide on node' : 'Show on node'}
className="text-[#8b949e] hover:text-[#00d4ff] transition-colors"
>
{prop.visible ? <Eye size={10} /> : <EyeOff size={10} />}
</button>
<button onClick={onEdit} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff]" title="Edit property">
<Pencil size={10} />
</button>
<button onClick={onRemove} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149]" title="Remove property">
<X size={10} />
</button>
</div>
</div>
)
}
const CATEGORY_COLORS: Record<string, string> = {
web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
}
@@ -0,0 +1,134 @@
/**
* Rack-specific middle group of the header toolbar.
*
* Replaces the logical canvas actions (auto layout, YAML import/export) with the
* ones a rack canvas actually has: adding racks, patching, and controlling how
* much of the cabling is on screen.
*/
import { useState } from 'react'
import { Cable, Eye, EyeOff, Link2, MousePointer2, Plus, Trash2, X } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useRackStore } from '@/rack/store'
import { useRackPalette } from '@/rack/rackTheme'
import { loadNetworkLinks } from '@/rack/networkLinks'
import { useDesignStore } from '@/stores/designStore'
import type { CableVisibility } from '@/types'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const ghost =
'gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]'
/** Reads as one of the ghost buttons around it: no box until you hover it. */
const triggerClass =
'gap-1.5 border-transparent bg-transparent px-2 text-xs font-medium text-muted-foreground hover:bg-[#21262d] hover:text-foreground dark:bg-transparent dark:hover:bg-[#21262d] cursor-pointer'
const VISIBILITY_OPTIONS: { value: CableVisibility; label: string; icon: typeof Eye }[] = [
{ value: 'hover', label: 'Cables on hover', icon: MousePointer2 },
{ value: 'always', label: 'Cables always', icon: Eye },
{ value: 'hidden', label: 'Cables hidden', icon: EyeOff },
]
export function RackToolbarActions() {
const palette = useRackPalette()
const addRack = useRackStore((s) => s.addRack)
const cableMode = useRackStore((s) => s.cableMode)
const toggleCableMode = useRackStore((s) => s.toggleCableMode)
const cableVisibility = useRackStore((s) => s.cableVisibility)
const setCableVisibility = useRackStore((s) => s.setCableVisibility)
const cableDraft = useRackStore((s) => s.cableDraft)
const cancelCableDraft = useRackStore((s) => s.cancelCableDraft)
const selectedCableId = useRackStore((s) => s.selectedCableId)
const removeSelectedCable = useRackStore((s) => s.removeSelectedCable)
const importCables = useRackStore((s) => s.importCablesFromNetwork)
const designs = useDesignStore((s) => s.designs)
const [importing, setImporting] = useState(false)
async function handleImport() {
setImporting(true)
try {
const hints = STANDALONE ? [] : await loadNetworkLinks(designs)
const created = importCables(hints)
toast[created > 0 ? 'success' : 'info'](
created > 0
? `${created} cable${created > 1 ? 's' : ''} imported from the network canvas`
: 'No matching link found — rack the devices first',
)
} finally {
setImporting(false)
}
}
return (
<>
<Button size="sm" variant="ghost" className={ghost} onClick={() => addRack({ style: palette.defaultRackStyle })}>
<Plus size={14} /> Rack
</Button>
<Button
size="sm"
variant="ghost"
className={`${ghost} ${cableMode ? 'text-[#00d4ff]' : ''}`}
onClick={toggleCableMode}
title="Drag from one port to another to patch, or click both in turn. Click a cable to select it, then Delete to unplug."
>
<Cable size={14} /> {cableMode ? 'Exit patching' : 'Patch'}
</Button>
{cableMode && cableDraft && (
<Button
size="sm"
variant="ghost"
className={`${ghost} text-[#e3b341]`}
onClick={cancelCableDraft}
>
<X size={14} /> Cancel cable
</Button>
)}
{cableMode && selectedCableId && (
<Button
size="sm"
variant="ghost"
className={`${ghost} text-[#f85149]`}
onClick={removeSelectedCable}
title="Unplug the selected cable (Delete)"
>
<Trash2 size={14} /> Unplug
</Button>
)}
<Select value={cableVisibility} onValueChange={(v) => setCableVisibility(v as CableVisibility)}>
<SelectTrigger size="sm" className={triggerClass} aria-label="Cable visibility">
<SelectValue>
{(() => {
const { icon: Icon, label } = VISIBILITY_OPTIONS.find((o) => o.value === cableVisibility)!
return <><Icon size={14} /> {label}</>
})()}
</SelectValue>
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
{VISIBILITY_OPTIONS.map(({ value, icon: Icon, label }) => (
<SelectItem key={value} value={value} className="text-xs">
<Icon size={14} /> {label}
</SelectItem>
))}
</SelectContent>
</Select>
{!STANDALONE && (
<Button
size="sm"
variant="ghost"
className={ghost}
disabled={importing}
onClick={() => void handleImport()}
title="One-shot: derive patches from the links already drawn on the logical canvases"
>
<Link2 size={14} /> Import links
</Button>
)}
</>
)
}
+69 -20
View File
@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect } from 'react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, RadioTower, Server, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, RadioTower, Server, Type, PlusCircle, Pencil, Trash2, Rows3 } from 'lucide-react'
import { Logo } from '@/components/ui/Logo'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvasStore'
@@ -9,6 +9,9 @@ import { authApi, designsApi, mediaApi } from '@/api/client'
import * as standaloneStorage from '@/utils/standaloneStorage'
import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal'
import { AccessoryTray } from '@/rack/components/AccessoryTray'
import { useRackStore } from '@/rack/store'
import { freeUnits } from '@/rack/layout'
import type { Design } from '@/types'
import { toast } from 'sonner'
import { useLatestRelease } from '@/hooks/useLatestRelease'
@@ -31,21 +34,25 @@ interface SidebarProps {
onSave: () => void
onOpenSettings: () => void
onOpenHistory: () => void
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
onOpenInventory: (deviceId?: string, status?: 'pending' | 'hidden') => void
}
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onZwaveImport, onProxmoxImport, onSave, onOpenSettings, onOpenHistory, onOpenPending }: SidebarProps) {
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onZwaveImport, onProxmoxImport, onSave, onOpenSettings, onOpenHistory, onOpenInventory }: SidebarProps) {
const [collapsed, setCollapsed] = useState(false)
const logout = useAuthStore((s) => s.logout)
const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore()
const { designs, activeDesignId, activeDesignType, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore()
const isRack = activeDesignType === 'rack'
const [designSwitcherOpen, setDesignSwitcherOpen] = useState(false)
const [designModal, setDesignModal] = useState<{ mode: 'create' | 'edit'; design?: Design } | null>(null)
// Bumped on every open so the modal remounts and re-seeds its local state from
// the current floor plan — otherwise a reopen keeps stale width/height/lock
// and Save would clobber canvas-side resize/move.
const [openSeq, setOpenSeq] = useState(0)
const { nodes, hasUnsavedChanges, floorMap, setFloorMap } = useCanvasStore()
const { nodes, hasUnsavedChanges: canvasDirty, floorMap, setFloorMap } = useCanvasStore()
const floorMapEditNonce = useCanvasStore((s) => s.floorMapEditNonce)
const rackDirty = useRackStore((s) => s.hasUnsavedChanges)
const openDeviceEditor = useRackStore((s) => s.openDeviceEditor)
const hasUnsavedChanges = isRack ? rackDirty : canvasDirty
const handleLogout = useCallback(async () => {
try {
@@ -72,9 +79,10 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
? standaloneStorage.copyDesign(data.sourceId, data.name, data.icon)
: (await designsApi.copy(data.sourceId, { name: data.name, icon: data.icon })).data
} else {
const designType = data.designType ?? 'network'
created = STANDALONE
? standaloneStorage.createDesign(data.name, data.icon)
: (await designsApi.create({ name: data.name, icon: data.icon })).data
? standaloneStorage.createDesign(data.name, data.icon, designType)
: (await designsApi.create({ name: data.name, icon: data.icon, design_type: designType })).data
}
addDesign(created)
} else if (designModal.design) {
@@ -164,6 +172,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
<div className="px-2 pt-2 pb-1 border-b border-border relative">
<button
onClick={() => setDesignSwitcherOpen((o) => !o)}
data-tour="canvas-switcher"
className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-xs font-medium bg-[#21262d] border border-border hover:border-[#30363d] transition-colors cursor-pointer"
>
{activeDesignId ? (() => {
@@ -231,8 +240,8 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
{/* Views */}
<nav className="flex flex-col gap-0.5 p-2">
<SidebarItem
icon={LayoutDashboard}
label="Canvas"
icon={isRack ? Rows3 : LayoutDashboard}
label={isRack ? 'Rack view' : 'Canvas'}
collapsed={collapsed}
active
/>
@@ -242,7 +251,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
icon={t.icon}
label={t.label}
collapsed={collapsed}
onClick={() => onOpenPending(undefined, t.kind)}
onClick={() => onOpenInventory(undefined, t.kind)}
/>
))}
{!STANDALONE && (
@@ -255,10 +264,13 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
)}
</nav>
{!collapsed && <div className="flex-1" />}
{/* The rack inventory is the sidebar's body: it scrolls, everything else
stays pinned. On a logical canvas the space is just a spacer. */}
{isRack && !collapsed ? <AccessoryTray /> : !collapsed && <div className="flex-1" />}
{/* Stats footer — hidden in standalone (no scan / live status to count) */}
{!collapsed && !STANDALONE && (
{!collapsed && isRack && <RackStats />}
{!collapsed && !isRack && !STANDALONE && (
<div className="px-3 py-2 border-t border-border text-xs text-muted-foreground space-y-0.5">
<div className="flex justify-between">
<span>Total</span>
@@ -277,16 +289,22 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
{/* Actions */}
<div className="flex flex-col gap-0.5 p-2 border-t border-border">
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} dataTour="add-node" />
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} dataTour="add-zone" />
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} dataTour="add-text" />
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} dataTour="scan-network" />}
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} dataTour="zigbee-import" />}
{!STANDALONE && <SidebarItem icon={RadioTower} label="Z-Wave Import" collapsed={collapsed} onClick={onZwaveImport} dataTour="zwave-import" />}
{!STANDALONE && <SidebarItem icon={Server} label="Proxmox Import" collapsed={collapsed} onClick={onProxmoxImport} dataTour="proxmox-import" />}
{isRack ? (
<SidebarItem icon={Plus} label="Add Device" collapsed={collapsed} onClick={() => openDeviceEditor()} />
) : (
<>
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} dataTour="add-node" />
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} dataTour="add-zone" />
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} dataTour="add-text" />
</>
)}
{!STANDALONE && !isRack && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} dataTour="scan-network" />}
{!STANDALONE && !isRack && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} dataTour="zigbee-import" />}
{!STANDALONE && !isRack && <SidebarItem icon={RadioTower} label="Z-Wave Import" collapsed={collapsed} onClick={onZwaveImport} dataTour="zwave-import" />}
{!STANDALONE && !isRack && <SidebarItem icon={Server} label="Proxmox Import" collapsed={collapsed} onClick={onProxmoxImport} dataTour="proxmox-import" />}
<SidebarItem
icon={Save}
label="Save Canvas"
label={isRack ? 'Save Rack' : 'Save Canvas'}
collapsed={collapsed}
onClick={() => onSave()}
badge={hasUnsavedChanges}
@@ -333,6 +351,37 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
)
}
/** Rack equivalent of the node stats footer: capacity rather than reachability. */
function RackStats() {
const racks = useRackStore((s) => s.racks)
const devices = useRackStore((s) => s.devices)
const cables = useRackStore((s) => s.cables)
const free = racks.reduce((sum, rack) => sum + freeUnits(rack, devices), 0)
const total = racks.reduce((sum, rack) => sum + rack.uHeight, 0)
return (
<div className="px-3 py-2 border-t border-border text-xs text-muted-foreground space-y-0.5">
<div className="flex justify-between">
<span>Racks</span>
<span className="text-foreground font-mono">{racks.length}</span>
</div>
<div className="flex justify-between">
<span>Mounted</span>
<span className="text-foreground font-mono">{devices.length}</span>
</div>
<div className="flex justify-between">
<span>Cables</span>
<span className="text-foreground font-mono">{cables.length}</span>
</div>
<div className="flex justify-between">
<span className="text-[#39d353]">Free</span>
<span className="font-mono text-[#39d353]">{free}U / {total}U</span>
</div>
</div>
)
}
function VersionBadge() {
const current = __APP_VERSION__
const { latest, hasUpdate } = useLatestRelease(current)
+44 -24
View File
@@ -3,6 +3,9 @@ import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Tab
import { Button } from '@/components/ui/button'
import { Logo } from '@/components/ui/Logo'
import { useCanvasStore } from '@/stores/canvasStore'
import { useDesignStore } from '@/stores/designStore'
import { useRackStore } from '@/rack/store'
import { RackToolbarActions } from './RackToolbarActions'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
@@ -21,7 +24,10 @@ interface ToolbarProps {
}
export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd, onExportYaml, onImportYaml, onViewOnly }: ToolbarProps) {
const { hasUnsavedChanges, past, future } = useCanvasStore()
const { hasUnsavedChanges: canvasDirty, past, future } = useCanvasStore()
const isRack = useDesignStore((s) => s.activeDesignType) === 'rack'
const rackDirty = useRackStore((s) => s.hasUnsavedChanges)
const hasUnsavedChanges = isRack ? rackDirty : canvasDirty
const fileInputRef = useRef<HTMLInputElement>(null)
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
@@ -40,25 +46,33 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
<header className="flex items-center gap-2 px-4 py-2 border-b border-border bg-[#161b22] shrink-0">
<Logo size={28} showText={true} />
<div className="flex-1" />
<Button
size="sm" variant="ghost"
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30 cursor-pointer hover:bg-[#21262d]"
onClick={onUndo}
disabled={past.length === 0}
title="Undo (Ctrl+Z)"
>
<Undo2 size={14} />
</Button>
<Button
size="sm" variant="ghost"
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30 cursor-pointer hover:bg-[#21262d]"
onClick={onRedo}
disabled={future.length === 0}
title="Redo (Ctrl+Y)"
>
<Redo2 size={14} />
</Button>
{/* Undo/redo are canvas history; the rack canvas has none yet. */}
{!isRack && (
<>
<Button
size="sm" variant="ghost"
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30 cursor-pointer hover:bg-[#21262d]"
onClick={onUndo}
disabled={past.length === 0}
title="Undo (Ctrl+Z)"
>
<Undo2 size={14} />
</Button>
<Button
size="sm" variant="ghost"
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30 cursor-pointer hover:bg-[#21262d]"
onClick={onRedo}
disabled={future.length === 0}
title="Redo (Ctrl+Y)"
>
<Redo2 size={14} />
</Button>
</>
)}
<div className="w-px h-4 bg-border mx-1" />
{isRack && <RackToolbarActions />}
{!isRack && (
<>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onAutoLayout}>
<LayoutDashboard size={14} /> Auto Layout
</Button>
@@ -78,15 +92,21 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExportYaml} title="Export canvas as YAML">
<Download size={14} /> Export
</Button>
</>
)}
{/* PNG capture is DOM-based, so it works for both canvas kinds. */}
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExport} title="Download canvas as PNG">
<FileDown size={14} /> PNG
</Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExportMd} title="Copy inventory as Markdown table">
<Table2 size={14} /> MD
</Button>
{!isRack && (
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExportMd} title="Copy inventory as Markdown table">
<Table2 size={14} /> MD
</Button>
)}
{/* Live view reads backend/localStorage canvas; pointless in standalone
where the editor already shows the only (localStorage) copy. */}
{!STANDALONE && (
where the editor already shows the only (localStorage) copy. Rack
canvases have no live view yet. */}
{!STANDALONE && !isRack && (
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onViewOnly} title="Open read-only live view of this canvas">
<Eye size={14} /> View
</Button>
@@ -0,0 +1,68 @@
/**
* Rack header actions. The Unplug button is the visible half of cable
* selection — the keyboard shortcut alone would be undiscoverable.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { RackToolbarActions } from '../RackToolbarActions'
import { useRackStore } from '@/rack/store'
vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
beforeEach(() => {
useRackStore.getState().loadDemo()
})
const unplug = () => screen.queryByRole('button', { name: /unplug/i })
describe('RackToolbarActions cable visibility', () => {
it('shows the active visibility on the trigger', () => {
useRackStore.getState().setCableVisibility('always')
render(<RackToolbarActions />)
expect(screen.getByLabelText('Cable visibility')).toHaveTextContent('Cables always')
})
it('applies the picked visibility to the store', async () => {
// base-ui commits its selection on real pointer sequences, not a bare click.
const user = userEvent.setup({ pointerEventsCheck: 0 })
render(<RackToolbarActions />)
await user.click(screen.getByLabelText('Cable visibility'))
await user.click(await screen.findByRole('option', { name: 'Cables hidden' }))
expect(useRackStore.getState().cableVisibility).toBe('hidden')
})
it('carries no cable type filter — copper and fibre always draw together', () => {
render(<RackToolbarActions />)
expect(screen.queryByLabelText('Cable type filter')).toBeNull()
expect(screen.queryByText('All types')).toBeNull()
expect('cableTypeFilter' in useRackStore.getState()).toBe(false)
})
})
describe('RackToolbarActions cable selection', () => {
it('hides Unplug outside patch mode', () => {
useRackStore.getState().selectCable(useRackStore.getState().cables[0].id)
render(<RackToolbarActions />)
expect(unplug()).toBeNull()
})
it('hides Unplug in patch mode while no cable is selected', () => {
useRackStore.getState().toggleCableMode()
render(<RackToolbarActions />)
expect(unplug()).toBeNull()
})
it('unplugs the selected cable', () => {
useRackStore.getState().toggleCableMode()
const cable = useRackStore.getState().cables[0]
useRackStore.getState().selectCable(cable.id)
render(<RackToolbarActions />)
fireEvent.click(unplug()!)
expect(useRackStore.getState().cables.some((c) => c.id === cable.id)).toBe(false)
expect(unplug()).toBeNull()
})
})
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'
import { Sidebar } from '../Sidebar'
import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore'
@@ -78,7 +78,7 @@ const defaultProps = {
onSave: vi.fn(),
onOpenSettings: vi.fn(),
onOpenHistory: vi.fn(),
onOpenPending: vi.fn(),
onOpenInventory: vi.fn(),
}
// ── Tests ─────────────────────────────────────────────────────────────────────
@@ -244,16 +244,16 @@ describe('Sidebar', () => {
// ── Pending / Hidden open modal ────────────────────────────────────────────
it('calls onOpenPending with pending status when Device Inventory is clicked', () => {
it('calls onOpenInventory with pending status when Device Inventory is clicked', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Device Inventory'))
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'pending')
expect(defaultProps.onOpenInventory).toHaveBeenCalledWith(undefined, 'pending')
})
it('calls onOpenPending with hidden status when Hidden Devices is clicked', () => {
it('calls onOpenInventory with hidden status when Hidden Devices is clicked', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Hidden Devices'))
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'hidden')
expect(defaultProps.onOpenInventory).toHaveBeenCalledWith(undefined, 'hidden')
})
it('calls onOpenHistory when Scan History nav item is clicked', () => {
@@ -284,6 +284,122 @@ describe('Sidebar', () => {
expect(mockLogout).toHaveBeenCalledOnce()
})
})
// ── Walkthrough anchor ─────────────────────────────────────────────────────
it('carries the walkthrough anchor the rack tour step spotlights', async () => {
const { useDesignStore } = await import('@/stores/designStore')
const { STEPS } = await import('@/walkthrough/steps')
const before = useDesignStore.getState()
useDesignStore.setState({
designs: [{
id: 'd1', name: 'Main', design_type: 'network', icon: null,
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
}],
activeDesignId: 'd1',
loaded: true,
})
const { unmount } = render(<Sidebar {...defaultProps} />)
const step = STEPS.find((s) => s.id === 'rack')
expect(step?.anchor).toBe('[data-tour="canvas-switcher"]')
// The overlay resolves that selector against the DOM — an anchor the sidebar
// stops carrying leaves the step spotlighting nothing.
expect(document.querySelector(step!.anchor!)).toBeInTheDocument()
// Unmount before restoring, so the store reset never re-renders the sidebar.
unmount()
useDesignStore.setState({
designs: before.designs,
activeDesignId: before.activeDesignId,
loaded: before.loaded,
})
})
})
// ── Rack canvas ────────────────────────────────────────────────────────────────
// Same component, a different half of it: the rack branch swaps the view label,
// the actions, the body and the footer. A mocked canvas store would happily hide
// all of that, so each branch is asserted rather than assumed.
describe('Sidebar (rack canvas)', () => {
let restore: { designs: Design[]; activeDesignId: string | null; activeDesignType: string }
beforeEach(async () => {
mockStore()
mockAuth()
const { useDesignStore } = await import('@/stores/designStore')
const { useRackStore } = await import('@/rack/store')
const s = useDesignStore.getState()
restore = { designs: s.designs, activeDesignId: s.activeDesignId, activeDesignType: s.activeDesignType }
useDesignStore.setState({ activeDesignType: 'rack' })
useRackStore.getState().loadDemo()
})
afterEach(async () => {
cleanup()
const { useDesignStore } = await import('@/stores/designStore')
useDesignStore.setState(restore)
})
it('swaps the view and the actions for their rack equivalents', () => {
render(<Sidebar {...defaultProps} />)
expect(screen.getByText('Rack view')).toBeInTheDocument()
expect(screen.getByText('Add Device')).toBeInTheDocument()
expect(screen.getByText('Save Rack')).toBeInTheDocument()
expect(screen.queryByText('Canvas')).not.toBeInTheDocument()
expect(screen.queryByText('Add Node')).not.toBeInTheDocument()
expect(screen.queryByText('Add Zone')).not.toBeInTheDocument()
expect(screen.queryByText('Add Text')).not.toBeInTheDocument()
})
it('hides the logical-canvas discovery actions', () => {
render(<Sidebar {...defaultProps} />)
// Nothing here is scanned onto a rack — those actions belong to a diagram.
expect(screen.queryByText('Scan Network')).not.toBeInTheDocument()
expect(screen.queryByText('Zigbee Import')).not.toBeInTheDocument()
expect(screen.queryByText('Z-Wave Import')).not.toBeInTheDocument()
expect(screen.queryByText('Proxmox Import')).not.toBeInTheDocument()
// The Device Inventory stays: a rack mounts what the scans found.
expect(screen.getByText('Device Inventory')).toBeInTheDocument()
})
it('opens the device editor from + Device', async () => {
const { useRackStore } = await import('@/rack/store')
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Add Device'))
expect(useRackStore.getState().deviceEditor).toEqual({ deviceId: null })
})
it('counts capacity instead of reachability', async () => {
const { useRackStore } = await import('@/rack/store')
const { freeUnits } = await import('@/rack/layout')
const { racks, devices, cables } = useRackStore.getState()
const free = racks.reduce((sum, r) => sum + freeUnits(r, devices), 0)
const total = racks.reduce((sum, r) => sum + r.uHeight, 0)
render(<Sidebar {...defaultProps} />)
expect(screen.getByText('Racks').nextElementSibling).toHaveTextContent(String(racks.length))
expect(screen.getByText('Mounted').nextElementSibling).toHaveTextContent(String(devices.length))
expect(screen.getByText('Cables').nextElementSibling).toHaveTextContent(String(cables.length))
expect(screen.getByText(`${free}U / ${total}U`)).toBeInTheDocument()
expect(screen.queryByText('Online')).not.toBeInTheDocument()
})
it('tracks the rack store for the unsaved badge, not the canvas store', async () => {
const { useRackStore } = await import('@/rack/store')
useRackStore.setState({ hasUnsavedChanges: true })
// The canvas store — mocked clean — must not be what answers here.
const { container } = render(<Sidebar {...defaultProps} />)
expect(container.querySelector('.bg-\\[\\#e3b341\\]')).toBeInTheDocument()
cleanup()
useRackStore.setState({ hasUnsavedChanges: false })
const { container: clean } = render(<Sidebar {...defaultProps} />)
expect(clean.querySelector('.bg-\\[\\#e3b341\\]')).toBeNull()
})
})
// ── Standalone mode ────────────────────────────────────────────────────────────
@@ -29,7 +29,7 @@ vi.mock('@/components/ui/tooltip', () => ({
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: () => null,
}))
vi.mock('@/components/modals/PendingDeviceModal', () => ({ PendingDeviceModal: () => null }))
vi.mock('@/components/modals/InventoryDeviceModal', () => ({ InventoryDeviceModal: () => null }))
vi.mock('@/components/modals/StatusTimelineModal', () => ({ StatusTimelineModal: () => null }))
vi.mock('@/hooks/useLatestRelease', () => ({
@@ -12,7 +12,7 @@ interface ProxmoxImportModalProps {
open: boolean
onClose: () => void
onAddToCanvas: (nodes: ProxmoxNode[], edges: ProxmoxEdge[]) => void
onPendingImported?: () => void
onInventoryImported?: () => void
}
type ImportMode = 'pending' | 'canvas'
@@ -53,7 +53,7 @@ const DEVICE_TYPE_COLOR: Record<ProxmoxNodeType, string> = {
lxc: '#39d353',
}
export function ProxmoxImportModal({ open, onClose, onAddToCanvas, onPendingImported }: ProxmoxImportModalProps) {
export function ProxmoxImportModal({ open, onClose, onAddToCanvas, onInventoryImported }: ProxmoxImportModalProps) {
const [form, setForm] = useState<ConnectionForm>(DEFAULT_FORM)
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>('idle')
const [connectionMsg, setConnectionMsg] = useState('')
@@ -101,7 +101,7 @@ export function ProxmoxImportModal({ open, onClose, onAddToCanvas, onPendingImpo
if (importMode === 'pending') {
await proxmoxApi.importToPending(buildPayload())
toast.success('Proxmox import started — track progress in Scan History')
onPendingImported?.()
onInventoryImported?.()
handleClose()
} else {
const res = await proxmoxApi.importNetwork(buildPayload())
@@ -18,7 +18,7 @@ const defaultProps = {
open: true,
onClose: vi.fn(),
onAddToCanvas: vi.fn(),
onPendingImported: vi.fn(),
onInventoryImported: vi.fn(),
}
const sampleNodes = [
@@ -44,7 +44,7 @@ describe('ProxmoxImportModal', () => {
vi.mocked(toast.info).mockReset()
defaultProps.onClose.mockReset()
defaultProps.onAddToCanvas.mockReset()
defaultProps.onPendingImported.mockReset()
defaultProps.onInventoryImported.mockReset()
})
it('renders nothing when closed', () => {
@@ -85,7 +85,7 @@ describe('ProxmoxImportModal', () => {
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or pve.local'), { target: { value: 'pve' } })
fireEvent.click(screen.getByRole('button', { name: /import to pending/i }))
await waitFor(() => expect(proxmoxApi.importToPending).toHaveBeenCalled())
expect(defaultProps.onPendingImported).toHaveBeenCalled()
expect(defaultProps.onInventoryImported).toHaveBeenCalled()
expect(proxmoxApi.importNetwork).not.toHaveBeenCalled()
})
@@ -12,7 +12,7 @@ interface ZigbeeImportModalProps {
open: boolean
onClose: () => void
onAddToCanvas: (nodes: ZigbeeNode[], edges: ZigbeeEdge[]) => void
onPendingImported?: (
onInventoryImported?: (
coordinator?: { id: string; label: string; ieee_address: string } | null,
) => void
}
@@ -59,7 +59,7 @@ const DEVICE_TYPE_COLOR = {
zigbee_enddevice: '#e3b341',
} as const
export function ZigbeeImportModal({ open, onClose, onAddToCanvas, onPendingImported }: ZigbeeImportModalProps) {
export function ZigbeeImportModal({ open, onClose, onAddToCanvas, onInventoryImported }: ZigbeeImportModalProps) {
const [form, setForm] = useState<ConnectionForm>(DEFAULT_FORM)
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>('idle')
const [connectionMsg, setConnectionMsg] = useState('')
@@ -140,7 +140,7 @@ export function ZigbeeImportModal({ open, onClose, onAddToCanvas, onPendingImpor
if (importMode === 'pending') {
await zigbeeApi.importToPending(buildPayload())
toast.success('Zigbee import started — track progress in Scan History')
onPendingImported?.(null)
onInventoryImported?.(null)
handleClose()
} else {
const res = await zigbeeApi.importNetwork(buildPayload())
@@ -191,16 +191,16 @@ describe('ZigbeeImportModal', () => {
error: null,
},
} as never)
const onPendingImported = vi.fn()
const onInventoryImported = vi.fn()
render(<ZigbeeImportModal {...defaultProps} onPendingImported={onPendingImported} />)
render(<ZigbeeImportModal {...defaultProps} onInventoryImported={onInventoryImported} />)
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
fireEvent.click(screen.getByRole('button', { name: /import to pending/i }))
await waitFor(() => {
expect(zigbeeApi.importToPending).toHaveBeenCalled()
expect(onPendingImported).toHaveBeenCalled()
expect(onInventoryImported).toHaveBeenCalled()
expect(defaultProps.onClose).toHaveBeenCalled()
})
expect(zigbeeApi.importNetwork).not.toHaveBeenCalled()
@@ -12,7 +12,7 @@ interface ZwaveImportModalProps {
open: boolean
onClose: () => void
onAddToCanvas: (nodes: ZwaveNode[], edges: ZwaveEdge[]) => void
onPendingImported?: (
onInventoryImported?: (
coordinator?: { id: string; label: string; ieee_address: string } | null,
) => void
}
@@ -63,7 +63,7 @@ const DEVICE_TYPE_COLOR = {
zwave_enddevice: '#e3b341',
} as const
export function ZwaveImportModal({ open, onClose, onAddToCanvas, onPendingImported }: ZwaveImportModalProps) {
export function ZwaveImportModal({ open, onClose, onAddToCanvas, onInventoryImported }: ZwaveImportModalProps) {
const [form, setForm] = useState<ConnectionForm>(DEFAULT_FORM)
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>('idle')
const [connectionMsg, setConnectionMsg] = useState('')
@@ -145,7 +145,7 @@ export function ZwaveImportModal({ open, onClose, onAddToCanvas, onPendingImport
if (importMode === 'pending') {
await zwaveApi.importToPending(buildPayload())
toast.success('Z-Wave import started — track progress in Scan History')
onPendingImported?.(null)
onInventoryImported?.(null)
handleClose()
} else {
const res = await zwaveApi.importNetwork(buildPayload())
@@ -152,9 +152,9 @@ describe('ZwaveImportModal', () => {
error: null,
},
} as never)
const onPendingImported = vi.fn()
const onInventoryImported = vi.fn()
render(<ZwaveImportModal {...defaultProps} onPendingImported={onPendingImported} />)
render(<ZwaveImportModal {...defaultProps} onInventoryImported={onInventoryImported} />)
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), {
target: { value: '192.168.1.100' },
})
@@ -162,7 +162,7 @@ describe('ZwaveImportModal', () => {
await waitFor(() => {
expect(zwaveApi.importToPending).toHaveBeenCalled()
expect(onPendingImported).toHaveBeenCalled()
expect(onInventoryImported).toHaveBeenCalled()
expect(defaultProps.onClose).toHaveBeenCalled()
})
expect(zwaveApi.importNetwork).not.toHaveBeenCalled()
+80
View File
@@ -0,0 +1,80 @@
# Rack canvas
A rack canvas is a design of `design_type: 'rack'` — the physical counterpart to
the logical network canvas. It lives in the same shell: same header, same left
rail, same right panel, same design switcher. `App` swaps the renderer when the
active design is a rack.
Create one from **New Canvas → Kind → Rack**.
## Model
| Concern | Choice |
|---|---|
| Vertical | `uStart` (1-based, **always counted from the bottom rail**) + `uHeight`. `numbering` only changes the printed labels. |
| Horizontal | 12-column grid (`RACK_COLUMNS`). Full = 12, half = 6, third = 4, quarter = 3 — so 2 or 3 machines share one U. |
| Collision | `canPlace` / `findSlot` in `layout.ts`. A drop snaps to the nearest free slot; an impossible drop shows a red preview. |
| Inventory | A mount references a **Device Inventory** entry (`device_inventory`) via `deviceId`. Those rows survive approval *and* node deletion, so unracking never removes the device. Accessories (blank, shelf, cable manager) have `deviceId: null`. `label` comes from `_device_label` in `racks.py`, which mirrors `deviceLabel` in `DeviceInventoryModal.tsx` (friendly name → host → app the ports say it runs → IP → IEEE → id) so a device is named the same in both lists. |
| Rack-created gear | A device created from this canvas is a normal inventory row with `discovery_source: "rack"`, so it inherits the inventory's search, filters, hide and delete. It shows under the **Rack devices** source filter, and both `POST /scan/pending/{id}/approve` and bulk-approve refuse it — a mount is not a host to document on a logical canvas. Its `suggested_type` comes from the plate it wears (`deviceTypeForFaceplate`), since no scan is there to guess one: without it the row had no icon, no role badge and no place in the type filter. Passive gear uses the rack-only kinds `patch_panel` and `pdu` — the logical canvas has no node type for it, and `pdu` must not be `socket`, which is excluded from the rack inventory. |
| Picking one | The **Device Inventory entry** field opens the real Device Inventory modal (`DeviceInventoryModal` in picker mode: a card click returns the device, bulk/clear controls are hidden) rather than a flat `<select>`, so the user gets its search, source/type filters and per-card detail. It opens with the **Rackable** filter armed — hardware only, `UNRACKABLE_TYPES` in `utils/rackable.ts` mirroring `_UNRACKABLE_TYPES` in `racks.py`, with `test_rackable_sync.py` failing on drift. The rack side still has the last word: a device missing from its own list, or already mounted in this design, is refused with a toast. Standalone keeps the `<select>` — no backend, no inventory modal. |
| Canvas node link | `nodeId` is a second, optional link to a logical-canvas node, resolved server-side by IEEE then IP. It supplies live status and lets the network-link import match endpoints. Never required. |
| Relinking one by hand | A plate created from the rack carries a placeholder inventory row with a name and nothing else, and the IEEE/IP guess behind `nodeId` finds nothing for gear with no MAC and no address. `DevicePickerModal` — opened from the Linked device panel, listing the rack's own `/racks/inventory` copy — points the mount at another **Device Inventory** row instead: `relinkDevice` writes `deviceId`, adopts that row's `nodeId`, status and (unless the user renamed the plate) label, and recomputes the racked flags, so the panel, the Status select and the plate LED follow before the canvas is even saved. One entry, one mount: a row another plate stands for is not offered, and the store refuses it anyway. The placeholder left behind is deleted through `DELETE /scan/pending/{id}` when the rack created it (`discovery_source: "rack"`) and nothing else mounts it — that route refuses a mounted device with a 409, since `rack_devices.device_id` is `ON DELETE SET NULL` and would otherwise blank the mount. A row from discovery or from the user is never touched. |
| Linked device panel | `LinkedDevicePanel`, under the port list in `RackDeviceModal`: what is known about the box a mount stands for — name, type, hostname, IP, MAC, OS, status check, the canvas it is drawn on, last seen, and the fingerprinted services. Read-only; the inventory and the logical canvas own them. `/racks/inventory` ships the canvas side as `node_*` alongside the inventory row's own `mac` / `hostname` / `os` / `services`, and the panel prefers the node value (curated) over the inventory one (what discovery last saw), dropping any row neither side can fill. A device on no canvas still prints everything discovery found — it is the record of a real box whether or not anyone drew it — and adds *Not on a logical canvas.*, which is why `auto` resolves to `unknown` there. *Link to another device…* is always on offer for a real mount, so a placeholder or the wrong twin of two look-alikes is not a dead end. A mount with no inventory entry — an accessory — gets no panel. |
| Status | A mount stores a `MountStatus`: `online` / `offline` / `unknown` pinned by hand, or `auto` — "check device", i.e. follow the status check already configured on the matching canvas node (ping, http, ssh…). The rack runs no checker of its own; `auto` resolves through the inventory entry's `node_status` (`resolveDeviceStatus` in `deviceStatus.ts`) and falls back to `unknown` when nothing is behind it. The Status select offers `auto` only when the mount — or the entry being picked — resolves to a node, and drops back to `unknown` when that link is lost. `useAutoStatusRefresh` polls the inventory every 60 s while at least one mount is on `auto`, and only then; the refresh writes inventory only, so autosave stays quiet. |
| Faceplates | Declarative templates in `faceplates.ts`, drawn as SVG in unit coordinates so a plate scales with U height and rack width. Applying a template seeds ports; the user edits them afterwards. `suggestFaceplate()` picks one from the device's discovery type. Chosen from `FaceplatePicker` — a visual catalog, not a `<select>`: every template is drawn with the canvas renderer at its real relative width and U height, grouped, searchable, and filterable by `kind` (accessories are offered only to accessories). |
| Ports | RJ45 and SFP/SFP+ only, drawn as real jack artwork at a fixed pixel size so plates of different U heights line up. Manual list per device. Power outlets are artwork, never a cable endpoint. |
| Port visibility | Patch-facing gear (switches, patch panels) shows its ports permanently. Everything else reveals them on hover, on selection, or when cables are on — a cable never ends on an invisible port. |
| Plate zones | Each template reserves three non-overlapping bands: status LED (fixed left), `labelBox` (name, clipped), then artwork and ports. A test asserts no port lands on the name band. `labelBox.y` (default 0.5) moves the name band *and* the LED off mid-height — desktop NAS boxes are drive doors over a badge strip at the bottom. |
| Cables | Port-to-port, a relation of their own — not React Flow edges. One cable per port. Drawn in a `ViewportPortal` so they pan/zoom with the canvas and can cross racks. |
| Cable visibility | Hidden by default; shown on hover/selection, or all-on via patch mode / the header. Plates stay opaque in every mode — fading them to 40 % showed the rails and the U grid through the gear. Copper vs fibre follows the port the patch starts from. A selected cable is drawn whatever the mode, `hidden` included — its panel is open on the right, so hiding the run it describes would read as a bug — but `hidden` reveals nothing else, not even on hover. |
| Cable annotations | A cable carries a `label` plus `properties` — the same `NodeProperty` records the logical canvas uses (`key`, `value`, `icon`, `visible`), edited with the shared `components/common/PropertyList`. `labelVisible` and each property's `visible` decide what is printed on the canvas: `CableLayer` draws the lines on a small plate at the midpoint of the run (the cubic solved at `t = 0.5`, not `getPointAtLength`, which needs a mounted path). |
| Colours | `rackTheme.ts` derives the whole rack palette from the active app theme rather than declaring one per theme, so a new theme works here for free. Per-rack chrome (frame, rails, interior) stays user-editable; only its default comes from the theme. |
## Persistence
Explicit save, like the logical canvas — plus the same opt-in autosave, which
debounces on the store's `editSeq`.
| Where | What |
|---|---|
| `racks`, `rack_devices`, `rack_cables` | One row set per design. `rack_devices.device_id → device_inventory` and `node_id → nodes` are both `ON DELETE SET NULL`, and `label` is denormalized, so a rack keeps rendering after an inventory purge. |
| `canvas_state.viewport` | Pan/zoom, shared with the logical canvas row for that design. |
| `GET /api/v1/racks?design_id=` | Full state. |
| `POST /api/v1/racks/save` | Full state: upsert what is sent, prune the rest. Rejects a device pointing at a rack outside the payload, or a cable pointing at a device outside it. Also cross-checks geometry: a mount against the height of the rack it names, and `col_start + col_span` against the grid — each field is legal alone and the pair is not. |
| `GET /api/v1/racks/inventory?design_id=` | Rackable inventory entries, each flagged `racked` and resolved to a canvas node when one matches. |
| `POST /api/v1/scan/pending` | Manual inventory entry (`discovery_source: "manual"`), for hardware no scan can find. |
| Standalone | `homelable_rack:<designId>` in localStorage, inventory included (no `device_inventory` table to read). |
`@/utils/rackSerializer` maps the snake_case wire shapes to the camelCase domain
types in `@/types/rack`, narrowing every enum on the way in.
## Interactions
- **+ Device** (left rail) → `RackDeviceModal`. Source is either an existing **Device Inventory** entry, a new device created here (which lands in the inventory too), or a rack-only accessory. Nothing is preselected: the entry is picked in the Device Inventory modal, which the field opens.
- Drag from the sidebar tray onto a rack → snaps to a free U.
- Drag a mounted device inside the rack → same snapping, own slot ignored.
- The **Faceplate** field previews the current plate; clicking it opens `FaceplatePicker`. Switching the source between device and accessory swaps the plate to a sane default of the other kind.
- Double-click a plate → the same modal in edit mode: label, faceplate, U/height/column/width, status, colour, port list, the **Linked device** panel, Unmount. Single click only selects.
- Double-click empty rack chrome → `RackSettingsModal` (name, location, U height, 19"/10", numbering direction, frame/rail/interior colours, U numbers, enclosed, delete).
- Growing a device — by hand or by picking a taller plate — relocates it to the nearest slot that takes the new size. Only a rack with no such slot rejects the edit, and says so.
- Shrinking a **rack** follows the same rule: `updateRack` clamps `uHeight` to `[MIN_RACK_U, MAX_RACK_U]` (1–48; the backend tolerates 100) and relocates every mount the new height would push above the top rail, one at a time so two never land on the same slot. It returns false and changes nothing when one has nowhere to go. The number input's `min`/`max` are hints the browser does not enforce on typed input — a raw 999 used to make every save fail the backend's `1..100` check with no cause shown, and a shrink left plates drawn outside the chassis that nothing could drag back.
- The height field commits on **blur or Enter**, never per keystroke: backspacing `24` walks through `2`, and the relocation that would trigger is not undone when the digits come back — the rack canvas has no undo. The colour hex field in `RackCablePanel` commits the same way, falling back to the cable type's default when the value does not parse, rather than feeding the SVG `stroke` a half-typed `#39d`.
- **Delete rack** asks first, naming how many mounts it unmounts. It takes every mount and every cable touching them; only the Device Inventory survives.
- **Patch mode**: drag from port A to port B to cable them — a dashed rubber band follows the pointer, exactly like dragging an edge on the logical canvas. Clicking A then B still works; Escape drops a half-drawn patch. Rack dragging is disabled while in patch mode.
- **Click a cable** — in patch mode or out of it — to select it: it gets an accent halo and opens `RackCablePanel` on the right (endpoints, type, colour, label, properties, Unplug). Delete/Backspace unplugs the selection; Escape or a pane click deselects. Selecting a cable drops any mount/rack selection and vice versa — one rail, one occupant.
- **Import links**: reads the physical edges (ethernet/fibre/vlan/cluster) of every non-rack design and matches them on `nodeId`. Idempotent — a device pair already cabled is skipped, whichever ports carry it, so a re-run after racking more gear adds only what is missing. There is no "done" flag: one lived in memory only, and a reload re-armed the import onto the next free ports.
- **New device**: the modal's *New device* source adds a Device Inventory entry (tagged `rack`), for gear no scan will ever discover. The left-rail tray only carries accessories, as a drag source.
## Panel behaviour in rack mode
- **Header**: undo/redo, auto layout, YAML import/export, MD and live View are hidden. Add Rack, Patch, cable visibility and Import links replace them. PNG export and Save stay.
- **Left rail**: the design switcher, Device Inventory, Scan History, Settings and Logout stay. The node/zone/text/scan/import block becomes the accessory tray plus **+ Device**. Devices themselves are not listed there — they live in the Device Inventory modal. The footer counts racks, mounts, cables and free U instead of online/offline nodes.
- **Right rail**: only for a selected cable (`RackCablePanel`). A mount is edited in `RackDeviceModal` and a rack in `RackSettingsModal` — a cable has no plate to double-click, so it gets the rail instead. With nothing selected the canvas keeps the full width.
## Known gaps
- Front view only — no rear, no half-depth pairing.
- No 0U side-mounted PDU.
- Power draw / outlet budgeting out of scope (v1 decision).
- No undo/redo on the rack canvas.
- No read-only live view for rack designs.
@@ -0,0 +1,177 @@
/**
* Cabling overlay: in patch mode a click selects a cable rather than deleting
* it outright, so a misclick costs nothing and Delete is the destructive step.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, fireEvent } from '@testing-library/react'
import { CableLayer } from '../components/CableLayer'
import { useRackStore } from '../store'
vi.mock('@xyflow/react', async () => {
const { mockReactFlow } = await import('@/test/mocks')
const React = await import('react')
return mockReactFlow({
ViewportPortal: ({ children }: { children?: React.ReactNode }) =>
React.createElement('div', null, children),
})
})
/** The fat invisible click targets, one per drawn cable. */
const hitPaths = (container: HTMLElement) =>
Array.from(container.querySelectorAll<SVGPathElement>('path[stroke="transparent"]'))
beforeEach(() => {
useRackStore.getState().loadDemo()
})
describe('CableLayer selection', () => {
it('selects a cable on click outside patch mode too', () => {
// A cable is clicked to read and edit it in the rail, not only to unplug it,
// so the hit target is not gated on patch mode.
useRackStore.getState().setCableVisibility('always')
const { container } = render(<CableLayer />)
const paths = hitPaths(container)
expect(paths).toHaveLength(useRackStore.getState().cables.length)
fireEvent.click(paths[0])
expect(useRackStore.getState().cableMode).toBe(false)
expect(useRackStore.getState().selectedCableId).toBe(useRackStore.getState().cables[0].id)
})
it('keeps the selected cable drawn when cables are hidden', () => {
// Its panel is open on the right; hiding the run being edited reads as a
// bug. The header select used to make the selection vanish under the panel.
const store = useRackStore.getState()
store.selectCable(store.cables[0].id)
store.setCableVisibility('hidden')
const { container } = render(<CableLayer />)
expect(hitPaths(container)).toHaveLength(1)
})
it('draws nothing else when cables are hidden, even on hover', () => {
const store = useRackStore.getState()
store.setCableVisibility('hidden')
store.hoverDevice(store.cables[0].from.deviceId)
store.selectCable(store.cables[0].id)
const { container } = render(<CableLayer />)
expect(hitPaths(container)).toHaveLength(1)
})
it('draws nothing when cables are hidden and none is selected', () => {
useRackStore.getState().setCableVisibility('hidden')
const { container } = render(<CableLayer />)
expect(container).toBeEmptyDOMElement()
})
it('keeps the selected cable drawn when visibility is hover and nothing is focused', () => {
const store = useRackStore.getState()
store.setCableVisibility('hover')
store.selectCable(store.cables[0].id)
const { container } = render(<CableLayer />)
expect(hitPaths(container)).toHaveLength(1)
})
it('selects a cable on click in patch mode', () => {
useRackStore.getState().toggleCableMode()
const { container } = render(<CableLayer />)
const paths = hitPaths(container)
expect(paths.length).toBe(useRackStore.getState().cables.length)
fireEvent.click(paths[0])
expect(useRackStore.getState().selectedCableId).toBe(useRackStore.getState().cables[0].id)
})
it('deselects when the selected cable is clicked again', () => {
useRackStore.getState().toggleCableMode()
const { container } = render(<CableLayer />)
fireEvent.click(hitPaths(container)[0])
fireEvent.click(hitPaths(container)[0])
expect(useRackStore.getState().selectedCableId).toBeNull()
})
it('leaves the cable in place — clicking never deletes', () => {
useRackStore.getState().toggleCableMode()
const before = useRackStore.getState().cables.length
const { container } = render(<CableLayer />)
fireEvent.click(hitPaths(container)[0])
expect(useRackStore.getState().cables).toHaveLength(before)
})
it('draws no rubber band until the drag moves', () => {
const store = useRackStore.getState()
store.toggleCableMode()
const port = store.devices[0].ports[0]
store.startCableDrag(store.devices[0].id, port.id)
const { queryByTestId } = render(<CableLayer />)
expect(queryByTestId('cable-draft')).toBeNull()
})
it('draws a rubber band from the armed port to the pointer', () => {
const store = useRackStore.getState()
store.toggleCableMode()
const device = store.devices[0]
store.startCableDrag(device.id, device.ports[0].id)
store.moveCableDrag({ x: 420, y: 300 })
const { getByTestId } = render(<CableLayer />)
const band = getByTestId('cable-draft')
const end = band.querySelectorAll('circle')[1]
expect(end.getAttribute('cx')).toBe('420')
expect(end.getAttribute('cy')).toBe('300')
expect(band.querySelector('path')!.getAttribute('stroke-dasharray')).toBe('5 4')
})
it('draws copper and fibre alike — there is no type filter any more', () => {
useRackStore.getState().toggleCableMode()
const { cables } = useRackStore.getState()
expect(new Set(cables.map((c) => c.type))).toEqual(new Set(['ethernet', 'fiber']))
const { container } = render(<CableLayer />)
expect(hitPaths(container)).toHaveLength(cables.length)
})
it('prints nothing beside a cable that has no visible annotation', () => {
const store = useRackStore.getState()
store.setCableVisibility('always')
// A label alone is not enough — it is printed only once the user asks.
store.updateCable(store.cables[0].id, { label: 'Uplink', labelVisible: false })
const { queryAllByTestId } = render(<CableLayer />)
expect(queryAllByTestId('cable-annotation')).toHaveLength(0)
})
it('prints the label and the visible properties on the canvas', () => {
const store = useRackStore.getState()
store.setCableVisibility('always')
store.updateCable(store.cables[0].id, {
label: 'Uplink',
labelVisible: true,
properties: [
{ key: 'Length', value: '2 m', icon: null, visible: true },
{ key: 'VLAN', value: '20', icon: null, visible: false },
],
})
const { getAllByTestId } = render(<CableLayer />)
const badges = getAllByTestId('cable-annotation')
expect(badges).toHaveLength(1)
const lines = Array.from(badges[0].querySelectorAll('text')).map((t) => t.textContent)
expect(lines).toEqual(['Uplink', 'Length: 2 m'])
})
it('draws the selected cable with a highlight halo', () => {
useRackStore.getState().toggleCableMode()
const { container, rerender } = render(<CableLayer />)
const strokeWidths = () =>
Array.from(container.querySelectorAll<SVGPathElement>('path')).map((p) =>
p.getAttribute('stroke-width'),
)
expect(strokeWidths()).not.toContain('7')
fireEvent.click(hitPaths(container)[0])
rerender(<CableLayer />)
expect(strokeWidths()).toContain('7')
})
})
@@ -0,0 +1,138 @@
/** Pointing a mount at another Device Inventory entry. */
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { DevicePickerModal } from '../components/DevicePickerModal'
import { useRackStore } from '../store'
import type { InventoryDevice } from '@/types'
const refreshInventory = vi.hoisted(() => vi.fn())
function makeEntry(overrides: Partial<InventoryDevice> = {}): InventoryDevice {
return {
id: 'inv1',
label: 'nas-truenas',
type: 'nas',
discoverySource: 'arp',
ip: '192.168.1.40',
mac: 'aa:bb:cc:dd:ee:ff',
hostname: 'nas.lan',
os: null,
services: [],
status: 'online',
nodeId: null,
node: null,
racked: false,
suggestedFaceplateId: 'nas-2u',
...overrides,
}
}
function seed(inventory: InventoryDevice[]) {
useRackStore.setState({ inventory, refreshInventory })
}
beforeEach(() => {
refreshInventory.mockClear()
seed([])
})
describe('DevicePickerModal', () => {
it('offers the Device Inventory, canvas node or not', async () => {
// The whole point: a device no one ever approved onto a logical canvas is
// still the record of a real box, and is what racks are built out of.
seed([
makeEntry(),
makeEntry({ id: 'inv2', label: 'pve-01', ip: '192.168.1.10', nodeId: 'node2' }),
])
render(<DevicePickerModal open onPick={vi.fn()} onClose={vi.fn()} />)
expect(screen.getByText('nas-truenas')).toBeInTheDocument()
expect(screen.getByText('pve-01')).toBeInTheDocument()
expect(refreshInventory).toHaveBeenCalled()
})
it('hands the picked entry back', async () => {
const onPick = vi.fn()
seed([makeEntry()])
render(<DevicePickerModal open onPick={onPick} onClose={vi.fn()} />)
await userEvent.click(screen.getByText('nas-truenas'))
expect(onPick).toHaveBeenCalledWith(expect.objectContaining({ id: 'inv1' }))
})
it('prints the source and the canvas, which tell look-alikes apart', () => {
seed([
makeEntry({
discoverySource: 'rack',
node: {
id: 'node1',
label: 'nas-truenas',
type: 'nas',
ip: '192.168.1.40',
mac: null,
hostname: null,
os: null,
checkMethod: 'ping',
designId: 'd1',
designName: 'Network Topology',
lastSeen: null,
},
}),
])
render(<DevicePickerModal open onPick={vi.fn()} onClose={vi.fn()} />)
expect(screen.getByText(/Rack device/)).toBeInTheDocument()
expect(screen.getByText(/Network Topology/)).toBeInTheDocument()
})
it('hides a device another plate already stands for, keeping the current one', () => {
// One entry, one mount — but the mount being edited must still see its own.
seed([
makeEntry({ id: 'inv1', label: 'mine', racked: true }),
makeEntry({ id: 'inv2', label: 'taken', racked: true }),
makeEntry({ id: 'inv3', label: 'free' }),
])
render(<DevicePickerModal open value="inv1" onPick={vi.fn()} onClose={vi.fn()} />)
expect(screen.getByText('mine')).toBeInTheDocument()
expect(screen.getByText('free')).toBeInTheDocument()
expect(screen.queryByText('taken')).not.toBeInTheDocument()
})
it('searches name, IP, hostname and source', async () => {
seed([makeEntry(), makeEntry({ id: 'inv2', label: 'pve-01', ip: '192.168.1.10', hostname: 'pve.lan' })])
render(<DevicePickerModal open onPick={vi.fn()} onClose={vi.fn()} />)
const search = screen.getByLabelText('Search devices')
await userEvent.type(search, '192.168.1.10')
expect(screen.queryByText('nas-truenas')).not.toBeInTheDocument()
expect(screen.getByText('pve-01')).toBeInTheDocument()
await userEvent.clear(search)
await userEvent.type(search, 'pve.lan')
expect(screen.getByText('pve-01')).toBeInTheDocument()
await userEvent.clear(search)
await userEvent.type(search, 'network scan')
expect(screen.getByText('nas-truenas')).toBeInTheDocument()
})
it('points at the Device Inventory when it is empty', () => {
render(<DevicePickerModal open onPick={vi.fn()} onClose={vi.fn()} />)
expect(screen.getByText(/The Device Inventory is empty/)).toBeInTheDocument()
})
it('says a search matched nothing, which is not the same thing', async () => {
seed([makeEntry()])
render(<DevicePickerModal open onPick={vi.fn()} onClose={vi.fn()} />)
await userEvent.type(screen.getByLabelText('Search devices'), 'nothing')
expect(screen.getByText('No device matches that search.')).toBeInTheDocument()
})
it('does not refetch while closed', () => {
render(<DevicePickerModal open={false} onPick={vi.fn()} onClose={vi.fn()} />)
expect(refreshInventory).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,88 @@
/**
* The renderer places the name band and the status LED; a template can move
* both off mid-height, which is what makes a desktop NAS look like one.
*/
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { Faceplate } from '../components/Faceplate'
import { getFaceplate } from '../faceplates'
const HEIGHT = 100
function draw(faceplateId: string) {
const plate = getFaceplate(faceplateId)
const { container } = render(
<Faceplate
faceplateId={faceplateId}
label="nas-01"
status="online"
ports={plate.ports.map((p, i) => ({ ...p, id: `p${i}` }))}
width={200}
height={HEIGHT}
revealed
/>,
)
return {
led: container.querySelector('circle'),
text: container.querySelector('text'),
}
}
/** Every RJ45 silhouette drawn on a plate, as its path `d`. */
function rj45Paths(faceplateId: string, height: number) {
const plate = getFaceplate(faceplateId)
const ports = plate.ports.map((p, i) => ({ ...p, id: `p${i}` }))
const { container } = render(
<Faceplate
faceplateId={faceplateId}
label="gear"
status="online"
ports={ports}
width={400}
height={height}
revealed
/>,
)
return Array.from(container.querySelectorAll('g'))
.filter((g) => g.querySelector('title')?.textContent?.endsWith('· rj45'))
.map((g) => g.querySelector('path')?.getAttribute('d'))
.filter(Boolean)
}
describe('Faceplate — port artwork', () => {
it('draws every RJ45 at the same size, whatever the plate', () => {
// A 1U switch, a 1U patch panel and a 4U NAS: same socket on all three.
const reference = rj45Paths('switch-24', 24)[0]
expect(reference).toBeTruthy()
for (const [id, height] of [
['patch-24', 24],
['server-1u', 24],
['nas-2u', 48],
['server-4u-storage', 96],
] as const) {
const paths = rj45Paths(id, height)
expect(paths.length).toBeGreaterThan(0)
for (const d of paths) expect(d).toBe(reference)
}
})
it('keeps the socket the same size on a plate stretched to 4U', () => {
expect(rj45Paths('switch-24', 96)[0]).toBe(rj45Paths('switch-24', 24)[0])
})
})
describe('Faceplate — name band', () => {
it('keeps rack gear labelled across the middle', () => {
const { led, text } = draw('server-1u')
expect(led).toHaveAttribute('cy', String(HEIGHT / 2))
expect(text).toHaveAttribute('y', String(HEIGHT / 2))
})
it('drops the badge and the LED to the bottom strip of a desktop NAS', () => {
const band = getFaceplate('nas-desktop-2').labelBox.y!
const { led, text } = draw('nas-desktop-2')
// Drive trays own the height; the badge belongs under them, not across them.
expect(led).toHaveAttribute('cy', String(band * HEIGHT))
expect(text).toHaveAttribute('y', String(band * HEIGHT))
})
})
@@ -0,0 +1,84 @@
/**
* The picker is the only place the faceplate catalog is browsable, so it has to
* show every template, filter honestly and report the pick.
*/
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent, within } from '@testing-library/react'
import { FaceplatePicker } from '../components/FaceplatePicker'
import { FACEPLATES, faceplateGroups } from '../faceplates'
function setup(props: Partial<React.ComponentProps<typeof FaceplatePicker>> = {}) {
const onPick = vi.fn()
const onClose = vi.fn()
render(
<FaceplatePicker open value="server-1u" onPick={onPick} onClose={onClose} {...props} />,
)
return { onPick, onClose }
}
describe('FaceplatePicker', () => {
it('renders a tile for every template, grouped', () => {
setup()
for (const plate of FACEPLATES) {
expect(screen.getByRole('button', { name: plate.label })).toBeInTheDocument()
}
for (const { group } of faceplateGroups()) {
expect(screen.getByText(group)).toBeInTheDocument()
}
})
it('draws each plate rather than listing its name only', () => {
setup()
const tile = screen.getByRole('button', { name: 'Switch 24 ports + 2 SFP' })
expect(tile.querySelector('svg')).toBeInTheDocument()
// Height and width read from the template, so sizes compare across tiles.
expect(within(tile).getByText(/^1U · Full width · 26 ports$/)).toBeInTheDocument()
})
it('marks the applied plate as selected', () => {
setup({ value: 'nas-2u' })
expect(screen.getByRole('button', { name: 'NAS 2U — 8 bays' })).toHaveAttribute(
'aria-pressed',
'true',
)
expect(screen.getByRole('button', { name: 'Server 1U' })).toHaveAttribute(
'aria-pressed',
'false',
)
})
it('filters on name and on category', () => {
setup()
fireEvent.change(screen.getByLabelText('Search faceplates'), { target: { value: 'switch' } })
expect(screen.getByRole('button', { name: 'Switch 8 ports' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'NAS 2U — 8 bays' })).not.toBeInTheDocument()
fireEvent.change(screen.getByLabelText('Search faceplates'), { target: { value: 'power' } })
expect(screen.getByRole('button', { name: 'PDU 1U — 8 outlets' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Switch 8 ports' })).not.toBeInTheDocument()
})
it('says so when nothing matches', () => {
setup()
fireEvent.change(screen.getByLabelText('Search faceplates'), { target: { value: 'zzz' } })
expect(screen.getByText(/No faceplate matches/)).toBeInTheDocument()
})
it('restricts the catalog to a kind', () => {
setup({ kind: 'accessory' })
expect(screen.getByRole('button', { name: 'Blank panel 1U' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Server 1U' })).not.toBeInTheDocument()
})
it('reports the pick and closes', () => {
const { onPick, onClose } = setup()
fireEvent.click(screen.getByRole('button', { name: 'Router / firewall 1U' }))
expect(onPick).toHaveBeenCalledWith('router-1u')
expect(onClose).toHaveBeenCalled()
})
it('renders nothing while closed', () => {
setup({ open: false })
expect(screen.queryByRole('button', { name: 'Server 1U' })).not.toBeInTheDocument()
})
})
@@ -0,0 +1,143 @@
/** The logical-canvas facts printed beside a mount's ports. */
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LinkedDevicePanel } from '../components/LinkedDevicePanel'
import type { InventoryDevice } from '@/types'
function makeEntry(overrides: Partial<InventoryDevice> = {}): InventoryDevice {
return {
id: 'inv1',
label: 'nas',
type: 'nas',
ip: '192.168.1.9',
mac: 'aa:bb:cc:dd:ee:ff',
hostname: 'nas.lan',
os: 'TrueNAS',
services: [{ port: 445, name: 'smb' }],
status: 'online',
nodeId: 'node1',
node: {
id: 'node1',
label: 'nas-truenas',
type: 'nas',
ip: '192.168.1.9',
mac: 'aa:bb:cc:dd:ee:ff',
hostname: 'nas.lan',
os: 'TrueNAS SCALE',
checkMethod: 'http',
designId: 'd1',
designName: 'Network',
lastSeen: null,
},
racked: true,
suggestedFaceplateId: 'nas-2u',
...overrides,
}
}
describe('LinkedDevicePanel', () => {
it('renders nothing for a mount with no inventory entry', () => {
const { container } = render(<LinkedDevicePanel entry={null} status="unknown" />)
expect(container).toBeEmptyDOMElement()
})
it('prints the linked node facts and its services', () => {
render(<LinkedDevicePanel entry={makeEntry()} status="online" />)
expect(screen.getByText('nas-truenas')).toBeInTheDocument()
expect(screen.getByText('192.168.1.9')).toBeInTheDocument()
expect(screen.getByText('aa:bb:cc:dd:ee:ff')).toBeInTheDocument()
expect(screen.getByText('nas.lan')).toBeInTheDocument()
expect(screen.getByText('http')).toBeInTheDocument()
expect(screen.getByText('Network')).toBeInTheDocument()
expect(screen.getByText('smb:445')).toBeInTheDocument()
})
it('prefers the node value over the inventory row it disagrees with', () => {
// The node is what the user curates; the inventory row is what discovery
// last saw, and it goes stale after a rename or a DHCP move.
const entry = makeEntry({
ip: '192.168.1.99',
node: { ...makeEntry().node!, ip: '192.168.1.9' },
})
render(<LinkedDevicePanel entry={entry} status="online" />)
expect(screen.getByText('192.168.1.9')).toBeInTheDocument()
expect(screen.queryByText('192.168.1.99')).not.toBeInTheDocument()
})
it('still prints what discovery found when the device is on no canvas', () => {
// The inventory row is the record of a real box whether or not anyone ever
// drew it on a canvas — only the canvas-side rows go missing.
render(<LinkedDevicePanel entry={makeEntry({ nodeId: null, node: null })} status="unknown" />)
expect(screen.getByText('192.168.1.9')).toBeInTheDocument()
expect(screen.getByText('TrueNAS')).toBeInTheDocument()
expect(screen.getByText('smb:445')).toBeInTheDocument()
// No node means no check to follow and no canvas to name.
expect(screen.getByText('Not on a logical canvas.')).toBeInTheDocument()
expect(screen.queryByText('Canvas')).not.toBeInTheDocument()
})
it('offers no link when relinking is not on offer', () => {
// A mount that does not exist yet has nothing to hang a link on.
render(<LinkedDevicePanel entry={makeEntry({ nodeId: null, node: null })} status="unknown" />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('lets a mount be pointed at another device', async () => {
// The device behind a plate can be picked wrong — a placeholder, or the
// wrong twin of two look-alikes — so the panel must not be a dead end.
const onLink = vi.fn()
render(<LinkedDevicePanel entry={makeEntry()} status="online" onLink={onLink} />)
await userEvent.click(screen.getByRole('button', { name: /Link to another device/ }))
expect(onLink).toHaveBeenCalled()
})
it('shows the status it was handed, not the inventory row own', () => {
// The plate resolves `auto` through the inventory; the panel must agree with
// the LED next to it rather than resolve a second time.
render(<LinkedDevicePanel entry={makeEntry()} status="offline" />)
expect(screen.getByText('offline')).toBeInTheDocument()
})
it('skips the rows nothing knows about', () => {
const bare: InventoryDevice = {
id: 'inv2',
// A row discovery could not even name — nothing to fall back on.
label: '',
type: null,
status: 'unknown',
nodeId: 'node2',
// A node exists, and holds nothing worth printing.
node: {
id: 'node2',
label: null,
type: null,
ip: null,
mac: null,
hostname: null,
os: null,
checkMethod: null,
designId: null,
designName: null,
lastSeen: null,
},
racked: false,
suggestedFaceplateId: 'patch-24',
}
render(<LinkedDevicePanel entry={bare} status="unknown" />)
expect(screen.getByText(/Nothing known about this device yet/)).toBeInTheDocument()
expect(screen.queryByText('IP')).not.toBeInTheDocument()
expect(screen.queryByText('Services')).not.toBeInTheDocument()
})
it('prints a last-seen timestamp only when it parses', () => {
const node = makeEntry().node!
render(<LinkedDevicePanel entry={makeEntry({ node: { ...node, lastSeen: 'not-a-date' } })} status="online" />)
expect(screen.queryByText('Last seen')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,165 @@
/**
* The rail a selected cable opens: physical facts up top, then the same
* property records the logical canvas uses for nodes.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { RackCablePanel } from '../components/RackCablePanel'
import { CABLE_COLORS } from '../rackDefaults'
import { useRackStore } from '../store'
vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
const store = () => useRackStore.getState()
const selected = () => store().cables.find((c) => c.id === store().selectedCableId)!
beforeEach(() => {
store().loadDemo()
})
describe('RackCablePanel', () => {
it('renders nothing until a cable is selected', () => {
const { container } = render(<RackCablePanel />)
expect(container).toBeEmptyDOMElement()
})
it('names both endpoints by device and port', () => {
const cable = store().cables[0]
store().selectCable(cable.id)
const device = store().devices.find((d) => d.id === cable.from.deviceId)!
const port = device.ports.find((p) => p.id === cable.from.portId)!
render(<RackCablePanel />)
expect(screen.getAllByTitle(device.label).length).toBeGreaterThan(0)
expect(screen.getAllByTitle(port.label).length).toBeGreaterThan(0)
})
it('edits the label and toggles printing it on the canvas', () => {
store().selectCable(store().cables[0].id)
render(<RackCablePanel />)
fireEvent.change(screen.getByLabelText('Cable label'), { target: { value: 'Uplink' } })
expect(selected().label).toBe('Uplink')
fireEvent.click(screen.getByRole('checkbox'))
expect(selected().labelVisible).toBe(true)
})
it('recolours from a swatch and from the hex field', () => {
store().selectCable(store().cables[0].id)
render(<RackCablePanel />)
fireEvent.click(screen.getByLabelText('Cable colour #a855f7'))
expect(selected().color).toBe('#a855f7')
const hex = screen.getByLabelText('Cable colour hex')
fireEvent.change(hex, { target: { value: '#123456' } })
fireEvent.blur(hex)
expect(selected().color).toBe('#123456')
})
it('leaves the cable alone while a hex is half-typed', () => {
// "#39d" parses as a colour, but "#3" does not — a per-keystroke write fed
// the SVG stroke junk and the run stopped rendering.
const cable = store().cables[0]
store().selectCable(cable.id)
render(<RackCablePanel />)
fireEvent.change(screen.getByLabelText('Cable colour hex'), { target: { value: '#12' } })
expect(selected().color).toBe(cable.color)
})
it('falls back to the type default when the hex does not parse', () => {
const cable = store().cables.find((c) => c.type === 'ethernet')!
store().selectCable(cable.id)
render(<RackCablePanel />)
const hex = screen.getByLabelText('Cable colour hex')
fireEvent.change(hex, { target: { value: 'zzz' } })
fireEvent.blur(hex)
expect(selected().color).toBe(CABLE_COLORS.ethernet)
})
it('accepts the three-digit shorthand', () => {
store().selectCable(store().cables[0].id)
render(<RackCablePanel />)
const hex = screen.getByLabelText('Cable colour hex')
fireEvent.change(hex, { target: { value: '#abc' } })
fireEvent.blur(hex)
expect(selected().color).toBe('#abc')
})
it('recolours a default-coloured cable when its type changes', () => {
const ethernet = store().cables.find((c) => c.type === 'ethernet')!
store().updateCable(ethernet.id, { color: CABLE_COLORS.ethernet })
store().selectCable(ethernet.id)
render(<RackCablePanel />)
fireEvent.click(screen.getByText('Fibre'))
expect(selected().type).toBe('fiber')
expect(selected().color).toBe(CABLE_COLORS.fiber)
})
it('keeps a hand-picked colour across a type change', () => {
const ethernet = store().cables.find((c) => c.type === 'ethernet')!
store().updateCable(ethernet.id, { color: '#ff00ff' })
store().selectCable(ethernet.id)
render(<RackCablePanel />)
fireEvent.click(screen.getByText('Fibre'))
expect(selected().type).toBe('fiber')
expect(selected().color).toBe('#ff00ff')
})
it('adds a property through a suggestion and stores it on the cable', () => {
store().selectCable(store().cables[0].id)
render(<RackCablePanel />)
fireEvent.click(screen.getByText('+ Length'))
fireEvent.change(screen.getByPlaceholderText('Value (e.g. 2 m)'), { target: { value: '2 m' } })
// Two buttons read "Add": the section's, then the form's confirm.
const addButtons = screen.getAllByRole('button', { name: 'Add' })
fireEvent.click(addButtons[addButtons.length - 1])
expect(selected().properties).toEqual([
{ key: 'Length', value: '2 m', icon: null, visible: true },
])
})
it('hides a property from the canvas without deleting it', () => {
const cable = store().cables[0]
store().updateCable(cable.id, {
properties: [{ key: 'VLAN', value: '20', icon: null, visible: true }],
})
store().selectCable(cable.id)
render(<RackCablePanel />)
fireEvent.click(screen.getByTitle('Hide on canvas'))
expect(selected().properties).toEqual([
{ key: 'VLAN', value: '20', icon: null, visible: false },
])
})
it('unplugs the cable', () => {
const cable = store().cables[0]
store().selectCable(cable.id)
render(<RackCablePanel />)
fireEvent.click(screen.getByRole('button', { name: /Unplug cable/ }))
expect(store().cables.some((c) => c.id === cable.id)).toBe(false)
expect(store().selectedCableId).toBeNull()
})
it('closes without touching the cable', () => {
const before = store().cables.length
store().selectCable(store().cables[0].id)
render(<RackCablePanel />)
fireEvent.click(screen.getByLabelText('Close panel'))
expect(store().selectedCableId).toBeNull()
expect(store().cables).toHaveLength(before)
})
})
@@ -0,0 +1,166 @@
/**
* The empty-state overlay is a child of `<ReactFlow>`, i.e. a sibling of
* `.react-flow__renderer` (z-index 4). Without an explicit stacking context it
* paints *under* the pane, which then eats the clicks as a canvas drag — the
* buttons look enabled but do nothing.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { RackCanvas } from '../components/RackCanvas'
import { useRackStore } from '../store'
vi.mock('@xyflow/react', async () => {
const { mockReactFlow } = await import('@/test/mocks')
const React = await import('react')
return mockReactFlow({
ReactFlow: ({ children }: { children?: React.ReactNode }) =>
React.createElement('div', { 'data-testid': 'flow' }, children),
Background: () => null,
BackgroundVariant: { Dots: 'dots' },
Controls: () => null,
ViewportPortal: () => null,
useReactFlow: () => ({
setViewport: vi.fn(),
screenToFlowPosition: (p: { x: number; y: number }) => p,
}),
})
})
beforeEach(() => {
useRackStore.getState().reset()
})
describe('RackCanvas empty state', () => {
it('lifts the overlay above the React Flow pane so its buttons are clickable', () => {
render(<RackCanvas />)
const overlay = screen.getByText('This rack canvas is empty.').parentElement!
expect(overlay.className).toContain('z-10')
// The wrapper stays click-through; only the button row takes pointers.
expect(overlay.className).toContain('pointer-events-none')
expect(screen.getByRole('button', { name: /add a rack/i }).parentElement!.className).toContain(
'pointer-events-auto',
)
})
it('adds a rack from the empty state', () => {
render(<RackCanvas />)
fireEvent.click(screen.getByRole('button', { name: /add a rack/i }))
expect(useRackStore.getState().racks).toHaveLength(1)
})
it('loads the sample rack from the empty state', () => {
render(<RackCanvas />)
fireEvent.click(screen.getByRole('button', { name: /load a sample rack/i }))
expect(useRackStore.getState().racks).toHaveLength(1)
expect(useRackStore.getState().devices.length).toBeGreaterThan(0)
})
it('hides the overlay once a rack exists', () => {
useRackStore.getState().addRack()
render(<RackCanvas />)
expect(screen.queryByText('This rack canvas is empty.')).toBeNull()
})
})
describe('RackCanvas cable keyboard shortcuts', () => {
const selectFirstCable = () => {
const store = useRackStore.getState()
store.loadDemo()
store.toggleCableMode()
const cable = useRackStore.getState().cables[0]
useRackStore.getState().selectCable(cable.id)
return cable
}
it('unplugs the selected cable on Delete', () => {
const cable = selectFirstCable()
render(<RackCanvas />)
fireEvent.keyDown(window, { key: 'Delete' })
expect(useRackStore.getState().cables.some((c) => c.id === cable.id)).toBe(false)
})
it('unplugs the selected cable on Backspace', () => {
const cable = selectFirstCable()
render(<RackCanvas />)
fireEvent.keyDown(window, { key: 'Backspace' })
expect(useRackStore.getState().cables.some((c) => c.id === cable.id)).toBe(false)
})
it('deselects on Escape without removing the cable', () => {
const cable = selectFirstCable()
render(<RackCanvas />)
fireEvent.keyDown(window, { key: 'Escape' })
expect(useRackStore.getState().selectedCableId).toBeNull()
expect(useRackStore.getState().cables.some((c) => c.id === cable.id)).toBe(true)
})
it('ignores Delete while typing in an input', () => {
const cable = selectFirstCable()
render(
<>
<RackCanvas />
<input data-testid="typing" />
</>,
)
fireEvent.keyDown(screen.getByTestId('typing'), { key: 'Delete' })
expect(useRackStore.getState().cables.some((c) => c.id === cable.id)).toBe(true)
})
it('drops a half-drawn patch on Escape', () => {
const store = useRackStore.getState()
store.loadDemo()
store.toggleCableMode()
const device = useRackStore.getState().devices[0]
useRackStore.getState().startCableDrag(device.id, device.ports[0].id)
render(<RackCanvas />)
fireEvent.keyDown(window, { key: 'Escape' })
expect(useRackStore.getState().cableDraft).toBeNull()
})
it('does nothing on Delete with no cable selected', () => {
useRackStore.getState().loadDemo()
const before = useRackStore.getState().cables.length
render(<RackCanvas />)
fireEvent.keyDown(window, { key: 'Delete' })
expect(useRackStore.getState().cables).toHaveLength(before)
})
})
describe('RackCanvas cable dragging', () => {
const armPort = () => {
const store = useRackStore.getState()
store.loadDemo()
store.toggleCableMode()
const device = useRackStore.getState().devices[0]
useRackStore.getState().startCableDrag(device.id, device.ports[0].id)
}
it('tracks the pointer while a patch is being dragged', () => {
armPort()
render(<RackCanvas />)
fireEvent.pointerMove(window, { clientX: 64, clientY: 128 })
// The mocked screenToFlowPosition is the identity.
expect(useRackStore.getState().cableDrag).toEqual({
pointer: { x: 64, y: 128 },
moved: true,
})
})
it('cancels the draft when the drag is released on nothing', () => {
armPort()
render(<RackCanvas />)
fireEvent.pointerMove(window, { clientX: 64, clientY: 128 })
fireEvent.pointerUp(window)
expect(useRackStore.getState().cableDraft).toBeNull()
expect(useRackStore.getState().cableDrag).toBeNull()
})
it('keeps the port armed when the press never moved', () => {
armPort()
const armed = useRackStore.getState().cableDraft
render(<RackCanvas />)
fireEvent.pointerUp(window)
expect(useRackStore.getState().cableDraft).toEqual(armed)
expect(useRackStore.getState().cableDrag).toBeNull()
})
})
@@ -0,0 +1,685 @@
/**
* The device modal is the only editor for a mount — the rack canvas has no
* right rail — so everything the old inspector did has to work from here.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { act, render, screen, fireEvent, waitFor } from '@testing-library/react'
import { RackDeviceModal } from '../components/RackDeviceModal'
import { useRackStore } from '../store'
const createPending = vi.fn()
const pendingList = vi.fn()
const deletePending = vi.fn()
vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
vi.mock('@/api/client', () => ({
racksApi: { load: vi.fn(), inventory: vi.fn(), save: vi.fn() },
scanApi: {
createPending: (...args: unknown[]) => createPending(...args),
// The inventory entry is now picked in the Device Inventory modal, which
// reads `pending_devices` itself rather than the rack's mirrored list.
pending: (...args: unknown[]) => pendingList(...args),
hidden: vi.fn().mockResolvedValue({ data: [] }),
// Cleanup after a relink: the placeholder the rack created is dropped.
deletePending: (...args: unknown[]) => deletePending(...args),
},
}))
const store = () => useRackStore.getState()
beforeEach(() => {
vi.clearAllMocks()
store().loadDemo()
pendingList.mockResolvedValue({ data: [] })
deletePending.mockResolvedValue({ data: { deleted: true } })
})
/** Shape the Device Inventory modal expects for one of the rack's entries. */
function pendingRow(id: string, extra: Record<string, unknown> = {}) {
return {
id,
ip: null,
hostname: id,
mac: null,
os: null,
services: [],
suggested_type: 'server',
status: 'pending',
discovery_source: 'arp',
discovered_at: '2026-01-01T00:00:00Z',
...extra,
}
}
/** Open the Device Inventory from the rack modal and click one of its cards. */
async function pickFromInventory(id: string, extra: Record<string, unknown> = {}) {
pendingList.mockResolvedValue({ data: [pendingRow(id, extra)] })
fireEvent.click(screen.getByLabelText('Device Inventory entry'))
const card = await screen.findByTestId(`pending-card-${id}`)
await act(async () => {
fireEvent.click(card)
})
}
function submit() {
fireEvent.click(screen.getByRole('button', { name: /^(Add|Save)$/ }))
}
/** The plate is picked from the visual catalog, not a native select. */
function pickFaceplate(label: string) {
fireEvent.click(screen.getByLabelText('Faceplate'))
fireEvent.click(screen.getByRole('button', { name: label }))
}
describe('RackDeviceModal — nothing to edit', () => {
it('renders nothing while no editor is open', () => {
const { container } = render(<RackDeviceModal />)
expect(container).toBeEmptyDOMElement()
})
})
describe('RackDeviceModal — adding', () => {
it('mounts an entry picked from the Device Inventory', async () => {
store().openDeviceEditor()
render(<RackDeviceModal />)
const unracked = store().inventory.find((i) => !i.racked)!
await pickFromInventory(unracked.id)
submit()
await waitFor(() =>
expect(store().devices.some((d) => d.deviceId === unracked.id)).toBe(true),
)
// The mount is a rack row; the inventory entry itself stays, now flagged.
expect(store().inventory.find((i) => i.id === unracked.id)!.racked).toBe(true)
expect(store().deviceEditor).toBeNull()
})
it('takes the picked entry status instead of saving unknown over it', async () => {
store().openDeviceEditor()
const first = store().inventory.find((i) => !i.racked)!
useRackStore.setState({
inventory: store().inventory.map((i) =>
i.id === first.id ? { ...i, status: 'online' as const } : i,
),
})
render(<RackDeviceModal />)
// Picking and submitting without touching the Status select is the common
// path — it used to commit `unknown` over the entry's live status.
await pickFromInventory(first.id)
submit()
await waitFor(() => expect(store().deviceEditor).toBeNull())
expect(store().devices.find((d) => d.deviceId === first.id)!.status).toBe('online')
})
it('refuses an entry already mounted in this design', async () => {
const { toast } = await import('sonner')
store().openDeviceEditor()
const racked = store().inventory.find((i) => i.racked)!
render(<RackDeviceModal />)
// The Device Inventory lists every device, racked or not — the rack side is
// what knows one is already mounted here, and one mount per entry is the rule.
await pickFromInventory(racked.id)
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('already mounted'))
expect(screen.getByLabelText('Device Inventory entry')).toHaveAttribute('data-device-id', '')
})
it('refuses a device the rack inventory does not carry', async () => {
const { toast } = await import('sonner')
store().openDeviceEditor()
render(<RackDeviceModal />)
// The two lists come from different endpoints: a row can be in the Device
// Inventory and absent from the rack's (hidden there, or a kind no rack can
// hold). Refuse it rather than mount a ghost with no entry behind it.
await pickFromInventory('dev-ghost')
expect(toast.error).toHaveBeenCalledWith('That device cannot be mounted in a rack')
expect(screen.getByLabelText('Device Inventory entry')).toHaveAttribute('data-device-id', '')
})
it('creates nothing in the inventory when the rack has no room', async () => {
store().openDeviceEditor()
const rack = store().racks[0]
// A 1U rack with its only slot taken: the mount cannot succeed.
useRackStore.setState({
racks: [{ ...rack, uHeight: 1 }],
devices: [
{
id: 'blocker',
rackId: rack.id,
deviceId: null,
nodeId: null,
label: 'blocker',
faceplateId: 'blank-1u',
uStart: 1,
uHeight: 1,
colStart: 0,
colSpan: 12,
status: 'unknown',
ports: [],
},
],
cables: [],
})
const inventoryBefore = store().inventory.length
render(<RackDeviceModal />)
fireEvent.click(screen.getByRole('button', { name: 'New device' }))
fireEvent.change(screen.getByLabelText('Label'), { target: { value: 'orphan' } })
// The submit path is async (it may POST), so let it settle before asserting.
await act(async () => submit())
const { toast } = await import('sonner')
expect(toast.error).toHaveBeenCalled()
// The row is POSTed before the mount, so a late failure would strand it —
// and strand another one on every retry.
expect(createPending).not.toHaveBeenCalled()
expect(store().inventory).toHaveLength(inventoryBefore)
expect(store().deviceEditor).not.toBeNull()
})
it('blames the missing entry, not the rack, when the pick is gone', async () => {
store().openDeviceEditor()
const picked = store().inventory.find((i) => !i.racked)!
render(<RackDeviceModal />)
await pickFromInventory(picked.id)
// Racked from another modal, or purged from the inventory, while this one
// was open: the mount fails, and it used to read "No free slot in this rack".
act(() => {
useRackStore.setState({ inventory: store().inventory.filter((i) => i.id !== picked.id) })
})
// The field drops back to its placeholder rather than holding a dead id…
expect(screen.getByLabelText('Device Inventory entry')).toHaveAttribute('data-device-id', '')
await act(async () => submit())
// …so the error names the real cause instead of the rack's capacity.
const { toast } = await import('sonner')
expect(toast.error).toHaveBeenCalledWith('Pick a device from the inventory')
expect(store().deviceEditor).not.toBeNull()
})
it('keeps the slot the mount picked instead of dragging it back', async () => {
store().openDeviceEditor()
const entry = store().inventory.find((i) => !i.racked)!
render(<RackDeviceModal />)
await pickFromInventory(entry.id)
// U 1 is taken in the demo rack, so the mount relocates. Patching the form's
// own geometry afterwards used to undo that, silently.
fireEvent.change(screen.getByLabelText('U position'), { target: { value: '1' } })
submit()
await waitFor(() => expect(store().deviceEditor).toBeNull())
const mounted = store().devices.find((d) => d.deviceId === entry.id)!
const others = store().devices.filter((d) => d.id !== mounted.id && d.rackId === mounted.rackId)
for (const other of others) {
const overlaps =
mounted.uStart < other.uStart + other.uHeight &&
other.uStart < mounted.uStart + mounted.uHeight &&
mounted.colStart < other.colStart + other.colSpan &&
other.colStart < mounted.colStart + mounted.colSpan
expect(overlaps).toBe(false)
}
})
it('names the picked entry without repeating the IP', async () => {
store().openDeviceEditor()
useRackStore.setState({
inventory: [
{
id: 'inv-ip',
label: '192.168.1.63',
type: 'server',
ip: '192.168.1.63',
status: 'unknown',
nodeId: null,
racked: false,
suggestedFaceplateId: 'server-1u',
},
],
})
render(<RackDeviceModal />)
await pickFromInventory('inv-ip')
// Regression: an IP-labelled entry read "192.168.1.63 · 192.168.1.63".
const field = screen.getByLabelText('Device Inventory entry')
expect(field).toHaveTextContent('192.168.1.63 · server')
expect(field).not.toHaveTextContent('192.168.1.63 · 192.168.1.63')
})
it('lays the form out in two columns inside a NodeModal-sized dialog', () => {
store().openDeviceEditor()
render(<RackDeviceModal />)
// Regression: a max-w-md dialog with one column pushed the port list — and
// the Add button — below the fold on a laptop screen.
expect(screen.getByRole('dialog')).toHaveClass('sm:max-w-3xl')
const columns = document.querySelector('.sm\\:grid-cols-2')!
expect(columns).not.toBeNull()
// Fields on the left, ports on the right, both inside the same grid.
expect(columns).toContainElement(screen.getByLabelText('Label'))
expect(columns).toContainElement(screen.getByRole('button', { name: 'Add port' }))
})
it('offers a Width option for every plate width in the catalog', async () => {
const { FACEPLATES } = await import('../faceplates')
store().openDeviceEditor()
render(<RackDeviceModal />)
// A plate whose colSpan has no option (the sixth-width NAS did) leaves the
// select blank and the field looks broken.
const offered = [...screen.getByLabelText('Width').querySelectorAll('option')].map((o) =>
Number(o.getAttribute('value')),
)
for (const span of new Set(FACEPLATES.map((f) => f.colSpan))) {
expect(offered).toContain(span)
}
})
it('creates a brand new inventory entry from the canvas and mounts it', async () => {
createPending.mockResolvedValue({ data: { id: 'pending-1' } })
store().openDeviceEditor()
render(<RackDeviceModal />)
fireEvent.click(screen.getByRole('button', { name: 'New device' }))
fireEvent.change(screen.getByLabelText('Label'), { target: { value: 'tape-lib' } })
fireEvent.change(screen.getByLabelText('IP'), { target: { value: '192.168.1.99' } })
submit()
await waitFor(() => expect(createPending).toHaveBeenCalledTimes(1))
expect(createPending).toHaveBeenCalledWith(
expect.objectContaining({ hostname: 'tape-lib', ip: '192.168.1.99' }),
)
await waitFor(() =>
expect(store().devices.some((d) => d.label === 'tape-lib')).toBe(true),
)
expect(store().inventory.some((i) => i.id === 'pending-1')).toBe(true)
})
it('types a canvas-created device from the plate it wears', async () => {
createPending.mockResolvedValue({ data: { id: 'pending-pdu' } })
store().openDeviceEditor()
render(<RackDeviceModal />)
fireEvent.click(screen.getByRole('button', { name: 'New device' }))
fireEvent.change(screen.getByLabelText('Label'), { target: { value: 'pdu-a' } })
pickFaceplate('PDU 1U — 8 outlets')
submit()
// Nothing discovered this device, so the plate is what says what it is —
// the row used to land in the Device Inventory with no type at all.
await waitFor(() => expect(createPending).toHaveBeenCalledTimes(1))
expect(createPending).toHaveBeenCalledWith(
expect.objectContaining({ suggested_type: 'pdu', discovery_source: 'rack' }),
)
await waitFor(() => expect(store().deviceEditor).toBeNull())
expect(store().inventory.find((i) => i.id === 'pending-pdu')!.type).toBe('pdu')
})
it('mounts an accessory without touching the inventory', async () => {
const before = store().inventory.length
store().openDeviceEditor()
render(<RackDeviceModal />)
fireEvent.click(screen.getByRole('button', { name: 'Accessory' }))
pickFaceplate('Blank panel 1U')
fireEvent.change(screen.getByLabelText('Label'), { target: { value: 'filler' } })
submit()
await waitFor(() => expect(store().devices.some((d) => d.label === 'filler')).toBe(true))
const mounted = store().devices.find((d) => d.label === 'filler')!
expect(mounted.deviceId).toBeNull()
expect(store().inventory).toHaveLength(before)
})
it('swaps to an accessory plate — and back — with the source', () => {
store().openDeviceEditor()
render(<RackDeviceModal />)
fireEvent.click(screen.getByRole('button', { name: 'Accessory' }))
expect(screen.getByLabelText('Faceplate')).toHaveAttribute('data-faceplate', 'blank-1u')
// The picker then only offers rack furniture.
fireEvent.click(screen.getByLabelText('Faceplate'))
expect(screen.queryByRole('button', { name: 'Server 1U' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Shelf 1U' }))
expect(screen.getByLabelText('Faceplate')).toHaveAttribute('data-faceplate', 'shelf-1u')
fireEvent.click(screen.getByRole('button', { name: 'New device' }))
expect(screen.getByLabelText('Faceplate')).toHaveAttribute('data-faceplate', 'server-1u')
})
it('offers "Check device" only once the pick resolves to a canvas node', async () => {
store().openDeviceEditor()
render(<RackDeviceModal />)
// Nothing picked yet: there is no check to follow, so the option would lie.
const select = screen.getByLabelText('Status')
expect(select).not.toHaveTextContent('Check device')
const linked = store().inventory.find((i) => !i.racked && i.nodeId)!
await pickFromInventory(linked.id)
expect(select).toHaveTextContent('Check device')
})
it('hides "Check device" for an entry with no canvas node behind it', async () => {
store().openDeviceEditor()
const orphan = store().inventory.find((i) => !i.racked)!
useRackStore.setState({
inventory: store().inventory.map((i) => (i.id === orphan.id ? { ...i, nodeId: null } : i)),
})
render(<RackDeviceModal />)
await pickFromInventory(orphan.id)
expect(screen.getByLabelText('Status')).not.toHaveTextContent('Check device')
})
it('mounts a device that follows its node check', async () => {
store().openDeviceEditor()
const linked = store().inventory.find((i) => !i.racked && i.nodeId)!
render(<RackDeviceModal />)
await pickFromInventory(linked.id)
fireEvent.change(screen.getByLabelText('Status'), { target: { value: 'auto' } })
submit()
await waitFor(() => expect(store().deviceEditor).toBeNull())
// The mount stores the intent, not a colour — the LED is resolved from the
// inventory's live `node_status` at render time.
expect(store().devices.find((d) => d.deviceId === linked.id)!.status).toBe('auto')
})
})
describe('RackDeviceModal — editing', () => {
it('seeds every field from the mounted device', () => {
store().openDeviceEditor('dev-pve1')
render(<RackDeviceModal />)
const device = store().devices.find((d) => d.id === 'dev-pve1')!
expect(screen.getByLabelText('Label')).toHaveValue(device.label)
expect(screen.getByLabelText('U position')).toHaveValue(device.uStart)
expect(screen.getByLabelText('Height (U)')).toHaveValue(device.uHeight)
expect(screen.getByLabelText('Faceplate')).toHaveAttribute(
'data-faceplate',
device.faceplateId,
)
// The old right-panel fields all moved here.
expect(screen.getByLabelText('Status')).toBeInTheDocument()
expect(screen.getByLabelText('Colour override')).toBeInTheDocument()
expect(screen.getByText(`Ports (${device.ports.length})`)).toBeInTheDocument()
})
it('saves label, status and geometry in one go', async () => {
store().openDeviceEditor('dev-shelf')
render(<RackDeviceModal />)
fireEvent.change(screen.getByLabelText('Label'), { target: { value: 'kvm tray' } })
fireEvent.change(screen.getByLabelText('Status'), { target: { value: 'online' } })
fireEvent.change(screen.getByLabelText('Height (U)'), { target: { value: '2' } })
submit()
await waitFor(() => expect(store().deviceEditor).toBeNull())
const device = store().devices.find((d) => d.id === 'dev-shelf')!
expect(device.label).toBe('kvm tray')
expect(device.status).toBe('online')
expect(device.uHeight).toBe(2)
})
it('applies a taller faceplate, height included', async () => {
store().openDeviceEditor('dev-shelf')
render(<RackDeviceModal />)
// Regression: a 2U plate used to leave the device 1U, so 1U and 2U plates
// rendered at the same height and the field looked locked.
pickFaceplate('UPS 2U')
expect(screen.getByLabelText('Height (U)')).toHaveValue(2)
submit()
await waitFor(() => expect(store().deviceEditor).toBeNull())
const device = store().devices.find((d) => d.id === 'dev-shelf')!
expect(device.faceplateId).toBe('ups-2u')
expect(device.uHeight).toBe(2)
})
it('keeps the ports and cables when the plate choice is reverted', async () => {
store().openDeviceEditor('dev-pve1')
render(<RackDeviceModal />)
const before = store().devices.find((d) => d.id === 'dev-pve1')!
const cablesBefore = store().cables.filter(
(c) => c.from.deviceId === 'dev-pve1' || c.to.deviceId === 'dev-pve1',
)
expect(cablesBefore.length).toBeGreaterThan(0)
// Browse away and back. The plate is unchanged, so `applyFaceplate` is
// skipped on save — reseeded port ids would then reach `setPorts` and take
// every cable on the device down with them, with no warning shown.
pickFaceplate('NAS 2U — 8 bays')
pickFaceplate('Server 2U — 8 bays')
expect(screen.queryByText(/replaces its ports/)).not.toBeInTheDocument()
submit()
await waitFor(() => expect(store().deviceEditor).toBeNull())
const after = store().devices.find((d) => d.id === 'dev-pve1')!
expect(after.ports.map((p) => p.id)).toEqual(before.ports.map((p) => p.id))
expect(
store().cables.filter(
(c) => c.from.deviceId === 'dev-pve1' || c.to.deviceId === 'dev-pve1',
),
).toHaveLength(cablesBefore.length)
})
it('refuses a plate the rack cannot take instead of half-applying it', async () => {
const { toast } = await import('sonner')
store().openDeviceEditor('dev-pve1')
render(<RackDeviceModal />)
const before = store().devices.find((d) => d.id === 'dev-pve1')!
// The demo rack's longest free run is 3U, so the 4U plate cannot be applied.
// Shrinking the height then let `updateDevice` succeed on its own, and the
// device ended up wearing its old plate with the new plate's ports.
pickFaceplate('Storage 4U — 12 bays')
fireEvent.change(screen.getByLabelText('Height (U)'), { target: { value: '1' } })
submit()
await waitFor(() => expect(toast.error).toHaveBeenCalled())
const after = store().devices.find((d) => d.id === 'dev-pve1')!
expect(after.faceplateId).toBe(before.faceplateId)
expect(after.ports.map((p) => p.id)).toEqual(before.ports.map((p) => p.id))
expect(after.uHeight).toBe(before.uHeight)
expect(store().deviceEditor).not.toBeNull()
})
it('edits the port list and commits it on save', async () => {
store().openDeviceEditor('dev-pve1')
render(<RackDeviceModal />)
const before = store().devices.find((d) => d.id === 'dev-pve1')!.ports.length
fireEvent.click(screen.getByRole('button', { name: 'Add port' }))
expect(screen.getByText(`Ports (${before + 1})`)).toBeInTheDocument()
submit()
await waitFor(() =>
expect(store().devices.find((d) => d.id === 'dev-pve1')!.ports).toHaveLength(before + 1),
)
})
it('keeps the port name field usable next to the type select', () => {
store().openDeviceEditor('dev-pve1')
render(<RackDeviceModal />)
// Regression: both carried `w-full`, so the select ate the row and the name
// field collapsed into an unlabelled box.
const port = store().devices.find((d) => d.id === 'dev-pve1')!.ports[0]
const name = screen.getByLabelText(`Port ${port.label} label`)
expect(name).toHaveClass('flex-1', 'min-w-0')
expect(name).not.toHaveClass('w-full')
expect(name).toHaveAttribute('placeholder', 'Port name')
})
it('unmounts without dropping the inventory entry', async () => {
store().openDeviceEditor('dev-pve1')
render(<RackDeviceModal />)
fireEvent.click(screen.getByRole('button', { name: /Unmount/ }))
await waitFor(() => expect(store().devices.some((d) => d.id === 'dev-pve1')).toBe(false))
expect(store().inventory.some((i) => i.id === 'inv-pve1')).toBe(true)
expect(store().deviceEditor).toBeNull()
})
it('reports a resize the rack cannot take instead of failing silently', async () => {
const { toast } = await import('sonner')
store().openDeviceEditor('dev-pve1')
render(<RackDeviceModal />)
// The demo rack's longest free run is 3U.
fireEvent.change(screen.getByLabelText('Height (U)'), { target: { value: '9' } })
submit()
await waitFor(() => expect(toast.error).toHaveBeenCalled())
expect(store().deviceEditor).not.toBeNull()
expect(store().devices.find((d) => d.id === 'dev-pve1')!.uHeight).toBe(2)
})
it('keeps "Check device" for a mount already linked to a node', () => {
store().openDeviceEditor('dev-pve1')
render(<RackDeviceModal />)
expect(store().devices.find((d) => d.id === 'dev-pve1')!.nodeId).toBeTruthy()
expect(screen.getByLabelText('Status')).toHaveTextContent('Check device')
})
it('drops a mount off the node check when the link is gone', async () => {
store().openDeviceEditor('dev-pve1')
// The canvas node was deleted (`node_id` is ON DELETE SET NULL) and the
// inventory entry no longer resolves to one: nothing left to follow.
useRackStore.setState({
devices: store().devices.map((d) =>
d.id === 'dev-pve1' ? { ...d, nodeId: null, status: 'auto' as const } : d,
),
inventory: store().inventory.map((i) =>
i.id === 'inv-pve1' ? { ...i, nodeId: null } : i,
),
})
render(<RackDeviceModal />)
const select = screen.getByLabelText('Status')
expect(select).not.toHaveTextContent('Check device')
expect(select).toHaveValue('unknown')
submit()
await waitFor(() => expect(store().deviceEditor).toBeNull())
expect(store().devices.find((d) => d.id === 'dev-pve1')!.status).toBe('unknown')
})
it('prints what the logical canvas knows about the mounted device', () => {
store().openDeviceEditor('dev-pve1')
render(<RackDeviceModal />)
const entry = store().inventory.find((i) => i.id === 'inv-pve1')!
const panel = screen.getByLabelText('Linked device')
expect(panel).toHaveTextContent(entry.node!.hostname!)
expect(panel).toHaveTextContent(entry.ip!)
expect(panel).toHaveTextContent(entry.mac!)
expect(panel).toHaveTextContent('proxmox')
})
it('leaves an accessory without a logical view — it stands for no device', () => {
store().openDeviceEditor('dev-shelf')
render(<RackDeviceModal />)
expect(store().devices.find((d) => d.id === 'dev-shelf')!.deviceId).toBeNull()
expect(screen.queryByLabelText('Linked device')).not.toBeInTheDocument()
})
/** A discovered row, offered in the inventory the picker reads. */
function offerScannedRow() {
useRackStore.setState((s) => ({
inventory: [
...s.inventory,
{
id: 'inv-scanned',
label: 'pdu-real',
type: 'pdu',
discoverySource: 'arp',
ip: '192.168.1.60',
mac: null,
hostname: 'pdu.lan',
os: null,
services: [],
status: 'online' as const,
nodeId: 'node-pdu',
node: {
id: 'node-pdu',
label: 'pdu-main',
type: 'pdu',
ip: '192.168.1.60',
mac: null,
hostname: 'pdu.lan',
os: null,
checkMethod: 'ping',
designId: 'demo-network',
designName: 'Network',
lastSeen: null,
},
racked: false,
suggestedFaceplateId: 'pdu-1u',
},
],
}))
}
it('repoints a mount at the inventory device the user picks', async () => {
// `dev-pdu` was created from the rack: its entry is a placeholder with a
// name and nothing else, and no IEEE or IP for the backend to guess from.
store().openDeviceEditor('dev-pdu')
render(<RackDeviceModal />)
expect(screen.getByLabelText('Linked device')).not.toHaveTextContent('pdu.lan')
act(() => offerScannedRow())
fireEvent.click(screen.getByRole('button', { name: /Link to another device/ }))
// By role: the plate preview draws the same label in its SVG artwork.
const row = await screen.findByRole('button', { name: /pdu-real/ })
await act(async () => {
fireEvent.click(row)
})
const mount = store().devices.find((d) => d.id === 'dev-pdu')!
expect(mount.deviceId).toBe('inv-scanned')
expect(mount.nodeId).toBe('node-pdu')
expect(screen.getByLabelText('Linked device')).toHaveTextContent('pdu.lan')
// The form follows the plate's new name rather than saving the old one back.
expect(screen.getByLabelText('Label')).toHaveValue('pdu-real')
})
it('offers "Check device" once a device with a node has been linked', async () => {
store().openDeviceEditor('dev-pdu')
render(<RackDeviceModal />)
expect(screen.queryByRole('option', { name: /Check device/ })).not.toBeInTheDocument()
act(() => offerScannedRow())
await act(async () => {
await store().relinkDevice('dev-pdu', 'inv-scanned')
})
await waitFor(() =>
expect(screen.getByRole('option', { name: /Check device/ })).toBeInTheDocument(),
)
})
it('does not offer linking while the mount is still being created', async () => {
store().openDeviceEditor()
render(<RackDeviceModal />)
await pickFromInventory('inv-pdu')
// Nothing to hang a link on yet: the mount does not exist.
expect(screen.queryByRole('button', { name: /Link to/ })).not.toBeInTheDocument()
})
})
@@ -0,0 +1,157 @@
/**
* The rack draws a colour, never a mode: a mount set to "check device" reads
* its LED from the Device Inventory's live `node_status`.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { RackFlowNode } from '../components/RackFlowNode'
import { useRackStore } from '../store'
import { rackPalette } from '../rackTheme'
import { useThemeStore } from '@/stores/themeStore'
import { getFaceplate } from '../faceplates'
import type { NodeProps } from '@xyflow/react'
vi.mock('@xyflow/react', async () => {
const { mockReactFlow } = await import('@/test/mocks')
return mockReactFlow({ useReactFlow: () => ({ getZoom: () => 1 }) })
})
const store = () => useRackStore.getState()
beforeEach(() => {
store().reset()
store().loadDemo()
})
/** A mounted device whose plate actually draws a status LED. */
function ledDevice() {
return store().devices.find((d) => d.deviceId && getFaceplate(d.faceplateId).statusLed)!
}
function renderRack() {
const rackId = store().racks[0].id
return render(<RackFlowNode {...({ id: rackId } as NodeProps)} />)
}
/** Whatever theme the app is on — the rack derives its colours from it. */
const palette = () => rackPalette(useThemeStore.getState().activeTheme)
/** The LED is the only circle the plate draws. */
function ledFill(label: string): string | null {
const plate = screen.getByTitle(new RegExp(`^${label} ·`)).querySelector('svg')!
return plate.querySelector('circle')!.getAttribute('fill')
}
describe('RackFlowNode status', () => {
it('follows the inventory live status for an auto mount', () => {
const device = ledDevice()
useRackStore.setState({
devices: store().devices.map((d) =>
d.id === device.id ? { ...d, status: 'auto' as const } : d,
),
inventory: store().inventory.map((i) =>
i.id === device.deviceId ? { ...i, status: 'offline' as const } : i,
),
})
renderRack()
expect(ledFill(device.label)).toBe(palette().status.offline)
})
it('keeps drawing a pinned status as-is', () => {
const device = ledDevice()
useRackStore.setState({
devices: store().devices.map((d) =>
d.id === device.id ? { ...d, status: 'online' as const } : d,
),
inventory: store().inventory.map((i) =>
i.id === device.deviceId ? { ...i, status: 'offline' as const } : i,
),
})
renderRack()
expect(ledFill(device.label)).toBe(palette().status.online)
})
})
describe('RackFlowNode plate opacity', () => {
/** Every faceplate the rack draws, one <svg> each. */
const plateOpacities = (container: HTMLElement) =>
Array.from(container.querySelectorAll('svg')).map((s) => s.style.opacity)
it('keeps plates opaque with cables always on', () => {
// A faded plate let the rails and the U grid show through the gear.
store().setCableVisibility('always')
const { container } = renderRack()
expect(plateOpacities(container).every((o) => o === '')).toBe(true)
})
it('keeps plates opaque in patch mode too', () => {
store().toggleCableMode()
const { container } = renderRack()
expect(plateOpacities(container).every((o) => o === '')).toBe(true)
})
})
describe('RackFlowNode patching', () => {
/** Two mounted ports in this rack that nothing is plugged into yet. */
function freePorts() {
const patched = new Set(
store().cables.flatMap((c) => [c.from.portId, c.to.portId]),
)
const found = store()
.devices.filter((d) => d.rackId === store().racks[0].id)
.flatMap((d) => d.ports.filter((p) => !patched.has(p.id)).map((p) => ({ device: d, port: p })))
return { a: found[0], b: found[found.length - 1] }
}
/** The port's group, found through the <title> the plate gives it. */
function portGroup(label: string, portLabel: string, portType: string) {
const plate = screen.getByTitle(new RegExp(`^${label} ·`))
const title = Array.from(plate.querySelectorAll('title')).find(
(t) => t.textContent === `${portLabel} · ${portType}`,
)
if (!title) throw new Error(`no port ${portLabel} on ${label}`)
return title.parentElement!
}
it('ignores a press on a port outside patch mode', () => {
renderRack()
const { a } = freePorts()
fireEvent.pointerDown(portGroup(a.device.label, a.port.label, a.port.type))
expect(store().cableDraft).toBeNull()
expect(store().cableDrag).toBeNull()
})
it('patches a cable dragged from one port to another', () => {
store().toggleCableMode()
renderRack()
const { a, b } = freePorts()
const before = store().cables.length
fireEvent.pointerDown(portGroup(a.device.label, a.port.label, a.port.type))
expect(store().cableDraft).toEqual({ deviceId: a.device.id, portId: a.port.id })
fireEvent.pointerUp(portGroup(b.device.label, b.port.label, b.port.type))
expect(store().cables).toHaveLength(before + 1)
const created = store().cables[store().cables.length - 1]
expect(created.from.portId).toBe(a.port.id)
expect(created.to.portId).toBe(b.port.id)
})
it('still patches with two separate clicks', () => {
store().toggleCableMode()
renderRack()
const { a, b } = freePorts()
const before = store().cables.length
const portA = portGroup(a.device.label, a.port.label, a.port.type)
fireEvent.pointerDown(portA)
fireEvent.pointerUp(portA)
expect(store().cableDraft).toEqual({ deviceId: a.device.id, portId: a.port.id })
expect(store().cables).toHaveLength(before)
fireEvent.pointerDown(portGroup(b.device.label, b.port.label, b.port.type))
expect(store().cables).toHaveLength(before + 1)
})
})
@@ -0,0 +1,171 @@
/** Rack settings moved out of the right rail into their own dialog. */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { RackSettingsModal } from '../components/RackSettingsModal'
import { useRackStore } from '../store'
vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
const store = () => useRackStore.getState()
beforeEach(() => {
store().loadDemo()
// Deleting a rack takes its mounts and their patching with it, so the modal
// asks first. Default to "yes" and let the tests that care say otherwise.
vi.spyOn(window, 'confirm').mockReturnValue(true)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('RackSettingsModal', () => {
it('renders nothing until a rack is opened', () => {
const { container } = render(<RackSettingsModal />)
expect(container).toBeEmptyDOMElement()
})
it('edits name live and capacity on commit', () => {
store().openRackEditor('rack-main')
render(<RackSettingsModal />)
fireEvent.change(screen.getByLabelText('Rack name'), { target: { value: 'Garage' } })
const height = screen.getByLabelText('Rack height')
fireEvent.change(height, { target: { value: '24' } })
fireEvent.blur(height)
const rack = store().racks.find((r) => r.id === 'rack-main')!
expect(rack.name).toBe('Garage')
expect(rack.uHeight).toBe(24)
})
it('reports how much of the rack is in use', () => {
store().openRackEditor('rack-main')
render(<RackSettingsModal />)
expect(screen.getByText(/U used of 18U/)).toBeInTheDocument()
})
it('leaves the rack alone while the height is being typed', () => {
// Backspacing "18" walks through "1", which would shrink the rack and
// relocate every mount — a move nothing undoes when the digits come back.
store().openRackEditor('rack-main')
const before = store().devices.map((d) => ({ ...d }))
render(<RackSettingsModal />)
const height = screen.getByLabelText('Rack height')
fireEvent.change(height, { target: { value: '1' } })
expect(store().racks[0].uHeight).toBe(18)
expect(store().devices).toEqual(before)
})
it('commits the height on Enter', () => {
store().openRackEditor('rack-main')
render(<RackSettingsModal />)
const height = screen.getByLabelText('Rack height')
fireEvent.change(height, { target: { value: '30' } })
fireEvent.keyDown(height, { key: 'Enter' })
fireEvent.blur(height)
expect(store().racks[0].uHeight).toBe(30)
})
it('reverts the field when the shrink is refused', () => {
store().openRackEditor('rack-main')
render(<RackSettingsModal />)
const height = screen.getByLabelText('Rack height')
fireEvent.change(height, { target: { value: '1' } })
fireEvent.blur(height)
expect(store().racks[0].uHeight).toBe(18)
expect((height as HTMLInputElement).value).toBe('18')
})
it('treats a blanked height as no edit', () => {
// Number('') is 0, which used to fall through to 1 U: select-all, Delete,
// click elsewhere, and an empty rack silently became 1 U with no undo.
store().openRackEditor('rack-main')
render(<RackSettingsModal />)
const height = screen.getByLabelText('Rack height')
fireEvent.change(height, { target: { value: '' } })
fireEvent.blur(height)
expect(store().racks[0].uHeight).toBe(18)
expect((height as HTMLInputElement).value).toBe('18')
})
it('treats a blanked height as no edit on an empty rack too', () => {
// The dangerous case: with no mounts, updateRack has nothing to refuse.
const id = store().addRack({ name: 'Empty' })
const height = () => store().racks.find((r) => r.id === id)!.uHeight
const before = height()
store().openRackEditor(id)
render(<RackSettingsModal />)
const field = screen.getByLabelText('Rack height')
fireEvent.change(field, { target: { value: '' } })
fireEvent.blur(field)
expect(height()).toBe(before)
})
it('ignores a height that does not parse', () => {
store().openRackEditor('rack-main')
render(<RackSettingsModal />)
const height = screen.getByLabelText('Rack height')
fireEvent.change(height, { target: { value: 'abc' } })
fireEvent.blur(height)
expect(store().racks[0].uHeight).toBe(18)
})
it('commits nothing when the dialog closes on a typed height', () => {
// Escape closes the dialog; the uncommitted draft dies with the field.
store().openRackEditor('rack-main')
render(<RackSettingsModal />)
fireEvent.change(screen.getByLabelText('Rack height'), { target: { value: '5' } })
fireEvent.keyDown(screen.getByLabelText('Rack height'), { key: 'Escape' })
expect(store().racks[0].uHeight).toBe(18)
})
it('deletes the rack and closes once confirmed', () => {
store().openRackEditor('rack-main')
render(<RackSettingsModal />)
fireEvent.click(screen.getByRole('button', { name: 'Delete rack' }))
expect(window.confirm).toHaveBeenCalled()
expect(store().racks).toHaveLength(0)
expect(store().devices).toHaveLength(0)
expect(store().rackEditorId).toBeNull()
})
it('keeps the rack when the delete is declined', () => {
vi.mocked(window.confirm).mockReturnValue(false)
store().openRackEditor('rack-main')
const mounted = store().devices.length
render(<RackSettingsModal />)
fireEvent.click(screen.getByRole('button', { name: 'Delete rack' }))
expect(store().racks).toHaveLength(1)
expect(store().devices).toHaveLength(mounted)
expect(store().rackEditorId).toBe('rack-main')
})
it('counts the mounts it is about to unmount in the warning', () => {
store().openRackEditor('rack-main')
const mounted = store().devices.filter((d) => d.rackId === 'rack-main').length
render(<RackSettingsModal />)
fireEvent.click(screen.getByRole('button', { name: 'Delete rack' }))
expect(vi.mocked(window.confirm).mock.calls[0][0]).toContain(`${mounted} device`)
})
})
@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest'
import { MOUNT_STATUSES, canFollowNode, resolveDeviceStatus } from '../deviceStatus'
import type { InventoryDevice } from '@/types'
function entry(over: Partial<InventoryDevice> = {}): InventoryDevice {
return {
id: 'inv-1',
label: 'nas',
type: 'nas',
ip: '192.168.1.10',
status: 'online',
nodeId: 'node-1',
racked: false,
suggestedFaceplateId: 'server-1u',
...over,
}
}
describe('canFollowNode', () => {
it('needs an entry the backend matched to a canvas node', () => {
expect(canFollowNode(entry())).toBe(true)
expect(canFollowNode(entry({ nodeId: null }))).toBe(false)
expect(canFollowNode(undefined)).toBe(false)
})
})
describe('resolveDeviceStatus', () => {
it('returns a pinned status untouched', () => {
expect(resolveDeviceStatus({ status: 'offline', deviceId: 'inv-1' }, [entry()])).toBe('offline')
})
it('reads the inventory entry live status when the mount follows the node', () => {
expect(resolveDeviceStatus({ status: 'auto', deviceId: 'inv-1' }, [entry()])).toBe('online')
expect(
resolveDeviceStatus({ status: 'auto', deviceId: 'inv-1' }, [entry({ status: 'offline' })]),
).toBe('offline')
})
it('falls back to unknown when there is nothing to read', () => {
// Accessory (no entry), purged inventory, entry with no node behind it: the
// rack has no checker of its own, so it must not invent a colour.
expect(resolveDeviceStatus({ status: 'auto', deviceId: null }, [entry()])).toBe('unknown')
expect(resolveDeviceStatus({ status: 'auto', deviceId: 'gone' }, [entry()])).toBe('unknown')
expect(
resolveDeviceStatus({ status: 'auto', deviceId: 'inv-1' }, [
entry({ status: 'unknown', nodeId: null }),
]),
).toBe('unknown')
})
it('offers auto first in the Status select', () => {
expect(MOUNT_STATUSES).toEqual(['auto', 'online', 'offline', 'unknown'])
})
})
@@ -0,0 +1,211 @@
import { describe, it, expect } from 'vitest'
import {
FACEPLATES,
bank,
deviceTypeForFaceplate,
faceplateGroups,
getFaceplate,
suggestFaceplate,
} from '../faceplates'
import { UNRACKABLE_TYPES } from '@/utils/rackable'
import { RACK_COLUMNS, type PortType } from '@/types'
const ALLOWED_PORT_TYPES: PortType[] = ['rj45', 'sfp', 'sfp+']
describe('faceplate catalog', () => {
it('has unique ids', () => {
expect(new Set(FACEPLATES.map((f) => f.id)).size).toBe(FACEPLATES.length)
})
it('keeps every plate inside the rack grid', () => {
for (const plate of FACEPLATES) {
expect(plate.colSpan).toBeGreaterThan(0)
expect(plate.colSpan).toBeLessThanOrEqual(RACK_COLUMNS)
expect(plate.uHeight).toBeGreaterThan(0)
}
})
it('only uses data port types', () => {
for (const plate of FACEPLATES) {
for (const port of plate.ports) {
expect(ALLOWED_PORT_TYPES).toContain(port.type)
}
}
})
it('keeps every port inside the plate', () => {
for (const plate of FACEPLATES) {
for (const port of plate.ports) {
expect(port.x).toBeGreaterThanOrEqual(0)
expect(port.x).toBeLessThanOrEqual(1)
expect(port.y).toBeGreaterThanOrEqual(0)
expect(port.y).toBeLessThanOrEqual(1)
}
}
})
it('keeps ports clear of the name band', () => {
for (const plate of FACEPLATES) {
const labelEnd = plate.labelBox.x + plate.labelBox.w
for (const port of plate.ports) {
expect(
port.x >= labelEnd || port.x <= plate.labelBox.x,
`${plate.id}: port ${port.label} sits on the label`,
).toBe(true)
}
}
})
it('keeps the name band clear of the status LED and inside the plate', () => {
for (const plate of FACEPLATES) {
expect(plate.labelBox.x).toBeGreaterThan(0)
expect(plate.labelBox.x + plate.labelBox.w).toBeLessThanOrEqual(1)
}
})
it('gives accessories no ports and no status LED', () => {
for (const plate of FACEPLATES.filter((f) => f.kind === 'accessory')) {
expect(plate.ports).toHaveLength(0)
expect(plate.statusLed).toBeFalsy()
}
})
it('shows ports permanently only on patch-facing gear', () => {
const always = FACEPLATES.filter((f) => f.alwaysShowPorts).map((f) => f.id)
expect(always).toEqual([
'switch-8',
'switch-24',
'switch-48',
'patch-24',
'patch-fiber-12',
])
})
it('leaves power gear uncabled, with outlets as artwork', () => {
for (const id of ['pdu-1u', 'ups-2u']) {
const plate = getFaceplate(id)
expect(plate.ports).toHaveLength(0)
expect(plate.elements.some((e) => e.kind === 'outlets')).toBe(true)
}
})
it('keeps the desktop NAS boxes narrow and tall, one bay column each', () => {
// Bay count → { bays, colSpan }: two doors is a sixth-width tower, four and
// five a third. All the same height — more disks makes a box wider, not
// taller — and tall enough that the doors stand up (a U is 24px against a
// 456px inner width, so the canvas squashes the vertical axis).
const expected: Record<string, { bays: number; colSpan: number }> = {
'nas-desktop-2': { bays: 2, colSpan: RACK_COLUMNS / 6 },
'nas-desktop-4': { bays: 4, colSpan: RACK_COLUMNS / 3 },
'nas-desktop-5': { bays: 5, colSpan: RACK_COLUMNS / 3 },
}
for (const [id, { bays, colSpan }] of Object.entries(expected)) {
const plate = getFaceplate(id)
// Shelf-mounted desktop gear, not rack gear: a slice of the width, 5U tall.
expect(plate.colSpan).toBe(colSpan)
expect(plate.uHeight).toBe(5)
const tray = plate.elements.find((e) => e.kind === 'bays')
expect(tray).toMatchObject({ cols: bays, rows: 1 })
expect(plate.ports.length).toBeGreaterThan(0)
// The front is tray doors, portrait, over a thin bottom strip carrying the
// badge, the LED and the sockets — not a mid-height band like rack gear.
const band = plate.labelBox.y!
expect(band).toBeGreaterThan(0.7)
const trayBottom = tray!.kind === 'bays' ? tray.y + tray.h : 1
expect(trayBottom).toBeLessThan(band)
expect(plate.ports.every((p) => p.y === band)).toBe(true)
// Doors taller than they are wide, at the plate's real aspect (3U on a
// third of the rack is roughly as tall as it is wide).
const doorW = (tray!.kind === 'bays' ? tray.w : 0) / bays
expect(doorW).toBeLessThan(tray!.kind === 'bays' ? tray.h : 0)
}
})
it('draws every desktop NAS door the same size — a disk is a disk', () => {
// Regression: the stack stretched to fill the plate, so the 2-bay box wore
// two doors twice as wide as the 5-bay one's. Unit coordinates are
// fractions of the plate, so compare them scaled by the plate's own width —
// the 2-bay box is a quarter of the rack, the others a third.
const doors = ['nas-desktop-2', 'nas-desktop-4', 'nas-desktop-5'].map((id) => {
const plate = getFaceplate(id)
const tray = plate.elements.find((e) => e.kind === 'bays')!
if (tray.kind !== 'bays') throw new Error('no bays')
return { x: tray.x * plate.colSpan, doorW: (tray.w / tray.cols) * plate.colSpan }
})
for (const door of doors) {
expect(door.doorW).toBeCloseTo(doors[0].doorW, 5)
// …and the stack starts at the same inset, so the boxes line up on a shelf.
expect(door.x).toBeCloseTo(doors[0].x, 5)
}
// The widest stack still fits the plate.
const plate5 = getFaceplate('nas-desktop-5').elements.find((e) => e.kind === 'bays')!
if (plate5.kind !== 'bays') throw new Error('no bays')
expect(plate5.x + plate5.w).toBeLessThanOrEqual(1)
})
it('names the hardware behind every device plate', () => {
// A device created from the rack canvas has no discovery behind it: the
// plate is the only thing that says what it is, and a row with no type gets
// no icon, no role badge and no place in the type filter.
for (const plate of FACEPLATES.filter((f) => f.kind === 'device')) {
expect(deviceTypeForFaceplate(plate.id), `${plate.id} has no device type`).toBeTruthy()
}
for (const plate of FACEPLATES.filter((f) => f.kind === 'accessory')) {
expect(deviceTypeForFaceplate(plate.id)).toBeNull()
}
expect(deviceTypeForFaceplate('does-not-exist')).toBeNull()
})
it('keeps rack-only kinds rackable', () => {
// Passive gear has no logical node type, so the rack invents two. They must
// not collide with an excluded kind — a PDU typed `socket` would vanish from
// the rack inventory the moment it was created.
const rackOnly = ['patch_panel', 'pdu']
for (const type of rackOnly) {
expect(UNRACKABLE_TYPES.has(type)).toBe(false)
// …and each still proposes a sane plate when re-mounted later.
expect(getFaceplate(suggestFaceplate(type)).kind).toBe('device')
}
expect(suggestFaceplate('pdu')).toBe('pdu-1u')
expect(suggestFaceplate('patch_panel')).toBe('patch-24')
})
it('falls back to the first plate on an unknown id', () => {
expect(getFaceplate('does-not-exist')).toBe(FACEPLATES[0])
})
it('groups the picker without losing a plate', () => {
const groups = faceplateGroups()
expect(groups.flatMap((g) => g.items)).toHaveLength(FACEPLATES.length)
expect(new Set(groups.map((g) => g.group)).size).toBe(groups.length)
})
})
describe('bank', () => {
it('centres a single row vertically', () => {
const ports = bank({ type: 'rj45', count: 4, x: 0, w: 1 })
expect(ports).toHaveLength(4)
expect(ports.map((p) => p.x)).toEqual([0.125, 0.375, 0.625, 0.875])
expect(ports.every((p) => p.y === 0.5)).toBe(true)
})
it('spreads two rows evenly around the centre', () => {
const ports = bank({ type: 'rj45', count: 24, x: 0, w: 1, perRow: 12 })
const rows = [...new Set(ports.map((p) => p.y))]
expect(rows).toEqual([1 / 3, 2 / 3])
expect(ports[0].y).toBeLessThan(ports[12].y)
})
it('aligns columns across rows', () => {
const ports = bank({ type: 'rj45', count: 24, x: 0, w: 1, perRow: 12 })
for (let i = 0; i < 12; i++) {
expect(ports[i].x).toBe(ports[i + 12].x)
}
})
it('numbers ports from the given start', () => {
const ports = bank({ type: 'sfp+', count: 2, x: 0, w: 1, prefix: 'sfp', start: 3 })
expect(ports.map((p) => p.label)).toEqual(['sfp3', 'sfp4'])
})
})
+231
View File
@@ -0,0 +1,231 @@
import { describe, it, expect } from 'vitest'
import {
FRAME_PAD_PX,
INTERIOR_TOP_PX,
U_PX,
canPlace,
columnWidth,
deviceBox,
findSlot,
freeUnits,
isInBounds,
portPosition,
rackHeight,
rackWidth,
uLabel,
uToY,
xToCol,
yToU,
} from '../layout'
import { DEFAULT_RACK_STYLE } from '../rackDefaults'
import { RACK_COLUMNS, type Port, type Rack, type RackDevice } from '@/types'
function makeRack(patch: Partial<Rack> = {}): Rack {
return {
id: 'r1',
name: 'Rack',
uHeight: 10,
widthStandard: '19',
numbering: 'bottom-up',
style: { ...DEFAULT_RACK_STYLE },
position: { x: 0, y: 0 },
...patch,
}
}
function makeDevice(patch: Partial<RackDevice> = {}): RackDevice {
return {
id: 'd1',
rackId: 'r1',
nodeId: null,
label: 'dev',
uStart: 1,
uHeight: 1,
colStart: 0,
colSpan: RACK_COLUMNS,
faceplateId: 'blank-1u',
status: 'unknown',
ports: [],
...patch,
}
}
describe('rack geometry', () => {
it('places the top U at the top of the frame', () => {
const rack = makeRack()
expect(uToY(rack, rack.uHeight, 1)).toBe(INTERIOR_TOP_PX)
expect(uToY(rack, 1, 1)).toBe(INTERIOR_TOP_PX + (rack.uHeight - 1) * U_PX)
})
it('anchors a multi-U device by its lowest U', () => {
const rack = makeRack()
// A 2U device starting at U5 occupies U5+U6, so its top edge sits at U6.
expect(uToY(rack, 5, 2)).toBe(uToY(rack, 6, 1))
})
it('round-trips y -> U for every unit', () => {
const rack = makeRack()
for (let u = 1; u <= rack.uHeight; u++) {
expect(yToU(rack, uToY(rack, u, 1) + U_PX / 2)).toBe(u)
}
})
it('clamps out-of-frame pointers into the rack', () => {
const rack = makeRack()
expect(yToU(rack, -500)).toBe(rack.uHeight)
expect(yToU(rack, 5000)).toBe(1)
})
it('maps x to a column and clamps at both edges', () => {
const rack = makeRack()
expect(xToCol(rack, -100)).toBe(0)
expect(xToCol(rack, 1e6)).toBe(RACK_COLUMNS - 1)
const col = columnWidth(rack)
expect(xToCol(rack, deviceBox(rack, makeDevice()).x + col * 3.5)).toBe(3)
})
it('labels U according to the numbering direction', () => {
const bottomUp = makeRack()
const topDown = makeRack({ numbering: 'top-down' })
expect(uLabel(bottomUp, 1)).toBe(1)
expect(uLabel(bottomUp, 10)).toBe(10)
expect(uLabel(topDown, 1)).toBe(10)
expect(uLabel(topDown, 10)).toBe(1)
})
it('sizes the node from U height and width standard', () => {
expect(rackHeight(makeRack({ uHeight: 24 }))).toBe(24 * U_PX + INTERIOR_TOP_PX + FRAME_PAD_PX)
expect(rackWidth(makeRack({ widthStandard: '10' }))).toBeLessThan(
rackWidth(makeRack({ widthStandard: '19' })),
)
})
it('drops the number gutter when numbers are hidden', () => {
const shown = makeRack()
const hidden = makeRack({ style: { ...DEFAULT_RACK_STYLE, showNumbers: false } })
expect(rackWidth(hidden)).toBeLessThan(rackWidth(shown))
})
it('projects a port into canvas space, rack offset included', () => {
const rack = makeRack({ position: { x: 100, y: 50 } })
const device = makeDevice({ uStart: 1, uHeight: 1, colStart: 0, colSpan: RACK_COLUMNS })
const port: Port = { id: 'p', label: 'p', type: 'rj45', x: 0.5, y: 0.5 }
const box = deviceBox(rack, device)
expect(portPosition(rack, device, port)).toEqual({
x: 100 + box.x + box.width / 2,
y: 50 + box.y + box.height / 2,
})
})
})
describe('occupancy', () => {
it('rejects placements outside the rack', () => {
const rack = makeRack()
expect(isInBounds(rack, { uStart: 0, uHeight: 1, colStart: 0, colSpan: 12 })).toBe(false)
expect(isInBounds(rack, { uStart: 10, uHeight: 2, colStart: 0, colSpan: 12 })).toBe(false)
expect(isInBounds(rack, { uStart: 1, uHeight: 1, colStart: 6, colSpan: 12 })).toBe(false)
expect(isInBounds(rack, { uStart: 9, uHeight: 2, colStart: 0, colSpan: 12 })).toBe(true)
})
it('detects a full-width collision', () => {
const rack = makeRack()
const devices = [makeDevice({ uStart: 3 })]
expect(canPlace(rack, devices, { uStart: 3, uHeight: 1, colStart: 0, colSpan: 12 })).toBe(false)
})
it('lets two half-width devices share one U', () => {
const rack = makeRack()
const devices = [makeDevice({ uStart: 3, colStart: 0, colSpan: 6 })]
expect(canPlace(rack, devices, { uStart: 3, uHeight: 1, colStart: 6, colSpan: 6 })).toBe(true)
expect(canPlace(rack, devices, { uStart: 3, uHeight: 1, colStart: 4, colSpan: 6 })).toBe(false)
})
it('lets three third-width devices share one U', () => {
const rack = makeRack()
const devices = [
makeDevice({ id: 'a', uStart: 2, colStart: 0, colSpan: 4 }),
makeDevice({ id: 'b', uStart: 2, colStart: 4, colSpan: 4 }),
]
expect(canPlace(rack, devices, { uStart: 2, uHeight: 1, colStart: 8, colSpan: 4 })).toBe(true)
})
it('catches a multi-U overlap from either direction', () => {
const rack = makeRack()
const devices = [makeDevice({ uStart: 4, uHeight: 3 })] // U4..U6
expect(canPlace(rack, devices, { uStart: 6, uHeight: 2, colStart: 0, colSpan: 12 })).toBe(false)
expect(canPlace(rack, devices, { uStart: 2, uHeight: 3, colStart: 0, colSpan: 12 })).toBe(false)
expect(canPlace(rack, devices, { uStart: 7, uHeight: 2, colStart: 0, colSpan: 12 })).toBe(true)
})
it('ignores the device being moved so a no-op drag stays valid', () => {
const rack = makeRack()
const devices = [makeDevice({ id: 'd1', uStart: 5 })]
expect(canPlace(rack, devices, { uStart: 5, uHeight: 1, colStart: 0, colSpan: 12 })).toBe(false)
expect(canPlace(rack, devices, { uStart: 5, uHeight: 1, colStart: 0, colSpan: 12 }, 'd1')).toBe(
true,
)
})
it('ignores devices mounted in another rack', () => {
const rack = makeRack()
const devices = [makeDevice({ rackId: 'other', uStart: 5 })]
expect(canPlace(rack, devices, { uStart: 5, uHeight: 1, colStart: 0, colSpan: 12 })).toBe(true)
})
})
describe('findSlot', () => {
it('keeps the requested U when it is free', () => {
const rack = makeRack()
const slot = findSlot(rack, [], { uStart: 4, uHeight: 2, colStart: 0, colSpan: 12 })
expect(slot).toEqual({ uStart: 4, uHeight: 2, colStart: 0, colSpan: 12 })
})
it('slides to a free U when the target is taken', () => {
const rack = makeRack()
const devices = [makeDevice({ uStart: 4 })]
const slot = findSlot(rack, devices, { uStart: 4, uHeight: 1, colStart: 0, colSpan: 12 })
expect(slot).not.toBeNull()
expect(slot!.uStart).not.toBe(4)
})
it('uses free columns on the target U before moving away', () => {
const rack = makeRack()
const devices = [makeDevice({ uStart: 4, colStart: 0, colSpan: 6 })]
const slot = findSlot(rack, devices, { uStart: 4, uHeight: 1, colStart: 7, colSpan: 6 })
expect(slot).toEqual({ uStart: 4, uHeight: 1, colStart: 6, colSpan: 6 })
})
it('clamps a request that would overflow the top', () => {
const rack = makeRack()
const slot = findSlot(rack, [], { uStart: 10, uHeight: 3, colStart: 0, colSpan: 12 })
expect(slot!.uStart).toBe(8)
})
it('returns null when the device is taller than the rack', () => {
const rack = makeRack({ uHeight: 2 })
expect(findSlot(rack, [], { uStart: 1, uHeight: 4, colStart: 0, colSpan: 12 })).toBeNull()
})
it('returns null when the rack is full', () => {
const rack = makeRack({ uHeight: 2 })
const devices = [
makeDevice({ id: 'a', uStart: 1 }),
makeDevice({ id: 'b', uStart: 2 }),
]
expect(findSlot(rack, devices, { uStart: 1, uHeight: 1, colStart: 0, colSpan: 12 })).toBeNull()
})
})
describe('freeUnits', () => {
it('counts a partially filled U as busy', () => {
const rack = makeRack({ uHeight: 4 })
const devices = [makeDevice({ uStart: 2, colStart: 0, colSpan: 6 })]
expect(freeUnits(rack, devices)).toBe(3)
})
it('counts multi-U devices once per occupied U', () => {
const rack = makeRack({ uHeight: 4 })
const devices = [makeDevice({ uStart: 1, uHeight: 3 })]
expect(freeUnits(rack, devices)).toBe(1)
})
})
@@ -0,0 +1,69 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { loadNetworkLinks } from '../networkLinks'
import type { Design } from '@/types'
const canvasLoad = vi.fn()
vi.mock('@/api/client', () => ({
canvasApi: { load: (...args: unknown[]) => canvasLoad(...args) },
}))
function design(id: string, design_type: Design['design_type'] = 'network'): Design {
return { id, name: id, design_type, icon: null, created_at: '', updated_at: '' }
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('loadNetworkLinks', () => {
it('collects physical edges from a logical canvas', async () => {
canvasLoad.mockResolvedValue({
data: { edges: [{ source: 'a', target: 'b', type: 'ethernet', label: 'uplink' }] },
})
const hints = await loadNetworkLinks([design('d1')])
expect(hints).toEqual([{ from: 'a', to: 'b', type: 'ethernet', label: 'uplink' }])
})
it('maps a fibre edge onto a fiber patch', async () => {
canvasLoad.mockResolvedValue({ data: { edges: [{ source: 'a', target: 'b', type: 'fibre' }] } })
const [hint] = await loadNetworkLinks([design('d1')])
expect(hint.type).toBe('fiber')
})
it('skips links that are not physical', async () => {
canvasLoad.mockResolvedValue({
data: {
edges: [
{ source: 'a', target: 'b', type: 'wifi' },
{ source: 'c', target: 'd', type: 'virtual' },
{ source: 'e', target: 'f', type: 'ethernet' },
],
},
})
const hints = await loadNetworkLinks([design('d1')])
expect(hints.map((h) => h.from)).toEqual(['e'])
})
it('never reads a rack design — it has no edges to import', async () => {
canvasLoad.mockResolvedValue({ data: { edges: [] } })
await loadNetworkLinks([design('d1', 'rack')])
expect(canvasLoad).not.toHaveBeenCalled()
})
it('dedupes the same pair drawn on two canvases, either way round', async () => {
canvasLoad
.mockResolvedValueOnce({ data: { edges: [{ source: 'a', target: 'b', type: 'ethernet' }] } })
.mockResolvedValueOnce({ data: { edges: [{ source: 'b', target: 'a', type: 'ethernet' }] } })
const hints = await loadNetworkLinks([design('d1'), design('d2')])
expect(hints).toHaveLength(1)
})
it('skips a design that fails to load instead of failing the import', async () => {
canvasLoad
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValueOnce({ data: { edges: [{ source: 'a', target: 'b', type: 'ethernet' }] } })
const hints = await loadNetworkLinks([design('d1'), design('d2')])
expect(hints).toHaveLength(1)
})
})
@@ -0,0 +1,227 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useRackStore } from '../store'
const load = vi.fn()
const inventory = vi.fn()
const save = vi.fn()
const createPending = vi.fn()
vi.mock('@/api/client', () => ({
racksApi: {
load: (...args: unknown[]) => load(...args),
inventory: (...args: unknown[]) => inventory(...args),
save: (...args: unknown[]) => save(...args),
},
scanApi: {
createPending: (...args: unknown[]) => createPending(...args),
},
}))
const store = () => useRackStore.getState()
const apiState = {
racks: [
{
id: 'r1',
design_id: 'd1',
name: 'Main',
u_height: 12,
width_standard: '19',
numbering: 'bottom-up',
location: null,
style: {},
pos_x: 0,
pos_y: 0,
},
],
devices: [
{
id: 'dev1',
design_id: 'd1',
rack_id: 'r1',
device_id: 'inv1',
node_id: null,
label: 'sw-24',
u_start: 4,
u_height: 1,
col_start: 0,
col_span: 12,
faceplate_id: 'switch-24',
color: null,
status: 'online',
ports: [{ id: 'p1', label: '1', type: 'rj45', x: 0.3, y: 0.5 }],
},
],
cables: [],
viewport: { x: 5, y: 6, zoom: 0.8 },
}
const apiInventory = {
items: [
{
id: 'inv1',
label: 'sw-24',
suggested_type: 'switch',
ip: '192.168.1.2',
status: 'approved',
discovery_source: 'arp',
node_id: null,
node_status: null,
racked: false,
},
{
id: 'inv2',
label: 'nas',
suggested_type: 'nas',
ip: '192.168.1.9',
status: 'approved',
discovery_source: 'arp',
node_id: 'node2',
node_status: 'online',
racked: false,
},
],
}
beforeEach(() => {
vi.clearAllMocks()
useRackStore.getState().reset()
load.mockResolvedValue({ data: apiState })
inventory.mockResolvedValue({ data: apiInventory })
save.mockResolvedValue({ data: { saved: true } })
})
describe('loadDesign', () => {
it('pulls state and inventory for the design', async () => {
await store().loadDesign('d1')
expect(load).toHaveBeenCalledWith('d1')
expect(inventory).toHaveBeenCalledWith('d1')
expect(store().designId).toBe('d1')
expect(store().racks.map((r) => r.name)).toEqual(['Main'])
expect(store().devices[0].label).toBe('sw-24')
expect(store().viewport).toEqual({ x: 5, y: 6, zoom: 0.8 })
})
it('lands clean — a fresh load is not an unsaved change', async () => {
await store().loadDesign('d1')
expect(store().hasUnsavedChanges).toBe(false)
expect(store().loading).toBe(false)
})
it('recomputes racked flags from the loaded mounts, not the server hint', async () => {
await store().loadDesign('d1')
expect(store().inventory.find((i) => i.id === 'inv1')!.racked).toBe(true)
expect(store().inventory.find((i) => i.id === 'inv2')!.racked).toBe(false)
})
it('surfaces a failed load instead of showing an empty canvas as success', async () => {
load.mockRejectedValue(new Error('boom'))
await store().loadDesign('d1')
expect(store().loadError).toBe(true)
expect(store().loading).toBe(false)
})
it('clears the previous design before loading the next one', async () => {
await store().loadDesign('d1')
load.mockResolvedValue({ data: { ...apiState, racks: [], devices: [] } })
inventory.mockResolvedValue({ data: { items: [] } })
await store().loadDesign('d2')
expect(store().racks).toEqual([])
expect(store().devices).toEqual([])
})
})
describe('save', () => {
it('refuses to save before a design is loaded', async () => {
expect(await store().save()).toBe(false)
expect(save).not.toHaveBeenCalled()
})
it('posts the full state and clears the dirty flag', async () => {
await store().loadDesign('d1')
store().addRack({ id: 'r2', name: 'Second' })
expect(store().hasUnsavedChanges).toBe(true)
expect(await store().save()).toBe(true)
const payload = save.mock.calls[0][0]
expect(payload.design_id).toBe('d1')
expect(payload.racks.map((r: { id: string }) => r.id)).toEqual(['r1', 'r2'])
expect(payload.devices[0].u_start).toBe(4)
expect(store().hasUnsavedChanges).toBe(false)
})
it('keeps the canvas dirty when the save fails', async () => {
await store().loadDesign('d1')
store().addRack()
save.mockRejectedValue(new Error('nope'))
expect(await store().save()).toBe(false)
expect(store().hasUnsavedChanges).toBe(true)
})
it('saves when the design it was told to save is the one loaded', async () => {
await store().loadDesign('d1')
expect(await store().save('d1')).toBe(true)
expect(save.mock.calls[0][0].design_id).toBe('d1')
})
it('writes nothing when the store has moved on to another design', async () => {
// The design-switch flow saves the old design; if the store already holds
// the new one, saving would persist it under the wrong id.
await store().loadDesign('d1')
store().addRack()
expect(await store().save('d-other')).toBe(false)
expect(save).not.toHaveBeenCalled()
expect(store().hasUnsavedChanges).toBe(true)
})
})
describe('createInventoryDevice', () => {
it('creates the entry in the Device Inventory, tagged as rack gear', async () => {
await store().loadDesign('d1')
createPending.mockResolvedValue({ data: { id: 'inv-new', hostname: 'patch panel' } })
const created = await store().createInventoryDevice({ label: 'patch panel' })
expect(createPending).toHaveBeenCalledWith({
hostname: 'patch panel',
ip: null,
mac: null,
suggested_type: null,
// Files it under the inventory's "Rack devices" filter and keeps it off
// the logical canvases.
discovery_source: 'rack',
})
expect(created!.id).toBe('inv-new')
expect(store().inventory.some((i) => i.id === 'inv-new')).toBe(true)
expect(store().hasUnsavedChanges).toBe(true)
})
it('adds nothing when the backend refuses', async () => {
await store().loadDesign('d1')
const before = store().inventory.length
createPending.mockRejectedValue(new Error('nope'))
expect(await store().createInventoryDevice({ label: 'ghost' })).toBeNull()
expect(store().inventory).toHaveLength(before)
})
})
describe('refreshInventory', () => {
it('re-reads the tray without touching the mounts', async () => {
await store().loadDesign('d1')
inventory.mockResolvedValue({
data: { items: [...apiInventory.items, { ...apiInventory.items[1], id: 'inv3', label: 'new box' }] },
})
await store().refreshInventory()
expect(store().inventory.some((i) => i.id === 'inv3')).toBe(true)
expect(store().devices).toHaveLength(1)
})
it('keeps the existing tray when the refresh fails', async () => {
await store().loadDesign('d1')
const before = store().inventory.length
inventory.mockRejectedValue(new Error('offline'))
await store().refreshInventory()
expect(store().inventory).toHaveLength(before)
})
})
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest'
import { rackPalette } from '../rackTheme'
import { THEMES, THEME_ORDER } from '@/utils/themes'
describe('rackPalette', () => {
it('takes the canvas and status colours from the active theme', () => {
const palette = rackPalette('matrix')
const theme = THEMES.matrix.colors
expect(palette.canvas).toBe(theme.canvasBackground)
expect(palette.dot).toBe(theme.canvasDotColor)
expect(palette.status.online).toBe(theme.statusColors.online)
expect(palette.status.offline).toBe(theme.statusColors.offline)
})
it('resolves a full palette for every theme, so none renders a blank rack', () => {
for (const id of THEME_ORDER) {
const palette = rackPalette(id)
for (const value of [palette.canvas, palette.plate, palette.text, palette.muted, palette.accent]) {
expect(value).toBeTruthy()
}
expect(palette.defaultRackStyle.showNumbers).toBe(true)
}
})
it('keeps a port recess darker than the plate on a light theme', () => {
// A jack has to read as a hole, not a highlight.
const light = rackPalette('light')
expect(light.portRecess).not.toBe(light.plate)
})
it('seeds a new rack with chrome drawn from the theme', () => {
const palette = rackPalette('neon')
expect(palette.defaultRackStyle.interior).toBe(THEMES.neon.colors.canvasBackground)
expect(palette.defaultRackStyle.enclosed).toBe(false)
})
it('falls back to the default theme for an unknown id', () => {
// Guards against a stored theme id that a later release removed.
const palette = rackPalette('nope' as never)
expect(palette.canvas).toBe(THEMES.default.colors.canvasBackground)
})
})
+794
View File
@@ -0,0 +1,794 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useRackStore } from '../store'
import { getFaceplate } from '../faceplates'
import { RACK_COLUMNS, type InventoryDevice } from '@/types'
import { MAX_RACK_U, MIN_RACK_U } from '../rackDefaults'
import { demoNetworkLinks } from '../demoData'
const store = () => useRackStore.getState()
/**
* Dropping an inventory entry is the one store action that talks to the
* backend; the rest of the demo state is local, so only that call is stubbed.
*/
const deletePending = vi.hoisted(() => vi.fn())
vi.mock('@/api/client', () => ({
racksApi: { load: vi.fn(), inventory: vi.fn(), save: vi.fn() },
scanApi: { deletePending, createPending: vi.fn() },
}))
beforeEach(() => {
useRackStore.getState().loadDemo()
})
describe('racks', () => {
it('boots with the demo rack and its mounted gear', () => {
expect(store().racks).toHaveLength(1)
expect(store().devices.length).toBeGreaterThan(0)
expect(store().cables.length).toBeGreaterThan(0)
})
it('adds a rack and selects it', () => {
const id = store().addRack()
expect(store().racks).toHaveLength(2)
expect(store().selectedRackId).toBe(id)
})
it('deletes a rack with its devices and their cables', () => {
store().removeRack('rack-main')
expect(store().racks).toHaveLength(0)
expect(store().devices).toHaveLength(0)
expect(store().cables).toHaveLength(0)
})
it('patches style without dropping the other style keys', () => {
store().updateRackStyle('rack-main', { showNumbers: false })
const style = store().racks[0].style
expect(style.showNumbers).toBe(false)
expect(style.frame).toBeTruthy()
})
/** An empty rack, so a clamp test is not also a relocation test. */
const emptyRack = () => {
const id = store().addRack()
return { id, height: () => store().racks.find((r) => r.id === id)!.uHeight }
}
it('clamps the height to the supported range', () => {
// The number input's min/max do not survive a typed value, and the backend
// rejects anything over 100 U with a 422 the user cannot read.
const rack = emptyRack()
expect(store().updateRack(rack.id, { uHeight: 999 })).toBe(true)
expect(rack.height()).toBe(MAX_RACK_U)
store().updateRack(rack.id, { uHeight: 0 })
expect(rack.height()).toBe(MIN_RACK_U)
store().updateRack(rack.id, { uHeight: -5 })
expect(rack.height()).toBe(MIN_RACK_U)
})
it('rounds a fractional height', () => {
const rack = emptyRack()
store().updateRack(rack.id, { uHeight: 12.6 })
expect(rack.height()).toBe(13)
})
it('relocates the mounts a shrink would push above the top rail', () => {
const rack = store().racks[0]
const tallest = store().devices
.filter((d) => d.rackId === rack.id)
.reduce((max, d) => (d.uStart + d.uHeight - 1 > max.uStart + max.uHeight - 1 ? d : max))
const next = tallest.uStart - 1
const mountedBefore = store().devices.filter((d) => d.rackId === rack.id).length
expect(store().updateRack(rack.id, { uHeight: next })).toBe(true)
expect(store().racks[0].uHeight).toBe(next)
// Every mount is inside the chassis, and none was dropped on the way.
const after = store().devices.filter((d) => d.rackId === rack.id)
expect(after).toHaveLength(mountedBefore)
for (const d of after) expect(d.uStart + d.uHeight - 1).toBeLessThanOrEqual(next)
})
it('never stacks two relocated mounts on the same slot', () => {
const rack = store().racks[0]
store().updateRack(rack.id, { uHeight: 12 })
const mounted = store().devices.filter((d) => d.rackId === rack.id)
for (const a of mounted) {
for (const b of mounted) {
if (a.id === b.id) continue
const uOverlap = a.uStart < b.uStart + b.uHeight && b.uStart < a.uStart + a.uHeight
const colOverlap =
a.colStart < b.colStart + b.colSpan && b.colStart < a.colStart + a.colSpan
expect(uOverlap && colOverlap).toBe(false)
}
}
})
it('refuses a shrink that leaves a mount nowhere to go, changing nothing', () => {
const rack = store().racks[0]
const before = store().devices.map((d) => ({ ...d }))
expect(store().updateRack(rack.id, { uHeight: 1 })).toBe(false)
expect(store().racks[0].uHeight).toBe(rack.uHeight)
expect(store().devices).toEqual(before)
})
it('leaves the mounts alone when the rack grows', () => {
const before = store().devices.map((d) => ({ ...d }))
expect(store().updateRack('rack-main', { uHeight: MAX_RACK_U })).toBe(true)
expect(store().devices).toEqual(before)
})
it('returns false for a rack that does not exist', () => {
expect(store().updateRack('nope', { name: 'Ghost' })).toBe(false)
})
})
describe('mounting', () => {
it('mounts an inventory item using its suggested faceplate', () => {
const id = store().mountFromInventory('inv-sw8', 'rack-main', { uStart: 5 })
expect(id).not.toBeNull()
const device = store().devices.find((d) => d.id === id)!
expect(device.faceplateId).toBe('switch-8')
expect(device.deviceId).toBe('inv-sw8')
expect(device.ports.length).toBe(getFaceplate('switch-8').ports.length)
})
it('gives every mounted port a distinct id', () => {
const id = store().mountFromInventory('inv-sw8', 'rack-main', { uStart: 5 })!
const ports = store().devices.find((d) => d.id === id)!.ports
expect(new Set(ports.map((p) => p.id)).size).toBe(ports.length)
})
it('slides a drop onto an occupied U to a free one', () => {
const taken = store().devices.find((d) => d.id === 'dev-sw24')!
const id = store().mountFromInventory('inv-sw8', 'rack-main', { uStart: taken.uStart })!
expect(store().devices.find((d) => d.id === id)!.uStart).not.toBe(taken.uStart)
})
it('returns null when the rack has no room', () => {
const rackId = store().addRack({ uHeight: 1 })
store().mountAccessory('blank-1u', rackId, { uStart: 1 })
expect(store().mountAccessory('blank-1u', rackId, { uStart: 1 })).toBeNull()
})
it('mounts an accessory with no inventory link', () => {
const id = store().mountAccessory('shelf-1u', 'rack-main', { uStart: 6 })!
expect(store().devices.find((d) => d.id === id)!.deviceId).toBeNull()
})
it('flags the inventory entry as racked once it is mounted', () => {
expect(store().inventory.find((i) => i.id === 'inv-sw8')!.racked).toBe(false)
store().mountFromInventory('inv-sw8', 'rack-main', { uStart: 5 })
expect(store().inventory.find((i) => i.id === 'inv-sw8')!.racked).toBe(true)
})
it('clears the racked flag again when the device is unmounted', () => {
expect(store().inventory.find((i) => i.id === 'inv-pve1')!.racked).toBe(true)
store().unmountDevice('dev-pve1')
expect(store().inventory.find((i) => i.id === 'inv-pve1')!.racked).toBe(false)
})
it('carries the canvas node link onto the mount', () => {
const id = store().mountFromInventory('inv-sw8', 'rack-main', { uStart: 5 })!
expect(store().devices.find((d) => d.id === id)!.nodeId).toBe('node-inv-sw8')
})
it('keeps the inventory entry when a device is unmounted', () => {
const before = store().inventory.length
store().unmountDevice('dev-pve1')
expect(store().devices.find((d) => d.id === 'dev-pve1')).toBeUndefined()
expect(store().inventory).toHaveLength(before)
expect(store().inventory.some((i) => i.id === 'inv-pve1')).toBe(true)
})
it('drops the cables of an unmounted device', () => {
const had = store().cables.some(
(c) => c.from.deviceId === 'dev-nas' || c.to.deviceId === 'dev-nas',
)
expect(had).toBe(true)
store().unmountDevice('dev-nas')
expect(
store().cables.some((c) => c.from.deviceId === 'dev-nas' || c.to.deviceId === 'dev-nas'),
).toBe(false)
})
})
describe('relinking a mount to another inventory entry', () => {
/** A row discovery filled in: an IP, a MAC and a canvas node behind it. */
const scanned: InventoryDevice = {
id: 'inv-scanned',
label: 'pdu-real',
type: 'pdu',
discoverySource: 'arp',
ip: '192.168.1.60',
mac: 'aa:bb:cc:dd:ee:ff',
hostname: 'pdu.lan',
os: null,
services: [],
status: 'online',
nodeId: 'node-scanned',
node: {
id: 'node-scanned',
label: 'pdu-real',
type: 'pdu',
ip: '192.168.1.60',
mac: 'aa:bb:cc:dd:ee:ff',
hostname: 'pdu.lan',
os: null,
checkMethod: 'ping',
designId: 'demo-network',
designName: 'Network',
lastSeen: null,
},
racked: false,
suggestedFaceplateId: 'pdu-1u',
}
/** Offer `scanned` in the inventory the store holds. */
function offer(extra: Partial<InventoryDevice> = {}) {
useRackStore.setState((s) => ({ inventory: [...s.inventory, { ...scanned, ...extra }] }))
}
/** Turn `dev-pdu`'s entry into a placeholder the rack created. */
function placeholder() {
useRackStore.setState((s) => ({
inventory: s.inventory.map((i) =>
i.id === 'inv-pdu' ? { ...i, discoverySource: 'rack' } : i,
),
}))
}
beforeEach(() => {
deletePending.mockClear()
deletePending.mockResolvedValue({ data: { deleted: true } })
})
it('points the mount at the entry, with its node, status and label', async () => {
// `dev-pdu` is dumb hardware: the backend has no IEEE and no IP to guess a
// canvas node from, so the link can only ever come from the user.
expect(store().devices.find((d) => d.id === 'dev-pdu')!.nodeId).toBeNull()
offer()
expect(await store().relinkDevice('dev-pdu', 'inv-scanned')).toBe(true)
const mount = store().devices.find((d) => d.id === 'dev-pdu')!
expect(mount.deviceId).toBe('inv-scanned')
expect(mount.nodeId).toBe('node-scanned')
expect(mount.label).toBe('pdu-real')
expect(mount.status).toBe('online')
// The entry is now taken, so it stops being offered as a fresh mount.
expect(store().inventory.find((i) => i.id === 'inv-scanned')!.racked).toBe(true)
expect(store().inventory.find((i) => i.id === 'inv-pdu')!.racked).toBe(false)
})
it('keeps a plate the user renamed', async () => {
store().updateDevice('dev-pdu', { label: 'PDU (left)' })
offer()
await store().relinkDevice('dev-pdu', 'inv-scanned')
expect(store().devices.find((d) => d.id === 'dev-pdu')!.label).toBe('PDU (left)')
})
it('keeps `auto`, which is a choice about how to resolve the status', async () => {
store().updateDevice('dev-pdu', { status: 'auto' })
offer()
await store().relinkDevice('dev-pdu', 'inv-scanned')
expect(store().devices.find((d) => d.id === 'dev-pdu')!.status).toBe('auto')
})
it('marks the canvas dirty, so the link is saved with the rest', async () => {
offer()
store().markSaved()
await store().relinkDevice('dev-pdu', 'inv-scanned')
expect(store().hasUnsavedChanges).toBe(true)
})
it('refuses an accessory, which stands for no device', async () => {
offer()
const id = store().mountAccessory('shelf-1u', 'rack-main', { uStart: 20 })!
expect(await store().relinkDevice(id, 'inv-scanned')).toBe(false)
expect(store().devices.find((d) => d.id === id)!.deviceId).toBeNull()
})
it('refuses an entry another plate already stands for', async () => {
const taken = store().devices.find((d) => d.id === 'dev-nas')!.deviceId!
expect(await store().relinkDevice('dev-pdu', taken)).toBe(false)
expect(store().devices.find((d) => d.id === 'dev-pdu')!.deviceId).toBe('inv-pdu')
})
it('refuses an entry the inventory does not hold', async () => {
expect(await store().relinkDevice('dev-pdu', 'inv-ghost')).toBe(false)
})
it('deletes the placeholder the rack created and left behind', async () => {
placeholder()
offer()
await store().relinkDevice('dev-pdu', 'inv-scanned')
expect(deletePending).toHaveBeenCalledWith('inv-pdu')
expect(store().inventory.some((i) => i.id === 'inv-pdu')).toBe(false)
})
it('keeps a discovered entry the mount leaves behind', async () => {
offer()
await store().relinkDevice('dev-pdu', 'inv-scanned')
expect(deletePending).not.toHaveBeenCalled()
// Still there, and free to be mounted again.
expect(store().inventory.find((i) => i.id === 'inv-pdu')!.racked).toBe(false)
})
it('keeps the placeholder when the backend refuses to delete it', async () => {
deletePending.mockRejectedValue(new Error('409'))
placeholder()
offer()
// The relink itself still stands — only the cleanup failed.
expect(await store().relinkDevice('dev-pdu', 'inv-scanned')).toBe(true)
expect(store().inventory.some((i) => i.id === 'inv-pdu')).toBe(true)
})
it('refuses to discard an entry a plate still stands for', async () => {
placeholder()
expect(await store().discardInventoryDevice('inv-pdu')).toBe(false)
expect(deletePending).not.toHaveBeenCalled()
})
})
describe('moving and resizing', () => {
it('refuses a move onto an occupied slot', () => {
const target = store().devices.find((d) => d.id === 'dev-sw24')!
const ok = store().moveDevice('dev-fw', 'rack-main', {
uStart: target.uStart,
uHeight: 1,
colStart: 0,
colSpan: RACK_COLUMNS,
})
expect(ok).toBe(false)
expect(store().devices.find((d) => d.id === 'dev-fw')!.uStart).toBe(15)
})
it('accepts a move onto a free slot', () => {
const ok = store().moveDevice('dev-fw', 'rack-main', {
uStart: 5,
uHeight: 1,
colStart: 0,
colSpan: RACK_COLUMNS,
})
expect(ok).toBe(true)
expect(store().devices.find((d) => d.id === 'dev-fw')!.uStart).toBe(5)
})
it('refuses a geometry edit no slot in the rack can take', () => {
// The demo rack's longest free run is 3U (U4-U6), so 5U fits nowhere.
expect(store().updateDevice('dev-pve1', { uHeight: 5 })).toBe(false)
expect(store().devices.find((d) => d.id === 'dev-pve1')!.uHeight).toBe(2)
})
it('relocates a device that outgrows its own slot', () => {
// dev-shelf is 1U at U7 with dev-nas right above; growing it to 3U has to
// slide it down into the free U4-U6 run rather than silently do nothing.
expect(store().updateDevice('dev-shelf', { uHeight: 3 })).toBe(true)
const device = store().devices.find((d) => d.id === 'dev-shelf')!
expect(device.uHeight).toBe(3)
expect(device.uStart).toBe(5) // nearest fit, keeping its own U7
})
it('applies a non-geometry edit even in a tight rack', () => {
store().updateDevice('dev-pve1', { label: 'renamed' })
expect(store().devices.find((d) => d.id === 'dev-pve1')!.label).toBe('renamed')
})
it('resizes to the new faceplate when it fits', () => {
store().updateDevice('dev-blank', { label: 'slot' })
store().applyFaceplate('dev-pve2', 'server-1u')
const device = store().devices.find((d) => d.id === 'dev-pve2')!
expect(device.faceplateId).toBe('server-1u')
expect(device.uHeight).toBe(1)
})
it('relocates rather than keep the old height when a taller plate collides', () => {
// dev-shelf is 1U at U7, hemmed in by dev-nas above. A 2U plate has to land
// somewhere it fits — keeping it 1U is how the height looked "locked".
expect(store().applyFaceplate('dev-shelf', 'ups-2u')).toBe(true)
const device = store().devices.find((d) => d.id === 'dev-shelf')!
expect(device.faceplateId).toBe('ups-2u')
expect(device.uHeight).toBe(2)
expect(device.uStart).not.toBe(7)
})
it('changes nothing when no slot in the rack takes the new plate', () => {
// 4U, and the longest free run is 3U.
expect(store().applyFaceplate('dev-sw24', 'server-4u-storage')).toBe(false)
const device = store().devices.find((d) => d.id === 'dev-sw24')!
expect(device.faceplateId).toBe('switch-24')
expect(device.uHeight).toBe(1)
})
})
describe('ports', () => {
it('adds and removes a port', () => {
const before = store().devices.find((d) => d.id === 'dev-pve1')!.ports.length
store().addPort('dev-pve1', { label: 'ipmi', type: 'rj45', x: 0.5, y: 0.5 })
const ports = store().devices.find((d) => d.id === 'dev-pve1')!.ports
expect(ports).toHaveLength(before + 1)
store().removePort('dev-pve1', ports[ports.length - 1].id)
expect(store().devices.find((d) => d.id === 'dev-pve1')!.ports).toHaveLength(before)
})
it('removes the cable attached to a deleted port', () => {
const cable = store().cables[0]
store().removePort(cable.from.deviceId, cable.from.portId)
expect(store().cables.find((c) => c.id === cable.id)).toBeUndefined()
})
it('renames a port in place', () => {
const port = store().devices.find((d) => d.id === 'dev-pve1')!.ports[0]
store().updatePort('dev-pve1', port.id, { label: 'wan' })
expect(store().devices.find((d) => d.id === 'dev-pve1')!.ports[0].label).toBe('wan')
})
})
describe('cables', () => {
const freePorts = () => {
const used = new Set(
store().cables.flatMap((c) => [
`${c.from.deviceId}:${c.from.portId}`,
`${c.to.deviceId}:${c.to.portId}`,
]),
)
const pick = (deviceId: string) => {
const device = store().devices.find((d) => d.id === deviceId)!
const port = device.ports.find((p) => !used.has(`${deviceId}:${p.id}`))!
return { deviceId, portId: port.id }
}
return { a: pick('dev-sw24'), b: pick('dev-pve1') }
}
it('creates a cable between two free ports', () => {
const { a, b } = freePorts()
const id = store().addCable(a, b)
expect(id).not.toBeNull()
expect(store().cables.find((c) => c.id === id)!.color).toBeTruthy()
})
it('infers the cable type from the port it starts on', () => {
const sw = store().devices.find((d) => d.id === 'dev-sw24')!
const nas = store().devices.find((d) => d.id === 'dev-nas')!
const freeOf = (deviceId: string, type: string) => {
const patched = new Set(store().cables.flatMap((c) => [c.from.portId, c.to.portId]))
const device = store().devices.find((d) => d.id === deviceId)!
return device.ports.find((p) => p.type === type && !patched.has(p.id))!
}
const copper = store().addCable(
{ deviceId: sw.id, portId: freeOf(sw.id, 'rj45').id },
{ deviceId: nas.id, portId: freeOf(nas.id, 'rj45').id },
)
expect(store().cables.find((c) => c.id === copper)!.type).toBe('ethernet')
const fiber = store().addCable(
{ deviceId: sw.id, portId: freeOf(sw.id, 'sfp+').id },
{ deviceId: nas.id, portId: freeOf(nas.id, 'rj45').id },
)
expect(store().cables.find((c) => c.id === fiber)!.type).toBe('fiber')
})
it('refuses a second cable on an already patched port', () => {
const existing = store().cables[0]
const { b } = freePorts()
expect(store().addCable(existing.from, b)).toBeNull()
})
it('refuses a port patched to itself', () => {
const { a } = freePorts()
expect(store().addCable(a, a)).toBeNull()
})
it('refuses an unknown port', () => {
const { a } = freePorts()
expect(store().addCable(a, { deviceId: 'dev-pve1', portId: 'nope' })).toBeNull()
})
it('builds a cable across two clicks in patch mode', () => {
const { a, b } = freePorts()
const before = store().cables.length
store().pickPort(a.deviceId, a.portId)
expect(store().cableDraft).toEqual(a)
store().pickPort(b.deviceId, b.portId)
expect(store().cableDraft).toBeNull()
expect(store().cables).toHaveLength(before + 1)
})
it('patches by dragging from one port to another', () => {
const { a, b } = freePorts()
const before = store().cables.length
store().startCableDrag(a.deviceId, a.portId)
expect(store().cableDraft).toEqual(a)
store().moveCableDrag({ x: 120, y: 80 })
expect(store().cableDrag).toEqual({ pointer: { x: 120, y: 80 }, moved: true })
store().endCableDrag(b)
expect(store().cables).toHaveLength(before + 1)
expect(store().cableDraft).toBeNull()
expect(store().cableDrag).toBeNull()
})
it('drops the draft when a drag is released on nothing', () => {
const { a } = freePorts()
const before = store().cables.length
store().startCableDrag(a.deviceId, a.portId)
store().moveCableDrag({ x: 10, y: 10 })
store().endCableDrag(null)
expect(store().cables).toHaveLength(before)
expect(store().cableDraft).toBeNull()
expect(store().cableDrag).toBeNull()
})
it('keeps the port armed when a press never moved, so click-then-click works', () => {
const { a, b } = freePorts()
const before = store().cables.length
store().startCableDrag(a.deviceId, a.portId)
store().endCableDrag(null)
expect(store().cableDraft).toEqual(a)
expect(store().cableDrag).toBeNull()
store().startCableDrag(b.deviceId, b.portId)
expect(store().cables).toHaveLength(before + 1)
expect(store().cableDraft).toBeNull()
})
it('disarms when the armed port is pressed again', () => {
const { a } = freePorts()
const before = store().cables.length
store().startCableDrag(a.deviceId, a.portId)
store().startCableDrag(a.deviceId, a.portId)
expect(store().cableDraft).toBeNull()
expect(store().cables).toHaveLength(before)
})
it('ignores a release with no drag in flight', () => {
const { a, b } = freePorts()
const before = store().cables.length
store().endCableDrag(b)
expect(store().cables).toHaveLength(before)
expect(store().cableDraft).toBeNull()
// And a move without a drag changes nothing.
store().moveCableDrag({ x: 1, y: 1 })
expect(store().cableDrag).toBeNull()
expect(a).toBeTruthy()
})
it('cancels a draft and its drag together', () => {
const { a } = freePorts()
store().startCableDrag(a.deviceId, a.portId)
store().moveCableDrag({ x: 5, y: 5 })
store().cancelCableDraft()
expect(store().cableDraft).toBeNull()
expect(store().cableDrag).toBeNull()
})
it('selects a cable and unplugs it with removeSelectedCable', () => {
const cable = store().cables[0]
store().selectCable(cable.id)
expect(store().selectedCableId).toBe(cable.id)
store().removeSelectedCable()
expect(store().cables.some((c) => c.id === cable.id)).toBe(false)
expect(store().selectedCableId).toBeNull()
})
it('removeSelectedCable is a no-op with nothing selected', () => {
const before = store().cables.length
store().removeSelectedCable()
expect(store().cables).toHaveLength(before)
})
it('clears the cable selection when the cable is removed by id', () => {
const cable = store().cables[0]
store().selectCable(cable.id)
store().removeCable(cable.id)
expect(store().selectedCableId).toBeNull()
})
it('keeps the cable selection when another cable is removed', () => {
const [first, second] = store().cables
store().selectCable(first.id)
store().removeCable(second.id)
expect(store().selectedCableId).toBe(first.id)
})
it('drops the cable selection when a device or rack is selected', () => {
store().selectCable(store().cables[0].id)
store().selectDevice(store().devices[0].id)
expect(store().selectedCableId).toBeNull()
store().selectCable(store().cables[0].id)
store().selectRack('rack-main')
expect(store().selectedCableId).toBeNull()
})
it('drops the device and rack selection when a cable is selected', () => {
// Both drive the right rail, so the two selections cannot coexist.
store().selectDevice(store().devices[0].id)
store().selectCable(store().cables[0].id)
expect(store().selectedDeviceId).toBeNull()
expect(store().selectedRackId).toBeNull()
})
it('keeps the device selection when the cable selection is cleared', () => {
store().selectDevice(store().devices[0].id)
store().selectCable(null)
expect(store().selectedDeviceId).toBe(store().devices[0].id)
})
it('edits a cable label, its canvas visibility and its properties', () => {
const cable = store().cables[0]
store().updateCable(cable.id, {
label: 'Uplink to core',
labelVisible: true,
properties: [{ key: 'Length', value: '2 m', icon: null, visible: true }],
})
const updated = store().cables.find((c) => c.id === cable.id)!
expect(updated.label).toBe('Uplink to core')
expect(updated.labelVisible).toBe(true)
expect(updated.properties).toEqual([{ key: 'Length', value: '2 m', icon: null, visible: true }])
expect(store().hasUnsavedChanges).toBe(true)
})
it('leaves other cables untouched when one is edited', () => {
const [first, second] = store().cables
store().updateCable(first.id, { color: '#ff00ff' })
expect(store().cables.find((c) => c.id === second.id)!.color).toBe(second.color)
})
it('drops the cable selection when leaving patch mode', () => {
store().toggleCableMode()
store().selectCable(store().cables[0].id)
store().toggleCableMode()
expect(store().cableMode).toBe(false)
expect(store().selectedCableId).toBeNull()
})
it('turns cables on when entering patch mode', () => {
expect(store().cableVisibility).toBe('hover')
store().toggleCableMode()
expect(store().cableMode).toBe(true)
expect(store().cableVisibility).toBe('always')
})
it('restores the previous cable visibility when leaving patch mode', () => {
store().toggleCableMode()
store().toggleCableMode()
expect(store().cableMode).toBe(false)
expect(store().cableVisibility).toBe('hover')
})
it('keeps a visibility the user picked while patching', () => {
store().toggleCableMode()
store().setCableVisibility('hidden')
store().toggleCableMode()
expect(store().cableVisibility).toBe('hidden')
})
it('leaves an always-on canvas alone across patch mode', () => {
store().setCableVisibility('always')
store().toggleCableMode()
store().toggleCableMode()
expect(store().cableVisibility).toBe('always')
})
it('imports links from the network canvas', () => {
// Mount the inventory item the hints reference; the other end is racked already.
store().mountFromInventory('inv-sw8', 'rack-main', { uStart: 4 })
const first = store().importCablesFromNetwork(demoNetworkLinks())
expect(first).toBeGreaterThan(0)
})
it('re-running the import adds nothing to a pair already patched', () => {
// The guard used to be an in-memory flag, so a reload re-armed the import and
// every link came back on the next free pair of ports. The pair itself is the
// guard now, and it survives a reload because the cables do.
store().mountFromInventory('inv-sw8', 'rack-main', { uStart: 4 })
const created = store().importCablesFromNetwork(demoNetworkLinks())
const after = store().cables.length
expect(store().importCablesFromNetwork(demoNetworkLinks())).toBe(0)
expect(store().cables).toHaveLength(after)
expect(created).toBeGreaterThan(0)
})
it('imports what is newly rackable after a run that matched nothing', () => {
// Import before racking anything: nothing matches, and the toast tells the
// user to rack the devices first. That retry has to still work.
expect(store().importCablesFromNetwork([
{ from: 'node-nowhere-a', to: 'node-nowhere-b', type: 'ethernet' },
])).toBe(0)
store().mountFromInventory('inv-sw8', 'rack-main', { uStart: 4 })
expect(store().importCablesFromNetwork(demoNetworkLinks())).toBeGreaterThan(0)
})
it('lands a fibre link on fibre ports when both plates have a free one', () => {
const used = new Set(
store().cables.flatMap((c) => [
`${c.from.deviceId}:${c.from.portId}`,
`${c.to.deviceId}:${c.to.portId}`,
]),
)
// Devices that carry both kinds of free port, so the first free one is copper
// and picking the fibre one is a real choice rather than the only option.
const mixed = store().devices.filter((d) => {
const free = d.ports.filter((p) => !used.has(`${d.id}:${p.id}`))
return d.nodeId && free.some((p) => p.type === 'rj45') && free.some((p) => p.type !== 'rj45')
})
const patched = new Set(
store().cables.map((c) => [c.from.deviceId, c.to.deviceId].sort().join('|')),
)
const pair = mixed.flatMap((x, i) =>
mixed.slice(i + 1).map((y) => [x, y] as const),
).find(([x, y]) => !patched.has([x.id, y.id].sort().join('|')))
expect(pair).toBeDefined()
const [a, b] = pair!
const before = new Set(store().cables.map((c) => c.id))
store().importCablesFromNetwork([{ from: a.nodeId!, to: b.nodeId!, type: 'fiber' }])
const cable = store().cables.find((c) => !before.has(c.id))!
const portOf = (deviceId: string, portId: string) =>
store().devices.find((d) => d.id === deviceId)!.ports.find((p) => p.id === portId)!
expect(portOf(cable.from.deviceId, cable.from.portId).type).not.toBe('rj45')
expect(portOf(cable.to.deviceId, cable.to.portId).type).not.toBe('rj45')
})
it('skips hints whose devices are not racked', () => {
// inv-jbod is never mounted in the demo, so its hint cannot resolve.
store().importCablesFromNetwork(demoNetworkLinks())
expect(
store().cables.some((c) => {
const from = store().devices.find((d) => d.id === c.from.deviceId)
return from?.nodeId === 'node-inv-jbod'
}),
).toBe(false)
})
})
describe('dirty tracking', () => {
it('starts clean after loading the sample and marks edits', () => {
// loadDemo seeds unsaved sample data on purpose; a real load starts clean.
store().markSaved()
expect(store().hasUnsavedChanges).toBe(false)
store().addRack()
expect(store().hasUnsavedChanges).toBe(true)
})
it('bumps the edit counter on every mutation', () => {
const before = store().editSeq
store().updateRack('rack-main', { name: 'renamed' })
expect(store().editSeq).toBeGreaterThan(before)
})
it('does not dirty the canvas for pan and zoom', () => {
store().markSaved()
store().setViewport({ x: 12, y: 34, zoom: 2 })
expect(store().hasUnsavedChanges).toBe(false)
expect(store().viewport).toEqual({ x: 12, y: 34, zoom: 2 })
})
it('does not dirty the canvas for selection or cable visibility', () => {
store().markSaved()
store().selectDevice('dev-sw24')
store().setCableVisibility('always')
store().toggleCableMode()
expect(store().hasUnsavedChanges).toBe(false)
})
it('clears everything on reset', () => {
store().reset()
expect(store().racks).toEqual([])
expect(store().devices).toEqual([])
expect(store().inventory).toEqual([])
expect(store().hasUnsavedChanges).toBe(false)
})
})
@@ -0,0 +1,69 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useAutoStatusRefresh } from '../useAutoStatusRefresh'
import { useRackStore } from '../store'
import type { RackDevice } from '@/types'
vi.mock('@/api/client', () => ({
racksApi: { load: vi.fn(), inventory: vi.fn(), save: vi.fn() },
scanApi: { createPending: vi.fn(), pending: vi.fn(), hidden: vi.fn() },
}))
const refreshInventory = vi.fn().mockResolvedValue(undefined)
function mount(status: RackDevice['status']): RackDevice {
return {
id: 'd1',
rackId: 'r1',
deviceId: 'inv-1',
nodeId: 'node-1',
label: 'nas',
uStart: 1,
uHeight: 1,
colStart: 0,
colSpan: 12,
faceplateId: 'server-1u',
status,
ports: [],
}
}
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
useRackStore.setState({ refreshInventory })
})
afterEach(() => {
vi.useRealTimers()
})
describe('useAutoStatusRefresh', () => {
it('polls the inventory while a mount follows its node', () => {
useRackStore.setState({ devices: [mount('auto')] })
renderHook(() => useAutoStatusRefresh(1000))
vi.advanceTimersByTime(3000)
expect(refreshInventory).toHaveBeenCalledTimes(3)
})
it('stays quiet when every mount pins its own status', () => {
// Nothing to refresh for: the LEDs are what the user set, and a needless
// poll would hit the backend on every rack canvas ever opened.
useRackStore.setState({ devices: [mount('online'), mount('unknown')] })
renderHook(() => useAutoStatusRefresh(1000))
vi.advanceTimersByTime(5000)
expect(refreshInventory).not.toHaveBeenCalled()
})
it('stops polling once unmounted', () => {
useRackStore.setState({ devices: [mount('auto')] })
const { unmount } = renderHook(() => useAutoStatusRefresh(1000))
vi.advanceTimersByTime(1000)
unmount()
vi.advanceTimersByTime(5000)
expect(refreshInventory).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,45 @@
/**
* Sidebar section for rack designs: rack accessories, as drag sources.
*
* Devices themselves are not listed here — they live in the app's **Device
* Inventory** (`pending_devices`), under its "Rack devices" source filter, so
* they get the same search / filter / hide / delete tooling as scanned gear.
* Mounting one is the `+ Device` modal's job. Accessories (blanks, shelves,
* cable managers) are rack-only artwork with no inventory row, so they stay.
*/
import { FACEPLATES } from '../faceplates'
import { endDrag, startDrag } from './dragPayload'
export function AccessoryTray() {
const accessories = FACEPLATES.filter((f) => f.kind === 'accessory')
return (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-2 pb-2 text-sm text-foreground">
<h2 className="pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Accessories
</h2>
<p className="mb-2 text-[11px] leading-snug text-muted-foreground">
Drag onto a rack. Devices come from the Device Inventory — use + Device.
</p>
<ul className="space-y-1">
{accessories.map((plate) => (
<li
key={plate.id}
draggable
onDragStart={(e) =>
startDrag(e.dataTransfer, {
kind: 'accessory',
id: plate.id,
faceplateId: plate.id,
})
}
onDragEnd={endDrag}
className="cursor-grab rounded border border-border bg-[#161b22] px-2 py-1.5 text-xs hover:border-[#00d4ff]"
>
{plate.label}
</li>
))}
</ul>
</div>
)
}
+273
View File
@@ -0,0 +1,273 @@
/**
* Port-to-port cabling, drawn in canvas space above the racks.
*
* Lives in a ViewportPortal so the paths pan and zoom with the flow without
* being React Flow edges — rack cables are a physical relation, not a logical
* one, and they attach to ports rather than to node handles.
*/
import { ViewportPortal } from '@xyflow/react'
import { portPosition } from '../layout'
import { useRackPalette, type RackPalette } from '../rackTheme'
import { useRackStore } from '../store'
import type { Cable, Port, Rack, RackDevice } from '@/types'
interface Resolved {
cable: Cable
from: { x: number; y: number }
to: { x: number; y: number }
}
/** Slack loop: cables bulge out sideways, more so over long vertical runs. */
function bulgeFor(a: { x: number; y: number }, b: { x: number; y: number }): number {
return Math.min(90, 18 + Math.abs(b.y - a.y) * 0.45)
}
function cablePath(a: { x: number; y: number }, b: { x: number; y: number }): string {
const bulge = bulgeFor(a, b)
return `M ${a.x} ${a.y} C ${a.x + bulge} ${a.y + bulge * 0.4}, ${b.x + bulge} ${b.y + bulge * 0.4}, ${b.x} ${b.y}`
}
/**
* Midpoint of the same cubic, evaluated at t = 0.5 — where the annotations
* hang. Solved analytically rather than with `getPointAtLength`, which needs a
* mounted path element and would not work under jsdom.
*/
function cableMidpoint(a: { x: number; y: number }, b: { x: number; y: number }) {
const bulge = bulgeFor(a, b)
const c1 = { x: a.x + bulge, y: a.y + bulge * 0.4 }
const c2 = { x: b.x + bulge, y: b.y + bulge * 0.4 }
return {
x: (a.x + 3 * c1.x + 3 * c2.x + b.x) / 8,
y: (a.y + 3 * c1.y + 3 * c2.y + b.y) / 8,
}
}
/** Lines a cable prints on the canvas: its label, then its visible properties. */
function annotationLines(cable: Cable): string[] {
const lines: string[] = []
if (cable.labelVisible && cable.label?.trim()) lines.push(cable.label.trim())
for (const prop of cable.properties ?? []) {
if (prop.visible) lines.push(`${prop.key}: ${prop.value}`)
}
return lines
}
export function CableLayer() {
const racks = useRackStore((s) => s.racks)
const devices = useRackStore((s) => s.devices)
const cables = useRackStore((s) => s.cables)
const visibility = useRackStore((s) => s.cableVisibility)
const hoveredDeviceId = useRackStore((s) => s.hoveredDeviceId)
const selectedDeviceId = useRackStore((s) => s.selectedDeviceId)
const cableMode = useRackStore((s) => s.cableMode)
const selectedCableId = useRackStore((s) => s.selectedCableId)
const selectCable = useRackStore((s) => s.selectCable)
const cableDraft = useRackStore((s) => s.cableDraft)
const cableDrag = useRackStore((s) => s.cableDrag)
const palette = useRackPalette()
// `hidden` still draws the selected cable: its panel is open on the right and
// editing a run nothing shows reads as a bug. The per-cable filter below is
// what decides; this only skips the work when there is nothing to draw.
if (visibility === 'hidden' && !cableMode && !selectedCableId) return null
const rackById = new Map<string, Rack>(racks.map((r) => [r.id, r]))
const deviceById = new Map<string, RackDevice>(devices.map((d) => [d.id, d]))
const focus = hoveredDeviceId ?? selectedDeviceId
const showAll = cableMode || visibility === 'always'
const resolve = (deviceId: string, portId: string) => {
const device = deviceById.get(deviceId)
const rack = device && rackById.get(device.rackId)
const port: Port | undefined = device?.ports.find((p) => p.id === portId)
if (!device || !rack || !port) return null
return portPosition(rack, device, port)
}
const visible: Resolved[] = []
for (const cable of cables) {
// A selected cable stays on screen whatever the visibility mode — its panel
// is open on the right, so hiding the run it describes reads as a bug.
if (!showAll && cable.id !== selectedCableId) {
// `hidden` means hidden: hovering a plate reveals nothing extra, or the
// selection would drag its neighbours back on screen with it.
if (visibility === 'hidden') continue
if (!focus) continue
if (cable.from.deviceId !== focus && cable.to.deviceId !== focus) continue
}
const from = resolve(cable.from.deviceId, cable.from.portId)
const to = resolve(cable.to.deviceId, cable.to.portId)
if (!from || !to) continue
visible.push({ cable, from, to })
}
// Rubber band: the patch being dragged out of its source port.
const dragFrom = cableDraft && resolve(cableDraft.deviceId, cableDraft.portId)
const dragTo = cableDrag?.pointer ?? null
const draftLine = dragFrom && dragTo ? { from: dragFrom, to: dragTo } : null
if (visible.length === 0 && !draftLine) return null
return (
<ViewportPortal>
<svg
style={{
position: 'absolute',
left: 0,
top: 0,
width: 1,
height: 1,
overflow: 'visible',
pointerEvents: 'none',
zIndex: 5,
}}
>
{visible.map(({ cable, from, to }) => {
const selected = cable.id === selectedCableId
const dimmed =
!selected && focus != null && cable.from.deviceId !== focus && cable.to.deviceId !== focus
const d = cablePath(from, to)
const lines = annotationLines(cable)
return (
<g key={cable.id} opacity={dimmed ? 0.28 : 1}>
{selected && (
<path
d={d}
fill="none"
stroke={palette.accent}
strokeWidth={7}
strokeLinecap="round"
opacity={0.45}
style={{ pointerEvents: 'none' }}
/>
)}
<path
d={d}
fill="none"
stroke="#0d1117"
strokeWidth={3.5}
strokeLinecap="round"
/>
<path
d={d}
fill="none"
stroke={selected ? palette.accent : cable.color}
strokeWidth={selected ? 2.6 : 1.8}
strokeLinecap="round"
style={{ pointerEvents: 'none' }}
/>
{/* Fat invisible hit area — a 1.8px path is hard to hit at low
zoom. Clickable outside patch mode too: a cable is selected to
read and edit it, not only to unplug it. */}
<path
data-testid={`cable-hit-${cable.id}`}
d={d}
fill="none"
stroke="transparent"
strokeWidth={14}
strokeLinecap="round"
style={{ pointerEvents: 'stroke', cursor: 'pointer' }}
onClick={(e) => {
// The pane click behind would clear the selection again.
e.stopPropagation()
selectCable(selected ? null : cable.id)
}}
>
<title>
{cable.label ? `${cable.label} — ${cable.type}` : cable.type}
{' (click to select, Delete to remove)'}
</title>
</path>
<circle cx={from.x} cy={from.y} r={selected ? 3.2 : 2.2} fill={selected ? palette.accent : cable.color} />
<circle cx={to.x} cy={to.y} r={selected ? 3.2 : 2.2} fill={selected ? palette.accent : cable.color} />
{lines.length > 0 && (
<CableAnnotation
lines={lines}
at={cableMidpoint(from, to)}
color={selected ? palette.accent : cable.color}
palette={palette}
/>
)}
</g>
)
})}
{draftLine && (
<g data-testid="cable-draft" style={{ pointerEvents: 'none' }}>
<path
d={cablePath(draftLine.from, draftLine.to)}
fill="none"
stroke={palette.accent}
strokeWidth={2}
strokeDasharray="5 4"
strokeLinecap="round"
/>
<circle cx={draftLine.from.x} cy={draftLine.from.y} r={3} fill={palette.accent} />
<circle
cx={draftLine.to.x}
cy={draftLine.to.y}
r={3.5}
fill="none"
stroke={palette.accent}
strokeWidth={1.5}
/>
</g>
)}
</svg>
</ViewportPortal>
)
}
const ANNOTATION_FONT = 7
const ANNOTATION_LINE = 9
/**
* The label and visible properties of one cable, printed on a plate at the
* midpoint of its run.
*
* Drawn in flow coordinates like the rest of the overlay, so it pans and zooms
* with the racks. The plate is sized from the character count — SVG has no text
* metrics before layout, and measuring in the DOM would cost a reflow per cable.
*/
function CableAnnotation({ lines, at, color, palette }: {
lines: string[]
at: { x: number; y: number }
color: string
palette: RackPalette
}) {
const longest = lines.reduce((max, line) => Math.max(max, line.length), 0)
const width = Math.max(24, longest * ANNOTATION_FONT * 0.56 + 8)
const height = lines.length * ANNOTATION_LINE + 4
const x = at.x - width / 2
const y = at.y - height / 2
return (
<g style={{ pointerEvents: 'none' }} data-testid="cable-annotation">
<rect
x={x}
y={y}
width={width}
height={height}
rx={2}
fill={palette.plate}
stroke={color}
strokeWidth={0.6}
opacity={0.95}
/>
{lines.map((line, i) => (
<text
key={`${line}-${i}`}
x={at.x}
y={y + 2 + ANNOTATION_LINE * (i + 1) - 2.5}
textAnchor="middle"
fontSize={ANNOTATION_FONT}
fill={palette.text}
style={{ fontFamily: 'Inter, sans-serif' }}
>
{line}
</text>
))}
</g>
)
}
@@ -0,0 +1,147 @@
/**
* Pick the Device Inventory entry a mount stands for.
*
* A plate created from the rack carries a placeholder entry with nothing in it
* but a name, and discovery only *guesses* the canvas node behind an entry
* (IEEE, then IP) — so a device it cannot correlate stays bare however complete
* its twin is. This is the way in: point the plate at the inventory row that
* already holds the IP, the MAC, the services and, when there is one, the
* canvas node.
*
* The list is the Device Inventory itself, not the logical canvases: an entry
* no one ever approved onto a canvas is still the record of a real box, and is
* exactly what a rack is built out of.
*/
import { useEffect, useMemo, useState } from 'react'
import { Search } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { useRackStore } from '../store'
import { useRackPalette } from '../rackTheme'
import type { InventoryDevice } from '@/types'
/** Discovery labels, as the Device Inventory prints them. */
const SOURCE_LABELS: Record<string, string> = {
arp: 'Network scan',
proxmox: 'Proxmox',
zigbee: 'Zigbee',
zwave: 'Z-Wave',
manual: 'Added by hand',
rack: 'Rack device',
}
function sourceLabel(source: string | null | undefined): string | null {
if (!source) return null
return SOURCE_LABELS[source] ?? source
}
function matches(item: InventoryDevice, query: string): boolean {
if (!query) return true
const q = query.toLowerCase()
return [
item.label,
item.type,
item.ip,
item.hostname,
item.mac,
item.node?.designName,
sourceLabel(item.discoverySource),
].some((v) => v?.toLowerCase().includes(q))
}
interface Props {
open: boolean
/** Entry the mount already points at, highlighted in the list. */
value?: string | null
onPick: (entry: InventoryDevice) => void
onClose: () => void
}
export function DevicePickerModal({ open, value, onPick, onClose }: Props) {
const palette = useRackPalette()
const inventory = useRackStore((s) => s.inventory)
const refreshInventory = useRackStore((s) => s.refreshInventory)
const [query, setQuery] = useState('')
// The Device Inventory lives outside the rack — a scan, an approval or a
// rename can land while this modal's parent stays open.
useEffect(() => {
if (open) void refreshInventory()
}, [open, refreshInventory])
// One entry, one mount: a device already on a plate in this design is not on
// offer, or two plates would follow the same box.
const visible = useMemo(
() => inventory.filter((i) => (!i.racked || i.id === value) && matches(i, query)),
[inventory, value, query],
)
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="border-[#30363d] bg-[#161b22] text-foreground max-w-[calc(100%-2rem)] sm:max-w-xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-sm font-semibold">Link to an inventory device</DialogTitle>
</DialogHeader>
<div className="relative">
<Search
size={13}
className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<input
autoFocus
aria-label="Search devices"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by name, IP, hostname or source…"
className="w-full rounded border border-[#30363d] bg-[#21262d] py-1 pl-7 pr-2 text-sm text-foreground outline-none focus:border-[#00d4ff]"
/>
</div>
{visible.length === 0 && (
<p className="text-[11px] text-muted-foreground">
{inventory.length === 0
? 'The Device Inventory is empty. Run a scan or add a device by hand.'
: 'No device matches that search.'}
</p>
)}
<ul className="flex max-h-[50vh] flex-col gap-1 overflow-y-auto">
{visible.map((item) => {
const source = sourceLabel(item.discoverySource)
// Where the logical twin is drawn, when discovery found one — the
// reason two look-alike entries are not the same device.
const canvas = item.node?.designName ?? null
return (
<li key={item.id}>
<button
type="button"
onClick={() => onPick(item)}
className={`flex w-full cursor-pointer items-center gap-2 rounded border px-2 py-1.5 text-left hover:border-[#00d4ff] ${
item.id === value ? 'border-[#00d4ff]' : 'border-[#30363d]'
}`}
>
<span
aria-hidden
className="inline-block h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: palette.status[item.status] }}
/>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{item.label}</span>
<span className="truncate text-[11px] text-muted-foreground">
{[item.type, source, canvas].filter(Boolean).join(' · ')}
</span>
</span>
{(item.ip || item.mac) && (
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
{item.ip || item.mac}
</span>
)}
</button>
</li>
)
})}
</ul>
</DialogContent>
</Dialog>
)
}
+334
View File
@@ -0,0 +1,334 @@
/**
* Draws a device front panel from its template.
*
* Layout is pixel-based on purpose: the status LED, the name band and the port
* artwork all keep a fixed size whatever the U height, so plates of different
* heights line up with each other instead of scaling apart.
*/
import { memo, useId } from 'react'
import { getFaceplate } from '../faceplates'
import { U_PX } from '../layout'
import { useRackPalette } from '../rackTheme'
import type { DeviceStatus, FaceplateElement, Port, PortType } from '@/types'
const LED_X = 9
const LED_R = 3
interface PortShapeSize {
w: number
h: number
}
/**
* Port artwork is the same size on every plate — a socket is a socket, whether
* it sits on a 1U switch or a 4U NAS. The reference is a 1U patch panel, the
* densest thing that has to stay legible.
*/
const PORT_W = U_PX * 0.3
function portShapeSize(type: PortType): PortShapeSize {
return type === 'rj45'
? { w: PORT_W, h: PORT_W * 0.92 }
: { w: PORT_W * 1.15, h: PORT_W * 0.62 }
}
/** RJ45 silhouette: body with the latch slot cut into the top, centred on (0,0). */
function rj45Path(w: number, h: number): string {
const left = -w / 2
const top = -h / 2
const notchW = w * 0.4
const notchH = h * 0.4
const shoulder = (w - notchW) / 2
return [
`M ${left} ${top + notchH}`,
`h ${shoulder}`,
`v ${-notchH}`,
`h ${notchW}`,
`v ${notchH}`,
`h ${shoulder}`,
`v ${h - notchH}`,
`h ${-w}`,
`Z`,
].join(' ')
}
function drawElement(el: FaceplateElement, i: number, w: number, h: number) {
switch (el.kind) {
case 'panel':
return null // drawn by the caller as the plate background
case 'vents': {
const cellW = (el.w * w) / el.cols
const cellH = (el.h * h) / el.rows
const cells = []
for (let r = 0; r < el.rows; r++) {
for (let c = 0; c < el.cols; c++) {
cells.push(
<rect
key={`${i}-${r}-${c}`}
x={el.x * w + c * cellW + cellW * 0.15}
y={el.y * h + r * cellH + cellH * 0.15}
width={Math.max(1, cellW * 0.7)}
height={Math.max(1, cellH * 0.7)}
rx={1}
fill={el.fill ?? '#141922'}
/>,
)
}
}
return <g key={i}>{cells}</g>
}
case 'bays': {
const cellW = (el.w * w) / el.cols
const cellH = (el.h * h) / el.rows
// Tray doors are rounded; a flat 1.5px radius reads as a square hole once
// the cells get big, as they do on a tall desktop NAS.
const rx = Math.max(1.5, Math.min(cellW, cellH) * 0.14)
const cells = []
for (let r = 0; r < el.rows; r++) {
for (let c = 0; c < el.cols; c++) {
cells.push(
<rect
key={`${i}-${r}-${c}`}
x={el.x * w + c * cellW + 1}
y={el.y * h + r * cellH + 1}
width={Math.max(2, cellW - 2)}
height={Math.max(2, cellH - 2)}
rx={rx}
fill={el.fill ?? '#1b1f26'}
stroke="#0d1117"
strokeWidth={0.5}
/>,
)
}
}
return <g key={i}>{cells}</g>
}
case 'strip':
return (
<rect
key={i}
x={el.x * w}
y={el.y * h}
width={el.w * w}
height={el.h * h}
rx={2}
fill={el.fill}
/>
)
case 'outlets': {
// C13-style outlets: artwork only, never a cable endpoint.
const slotW = (el.w * w) / el.count
const outletW = Math.min(slotW * 0.72, el.h * h * 0.9)
const outletH = Math.min(el.h * h, outletW * 0.85)
const cy = el.y * h + (el.h * h) / 2
return (
<g key={i}>
{Array.from({ length: el.count }, (_, n) => {
const cx = el.x * w + slotW * (n + 0.5)
const pinW = Math.max(0.8, outletW * 0.12)
const pinH = Math.max(1, outletH * 0.3)
return (
<g key={n}>
<rect
x={cx - outletW / 2}
y={cy - outletH / 2}
width={outletW}
height={outletH}
rx={1.5}
fill="#0b0e13"
stroke="#4b535e"
strokeWidth={0.8}
/>
<rect x={cx - outletW * 0.26 - pinW / 2} y={cy - pinH / 2} width={pinW} height={pinH} fill="#4b535e" />
<rect x={cx - pinW / 2} y={cy - pinH / 2} width={pinW} height={pinH} fill="#4b535e" />
<rect x={cx + outletW * 0.26 - pinW / 2} y={cy - pinH / 2} width={pinW} height={pinH} fill="#4b535e" />
</g>
)
})}
</g>
)
}
}
}
interface Props {
faceplateId: string
label: string
status: DeviceStatus
ports: Port[]
/** Rendered size in canvas pixels. */
width: number
height: number
colorOverride?: string
selected?: boolean
/** Hover or selection: reveals the ports of non-patch gear. */
revealed?: boolean
/** Ports take the pointer (patch mode). */
interactivePorts?: boolean
/** Port id -> colour of the cable plugged into it. */
patchedPorts?: Map<string, string>
draftPortId?: string | null
/** Press arms the port; release over another one closes the patch. */
onPortPointerDown?: (portId: string) => void
onPortPointerUp?: (portId: string) => void
}
export const Faceplate = memo(function Faceplate({
faceplateId,
label,
status,
ports,
width,
height,
colorOverride,
selected,
revealed,
interactivePorts,
patchedPorts,
draftPortId,
onPortPointerDown,
onPortPointerUp,
}: Props) {
const template = getFaceplate(faceplateId)
const palette = useRackPalette()
const clipId = useId()
const panel = template.elements.find((e) => e.kind === 'panel')
const fill = colorOverride ?? (panel?.kind === 'panel' ? panel.fill : palette.plate)
const showPorts = template.alwaysShowPorts || revealed || interactivePorts
const labelX = template.labelBox.x * width
const labelW = template.labelBox.w * width
const bandY = (template.labelBox.y ?? 0.5) * height
const fontSize = Math.max(8, Math.min(11, height * 0.42))
const showLabel = labelW > 24 && height >= 14
// Plates stay opaque whatever the cable visibility. Fading them let the rail
// strips and the U grid show through the gear, which reads as a rendering
// bug; cables are drawn above the plates anyway, so nothing needs to see
// through them.
return (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
style={{ display: 'block' }}
>
<defs>
<clipPath id={clipId}>
<rect x={labelX} y={0} width={labelW} height={height} />
</clipPath>
</defs>
<rect
x={0.5}
y={0.5}
width={width - 1}
height={height - 1}
rx={2}
fill={fill}
stroke={selected ? palette.accent : palette.plateOutline}
strokeWidth={selected ? 1.5 : 1}
/>
{/* Mounting ears */}
<rect x={1.5} y={height * 0.3} width={2.5} height={height * 0.4} rx={1} fill="#0d1117" opacity={0.55} />
<rect x={width - 4} y={height * 0.3} width={2.5} height={height * 0.4} rx={1} fill="#0d1117" opacity={0.55} />
{template.elements.map((el, i) => drawElement(el, i, width, height))}
{template.statusLed && (
<circle cx={LED_X} cy={bandY} r={LED_R} fill={palette.status[status]} />
)}
{showLabel && (
<text
x={labelX}
y={bandY}
clipPath={`url(#${clipId})`}
fontSize={fontSize}
fill={palette.text}
dominantBaseline="central"
textAnchor="start"
fontFamily="Inter, system-ui, sans-serif"
>
{label}
</text>
)}
{showPorts &&
ports.map((port) => {
const cx = port.x * width
const cy = port.y * height
const patchColor = patchedPorts?.get(port.id)
const isDraft = draftPortId === port.id
const { w, h } = portShapeSize(port.type)
const stroke = isDraft ? palette.accent : patchColor ?? palette.portBezel
return (
<g
key={port.id}
transform={`translate(${cx} ${cy})`}
style={{
cursor: interactivePorts ? 'crosshair' : undefined,
pointerEvents: interactivePorts ? 'auto' : 'none',
}}
onPointerDown={
interactivePorts
? (e) => {
// Keep the press off the plate: it would start an HTML5
// drag of the mount instead of a cable.
e.stopPropagation()
e.preventDefault()
onPortPointerDown?.(port.id)
}
: undefined
}
onPointerUp={
interactivePorts
? (e) => {
e.stopPropagation()
onPortPointerUp?.(port.id)
}
: undefined
}
>
{/* Grab area: a 6px socket is too small to aim a cable at. */}
{interactivePorts && <circle r={Math.max(w, h) * 0.85} fill="transparent" />}
{port.type === 'rj45' ? (
<path
d={rj45Path(w, h)}
fill={palette.portRecess}
stroke={stroke}
strokeWidth={isDraft ? 1.4 : 0.9}
strokeLinejoin="round"
/>
) : (
<>
<rect
x={-w / 2}
y={-h / 2}
width={w}
height={h}
rx={1}
fill={palette.portRecess}
stroke={stroke}
strokeWidth={isDraft ? 1.4 : 0.9}
/>
<rect
x={-w / 2 + w * 0.18}
y={-h / 2 + h * 0.3}
width={w * 0.64}
height={Math.max(0.8, h * 0.22)}
fill={stroke}
opacity={0.65}
/>
</>
)}
<title>{`${port.label} · ${port.type}`}</title>
</g>
)
})}
</svg>
)
})
@@ -0,0 +1,180 @@
/**
* Visual faceplate catalog.
*
* A plate is a drawing, not a name: "Switch 24 ports + 2 SFP" tells you far less
* than seeing the plate. The picker renders every template with the same
* component the canvas uses, at its real relative width (a half-width plate
* draws half as wide) and a height proportional to its U count, so sizes compare
* honestly against each other.
*/
import { useMemo, useState } from 'react'
import { Search } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Faceplate } from './Faceplate'
import { faceplateGroups } from '../faceplates'
import { useRackPalette } from '../rackTheme'
import { RACK_COLUMNS, type FaceplateKind, type FaceplateTemplate, type Port } from '@/types'
/** Pixel width of a full-width plate in the preview strip. */
const PREVIEW_WIDTH = 400
/** Pixel height of one U in the preview strip. */
const PREVIEW_U = 34
/**
* The strip hugs the plate — a 1U plate in a 4U-tall box reads as a speck. Tall
* plates simply make their tile taller, which is the honest cue anyway.
*/
const PREVIEW_MIN = 2 * PREVIEW_U
const WIDTH_LABEL: Record<number, string> = {
[RACK_COLUMNS]: 'Full width',
[RACK_COLUMNS / 2]: 'Half width',
[RACK_COLUMNS / 3]: 'Third width',
[RACK_COLUMNS / 4]: 'Quarter width',
[RACK_COLUMNS / 6]: 'Sixth width',
}
/** Ports carry no id in a template — seed stable ones for the preview only. */
function previewPorts(plate: FaceplateTemplate): Port[] {
return plate.ports.map((p, i) => ({ ...p, id: `${plate.id}-${i}` }))
}
function matches(plate: FaceplateTemplate, query: string): boolean {
if (!query) return true
const q = query.toLowerCase()
return plate.label.toLowerCase().includes(q) || plate.group.toLowerCase().includes(q)
}
interface TileProps {
plate: FaceplateTemplate
selected: boolean
onPick: (id: string) => void
}
function FaceplateTile({ plate, selected, onPick }: TileProps) {
const palette = useRackPalette()
const ports = useMemo(() => previewPorts(plate), [plate])
return (
<button
type="button"
aria-label={plate.label}
aria-pressed={selected}
onClick={() => onPick(plate.id)}
className={`flex cursor-pointer flex-col gap-2 rounded border p-2 text-left transition-colors ${
selected
? 'border-[#00d4ff] bg-[#00d4ff11]'
: 'border-[#30363d] hover:border-[#00d4ff66] hover:bg-[#21262d]'
}`}
>
<div
className="flex items-center justify-center rounded p-2"
style={{ background: palette.canvas, minHeight: PREVIEW_MIN }}
>
{/* The plate is drawn at a nominal size, then scaled to the tile by CSS:
an SVG is a flex item and would otherwise be shrunk, never grown. A
sub-width plate keeps its fraction of the strip, so widths compare. */}
<div
className="[&>svg]:h-auto [&>svg]:w-full"
style={{ width: `${(100 * plate.colSpan) / RACK_COLUMNS}%` }}
>
<Faceplate
faceplateId={plate.id}
label={plate.label}
status="unknown"
ports={ports}
width={(PREVIEW_WIDTH * plate.colSpan) / RACK_COLUMNS}
height={plate.uHeight * PREVIEW_U}
revealed
/>
</div>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-foreground">{plate.label}</span>
<span className="font-mono text-[10px] text-muted-foreground">
{plate.uHeight}U · {WIDTH_LABEL[plate.colSpan] ?? `${plate.colSpan}/${RACK_COLUMNS}`} ·{' '}
{plate.ports.length} port{plate.ports.length === 1 ? '' : 's'}
</span>
</div>
</button>
)
}
interface Props {
open: boolean
/** Currently applied plate, highlighted in the grid. */
value: string
/** Restrict the catalog — accessories are rack furniture, not devices. */
kind?: FaceplateKind
onPick: (id: string) => void
onClose: () => void
}
export function FaceplatePicker({ open, value, kind, onPick, onClose }: Props) {
const [query, setQuery] = useState('')
const groups = useMemo(
() =>
faceplateGroups()
.map(({ group, items }) => ({
group,
items: items.filter((p) => (!kind || p.kind === kind) && matches(p, query)),
}))
.filter((g) => g.items.length > 0),
[kind, query],
)
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
{/* `sm:max-w-sm` on the shared DialogContent is media-scoped, so it beats a
plain `max-w-*` here — the override has to carry the same prefix. */}
<DialogContent className="w-[96vw] max-w-[96vw] sm:max-w-[1440px] border-[#30363d] bg-[#161b22] text-foreground max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-sm font-semibold">Choose a faceplate</DialogTitle>
</DialogHeader>
<div className="relative">
<Search
size={13}
className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<input
autoFocus
aria-label="Search faceplates"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by name or category…"
className="w-full rounded border border-[#30363d] bg-[#21262d] py-1 pl-7 pr-2 text-sm text-foreground outline-none focus:border-[#00d4ff]"
/>
</div>
<div className="flex flex-col gap-4">
{groups.map(({ group, items }) => (
<section key={group} className="flex flex-col gap-2">
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{group}
</h3>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{items.map((plate) => (
<FaceplateTile
key={plate.id}
plate={plate}
selected={plate.id === value}
onPick={(id) => {
onPick(id)
onClose()
}}
/>
))}
</div>
</section>
))}
{groups.length === 0 && (
<p className="py-6 text-center text-xs text-muted-foreground">
No faceplate matches “{query}”.
</p>
)}
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,156 @@
/**
* What is known about the device a mount stands for.
*
* A mount points at a Device Inventory entry, which the backend correlates with
* a canvas node (IEEE, then IP) when it can. Everything here is read-only: the
* inventory and the logical canvas own these facts, the rack only prints them
* so the user does not have to open the other canvas to read an IP off it.
*
* Node values win over the inventory row's — the node is the record the user
* curates, the inventory row is what discovery last saw — and each row is
* dropped when neither side has anything to show. A device on no canvas still
* prints everything discovery found; only the canvas-side rows go missing.
*/
import { useRackPalette } from '../rackTheme'
import type { DeviceStatus, InventoryDevice } from '@/types'
interface Row {
label: string
value: string
/** Technical values (IP, MAC) are set in JetBrains Mono, per the design system. */
mono?: boolean
}
function firstOf(...values: (string | null | undefined)[]): string | null {
for (const value of values) {
const trimmed = value?.trim()
if (trimmed) return trimmed
}
return null
}
/** Absolute local time — a rack is read at a glance, "3 days ago" hides the date. */
function formatSeen(iso: string | null | undefined): string | null {
if (!iso) return null
const date = new Date(iso)
return Number.isNaN(date.getTime()) ? null : date.toLocaleString()
}
function serviceLabel(service: { port: number | null; name: string | null }): string {
if (service.name && service.port !== null) return `${service.name}:${service.port}`
return service.name ?? String(service.port)
}
export function LinkedDevicePanel({
entry,
status,
onLink,
}: {
/** The inventory row behind the mount. Absent for accessories. */
entry: InventoryDevice | null | undefined
/** Status already resolved for the plate, so both agree on `auto`. */
status: DeviceStatus
/**
* Opens the inventory picker. Omitted when relinking is not on offer — a
* mount that does not exist yet has nothing to hang a link on, and an
* accessory stands for no device.
*/
onLink?: () => void
}) {
const palette = useRackPalette()
if (!entry) return null
const node = entry.node ?? null
const rows: Row[] = []
const push = (label: string, value: string | null, mono = false) => {
if (value) rows.push({ label, value, mono })
}
push('Name', firstOf(node?.label, entry.label))
push('Type', firstOf(node?.type, entry.type))
push('Hostname', firstOf(node?.hostname, entry.hostname))
push('IP', firstOf(node?.ip, entry.ip), true)
push('MAC', firstOf(node?.mac, entry.mac), true)
push('OS', firstOf(node?.os, entry.os))
push('Check', firstOf(node?.checkMethod))
push('Canvas', firstOf(node?.designName))
push('Last seen', formatSeen(node?.lastSeen))
const services = entry.services ?? []
const header = (
<header className="mb-2 flex items-center justify-between gap-2">
<span className="text-xs font-medium">Linked device</span>
<span className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span
aria-hidden
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: palette.status[status] }}
/>
{status}
</span>
</header>
)
return (
<section
aria-label="Linked device"
className="rounded border border-[#30363d] bg-[#0d1117] p-2.5"
>
{header}
{rows.length === 0 && (
<p className="text-[11px] text-muted-foreground">
Nothing known about this device yet.
</p>
)}
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
{rows.map((row) => (
<div key={row.label} className="contents">
<dt className="text-[11px] text-muted-foreground">{row.label}</dt>
<dd className={`truncate text-[11px] ${row.mono ? 'font-mono' : ''}`} title={row.value}>
{row.value}
</dd>
</div>
))}
</dl>
{services.length > 0 && (
<div className="mt-2 flex flex-col gap-1">
<span className="text-[11px] text-muted-foreground">Services</span>
<ul className="flex flex-wrap gap-1">
{services.map((service) => (
<li
key={serviceLabel(service)}
className="rounded border border-[#30363d] px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground"
>
{serviceLabel(service)}
</li>
))}
</ul>
</div>
)}
{/* Discovery only guesses the canvas node behind an entry, so saying so is
part of the reading: no canvas node means `auto` status resolves to
`unknown` and no link can be imported. */}
{!node && (
<p className="mt-2 text-[11px] text-muted-foreground">Not on a logical canvas.</p>
)}
{/* The device behind a plate is a choice — a placeholder created from the
rack, or the wrong twin of two look-alikes — so it must be changeable
from where it is read. */}
{onLink && (
<button
type="button"
onClick={onLink}
className="mt-2 cursor-pointer text-[11px] text-[#00d4ff] hover:underline"
>
Link to another device…
</button>
)}
</section>
)
}
@@ -0,0 +1,223 @@
/**
* Right rail for the selected cable.
*
* The rack canvas has no rail for mounted gear — a mount is edited in its own
* modal — but a cable has no plate to double-click, so clicking the run opens
* this panel instead. It carries the physical facts (type, colour, label) and
* the same free-form property records the logical canvas uses for nodes, each
* with its own "show it on the canvas" toggle.
*/
import { useState } from 'react'
import { Cable as CableIcon, Trash2, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { PropertyList } from '@/components/common/PropertyList'
import { CABLE_COLOR_PRESETS, CABLE_COLORS, CABLE_PROPERTY_SUGGESTIONS } from '../rackDefaults'
import { useRackStore } from '../store'
import type { CableProperty, CableType, RackDevice } from '@/types'
const TYPE_LABELS: Record<CableType, string> = {
ethernet: 'Ethernet',
fiber: 'Fibre',
}
export function RackCablePanel() {
const cables = useRackStore((s) => s.cables)
const devices = useRackStore((s) => s.devices)
const selectedCableId = useRackStore((s) => s.selectedCableId)
const updateCable = useRackStore((s) => s.updateCable)
const removeCable = useRackStore((s) => s.removeCable)
const selectCable = useRackStore((s) => s.selectCable)
const cable = cables.find((c) => c.id === selectedCableId)
if (!cable) return null
const endpoint = (ref: { deviceId: string; portId: string }) => {
const device: RackDevice | undefined = devices.find((d) => d.id === ref.deviceId)
return {
device: device?.label ?? 'Unknown device',
port: device?.ports.find((p) => p.id === ref.portId)?.label ?? '—',
}
}
const from = endpoint(cable.from)
const to = endpoint(cable.to)
const properties: CableProperty[] = cable.properties ?? []
const handleType = (type: CableType) => {
if (type === cable.type) return
// Recolour only a cable still wearing its type's default, so a colour the
// user picked survives a type change.
const patch =
cable.color === CABLE_COLORS[cable.type] ? { type, color: CABLE_COLORS[type] } : { type }
updateCable(cable.id, patch)
}
return (
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<span className="flex items-center gap-2 min-w-0 font-semibold text-sm text-foreground">
<CableIcon size={14} className="shrink-0" style={{ color: cable.color }} />
<span className="truncate">{cable.label?.trim() || 'Cable'}</span>
</span>
<button
aria-label="Close panel"
onClick={() => selectCable(null)}
className="text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<X size={16} />
</button>
</div>
{/* Endpoints */}
<div className="flex flex-col gap-2 px-4 py-3 border-b border-border">
<span className="text-xs text-muted-foreground">Endpoints</span>
<EndpointRow side="A" device={from.device} port={from.port} />
<EndpointRow side="B" device={to.device} port={to.port} />
</div>
{/* Type */}
<div className="px-4 py-3 border-b border-border">
<span className="text-xs text-muted-foreground">Type</span>
<div className="mt-2 flex gap-1.5">
{(Object.keys(TYPE_LABELS) as CableType[]).map((type) => (
<button
key={type}
onClick={() => handleType(type)}
className={`flex-1 rounded border px-2 py-1 text-[11px] transition-colors cursor-pointer ${
cable.type === type
? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]'
: 'border-[#30363d] text-muted-foreground hover:border-[#8b949e]'
}`}
>
{TYPE_LABELS[type]}
</button>
))}
</div>
</div>
{/* Colour */}
<div className="px-4 py-3 border-b border-border">
<span className="text-xs text-muted-foreground">Colour</span>
<div className="mt-2 flex flex-wrap gap-1">
{CABLE_COLOR_PRESETS.map((color) => (
<button
key={color}
aria-label={`Cable colour ${color}`}
title={color}
onClick={() => updateCable(cable.id, { color })}
className={`h-6 w-6 rounded border transition-transform cursor-pointer hover:scale-110 ${
cable.color.toLowerCase() === color.toLowerCase() ? 'border-[#00d4ff]' : 'border-[#30363d]'
}`}
style={{ background: color }}
/>
))}
</div>
<HexField
value={cable.color}
fallback={CABLE_COLORS[cable.type]}
onCommit={(color) => updateCable(cable.id, { color })}
/>
</div>
{/* Label */}
<div className="px-4 py-3 border-b border-border">
<span className="text-xs text-muted-foreground">Label</span>
<Input
value={cable.label ?? ''}
onChange={(e) => updateCable(cable.id, { label: e.target.value })}
aria-label="Cable label"
placeholder="e.g. Uplink to core"
className="mt-2 bg-[#21262d] border-[#30363d] text-xs h-7"
/>
<label className="mt-2 flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={cable.labelVisible === true}
onChange={(e) => updateCable(cable.id, { labelVisible: e.target.checked })}
className="accent-[#00d4ff] w-3 h-3"
/>
<span className="text-[10px] text-muted-foreground">Show on canvas</span>
</label>
</div>
<PropertyList
properties={properties}
onChange={(next) => updateCable(cable.id, { properties: next })}
visibleLabel="Show on canvas"
keyPlaceholder="Label (e.g. Length)"
valuePlaceholder="Value (e.g. 2 m)"
suggestions={CABLE_PROPERTY_SUGGESTIONS}
emptyHint="No properties — add a length, a VLAN, whatever this run needs."
/>
<div className="px-4 py-3 border-t border-border mt-auto">
<Button
size="sm"
variant="ghost"
onClick={() => removeCable(cable.id)}
className="w-full h-7 text-[11px] text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10"
>
<Trash2 size={12} className="mr-1.5" /> Unplug cable
</Button>
</div>
</aside>
)
}
/**
* Free-text colour, committed on blur or Enter once it parses.
*
* Writing every keystroke into `cable.color` fed partial values (`#39d`) to the
* SVG `stroke`, and the run stopped rendering with no feedback. An unparseable
* value falls back to the cable type's default rather than leaving an invisible
* cable behind.
*/
function HexField({ value, fallback, onCommit }: {
value: string
fallback: string
onCommit: (color: string) => void
}) {
const [draft, setDraft] = useState(value)
// Follow a colour set elsewhere (a swatch, a type change). Adjusted during
// render, not in an effect, so the field never shows the stale value first.
const [seen, setSeen] = useState(value)
if (seen !== value) {
setSeen(value)
setDraft(value)
}
const commit = () => {
const next = draft.trim()
const valid = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(next)
const color = valid ? next : fallback
setDraft(color)
if (color !== value) onCommit(color)
}
return (
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur()
if (e.key === 'Escape') setDraft(value)
}}
aria-label="Cable colour hex"
placeholder="#39d353"
className="mt-2 bg-[#21262d] border-[#30363d] text-xs h-7 font-mono"
/>
)
}
function EndpointRow({ side, device, port }: { side: string; device: string; port: string }) {
return (
<div className="flex items-baseline gap-2 min-w-0">
<span className="w-3 shrink-0 text-[10px] text-muted-foreground">{side}</span>
<span className="truncate text-xs text-foreground" title={device}>{device}</span>
<span className="ml-auto shrink-0 font-mono text-[11px] text-[#00d4ff]" title={port}>{port}</span>
</div>
)
}
+187
View File
@@ -0,0 +1,187 @@
/**
* Rack canvas: one React Flow node per rack, plus the cabling overlay.
*
* Rendered by `App` in place of `CanvasContainer` when the active design is of
* type `rack`. React Flow context comes from the App-level provider.
*/
import { useCallback, useEffect, useMemo, useRef } from 'react'
import {
Background,
BackgroundVariant,
Controls,
ReactFlow,
useReactFlow,
type Node,
type NodeChange,
type Viewport,
} from '@xyflow/react'
import { Plus } from 'lucide-react'
import { rackHeight, rackWidth } from '../layout'
import { useRackStore } from '../store'
import { useRackPalette } from '../rackTheme'
import { useAutoStatusRefresh } from '../useAutoStatusRefresh'
import { CableLayer } from './CableLayer'
import { RackDeviceModal } from './RackDeviceModal'
import { RackFlowNode } from './RackFlowNode'
import { RackSettingsModal } from './RackSettingsModal'
const nodeTypes = { rack: RackFlowNode }
export function RackCanvas() {
const racks = useRackStore((s) => s.racks)
const moveRack = useRackStore((s) => s.moveRack)
const selectDevice = useRackStore((s) => s.selectDevice)
const cableMode = useRackStore((s) => s.cableMode)
const loading = useRackStore((s) => s.loading)
const loadError = useRackStore((s) => s.loadError)
const designId = useRackStore((s) => s.designId)
const storedViewport = useRackStore((s) => s.viewport)
const setViewport = useRackStore((s) => s.setViewport)
const addRack = useRackStore((s) => s.addRack)
const loadDemo = useRackStore((s) => s.loadDemo)
const selectCable = useRackStore((s) => s.selectCable)
const removeSelectedCable = useRackStore((s) => s.removeSelectedCable)
const selectedCableId = useRackStore((s) => s.selectedCableId)
const dragging = useRackStore((s) => s.cableDrag != null)
const moveCableDrag = useRackStore((s) => s.moveCableDrag)
const endCableDrag = useRackStore((s) => s.endCableDrag)
const cableDraft = useRackStore((s) => s.cableDraft)
const cancelCableDraft = useRackStore((s) => s.cancelCableDraft)
const palette = useRackPalette()
const { setViewport: applyViewport, screenToFlowPosition } = useReactFlow()
// Mounts set to "check device" read their LED from the inventory's
// `node_status`, which only a refetch moves.
useAutoStatusRefresh()
// Restore the saved pan/zoom once per design, not on every viewport nudge.
const restoredFor = useRef<string | null>(null)
useEffect(() => {
if (!designId || loading || restoredFor.current === designId) return
restoredFor.current = designId
if (storedViewport.zoom > 0) void applyViewport(storedViewport)
}, [designId, loading, storedViewport, applyViewport])
// A patch dragged out of a port follows the pointer until it is released.
// Both listeners are on the window: the drop target is often a port on
// another rack, and a release outside the canvas must still end the drag.
useEffect(() => {
if (!dragging) return
const move = (e: PointerEvent) =>
moveCableDrag(screenToFlowPosition({ x: e.clientX, y: e.clientY }))
// The port's own pointerup runs first and clears the drag, so reaching here
// means the cable was released on nothing.
const up = () => endCableDrag(null)
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
return () => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
}
}, [dragging, moveCableDrag, endCableDrag, screenToFlowPosition])
// Delete/Backspace unplugs the selected cable; Escape deselects it, or drops
// a half-drawn patch.
useEffect(() => {
if (!selectedCableId && !cableDraft) return
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null
const tag = target?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || target?.isContentEditable) return
if (selectedCableId && (e.key === 'Delete' || e.key === 'Backspace')) {
e.preventDefault()
removeSelectedCable()
} else if (e.key === 'Escape') {
selectCable(null)
cancelCableDraft()
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [selectedCableId, cableDraft, removeSelectedCable, selectCable, cancelCableDraft])
const nodes: Node[] = useMemo(
() =>
racks.map((rack) => ({
id: rack.id,
type: 'rack',
position: rack.position,
data: {},
draggable: !cableMode,
style: { width: rackWidth(rack), height: rackHeight(rack) },
})),
[racks, cableMode],
)
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
for (const change of changes) {
if (change.type === 'position' && change.position) {
moveRack(change.id, change.position)
}
}
},
[moveRack],
)
const onMoveEnd = useCallback(
(_: unknown, viewport: Viewport) => setViewport(viewport),
[setViewport],
)
if (loadError) {
return (
<div className="flex h-full items-center justify-center text-sm text-[#f85149]">
Rack canvas could not be loaded.
</div>
)
}
return (
<>
<ReactFlow
nodes={nodes}
edges={[]}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onMoveEnd={onMoveEnd}
onPaneClick={() => selectDevice(null)}
minZoom={0.2}
maxZoom={3}
proOptions={{ hideAttribution: true }}
style={{ background: palette.canvas }}
>
<Background variant={BackgroundVariant.Dots} gap={24} size={1} color={palette.dot} />
<Controls />
<CableLayer />
{!loading && racks.length === 0 && (
// z-10 clears .react-flow__renderer (z-index 4); without it the pane sits
// on top and swallows the clicks as a canvas drag.
<div className="pointer-events-none absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 text-center">
<p className="text-sm text-[#8b949e]">This rack canvas is empty.</p>
<div className="pointer-events-auto flex gap-2">
<button
type="button"
onClick={() => addRack({ style: palette.defaultRackStyle })}
className="flex items-center gap-1.5 rounded border border-[#00d4ff] px-3 py-1.5 text-xs text-[#00d4ff] hover:bg-[#00d4ff]/10 cursor-pointer"
>
<Plus size={14} /> Add a rack
</button>
<button
type="button"
onClick={loadDemo}
className="rounded border border-[#21262d] bg-[#161b22] px-3 py-1.5 text-xs text-[#8b949e] hover:border-[#30363d] hover:text-[#c9d1d9] cursor-pointer"
>
Load a sample rack
</button>
</div>
</div>
)}
</ReactFlow>
{/* Editors live outside the flow so a dialog is never clipped by it. */}
<RackDeviceModal />
<RackSettingsModal />
</>
)
}
@@ -0,0 +1,733 @@
/**
* Add / edit a mounted device.
*
* The rack canvas has no right rail: everything about a mount is edited here,
* the way `NodeModal` owns a network node. Opened from the sidebar (`+ Device`)
* or by double-clicking a plate.
*
* Creating offers the two sources the product recognises: an entry that already
* exists in the **Device Inventory**, or a brand new one created from this
* canvas (which lands in the inventory too). Accessories — blanks, shelves,
* cable managers — are rack-only and never touch the inventory.
*/
import { useEffect, useMemo, useState } from 'react'
import { Plus, X } from 'lucide-react'
import { toast } from 'sonner'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { DeviceInventoryModal } from '@/components/modals/DeviceInventoryModal'
import type { InventoryEntry } from '@/components/modals/InventoryDeviceModal'
import { useRackStore } from '../store'
import { FaceplatePicker } from './FaceplatePicker'
import { Faceplate } from './Faceplate'
import { LinkedDevicePanel } from './LinkedDevicePanel'
import { DevicePickerModal } from './DevicePickerModal'
import { deviceTypeForFaceplate, getFaceplate } from '../faceplates'
import { findSlot } from '../layout'
import { canFollowNode, resolveDeviceStatus } from '../deviceStatus'
import { generateUUID } from '@/utils/uuid'
import {
RACK_COLUMNS,
type InventoryDevice,
type MountStatus,
type Port,
type PortType,
} from '@/types'
const DEFAULT_COLOR = '#2b323c'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
type Source = 'inventory' | 'new' | 'accessory'
const inputBase =
'rounded border border-[#30363d] bg-[#21262d] px-2 py-1 text-sm text-foreground outline-none focus:border-[#00d4ff]'
const inputClass = `w-full ${inputBase}`
const fieldLabel = 'text-xs text-muted-foreground'
/**
* One line for the inventory picker.
*
* Entries with no hostname are already labelled with their IP by the backend, so
* appending it again printed "192.168.1.63 · 192.168.1.63". Show the IP only
* when it adds something, and the type as the tiebreaker between look-alikes.
*/
function inventoryOption(item: InventoryDevice): string {
const parts = [item.label]
if (item.ip && item.ip !== item.label) parts.push(item.ip)
if (item.type) parts.push(item.type)
return parts.join(' · ')
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<Label className={fieldLabel}>{label}</Label>
{children}
</div>
)
}
export function RackDeviceModal() {
const editor = useRackStore((s) => s.deviceEditor)
const close = useRackStore((s) => s.closeDeviceEditor)
if (!editor) return null
// Remount per target so the form always re-seeds from the device it edits.
return <DeviceForm key={editor.deviceId ?? 'new'} deviceId={editor.deviceId} onClose={close} />
}
function DeviceForm({ deviceId, onClose }: { deviceId: string | null; onClose: () => void }) {
const device = useRackStore((s) => s.devices.find((d) => d.id === deviceId))
const racks = useRackStore((s) => s.racks)
const devices = useRackStore((s) => s.devices)
const inventory = useRackStore((s) => s.inventory)
const mountFromInventory = useRackStore((s) => s.mountFromInventory)
const mountAccessory = useRackStore((s) => s.mountAccessory)
const createInventoryDevice = useRackStore((s) => s.createInventoryDevice)
const applyFaceplate = useRackStore((s) => s.applyFaceplate)
const updateDevice = useRackStore((s) => s.updateDevice)
const relinkDevice = useRackStore((s) => s.relinkDevice)
const unmountDevice = useRackStore((s) => s.unmountDevice)
const setPorts = useRackStore((s) => s.setPorts)
const isEdit = !!device
const unracked = useMemo(() => inventory.filter((i) => !i.racked), [inventory])
const [source, setSource] = useState<Source>(unracked.length > 0 ? 'inventory' : 'new')
// No preselection: the entry comes from the Device Inventory modal, which the
// user opens and picks from explicitly.
const [inventoryId, setInventoryId] = useState('')
const [newIp, setNewIp] = useState('')
const [rackId, setRackId] = useState(device?.rackId ?? racks[0]?.id ?? '')
const [label, setLabel] = useState(device?.label ?? '')
const [faceplateId, setFaceplateId] = useState(device?.faceplateId ?? 'server-1u')
const [uStart, setUStart] = useState(device?.uStart ?? 1)
const [uHeight, setUHeight] = useState(device?.uHeight ?? getFaceplate(faceplateId).uHeight)
const [colStart, setColStart] = useState(device?.colStart ?? 0)
const [colSpan, setColSpan] = useState(device?.colSpan ?? getFaceplate(faceplateId).colSpan)
const [status, setStatus] = useState<MountStatus>(device?.status ?? 'unknown')
const [color, setColor] = useState<string | undefined>(device?.color)
const [ports, setLocalPorts] = useState<Port[]>(
device?.ports ?? getFaceplate(faceplateId).ports.map((p) => ({ ...p, id: generateUUID() })),
)
const [plateChanged, setPlateChanged] = useState(false)
const [pickerOpen, setPickerOpen] = useState(false)
const [inventoryPickerOpen, setInventoryPickerOpen] = useState(false)
const [devicePickerOpen, setDevicePickerOpen] = useState(false)
const [busy, setBusy] = useState(false)
// The inventory lives in the Device Inventory, which the user can edit from
// elsewhere (or a scan can grow) — pull a fresh list when adding.
const refreshInventory = useRackStore((s) => s.refreshInventory)
useEffect(() => {
if (!isEdit) void refreshInventory()
}, [isEdit, refreshInventory])
// That refetch can drop the preselected entry (racked from elsewhere, deleted),
// leaving the select blank while the state still held the dead id.
useEffect(() => {
if (isEdit) return
setInventoryId((current) =>
current && !unracked.some((i) => i.id === current) ? '' : current,
)
}, [isEdit, unracked])
/** Swapping the plate reseeds size and ports — the old ports belong to it. */
function pickFaceplate(id: string) {
const plate = getFaceplate(id)
setFaceplateId(id)
setUHeight(plate.uHeight)
setColSpan(plate.colSpan)
setColStart((c) => Math.min(c, RACK_COLUMNS - plate.colSpan))
// Coming back to the plate the device already wears is not a change: keep
// its own ports, ids included. Reseeding them would hand `setPorts` a list
// of unknown ids on save and take every cable on the device down with it.
const reverted = device && id === device.faceplateId
setLocalPorts(
reverted ? device.ports : plate.ports.map((p) => ({ ...p, id: generateUUID() })),
)
setPlateChanged(!reverted)
}
/** Accessories and devices draw from disjoint halves of the catalog. */
function pickSource(next: Source) {
setSource(next)
const wantAccessory = next === 'accessory'
if ((getFaceplate(faceplateId).kind === 'accessory') !== wantAccessory) {
pickFaceplate(wantAccessory ? 'blank-1u' : 'server-1u')
}
}
function applyInventoryItem(item: InventoryDevice) {
setInventoryId(item.id)
if (!label.trim()) setLabel(item.label)
// Seed the entry's current colour, not `auto`: following the node's check
// is the user's call, made in the Status select.
setStatus(item.status)
pickFaceplate(item.suggestedFaceplateId)
}
/**
* A device picked in the Device Inventory modal.
*
* That modal reads `pending_devices` directly, while the rack keeps its own
* rackable-and-flagged copy — so a row can be visible there and absent here
* (added since this form opened, or a kind no rack can hold). Refetch once
* before refusing, and never mount a device twice in the same design.
*/
async function handlePickFromInventory(picked: InventoryEntry) {
let entry = inventory.find((i) => i.id === picked.id)
if (!entry) {
await refreshInventory()
entry = useRackStore.getState().inventory.find((i) => i.id === picked.id)
}
if (!entry) {
toast.error('That device cannot be mounted in a rack')
return
}
if (entry.racked) {
toast.error(`${entry.label} is already mounted in this design`)
return
}
applyInventoryItem(entry)
setInventoryPickerOpen(false)
}
/**
* Repointing needs a mount to repoint: a device being created has none yet,
* and an accessory stands for no hardware at all.
*/
const canRelink = isEdit && !!device?.deviceId
/**
* Another inventory entry, picked by hand.
*
* Applied straight away rather than on Save: the panel, the Status select and
* the plate LED all read the link from the store, and the picker is an
* explicit action of its own. It is persisted with the rest of the canvas on
* the next save, like every other rack edit.
*/
async function handleRelink(entry: InventoryDevice) {
setDevicePickerOpen(false)
if (!device) return
if (await relinkDevice(device.id, entry.id)) {
// The plate may have taken the entry's name; keep the open form in step.
const next = useRackStore.getState().devices.find((d) => d.id === device.id)
if (next) setLabel(next.label)
toast.success(`Linked to ${entry.label}`)
} else {
toast.error(`${entry.label} is already mounted in this design`)
}
}
/**
* The Device Inventory row behind this mount: the entry being picked while
* adding, the one the mount was created from while editing.
*/
const selectedEntry = inventory.find((i) => i.id === inventoryId)
const mountEntry = isEdit ? inventory.find((i) => i.id === device?.deviceId) : selectedEntry
// "Check device" needs a canvas node to read a check from — one the mount
// already carries, or one the picked entry resolves to.
const canCheck = (isEdit && !!device?.nodeId) || canFollowNode(mountEntry)
// Losing that link (another entry picked, node deleted) must not leave the
// form on a status nothing can resolve.
useEffect(() => {
if (!canCheck) setStatus((s) => (s === 'auto' ? 'unknown' : s))
}, [canCheck])
const geometry = { uStart, uHeight, colStart, colSpan }
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setBusy(true)
try {
const id = isEdit ? commitEdit() : await commitCreate()
if (!id) return
setPorts(id, ports)
onClose()
} finally {
setBusy(false)
}
}
function commitEdit(): string | null {
if (!device) return null
// A plate the rack has no room for changes nothing — and the caller would
// still write the new plate's ports onto the old faceplate, silently.
if (plateChanged && faceplateId !== device.faceplateId) {
if (!applyFaceplate(device.id, faceplateId)) {
toast.error('No room in the rack for that faceplate')
return null
}
}
const name = label.trim() || device.label
if (!updateDevice(device.id, { ...geometry, label: name, status, color })) {
toast.error('No room in the rack for that size')
return null
}
return device.id
}
async function commitCreate(): Promise<string | null> {
if (!rackId) {
toast.error('Add a rack first')
return null
}
const name = label.trim()
if (source === 'accessory') {
// Hand the mount the whole geometry: it snaps to the nearest slot that
// fits, and patching it afterwards would drag the plate back off it.
const id = mountAccessory(faceplateId, rackId, geometry)
if (!id) return noRoom()
updateDevice(id, { label: name || getFaceplate(faceplateId).label, color })
return id
}
let entryId = inventoryId
if (source === 'new') {
if (!name) {
toast.error('Name the device first')
return null
}
// `createInventoryDevice` POSTs straight to the Device Inventory, so a
// mount that fails afterwards would leave a row behind — and a second one
// on every retry. Check the rack has a slot before creating anything.
const rack = racks.find((r) => r.id === rackId)
if (!rack || !findSlot(rack, devices, geometry)) return noRoom()
// Nothing discovered this device, so the plate is what says what it is —
// otherwise the inventory row shows up with no type, no icon, no filter.
const created = await createInventoryDevice({
label: name,
type: deviceTypeForFaceplate(faceplateId),
ip: newIp.trim() || null,
})
if (!created) {
toast.error('Could not add the device to the inventory')
return null
}
entryId = created.id
}
if (!entryId) {
toast.error('Pick a device from the inventory')
return null
}
const id = mountFromInventory(entryId, rackId, { ...geometry, faceplateId })
if (!id) return noRoom()
// The mount takes its label and status from the inventory entry, and its
// slot from `findSlot` — only patch what the form actually overrides.
updateDevice(id, { status, color, ...(name ? { label: name } : {}) })
return id
}
function noRoom(): null {
toast.error('No free slot in this rack')
return null
}
function handleUnmount() {
if (!device) return
unmountDevice(device.id)
onClose()
}
const showInventoryPicker = !isEdit && source === 'inventory'
// The plate preview draws a colour, never the word `auto`.
const previewStatus = resolveDeviceStatus(
{ status, deviceId: mountEntry?.id ?? null },
inventory,
)
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
{/* Same footprint as NodeModal — the form is two columns wide so a mount
fits on screen without scrolling. */}
<DialogContent className="border-[#30363d] bg-[#161b22] text-foreground max-w-[calc(100%-2rem)] sm:max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-sm font-semibold">
{isEdit ? 'Edit Device' : 'Add Device'}
</DialogTitle>
</DialogHeader>
<form onSubmit={(e) => void handleSubmit(e)} className="mt-2 flex flex-col gap-3">
{!isEdit && (
<Field label="Source">
<div className="flex gap-1">
{(
[
['inventory', 'From inventory'],
['new', 'New device'],
['accessory', 'Accessory'],
] as [Source, string][]
).map(([value, text]) => (
<button
key={value}
type="button"
onClick={() => pickSource(value)}
className={`flex-1 cursor-pointer rounded border px-2 py-1 text-xs ${
source === value
? 'border-[#00d4ff] text-[#00d4ff]'
: 'border-[#30363d] text-muted-foreground hover:text-foreground'
}`}
>
{text}
</button>
))}
</div>
</Field>
)}
{/* Left column: what the device is and where it sits. Right column:
its ports, which is the list that used to push everything else
below the fold. */}
<div className="grid gap-x-4 gap-y-3 sm:grid-cols-2">
<div className="flex flex-col gap-3">
{showInventoryPicker && STANDALONE && (
/* No backend, so no Device Inventory modal to open (it reads
`pending_devices` over REST) — standalone keeps the flat list. */
<Field label="Device Inventory entry">
<select
className={inputClass}
aria-label="Device Inventory entry"
value={inventoryId}
onChange={(e) => {
const item = inventory.find((i) => i.id === e.target.value)
if (item) applyInventoryItem(item)
else setInventoryId('')
}}
>
<option value="">Select a device…</option>
{unracked.map((item) => (
<option key={item.id} value={item.id}>
{inventoryOption(item)}
</option>
))}
</select>
</Field>
)}
{showInventoryPicker && !STANDALONE && (
<Field label="Device Inventory entry">
{/* Opens the real Device Inventory rather than a flat <select>:
the user gets its search, source/type filters and the tech
detail on each card to tell look-alike hosts apart. */}
<button
type="button"
aria-label="Device Inventory entry"
data-device-id={inventoryId}
onClick={() => setInventoryPickerOpen(true)}
className="flex cursor-pointer flex-col items-start gap-0.5 rounded border border-[#30363d] bg-[#21262d] px-2 py-1.5 text-left hover:border-[#00d4ff]"
>
<span className="text-sm">
{selectedEntry ? inventoryOption(selectedEntry) : 'No device picked yet'}
</span>
<span className="text-[11px] text-[#00d4ff]">
{selectedEntry ? 'Change device…' : 'Browse the Device Inventory…'}
</span>
</button>
{unracked.length === 0 && (
<p className="text-[11px] text-muted-foreground">
Everything in the inventory is already racked — create a new device instead.
</p>
)}
{/* Rendered inside this DialogContent on purpose: an open Base UI
dialog marks outside content inert, so a sibling overlay would
be unclickable. */}
<DeviceInventoryModal
open={inventoryPickerOpen}
onClose={() => setInventoryPickerOpen(false)}
onPick={(d) => void handlePickFromInventory(d)}
initialRackableOnly
/>
</Field>
)}
{!isEdit && source === 'new' && (
<Field label="IP (optional)">
<input
className={`${inputClass} font-mono`}
aria-label="IP"
value={newIp}
onChange={(e) => setNewIp(e.target.value)}
placeholder="192.168.1.10"
/>
</Field>
)}
{!isEdit && racks.length > 1 && (
<Field label="Rack">
<select
className={inputClass}
aria-label="Rack"
value={rackId}
onChange={(e) => setRackId(e.target.value)}
>
{racks.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
</Field>
)}
<Field label="Label">
<input
className={inputClass}
aria-label="Label"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={source === 'accessory' ? getFaceplate(faceplateId).label : 'Device name'}
/>
</Field>
<Field label="Faceplate">
{/* The catalog is visual — the button previews the current plate and
opens the picker rather than hiding drawings behind a name. */}
<button
type="button"
aria-label="Faceplate"
data-faceplate={faceplateId}
onClick={() => setPickerOpen(true)}
className="flex cursor-pointer items-center gap-3 rounded border border-[#30363d] bg-[#21262d] p-2 text-left hover:border-[#00d4ff]"
>
<Faceplate
faceplateId={faceplateId}
label={label || getFaceplate(faceplateId).label}
status={previewStatus}
ports={ports}
width={(160 * colSpan) / RACK_COLUMNS}
height={Math.min(uHeight, 4) * 18}
colorOverride={color}
revealed
/>
<span className="flex flex-col">
<span className="text-xs">{getFaceplate(faceplateId).label}</span>
<span className="text-[11px] text-[#00d4ff]">Browse faceplates…</span>
</span>
</button>
<FaceplatePicker
open={pickerOpen}
value={faceplateId}
kind={!isEdit && source === 'accessory' ? 'accessory' : undefined}
onPick={pickFaceplate}
onClose={() => setPickerOpen(false)}
/>
{plateChanged && isEdit && (
<p className="text-[11px] text-[#e3b341]">
Changing the plate replaces its ports and drops their patches.
</p>
)}
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="U position">
<input
type="number"
min={1}
className={inputClass}
aria-label="U position"
value={uStart}
onChange={(e) => setUStart(Number(e.target.value) || 1)}
/>
</Field>
<Field label="Height (U)">
<input
type="number"
min={1}
max={12}
className={inputClass}
aria-label="Height (U)"
value={uHeight}
onChange={(e) => setUHeight(Number(e.target.value) || 1)}
/>
</Field>
<Field label="Column">
<input
type="number"
min={0}
max={RACK_COLUMNS - 1}
className={inputClass}
aria-label="Column"
value={colStart}
onChange={(e) => setColStart(Number(e.target.value) || 0)}
/>
</Field>
<Field label={`Width (/${RACK_COLUMNS})`}>
<select
className={inputClass}
aria-label="Width"
value={colSpan}
onChange={(e) => setColSpan(Number(e.target.value))}
>
<option value={RACK_COLUMNS}>Full width</option>
<option value={RACK_COLUMNS / 2}>Half width</option>
<option value={RACK_COLUMNS / 3}>Third width</option>
<option value={RACK_COLUMNS / 4}>Quarter width</option>
<option value={RACK_COLUMNS / 6}>Sixth width</option>
</select>
</Field>
</div>
<Field label="Status">
<select
className={inputClass}
aria-label="Status"
value={status}
onChange={(e) => setStatus(e.target.value as MountStatus)}
>
{/* Only offered when there is a node to read: the rack runs no
check of its own. */}
{canCheck && <option value="auto">Check device (live)</option>}
<option value="online">Online</option>
<option value="offline">Offline</option>
<option value="unknown">Unknown</option>
</select>
{status === 'auto' && (
<p className="text-[11px] text-muted-foreground">
Follows the status check configured on the matching canvas node.
</p>
)}
</Field>
<Field label="Colour override">
<div className="flex items-center gap-2">
<input
type="color"
aria-label="Colour override"
className="h-8 flex-1 cursor-pointer rounded border border-[#30363d] bg-[#21262d]"
value={color ?? DEFAULT_COLOR}
onChange={(e) => setColor(e.target.value)}
/>
<button
type="button"
onClick={() => setColor(undefined)}
className="rounded border border-[#30363d] px-2 py-1 text-[11px] text-muted-foreground hover:text-foreground cursor-pointer"
>
Reset
</button>
</div>
</Field>
</div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<Label className={fieldLabel}>Ports ({ports.length})</Label>
<button
type="button"
aria-label="Add port"
className="cursor-pointer rounded border border-[#30363d] px-2 py-0.5 text-xs hover:border-[#00d4ff]"
onClick={() =>
setLocalPorts((p) => [
...p,
{ id: generateUUID(), label: `p${p.length + 1}`, type: 'rj45', x: 0.5, y: 0.5 },
])
}
>
<Plus size={12} className="inline" /> Add
</button>
</div>
<ul className="max-h-[26rem] space-y-1 overflow-y-auto">
{ports.map((port) => (
<li key={port.id} className="flex items-center gap-1">
<input
// `inputClass` is w-full: without min-w-0 the flex row lets the
// type select win the space and the name field collapses to a box.
className={`${inputBase} min-w-0 flex-1`}
aria-label={`Port ${port.label} label`}
placeholder="Port name"
value={port.label}
onChange={(e) =>
setLocalPorts((list) =>
list.map((p) => (p.id === port.id ? { ...p, label: e.target.value } : p)),
)
}
/>
<select
className={`${inputBase} w-24 shrink-0`}
aria-label={`Port ${port.label} type`}
value={port.type}
onChange={(e) =>
setLocalPorts((list) =>
list.map((p) =>
p.id === port.id ? { ...p, type: e.target.value as PortType } : p,
),
)
}
>
{(['rj45', 'sfp', 'sfp+'] as PortType[]).map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
<button
type="button"
aria-label={`Remove port ${port.label}`}
className="cursor-pointer px-1 text-xs text-muted-foreground hover:text-[#f85149]"
onClick={() => setLocalPorts((list) => list.filter((p) => p.id !== port.id))}
>
<X size={12} />
</button>
</li>
))}
{ports.length === 0 && (
<li className="text-[11px] text-muted-foreground">No port on this plate.</li>
)}
</ul>
</div>
{/* The column below the port list used to be dead space. A mount
that stands for an inventory entry fills it with what the
logical canvas knows about the same box. */}
<LinkedDevicePanel
entry={mountEntry}
status={previewStatus}
onLink={canRelink ? () => setDevicePickerOpen(true) : undefined}
/>
{canRelink && devicePickerOpen && (
/* Inside this DialogContent for the same reason as the inventory
picker: an open Base UI dialog makes outside content inert.
Mounted per opening, so the inventory is fetched fresh. */
<DevicePickerModal
open={devicePickerOpen}
value={device?.deviceId}
onPick={handleRelink}
onClose={() => setDevicePickerOpen(false)}
/>
)}
</div>
</div>
<div className="mt-1 flex items-center gap-2">
{isEdit && (
<Button
type="button"
variant="ghost"
onClick={handleUnmount}
className="cursor-pointer text-[#f85149] hover:bg-[#f8514922] hover:text-[#f85149]"
>
Unmount{device?.deviceId ? ' (stays in inventory)' : ''}
</Button>
)}
<div className="ml-auto flex gap-2">
<Button type="button" variant="ghost" onClick={onClose} className="cursor-pointer">
Cancel
</Button>
<Button
type="submit"
disabled={busy}
className="cursor-pointer bg-[#00d4ff] text-[#0d1117] hover:bg-[#00b8e0]"
>
{isEdit ? 'Save' : 'Add'}
</Button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,332 @@
/** One rack = one React Flow node. The U grid inside is rendered by hand. */
import { useCallback, useState } from 'react'
import { useReactFlow, type NodeProps } from '@xyflow/react'
import { getFaceplate } from '../faceplates'
import {
HEADER_PX,
INTERIOR_TOP_PX,
NUMBER_GUTTER_PX,
RAIL_PX,
U_PX,
columnWidth,
deviceBox,
findSlot,
innerWidth,
interiorLeft,
rackHeight,
rackWidth,
uLabel,
xToCol,
yToU,
} from '../layout'
import { useRackStore } from '../store'
import { useRackPalette } from '../rackTheme'
import { resolveDeviceStatus } from '../deviceStatus'
import { RACK_COLUMNS } from '@/types'
import { Faceplate } from './Faceplate'
import { endDrag, getDragPayload, parseDragPayload, startDrag } from './dragPayload'
interface DropPreview {
uStart: number
uHeight: number
colStart: number
colSpan: number
valid: boolean
}
export function RackFlowNode({ id }: NodeProps) {
const palette = useRackPalette()
const rack = useRackStore((s) => s.racks.find((r) => r.id === id))
const devices = useRackStore((s) => s.devices)
const cables = useRackStore((s) => s.cables)
const inventory = useRackStore((s) => s.inventory)
const selectedDeviceId = useRackStore((s) => s.selectedDeviceId)
const selectedRackId = useRackStore((s) => s.selectedRackId)
const hoveredDeviceId = useRackStore((s) => s.hoveredDeviceId)
const cableMode = useRackStore((s) => s.cableMode)
const cableDraft = useRackStore((s) => s.cableDraft)
const cableVisibility = useRackStore((s) => s.cableVisibility)
const selectDevice = useRackStore((s) => s.selectDevice)
const selectRack = useRackStore((s) => s.selectRack)
const hoverDevice = useRackStore((s) => s.hoverDevice)
const startCableDrag = useRackStore((s) => s.startCableDrag)
const endCableDrag = useRackStore((s) => s.endCableDrag)
const openDeviceEditor = useRackStore((s) => s.openDeviceEditor)
const openRackEditor = useRackStore((s) => s.openRackEditor)
const mountFromInventory = useRackStore((s) => s.mountFromInventory)
const mountAccessory = useRackStore((s) => s.mountAccessory)
const moveDevice = useRackStore((s) => s.moveDevice)
const { getZoom } = useReactFlow()
const [preview, setPreview] = useState<DropPreview | null>(null)
const mounted = devices.filter((d) => d.rackId === id)
const localPoint = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect()
const zoom = getZoom() || 1
return { x: (e.clientX - rect.left) / zoom, y: (e.clientY - rect.top) / zoom }
},
[getZoom],
)
const onDragOver = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
if (!rack) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
const payload = getDragPayload()
if (!payload) return
const { x, y } = localPoint(e)
const source =
payload.kind === 'device'
? devices.find((d) => d.id === payload.id)
: getFaceplate(payload.faceplateId)
if (!source) return
const { uHeight, colSpan } = source
const desired = {
uStart: Math.max(1, yToU(rack, y) - Math.floor(uHeight / 2)),
uHeight,
colStart: Math.min(xToCol(rack, x), RACK_COLUMNS - colSpan),
colSpan,
}
const slot = findSlot(
rack,
devices,
desired,
payload.kind === 'device' ? payload.id : undefined,
)
setPreview(slot ? { ...slot, valid: true } : { ...desired, valid: false })
},
[rack, devices, localPoint],
)
const onDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
if (!rack) return
e.preventDefault()
const payload = parseDragPayload(e.dataTransfer)
setPreview(null)
endDrag()
if (!payload) return
const { x, y } = localPoint(e)
const col = xToCol(rack, x)
if (payload.kind === 'inventory') {
mountFromInventory(payload.id, rack.id, { uStart: yToU(rack, y), colStart: col })
} else if (payload.kind === 'accessory') {
mountAccessory(payload.id, rack.id, { uStart: yToU(rack, y), colStart: col })
} else {
const device = devices.find((d) => d.id === payload.id)
if (!device) return
const desired = {
uStart: Math.max(1, yToU(rack, y) - Math.floor(device.uHeight / 2)),
uHeight: device.uHeight,
colStart: Math.min(col, RACK_COLUMNS - device.colSpan),
colSpan: device.colSpan,
}
const slot = findSlot(rack, devices, desired, device.id)
if (slot) moveDevice(device.id, rack.id, slot)
}
},
[rack, devices, localPoint, mountFromInventory, mountAccessory, moveDevice],
)
if (!rack) return null
const width = rackWidth(rack)
const height = rackHeight(rack)
const gutter = rack.style.showNumbers ? NUMBER_GUTTER_PX : 0
const col = columnWidth(rack)
const patchedPorts = new Map<string, string>()
for (const cable of cables) {
patchedPorts.set(cable.from.portId, cable.color)
patchedPorts.set(cable.to.portId, cable.color)
}
const cablesOn = cableVisibility === 'always' || cableMode
// Ports of non-patch gear stay hidden until the plate is focused, or until
// cables are on globally — a cable must never end on an invisible port.
const revealAll = cablesOn
return (
<div
className="rack-node"
style={{ width, height, position: 'relative' }}
onDragOver={onDragOver}
onDragLeave={() => setPreview(null)}
onDrop={onDrop}
onClick={() => selectRack(rack.id)}
onDoubleClick={() => openRackEditor(rack.id)}
>
{/* Chassis */}
<div
style={{
position: 'absolute',
inset: 0,
left: gutter,
background: rack.style.frame,
border: `2px solid ${selectedRackId === rack.id ? palette.accent : palette.plateOutline}`,
borderRadius: 6,
boxShadow: rack.style.enclosed ? 'inset 0 0 0 4px rgba(0,0,0,0.35)' : undefined,
}}
/>
{/* Interior */}
<div
style={{
position: 'absolute',
left: interiorLeft(rack),
top: INTERIOR_TOP_PX,
width: innerWidth(rack),
height: rack.uHeight * U_PX,
background: rack.style.interior,
}}
/>
{/* Rails */}
{[interiorLeft(rack) - RAIL_PX, interiorLeft(rack) + innerWidth(rack)].map((x) => (
<div
key={x}
style={{
position: 'absolute',
left: x,
top: INTERIOR_TOP_PX,
width: RAIL_PX,
height: rack.uHeight * U_PX,
background: rack.style.rail,
backgroundImage:
'repeating-linear-gradient(to bottom, rgba(0,0,0,0.45) 0 2px, transparent 2px 8px)',
}}
/>
))}
{/* U number gutter + slot separators */}
{Array.from({ length: rack.uHeight }, (_, i) => {
const u = rack.uHeight - i
return (
<div key={u}>
{rack.style.showNumbers && (
<div
style={{
position: 'absolute',
left: 0,
top: INTERIOR_TOP_PX + i * U_PX,
width: gutter - 4,
height: U_PX,
fontSize: 9,
lineHeight: `${U_PX}px`,
textAlign: 'right',
color: palette.muted,
fontFamily: 'JetBrains Mono, monospace',
}}
>
{uLabel(rack, u)}
</div>
)}
<div
style={{
position: 'absolute',
left: interiorLeft(rack),
top: INTERIOR_TOP_PX + i * U_PX,
width: innerWidth(rack),
height: 1,
background: 'rgba(255,255,255,0.04)',
}}
/>
</div>
)
})}
{/* Drop preview */}
{preview && (
<div
style={{
position: 'absolute',
left: interiorLeft(rack) + preview.colStart * col,
top: INTERIOR_TOP_PX + (rack.uHeight - preview.uStart - preview.uHeight + 1) * U_PX,
width: preview.colSpan * col,
height: preview.uHeight * U_PX,
border: `1.5px dashed ${preview.valid ? palette.status.online : palette.status.offline}`,
background: preview.valid ? 'rgba(57,211,83,0.12)' : 'rgba(248,81,73,0.12)',
borderRadius: 3,
pointerEvents: 'none',
}}
/>
)}
{/* Mounted gear */}
{mounted.map((device) => {
const box = deviceBox(rack, device)
const isDraftDevice = cableDraft?.deviceId === device.id
const focused = hoveredDeviceId === device.id || selectedDeviceId === device.id
return (
<div
key={device.id}
className="nodrag"
draggable={!cableMode}
onDragStart={(e) => startDrag(e.dataTransfer, { kind: 'device', id: device.id })}
onDragEnd={endDrag}
onClick={(e) => {
e.stopPropagation()
selectDevice(device.id)
}}
onDoubleClick={(e) => {
e.stopPropagation()
openDeviceEditor(device.id)
}}
onMouseEnter={() => hoverDevice(device.id)}
onMouseLeave={() => hoverDevice(null)}
style={{
position: 'absolute',
left: box.x,
top: box.y,
width: box.width,
height: box.height,
cursor: cableMode ? 'default' : 'grab',
}}
title={`${device.label} · ${device.uHeight}U · U${device.uStart}`}
>
<Faceplate
faceplateId={device.faceplateId}
label={device.label}
status={resolveDeviceStatus(device, inventory)}
ports={device.ports}
width={box.width}
height={box.height}
colorOverride={device.color}
selected={selectedDeviceId === device.id}
revealed={revealAll || focused}
interactivePorts={cableMode}
patchedPorts={patchedPorts}
draftPortId={isDraftDevice ? cableDraft.portId : null}
onPortPointerDown={(portId) => startCableDrag(device.id, portId)}
onPortPointerUp={(portId) => endCableDrag({ deviceId: device.id, portId })}
/>
</div>
)
})}
{/* Name plate — inside the chassis so it never gets clipped by fitView */}
<div
style={{
position: 'absolute',
left: gutter + 12,
top: 6,
height: HEADER_PX,
lineHeight: `${HEADER_PX}px`,
fontSize: 12,
color: palette.text,
fontFamily: 'Inter, system-ui, sans-serif',
pointerEvents: 'none',
}}
>
{rack.name}
{rack.location && <span style={{ color: palette.muted }}> · {rack.location}</span>}
<span style={{ color: palette.muted }}> · {rack.uHeight}U</span>
</div>
</div>
)
}
@@ -0,0 +1,210 @@
/**
* Rack settings — name, capacity, chrome.
*
* Same reasoning as `RackDeviceModal`: the rack canvas has no right rail, so the
* settings live in a dialog. Opened by double-clicking a rack's chassis.
*/
import { useState } from 'react'
import { toast } from 'sonner'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { useRackStore } from '../store'
import { freeUnits } from '../layout'
import { MAX_RACK_U, MIN_RACK_U } from '../rackDefaults'
import type { RackNumbering, RackWidthStandard } from '@/types'
const inputClass =
'w-full rounded border border-[#30363d] bg-[#21262d] px-2 py-1 text-sm text-foreground outline-none focus:border-[#00d4ff]'
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">{label}</Label>
{children}
</div>
)
}
export function RackSettingsModal() {
const rackId = useRackStore((s) => s.rackEditorId)
const close = useRackStore((s) => s.closeRackEditor)
const rack = useRackStore((s) => s.racks.find((r) => r.id === s.rackEditorId))
const devices = useRackStore((s) => s.devices)
const updateRack = useRackStore((s) => s.updateRack)
const updateRackStyle = useRackStore((s) => s.updateRackStyle)
const removeRack = useRackStore((s) => s.removeRack)
if (!rackId || !rack) return null
const used = rack.uHeight - freeUnits(rack, devices)
return (
<Dialog open onOpenChange={(o) => !o && close()}>
<DialogContent className="max-w-md border-[#30363d] bg-[#161b22] text-foreground max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-sm font-semibold">Rack settings</DialogTitle>
</DialogHeader>
<div className="mt-2 flex flex-col gap-3">
<Field label="Name">
<input
className={inputClass}
aria-label="Rack name"
value={rack.name}
onChange={(e) => updateRack(rack.id, { name: e.target.value })}
/>
</Field>
<Field label="Location">
<input
className={inputClass}
aria-label="Location"
value={rack.location ?? ''}
onChange={(e) => updateRack(rack.id, { location: e.target.value })}
/>
</Field>
<Field label={`Height — ${used}U used of ${rack.uHeight}U`}>
<HeightField rackId={rack.id} uHeight={rack.uHeight} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Width standard">
<select
className={inputClass}
aria-label="Width standard"
value={rack.widthStandard}
onChange={(e) =>
updateRack(rack.id, { widthStandard: e.target.value as RackWidthStandard })
}
>
<option value="19">19 inch</option>
<option value="10">10 inch (mini)</option>
</select>
</Field>
<Field label="U numbering">
<select
className={inputClass}
aria-label="U numbering"
value={rack.numbering}
onChange={(e) => updateRack(rack.id, { numbering: e.target.value as RackNumbering })}
>
<option value="bottom-up">1 at the bottom</option>
<option value="top-down">1 at the top</option>
</select>
</Field>
</div>
<div className="grid grid-cols-3 gap-3">
{(['frame', 'rail', 'interior'] as const).map((key) => (
<Field key={key} label={key}>
<input
type="color"
aria-label={key}
className="h-8 w-full cursor-pointer rounded border border-[#30363d] bg-[#21262d]"
value={rack.style[key]}
onChange={(e) => updateRackStyle(rack.id, { [key]: e.target.value })}
/>
</Field>
))}
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={rack.style.showNumbers}
onChange={(e) => updateRackStyle(rack.id, { showNumbers: e.target.checked })}
/>
Show U numbers
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={rack.style.enclosed}
onChange={(e) => updateRackStyle(rack.id, { enclosed: e.target.checked })}
/>
Enclosed cabinet
</label>
<div className="mt-1 flex items-center gap-2">
<Button
type="button"
variant="ghost"
onClick={() => {
// Takes every mount and every patch touching them with it, and
// the rack canvas has no undo. Ask first.
const mounted = devices.filter((d) => d.rackId === rack.id).length
const warning = mounted
? `Delete "${rack.name}" and unmount ${mounted} device${mounted > 1 ? 's' : ''}? Their cabling is removed too. The Device Inventory is untouched.`
: `Delete "${rack.name}"?`
if (!confirm(warning)) return
removeRack(rack.id)
close()
}}
className="cursor-pointer text-[#f85149] hover:bg-[#f8514922] hover:text-[#f85149]"
>
Delete rack
</Button>
<Button type="button" onClick={close} className="ml-auto cursor-pointer">
Done
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
/**
* Rack height, committed on blur or Enter rather than per keystroke.
*
* Typing into a controlled number input walks through intermediate values —
* backspacing "24" yields "2" — and `updateRack` relocates the mounts a shrink
* pushes past the top rail. Those relocations are not undone when the digits
* come back, and the rack canvas has no undo, so a per-keystroke commit quietly
* rearranged the rack. Only the settled value reaches the store.
*/
function HeightField({ rackId, uHeight }: { rackId: string; uHeight: number }) {
const updateRack = useRackStore((s) => s.updateRack)
const [draft, setDraft] = useState(String(uHeight))
// Follow the store when the height changes elsewhere — a clamp, or another
// rack opened into the same field. Adjusted during render rather than in an
// effect, which would render the stale draft first.
const [seen, setSeen] = useState({ rackId, uHeight })
if (seen.rackId !== rackId || seen.uHeight !== uHeight) {
setSeen({ rackId, uHeight })
setDraft(String(uHeight))
}
const commit = () => {
// An empty or unparseable field is "no edit", not "1 U". `Number('')` is 0,
// so a select-all + Delete followed by a click elsewhere used to shrink an
// empty rack to a single U — silently, with no undo behind it.
const next = Number(draft.trim())
if (!draft.trim() || !Number.isFinite(next) || next === uHeight) {
setDraft(String(uHeight))
return
}
if (!updateRack(rackId, { uHeight: next })) {
toast.error('Not enough room to shrink the rack — unmount something first')
setDraft(String(uHeight))
}
}
return (
<input
type="number"
min={MIN_RACK_U}
max={MAX_RACK_U}
className={inputClass}
aria-label="Rack height"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
// Escape needs no branch here: the dialog closes on it, and the draft
// dies with the field having committed nothing.
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur()
}}
/>
)
}
@@ -0,0 +1,40 @@
/**
* Drag payload shared between the inventory tray and the rack drop targets.
*
* `dataTransfer.getData()` is blocked during `dragover` in every browser, and
* the drop preview needs the payload on every move — so the payload also lives
* in a module-level slot for the duration of the drag.
*/
export type DragPayload =
| { kind: 'inventory'; id: string; faceplateId: string }
| { kind: 'accessory'; id: string; faceplateId: string }
| { kind: 'device'; id: string }
export const DRAG_MIME = 'application/homelable-rack'
let current: DragPayload | null = null
export function startDrag(dataTransfer: DataTransfer, payload: DragPayload): void {
current = payload
dataTransfer.setData(DRAG_MIME, JSON.stringify(payload))
dataTransfer.effectAllowed = 'move'
}
export function getDragPayload(): DragPayload | null {
return current
}
export function endDrag(): void {
current = null
}
/** Falls back to the module slot when the browser withholds the data. */
export function parseDragPayload(dataTransfer: DataTransfer | null): DragPayload | null {
const raw = dataTransfer?.getData(DRAG_MIME)
if (!raw) return current
try {
return JSON.parse(raw) as DragPayload
} catch {
return current
}
}
+232
View File
@@ -0,0 +1,232 @@
/**
* A sample inventory + a pre-filled rack, offered on an empty rack canvas and
* used as fixtures by the rack tests. Ids are fixed strings (not UUIDs) to keep
* tests readable.
*
* `inv-*` ids stand in for Device Inventory entries, `node-*` for the matching
* canvas nodes — the same two links a real mount carries.
*/
import { getFaceplate } from './faceplates'
import { CABLE_COLORS, DEFAULT_RACK_STYLE } from './rackDefaults'
import type {
Cable,
CableType,
InventoryDevice,
InventoryService,
Port,
Rack,
RackDevice,
} from '@/types'
let portSeq = 0
function withIds(ports: Omit<Port, 'id'>[], deviceId: string): Port[] {
return ports.map((p) => ({ ...p, id: `${deviceId}-p${portSeq++}` }))
}
interface DemoInventorySpec {
id: string
label: string
type: string
ip?: string
status: InventoryDevice['status']
suggestedFaceplateId: string
os?: string
services?: InventoryService[]
/** Omitted for gear that was never placed on a logical canvas. */
onCanvas?: false
}
const DEMO_INVENTORY: DemoInventorySpec[] = [
{ id: 'inv-pve1', label: 'pve-01', type: 'proxmox', ip: '192.168.1.10', status: 'online', suggestedFaceplateId: 'server-2u-bays', os: 'Debian 12', services: [{ port: 8006, name: 'proxmox' }, { port: 22, name: 'ssh' }] },
{ id: 'inv-pve2', label: 'pve-02', type: 'proxmox', ip: '192.168.1.11', status: 'online', suggestedFaceplateId: 'server-1u-bays' },
{ id: 'inv-nuc1', label: 'nuc-k3s-a', type: 'server', ip: '192.168.1.21', status: 'online', suggestedFaceplateId: 'sff-half' },
{ id: 'inv-nuc2', label: 'nuc-k3s-b', type: 'server', ip: '192.168.1.22', status: 'online', suggestedFaceplateId: 'sff-half' },
{ id: 'inv-pi1', label: 'pi-dns', type: 'server', ip: '192.168.1.31', status: 'online', suggestedFaceplateId: 'mini-third' },
{ id: 'inv-pi2', label: 'pi-backup', type: 'server', ip: '192.168.1.32', status: 'offline', suggestedFaceplateId: 'mini-third' },
{ id: 'inv-sw24', label: 'sw-core-24', type: 'switch', ip: '192.168.1.2', status: 'online', suggestedFaceplateId: 'switch-24' },
{ id: 'inv-sw8', label: 'sw-lab-8', type: 'switch', ip: '192.168.1.3', status: 'online', suggestedFaceplateId: 'switch-8' },
{ id: 'inv-fw', label: 'opnsense', type: 'firewall', ip: '192.168.1.1', status: 'online', suggestedFaceplateId: 'router-1u' },
{ id: 'inv-nas', label: 'nas-truenas', type: 'nas', ip: '192.168.1.40', status: 'online', suggestedFaceplateId: 'nas-2u', os: 'TrueNAS SCALE', services: [{ port: 445, name: 'smb' }, { port: 443, name: 'https' }] },
{ id: 'inv-jbod', label: 'jbod-shelf', type: 'server', ip: '192.168.1.41', status: 'unknown', suggestedFaceplateId: 'server-4u-storage' },
{ id: 'inv-ups', label: 'ups-eaton', type: 'ups', ip: '192.168.1.50', status: 'online', suggestedFaceplateId: 'ups-2u' },
// Dumb hardware: documented by hand, never on a logical canvas.
{ id: 'inv-pdu', label: 'pdu-main', type: 'generic', status: 'unknown', suggestedFaceplateId: 'pdu-1u', onCanvas: false },
{ id: 'inv-patch', label: 'patch-house', type: 'generic', status: 'unknown', suggestedFaceplateId: 'patch-24', onCanvas: false },
]
/** Canvas node id for an inventory entry, when it has one. */
export function demoNodeId(inventoryId: string): string | null {
const spec = DEMO_INVENTORY.find((i) => i.id === inventoryId)
if (!spec || spec.onCanvas === false) return null
return `node-${inventoryId}`
}
/** Stable fake MAC, derived from the id so fixtures never shift between runs. */
function demoMac(index: number): string {
const pair = (n: number) => n.toString(16).padStart(2, '0')
return `aa:bb:cc:${pair(index)}:${pair(index * 7 + 1)}:${pair(index * 13 + 2)}`
}
export function demoInventory(): InventoryDevice[] {
return DEMO_INVENTORY.map((spec, index) => {
const nodeId = demoNodeId(spec.id)
const mac = demoMac(index)
const hostname = `${spec.label}.lan`
return {
id: spec.id,
label: spec.label,
type: spec.type,
ip: spec.ip ?? null,
mac,
hostname,
os: spec.os ?? null,
services: spec.services ?? [],
status: spec.status,
nodeId,
// The demo canvas the sample gear is drawn on, so the mount's info panel
// has something to show without a backend.
node: nodeId
? {
id: nodeId,
label: spec.label,
type: spec.type,
ip: spec.ip ?? null,
mac,
hostname,
os: spec.os ?? null,
checkMethod: 'ping',
designId: 'demo-network',
designName: 'Network',
lastSeen: null,
}
: null,
racked: false,
suggestedFaceplateId: spec.suggestedFaceplateId,
}
})
}
export function demoRacks(): Rack[] {
return [
{
id: 'rack-main',
name: 'Main rack',
location: 'Garage',
uHeight: 18,
widthStandard: '19',
numbering: 'bottom-up',
style: { ...DEFAULT_RACK_STYLE },
position: { x: 120, y: 60 },
},
]
}
interface DemoMount {
id: string
/** Device Inventory entry; null for rack-only accessories. */
deviceId: string | null
label: string
faceplateId: string
uStart: number
colStart?: number
colSpan?: number
status?: RackDevice['status']
}
/** Layout of the pre-filled rack, top (18U) down to the bottom. */
const DEMO_MOUNTS: DemoMount[] = [
{ id: 'dev-patch', deviceId: 'inv-patch', label: 'patch-house', faceplateId: 'patch-24', uStart: 18 },
{ id: 'dev-sw24', deviceId: 'inv-sw24', label: 'sw-core-24', faceplateId: 'switch-24', uStart: 17 },
{ id: 'dev-mgmt', deviceId: null, label: 'Cable manager', faceplateId: 'cable-manager-1u', uStart: 16 },
{ id: 'dev-fw', deviceId: 'inv-fw', label: 'opnsense', faceplateId: 'router-1u', uStart: 15 },
// Two half-width mini PCs sharing 14U.
{ id: 'dev-nuc1', deviceId: 'inv-nuc1', label: 'nuc-k3s-a', faceplateId: 'sff-half', uStart: 14, colStart: 0, colSpan: 6 },
{ id: 'dev-nuc2', deviceId: 'inv-nuc2', label: 'nuc-k3s-b', faceplateId: 'sff-half', uStart: 14, colStart: 6, colSpan: 6 },
// Three third-width nodes sharing 13U.
{ id: 'dev-pi1', deviceId: 'inv-pi1', label: 'pi-dns', faceplateId: 'mini-third', uStart: 13, colStart: 0, colSpan: 4 },
{ id: 'dev-pi2', deviceId: 'inv-pi2', label: 'pi-backup', faceplateId: 'mini-third', uStart: 13, colStart: 4, colSpan: 4, status: 'offline' },
{ id: 'dev-blank', deviceId: null, label: 'Blank', faceplateId: 'blank-1u', uStart: 13, colStart: 8, colSpan: 4 },
{ id: 'dev-pve1', deviceId: 'inv-pve1', label: 'pve-01', faceplateId: 'server-2u-bays', uStart: 11 },
{ id: 'dev-pve2', deviceId: 'inv-pve2', label: 'pve-02', faceplateId: 'server-1u-bays', uStart: 10 },
{ id: 'dev-nas', deviceId: 'inv-nas', label: 'nas-truenas', faceplateId: 'nas-2u', uStart: 8 },
{ id: 'dev-shelf', deviceId: null, label: 'Shelf', faceplateId: 'shelf-1u', uStart: 7 },
{ id: 'dev-pdu', deviceId: 'inv-pdu', label: 'pdu-main', faceplateId: 'pdu-1u', uStart: 3 },
{ id: 'dev-ups', deviceId: 'inv-ups', label: 'ups-eaton', faceplateId: 'ups-2u', uStart: 1 },
]
export function demoDevices(): RackDevice[] {
portSeq = 0
const inventory = demoInventory()
return DEMO_MOUNTS.map((mount) => {
const plate = getFaceplate(mount.faceplateId)
const inv = mount.deviceId ? inventory.find((i) => i.id === mount.deviceId) : undefined
return {
id: mount.id,
rackId: 'rack-main',
deviceId: mount.deviceId,
nodeId: mount.deviceId ? demoNodeId(mount.deviceId) : null,
label: mount.label,
uStart: mount.uStart,
uHeight: plate.uHeight,
colStart: mount.colStart ?? 0,
colSpan: mount.colSpan ?? plate.colSpan,
faceplateId: mount.faceplateId,
status: mount.status ?? inv?.status ?? 'unknown',
ports: withIds(plate.ports, mount.id),
}
})
}
/**
* A few patch cables so the cabling overlay has something to show on boot.
* Ports are picked by index into each device's generated port list.
*/
const DEMO_CABLE_SPECS: {
from: [string, number]
to: [string, number]
type: CableType
label?: string
}[] = [
{ from: ['dev-sw24', 0], to: ['dev-fw', 0], type: 'ethernet', label: 'WAN uplink' },
{ from: ['dev-sw24', 1], to: ['dev-pve1', 0], type: 'ethernet' },
{ from: ['dev-sw24', 2], to: ['dev-pve2', 0], type: 'ethernet' },
{ from: ['dev-sw24', 3], to: ['dev-nuc1', 0], type: 'ethernet' },
{ from: ['dev-sw24', 4], to: ['dev-nuc2', 0], type: 'ethernet' },
// sw24 ports: 0..23 are RJ45, 24..25 are SFP+.
{ from: ['dev-sw24', 24], to: ['dev-nas', 2], type: 'fiber', label: '10G SAN' },
{ from: ['dev-patch', 0], to: ['dev-sw24', 12], type: 'ethernet', label: 'office' },
{ from: ['dev-patch', 1], to: ['dev-sw24', 13], type: 'ethernet', label: 'living room' },
]
export function demoCables(): Cable[] {
const devices = demoDevices()
const byId = new Map(devices.map((d) => [d.id, d]))
const cables: Cable[] = []
for (const [i, spec] of DEMO_CABLE_SPECS.entries()) {
const from = byId.get(spec.from[0])?.ports[spec.from[1]]
const to = byId.get(spec.to[0])?.ports[spec.to[1]]
if (!from || !to) continue
cables.push({
id: `cable-${i}`,
type: spec.type,
color: CABLE_COLORS[spec.type],
label: spec.label,
from: { deviceId: spec.from[0], portId: from.id },
to: { deviceId: spec.to[0], portId: to.id },
})
}
return cables
}
/**
* Sample "links already drawn on the logical canvas", keyed by canvas node id.
* The real list comes from `networkLinks.ts`; this one backs the tests and the
* sample canvas.
*/
export function demoNetworkLinks(): { from: string; to: string; type: CableType; label?: string }[] {
return [
{ from: 'node-inv-sw8', to: 'node-inv-sw24', type: 'ethernet', label: 'lab uplink' },
{ from: 'node-inv-jbod', to: 'node-inv-nas', type: 'ethernet' },
{ from: 'node-inv-pi1', to: 'node-inv-sw24', type: 'ethernet' },
]
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Where a mount's status LED gets its colour.
*
* A mount stores a `MountStatus`: three states the user pins by hand, plus
* `auto` — "check device". The rack has no status checker of its own; the one
* that already runs is the node-level one on the logical canvas (ping, http,
* ssh, …). The backend reports that node's live state on every Device Inventory
* row (`node_status`), so `auto` resolves through the inventory entry the mount
* points at, and falls back to `unknown` when there is no node behind it.
*/
import type { DeviceStatus, InventoryDevice, MountStatus, RackDevice } from '@/types'
/** Every value the Status select offers, `auto` first. */
export const MOUNT_STATUSES: MountStatus[] = ['auto', 'online', 'offline', 'unknown']
/**
* Whether "check device" is worth offering: only an inventory entry the backend
* matched to a canvas node carries a check to follow.
*/
export function canFollowNode(entry: InventoryDevice | undefined | null): boolean {
return !!entry?.nodeId
}
/** The colour to draw, given what the mount stores and the current inventory. */
export function resolveDeviceStatus(
device: Pick<RackDevice, 'status' | 'deviceId'>,
inventory: InventoryDevice[],
): DeviceStatus {
if (device.status !== 'auto') return device.status
if (!device.deviceId) return 'unknown'
return inventory.find((i) => i.id === device.deviceId)?.status ?? 'unknown'
}
+547
View File
@@ -0,0 +1,547 @@
/**
* Front-panel templates.
*
* Artwork and port coordinates are unit coordinates (0..1 on both axes) so a
* plate scales with U height, rack width and zoom. Every plate reserves three
* bands that must not overlap: the status LED (far left), the name band
* (`labelBox`) and whatever sits to the right of it.
*
* Templates only seed the ports — the user edits the port list afterwards.
*/
import { RACK_COLUMNS, type FaceplateTemplate, type Port, type PortType } from '@/types'
interface BankOptions {
type: PortType
count: number
/** Horizontal band, in unit coordinates. */
x: number
w: number
/** Ports per row; the bank wraps into as many evenly spaced rows as needed. */
perRow?: number
/** Label prefix; ports are numbered from `start`. */
prefix?: string
start?: number
}
/**
* Evenly spread `count` ports across a horizontal band.
* Rows are centred vertically so a bank always looks balanced on the plate,
* whatever the U height.
*/
export function bank({
type,
count,
x,
w,
perRow = count,
prefix = 'P',
start = 1,
}: BankOptions): Omit<Port, 'id'>[] {
const rows = Math.max(1, Math.ceil(count / perRow))
const stepX = w / perRow
// Rows share the vertical space evenly: 1 row -> 0.5, 2 rows -> 0.3/0.7.
const rowY = (row: number) => (row + 1) / (rows + 1)
return Array.from({ length: count }, (_, i) => {
const row = Math.floor(i / perRow)
const col = i % perRow
return {
label: `${prefix}${start + i}`,
type,
x: x + stepX * (col + 0.5),
y: rowY(row),
}
})
}
const STEEL = '#2b323c'
const STEEL_LIGHT = '#39424f'
const BLACK_BOX = '#1b1f26'
const VENT = '#141922'
/** Name band used by full-width plates that keep their ports on the right. */
const LABEL_LEFT = { x: 0.055, w: 0.24 }
/** Narrower band for dense port panels. */
const LABEL_TIGHT = { x: 0.05, w: 0.15 }
/** Half/third-width plates: the LED is proportionally wider, so shift right. */
const LABEL_SMALL = { x: 0.1, w: 0.4 }
/** Bottom strip of a desktop NAS: badge, LED and sockets all sit on it. */
const NAS_BAND = 0.86
/** Drop a bank onto the NAS bottom strip — `bank` centres rows on the plate. */
function nasPorts(ports: Omit<Port, 'id'>[]): Omit<Port, 'id'>[] {
return ports.map((p) => ({ ...p, y: NAS_BAND }))
}
/**
* Desktop NAS height, in U.
*
* A U is drawn at 24px while the rack's inner width is 456px, so the canvas
* compresses the vertical axis about 1.8x against the real thing. A tower that
* measures 3U tall in metal reads as a squat box here — 5U is what makes the
* drive doors stand up the way they do on the desk.
*/
const NAS_U = 5
/** Plate width the NAS bay figures below are expressed against. */
const NAS_REF_SPAN = RACK_COLUMNS / 3
/** Left inset and per-bay width of a desktop NAS drive stack, on a NAS_REF_SPAN plate. */
const NAS_BAY_X = 0.05
const NAS_BAY_W = 0.18
/**
* A drive door is a 3.5" disk whatever the chassis holds, so the bay stack
* grows with the bay count instead of stretching to fill the plate — the 2-bay
* box wears two doors the same size as the 5-bay one's, not two fat ones.
*
* Unit coordinates are fractions of the plate, so a narrower plate needs larger
* fractions to draw the same door: `colSpan` scales them back to the reference.
*/
function nasBays(cols: number, colSpan: number = NAS_REF_SPAN) {
const scale = NAS_REF_SPAN / colSpan
return {
kind: 'bays' as const,
x: NAS_BAY_X * scale,
y: 0.07,
w: cols * NAS_BAY_W * scale,
h: 0.68,
cols,
rows: 1,
fill: BLACK_BOX,
}
}
export const FACEPLATES: FaceplateTemplate[] = [
// --- Servers ------------------------------------------------------------
{
id: 'server-1u',
label: 'Server 1U',
kind: 'device',
group: 'Servers',
uHeight: 1,
colSpan: RACK_COLUMNS,
statusLed: true,
labelBox: LABEL_LEFT,
elements: [
{ kind: 'panel', fill: STEEL, stroke: '#0d1117' },
{ kind: 'vents', x: 0.32, y: 0.22, w: 0.42, h: 0.56, cols: 16, rows: 2, fill: VENT },
],
ports: bank({ type: 'rj45', count: 2, x: 0.79, w: 0.16, prefix: 'eth' }),
},
{
id: 'server-1u-bays',
label: 'Server 1U — 4 bays',
kind: 'device',
group: 'Servers',
uHeight: 1,
colSpan: RACK_COLUMNS,
statusLed: true,
labelBox: LABEL_LEFT,
elements: [
{ kind: 'panel', fill: STEEL, stroke: '#0d1117' },
{ kind: 'bays', x: 0.32, y: 0.18, w: 0.42, h: 0.64, cols: 4, rows: 1, fill: BLACK_BOX },
],
ports: bank({ type: 'rj45', count: 2, x: 0.79, w: 0.16, prefix: 'eth' }),
},
{
id: 'server-2u-bays',
label: 'Server 2U — 8 bays',
kind: 'device',
group: 'Servers',
uHeight: 2,
colSpan: RACK_COLUMNS,
statusLed: true,
labelBox: LABEL_LEFT,
elements: [
{ kind: 'panel', fill: STEEL, stroke: '#0d1117' },
{ kind: 'bays', x: 0.32, y: 0.12, w: 0.42, h: 0.76, cols: 4, rows: 2, fill: BLACK_BOX },
],
ports: [
...bank({ type: 'rj45', count: 4, x: 0.78, w: 0.13, perRow: 2, prefix: 'eth' }),
...bank({ type: 'sfp+', count: 2, x: 0.92, w: 0.06, perRow: 1, prefix: 'sfp' }),
],
},
{
id: 'server-4u-storage',
label: 'Storage 4U — 12 bays',
kind: 'device',
group: 'Servers',
uHeight: 4,
colSpan: RACK_COLUMNS,
statusLed: true,
labelBox: { x: 0.055, w: 0.18 },
elements: [
{ kind: 'panel', fill: STEEL_LIGHT, stroke: '#0d1117' },
{ kind: 'bays', x: 0.26, y: 0.1, w: 0.5, h: 0.8, cols: 4, rows: 3, fill: BLACK_BOX },
],
ports: bank({ type: 'rj45', count: 2, x: 0.8, w: 0.14, prefix: 'eth' }),
},
{
id: 'sff-half',
label: 'SFF / mini PC (half width)',
kind: 'device',
group: 'Servers',
uHeight: 1,
colSpan: RACK_COLUMNS / 2,
statusLed: true,
labelBox: LABEL_SMALL,
elements: [
{ kind: 'panel', fill: STEEL_LIGHT, stroke: '#0d1117' },
{ kind: 'vents', x: 0.54, y: 0.25, w: 0.24, h: 0.5, cols: 8, rows: 2, fill: VENT },
],
ports: bank({ type: 'rj45', count: 1, x: 0.82, w: 0.12, prefix: 'eth' }),
},
{
id: 'mini-third',
label: 'Mini node (third width)',
kind: 'device',
group: 'Servers',
uHeight: 1,
colSpan: RACK_COLUMNS / 3,
statusLed: true,
labelBox: { x: 0.14, w: 0.42 },
elements: [
{ kind: 'panel', fill: STEEL_LIGHT, stroke: '#0d1117' },
{ kind: 'vents', x: 0.6, y: 0.3, w: 0.16, h: 0.4, cols: 5, rows: 2, fill: VENT },
],
ports: bank({ type: 'rj45', count: 1, x: 0.8, w: 0.14, prefix: 'eth' }),
},
// --- Network ------------------------------------------------------------
{
id: 'switch-8',
label: 'Switch 8 ports',
kind: 'device',
group: 'Network',
uHeight: 1,
colSpan: RACK_COLUMNS,
statusLed: true,
alwaysShowPorts: true,
labelBox: LABEL_LEFT,
elements: [
{ kind: 'panel', fill: BLACK_BOX, stroke: '#0d1117' },
{ kind: 'strip', x: 0.32, y: 0.16, w: 0.42, h: 0.68, fill: '#11161d' },
],
ports: [
...bank({ type: 'rj45', count: 8, x: 0.33, w: 0.4, prefix: '' }),
...bank({ type: 'sfp', count: 1, x: 0.79, w: 0.08, prefix: 'sfp' }),
],
},
{
id: 'switch-24',
label: 'Switch 24 ports + 2 SFP',
kind: 'device',
group: 'Network',
uHeight: 1,
colSpan: RACK_COLUMNS,
statusLed: true,
alwaysShowPorts: true,
labelBox: LABEL_TIGHT,
elements: [
{ kind: 'panel', fill: BLACK_BOX, stroke: '#0d1117' },
{ kind: 'strip', x: 0.22, y: 0.12, w: 0.55, h: 0.76, fill: '#11161d' },
],
ports: [
...bank({ type: 'rj45', count: 24, x: 0.23, w: 0.53, perRow: 12, prefix: '' }),
...bank({ type: 'sfp+', count: 2, x: 0.8, w: 0.12, prefix: 'sfp' }),
],
},
{
id: 'switch-48',
label: 'Switch 48 ports + 4 SFP+',
kind: 'device',
group: 'Network',
uHeight: 1,
colSpan: RACK_COLUMNS,
statusLed: true,
alwaysShowPorts: true,
labelBox: LABEL_TIGHT,
elements: [
{ kind: 'panel', fill: BLACK_BOX, stroke: '#0d1117' },
{ kind: 'strip', x: 0.21, y: 0.12, w: 0.56, h: 0.76, fill: '#11161d' },
],
ports: [
...bank({ type: 'rj45', count: 48, x: 0.22, w: 0.54, perRow: 24, prefix: '' }),
...bank({ type: 'sfp+', count: 4, x: 0.79, w: 0.14, perRow: 2, prefix: 'sfp' }),
],
},
{
id: 'router-1u',
label: 'Router / firewall 1U',
kind: 'device',
group: 'Network',
uHeight: 1,
colSpan: RACK_COLUMNS,
statusLed: true,
labelBox: LABEL_LEFT,
elements: [
{ kind: 'panel', fill: '#232a34', stroke: '#0d1117' },
{ kind: 'strip', x: 0.34, y: 0.16, w: 0.4, h: 0.68, fill: '#11161d' },
],
ports: [
...bank({ type: 'rj45', count: 6, x: 0.35, w: 0.38, prefix: 'lan' }),
...bank({ type: 'sfp+', count: 1, x: 0.79, w: 0.08, prefix: 'sfp' }),
],
},
{
id: 'patch-24',
label: 'Patch panel 24',
kind: 'device',
group: 'Network',
uHeight: 1,
colSpan: RACK_COLUMNS,
alwaysShowPorts: true,
labelBox: { x: 0.02, w: 0.16 },
elements: [
{ kind: 'panel', fill: '#3a3f47', stroke: '#0d1117' },
{ kind: 'strip', x: 0.2, y: 0.14, w: 0.78, h: 0.72, fill: '#22262c' },
],
ports: bank({ type: 'rj45', count: 24, x: 0.21, w: 0.76, perRow: 24, prefix: '' }),
},
{
id: 'patch-fiber-12',
label: 'Fiber panel 12 (LC)',
kind: 'device',
group: 'Network',
uHeight: 1,
colSpan: RACK_COLUMNS,
alwaysShowPorts: true,
labelBox: { x: 0.02, w: 0.16 },
elements: [
{ kind: 'panel', fill: '#3a3f47', stroke: '#0d1117' },
{ kind: 'strip', x: 0.2, y: 0.16, w: 0.6, h: 0.68, fill: '#22262c' },
],
ports: bank({ type: 'sfp', count: 12, x: 0.21, w: 0.58, prefix: 'lc' }),
},
// --- Storage / power ----------------------------------------------------
{
id: 'nas-2u',
label: 'NAS 2U — 8 bays',
kind: 'device',
group: 'Storage',
uHeight: 2,
colSpan: RACK_COLUMNS,
statusLed: true,
labelBox: LABEL_LEFT,
elements: [
{ kind: 'panel', fill: '#262c35', stroke: '#0d1117' },
{ kind: 'bays', x: 0.32, y: 0.12, w: 0.42, h: 0.76, cols: 4, rows: 2, fill: BLACK_BOX },
],
ports: [
...bank({ type: 'rj45', count: 2, x: 0.79, w: 0.12, perRow: 1, prefix: 'eth' }),
...bank({ type: 'sfp+', count: 1, x: 0.93, w: 0.05, prefix: 'sfp' }),
],
},
// Desktop NAS boxes (UGREEN DXP, Synology DS…) sat on a rack shelf: not rack
// gear, so they take a third of the width and stand ~3U tall. Their front is
// drive-tray doors, tall and portrait, filling everything above a thin bottom
// strip that carries the badge, the LED and the sockets — hence `labelBox.y`.
{
id: 'nas-desktop-2',
label: 'Desktop NAS — 2 bays',
kind: 'device',
group: 'Storage',
uHeight: NAS_U,
// Two doors side by side is a slim tower — a sixth of the rack.
colSpan: RACK_COLUMNS / 6,
statusLed: true,
labelBox: { x: 0.26, w: 0.4, y: NAS_BAND },
elements: [
{ kind: 'panel', fill: '#262c35', stroke: '#0d1117' },
nasBays(2, RACK_COLUMNS / 6),
],
ports: nasPorts(bank({ type: 'rj45', count: 1, x: 0.7, w: 0.26, prefix: 'eth' })),
},
{
id: 'nas-desktop-4',
label: 'Desktop NAS — 4 bays',
kind: 'device',
group: 'Storage',
uHeight: NAS_U,
colSpan: RACK_COLUMNS / 3,
statusLed: true,
labelBox: { x: 0.13, w: 0.42, y: NAS_BAND },
elements: [
{ kind: 'panel', fill: '#262c35', stroke: '#0d1117' },
nasBays(4),
],
ports: nasPorts(bank({ type: 'rj45', count: 2, x: 0.6, w: 0.34, prefix: 'eth' })),
},
{
id: 'nas-desktop-5',
label: 'Desktop NAS — 5 bays',
kind: 'device',
group: 'Storage',
uHeight: NAS_U,
colSpan: RACK_COLUMNS / 3,
statusLed: true,
labelBox: { x: 0.13, w: 0.42, y: NAS_BAND },
elements: [
{ kind: 'panel', fill: '#262c35', stroke: '#0d1117' },
nasBays(5),
],
ports: nasPorts([
...bank({ type: 'rj45', count: 2, x: 0.58, w: 0.24, prefix: 'eth' }),
...bank({ type: 'sfp+', count: 1, x: 0.84, w: 0.12, prefix: 'sfp' }),
]),
},
{
id: 'ups-2u',
label: 'UPS 2U',
kind: 'device',
group: 'Power',
uHeight: 2,
colSpan: RACK_COLUMNS,
statusLed: true,
labelBox: LABEL_LEFT,
elements: [
{ kind: 'panel', fill: '#1f242c', stroke: '#0d1117' },
{ kind: 'vents', x: 0.34, y: 0.22, w: 0.26, h: 0.56, cols: 10, rows: 3, fill: VENT },
{ kind: 'outlets', x: 0.64, y: 0.28, w: 0.32, h: 0.44, count: 4 },
],
// Outlets are artwork only — power cabling is out of v1.
ports: [],
},
{
id: 'pdu-1u',
label: 'PDU 1U — 8 outlets',
kind: 'device',
group: 'Power',
uHeight: 1,
colSpan: RACK_COLUMNS,
statusLed: true,
labelBox: LABEL_LEFT,
elements: [
{ kind: 'panel', fill: '#1f242c', stroke: '#0d1117' },
{ kind: 'outlets', x: 0.32, y: 0.22, w: 0.64, h: 0.56, count: 8 },
],
ports: [],
},
// --- Accessories --------------------------------------------------------
{
id: 'blank-1u',
label: 'Blank panel 1U',
kind: 'accessory',
group: 'Accessories',
uHeight: 1,
colSpan: RACK_COLUMNS,
labelBox: { x: 0.04, w: 0.4 },
elements: [{ kind: 'panel', fill: '#30363d', stroke: '#0d1117' }],
ports: [],
},
{
id: 'shelf-1u',
label: 'Shelf 1U',
kind: 'accessory',
group: 'Accessories',
uHeight: 1,
colSpan: RACK_COLUMNS,
labelBox: { x: 0.04, w: 0.4 },
elements: [
{ kind: 'panel', fill: '#3a4048', stroke: '#0d1117' },
{ kind: 'strip', x: 0.46, y: 0.58, w: 0.5, h: 0.26, fill: '#242a31' },
],
ports: [],
},
{
id: 'cable-manager-1u',
label: 'Cable manager 1U',
kind: 'accessory',
group: 'Accessories',
uHeight: 1,
colSpan: RACK_COLUMNS,
labelBox: { x: 0.04, w: 0.3 },
elements: [
{ kind: 'panel', fill: '#2a3038', stroke: '#0d1117' },
{ kind: 'vents', x: 0.36, y: 0.24, w: 0.6, h: 0.52, cols: 8, rows: 1, fill: '#161b22' },
],
ports: [],
},
]
const BY_ID = new Map(FACEPLATES.map((f) => [f.id, f]))
export function getFaceplate(id: string): FaceplateTemplate {
return BY_ID.get(id) ?? FACEPLATES[0]
}
/**
* Faceplate proposed when an inventory device is dropped into a rack.
*
* Keyed on the device's discovery `suggested_type`, which is free-form — an
* unknown or missing type falls back to a plain 1U server plate, which the user
* can swap in the device modal.
*/
const FACEPLATE_BY_DEVICE_TYPE: Record<string, string> = {
server: 'server-1u-bays',
proxmox: 'server-2u-bays',
nas: 'nas-2u',
switch: 'switch-24',
router: 'router-1u',
firewall: 'router-1u',
ap: 'sff-half',
computer: 'sff-half',
docker_host: 'server-1u',
ups: 'ups-2u',
pdu: 'pdu-1u',
patch_panel: 'patch-24',
printer: 'shelf-1u',
camera: 'mini-third',
iot: 'mini-third',
cpl: 'mini-third',
}
export function suggestFaceplate(deviceType: string | null | undefined): string {
return (deviceType && FACEPLATE_BY_DEVICE_TYPE[deviceType]) || 'server-1u'
}
/**
* The other direction: what kind of hardware a plate represents.
*
* A device created from the rack canvas has no discovery behind it, so the only
* thing that says what it is, is the plate the user picked. Without this the row
* landed in the Device Inventory with no type at all — no icon, no role badge,
* invisible to the type filter.
*
* `patch_panel` and `pdu` are rack-only kinds: the logical canvas has no node
* type for passive gear, and rack devices are never approved onto one anyway
* (`approve` returns 409). They must stay out of `UNRACKABLE_TYPES` — a PDU is
* the most rackable thing there is, which is also why it is not `socket`.
*/
const DEVICE_TYPE_BY_FACEPLATE: Record<string, string> = {
'server-1u': 'server',
'server-1u-bays': 'server',
'server-2u-bays': 'server',
'server-4u-storage': 'server',
'sff-half': 'computer',
'mini-third': 'computer',
'switch-8': 'switch',
'switch-24': 'switch',
'switch-48': 'switch',
'router-1u': 'router',
'patch-24': 'patch_panel',
'patch-fiber-12': 'patch_panel',
'nas-2u': 'nas',
'nas-desktop-2': 'nas',
'nas-desktop-4': 'nas',
'nas-desktop-5': 'nas',
'ups-2u': 'ups',
'pdu-1u': 'pdu',
}
/** Null for accessories, which are rack furniture and never inventory rows. */
export function deviceTypeForFaceplate(faceplateId: string): string | null {
return DEVICE_TYPE_BY_FACEPLATE[faceplateId] ?? null
}
/** Templates grouped for the picker, preserving catalog order. */
export function faceplateGroups(): { group: string; items: FaceplateTemplate[] }[] {
const groups: { group: string; items: FaceplateTemplate[] }[] = []
for (const plate of FACEPLATES) {
const existing = groups.find((g) => g.group === plate.group)
if (existing) existing.items.push(plate)
else groups.push({ group: plate.group, items: [plate] })
}
return groups
}
+205
View File
@@ -0,0 +1,205 @@
/**
* Rack geometry: U <-> pixel conversion, column math, occupancy and collision.
*
* All U values are 1-based and counted from the bottom rail, whatever the
* rack's `numbering` setting is — numbering only affects the printed labels.
*/
import { RACK_COLUMNS, type Port, type Rack, type RackDevice } from '@/types'
/** Pixel height of one rack unit at zoom 1. */
export const U_PX = 24
/** Inner usable width, per rack standard. */
export const INNER_WIDTH_PX: Record<Rack['widthStandard'], number> = {
'19': 456,
'10': 264,
}
/** Rail strip width on each side of the usable area. */
export const RAIL_PX = 14
/** U-number gutter drawn outside the left rail. */
export const NUMBER_GUTTER_PX = 22
/** Chassis padding above and below the rails. */
export const FRAME_PAD_PX = 10
/** Name bar drawn inside the chassis, above the rails. */
export const HEADER_PX = 20
/** Y of the first U's top edge — kept inside the node so nothing gets clipped. */
export const INTERIOR_TOP_PX = FRAME_PAD_PX + HEADER_PX
export function innerWidth(rack: Rack): number {
return INNER_WIDTH_PX[rack.widthStandard]
}
export function columnWidth(rack: Rack): number {
return innerWidth(rack) / RACK_COLUMNS
}
/** Total node width of a rack, gutter + frame included. */
export function rackWidth(rack: Rack): number {
const gutter = rack.style.showNumbers ? NUMBER_GUTTER_PX : 0
return gutter + RAIL_PX * 2 + innerWidth(rack) + FRAME_PAD_PX * 2
}
/** Total node height of a rack. */
export function rackHeight(rack: Rack): number {
return rack.uHeight * U_PX + INTERIOR_TOP_PX + FRAME_PAD_PX
}
/** X of the usable area's left edge, relative to the rack node. */
export function interiorLeft(rack: Rack): number {
const gutter = rack.style.showNumbers ? NUMBER_GUTTER_PX : 0
return gutter + FRAME_PAD_PX + RAIL_PX
}
/** Y of the top edge of a mount, relative to the rack node. */
export function uToY(rack: Rack, uStart: number, uHeight: number): number {
const topU = uStart + uHeight - 1
return INTERIOR_TOP_PX + (rack.uHeight - topU) * U_PX
}
/**
* Which U a pointer sits on. `y` is relative to the rack node.
* Clamped into [1, uHeight] so a drop slightly off the frame still lands.
*/
export function yToU(rack: Rack, y: number): number {
const fromTop = Math.floor((y - INTERIOR_TOP_PX) / U_PX)
const u = rack.uHeight - fromTop
return clamp(u, 1, rack.uHeight)
}
/** Which column a pointer sits on. `x` is relative to the rack node. */
export function xToCol(rack: Rack, x: number): number {
const col = Math.floor((x - interiorLeft(rack)) / columnWidth(rack))
return clamp(col, 0, RACK_COLUMNS - 1)
}
export function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
/** Printed label for a U, honouring the rack's numbering direction. */
export function uLabel(rack: Rack, u: number): number {
return rack.numbering === 'bottom-up' ? u : rack.uHeight - u + 1
}
// ---------------------------------------------------------------------------
// Device / port geometry
// ---------------------------------------------------------------------------
export interface Box {
x: number
y: number
width: number
height: number
}
/** Device box relative to the rack node. */
export function deviceBox(rack: Rack, device: RackDevice): Box {
const col = columnWidth(rack)
return {
x: interiorLeft(rack) + device.colStart * col,
y: uToY(rack, device.uStart, device.uHeight),
width: device.colSpan * col,
height: device.uHeight * U_PX,
}
}
/** Port centre in canvas coordinates (rack position included). */
export function portPosition(
rack: Rack,
device: RackDevice,
port: Port,
): { x: number; y: number } {
const box = deviceBox(rack, device)
return {
x: rack.position.x + box.x + port.x * box.width,
y: rack.position.y + box.y + port.y * box.height,
}
}
// ---------------------------------------------------------------------------
// Occupancy
// ---------------------------------------------------------------------------
export interface Placement {
uStart: number
uHeight: number
colStart: number
colSpan: number
}
function overlaps(a: Placement, b: Placement): boolean {
const uOverlap = a.uStart < b.uStart + b.uHeight && b.uStart < a.uStart + a.uHeight
const colOverlap =
a.colStart < b.colStart + b.colSpan && b.colStart < a.colStart + a.colSpan
return uOverlap && colOverlap
}
/** True when the placement fits inside the rack bounds. */
export function isInBounds(rack: Rack, p: Placement): boolean {
return (
p.uStart >= 1 &&
p.uHeight >= 1 &&
p.uStart + p.uHeight - 1 <= rack.uHeight &&
p.colStart >= 0 &&
p.colSpan >= 1 &&
p.colStart + p.colSpan <= RACK_COLUMNS
)
}
/**
* True when the placement fits and collides with nothing.
* `ignoreId` skips the device being moved, so a no-op drag stays valid.
*/
export function canPlace(
rack: Rack,
devices: RackDevice[],
p: Placement,
ignoreId?: string,
): boolean {
if (!isInBounds(rack, p)) return false
return !devices.some(
(d) => d.rackId === rack.id && d.id !== ignoreId && overlaps(p, d),
)
}
/**
* Nearest valid placement for a drop, searching the target U first and then
* walking outwards. Returns null when the rack has no room at all.
*/
export function findSlot(
rack: Rack,
devices: RackDevice[],
desired: Placement,
ignoreId?: string,
): Placement | null {
const maxU = rack.uHeight - desired.uHeight + 1
if (maxU < 1) return null
const colStart = clamp(desired.colStart, 0, RACK_COLUMNS - desired.colSpan)
const targetU = clamp(desired.uStart, 1, maxU)
for (let offset = 0; offset < rack.uHeight; offset++) {
for (const u of offset === 0 ? [targetU] : [targetU - offset, targetU + offset]) {
if (u < 1 || u > maxU) continue
const candidate = { ...desired, uStart: u, colStart }
if (canPlace(rack, devices, candidate, ignoreId)) return candidate
// Same U may still have room on another column run.
for (let c = 0; c <= RACK_COLUMNS - desired.colSpan; c++) {
const shifted = { ...desired, uStart: u, colStart: c }
if (canPlace(rack, devices, shifted, ignoreId)) return shifted
}
}
}
return null
}
/** Free U count, counting a U as free only when every column is free. */
export function freeUnits(rack: Rack, devices: RackDevice[]): number {
let free = 0
for (let u = 1; u <= rack.uHeight; u++) {
const busy = devices.some(
(d) => d.rackId === rack.id && u >= d.uStart && u < d.uStart + d.uHeight,
)
if (!busy) free++
}
return free
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Reads the links already drawn on the logical canvases, so a fresh rack can be
* seeded with the patches the user has already documented.
*
* Endpoints are canvas node ids, matched against `RackDevice.nodeId` — a mount
* only carries one when its inventory entry resolves to a node.
*/
import { canvasApi } from '@/api/client'
import type { CableType, Design } from '@/types'
import type { NetworkLinkHint } from './store'
interface ApiEdgeLike {
source: string
target: string
type?: string
label?: string | null
}
/** Edge types worth turning into a physical patch. */
const PHYSICAL_EDGE_TYPES = new Set(['ethernet', 'fibre', 'vlan', 'cluster'])
function toCableType(edgeType: string | undefined): CableType {
return edgeType === 'fibre' ? 'fiber' : 'ethernet'
}
/**
* Collect physical links across every non-rack design.
*
* Best-effort: a design that fails to load is skipped rather than failing the
* whole import, since this only ever seeds an optional starting point.
*/
export async function loadNetworkLinks(designs: Design[]): Promise<NetworkLinkHint[]> {
const sources = designs.filter((d) => d.design_type !== 'rack')
const hints: NetworkLinkHint[] = []
const seen = new Set<string>()
for (const design of sources) {
let edges: ApiEdgeLike[] = []
try {
const res = await canvasApi.load(design.id)
edges = ((res.data as { edges?: ApiEdgeLike[] })?.edges ?? [])
} catch {
continue
}
for (const edge of edges) {
if (edge.type && !PHYSICAL_EDGE_TYPES.has(edge.type)) continue
// Node ids are unique across designs, so an unordered pair dedupes links
// that the user drew on more than one canvas.
const key = [edge.source, edge.target].sort().join('|')
if (seen.has(key)) continue
seen.add(key)
hints.push({
from: edge.source,
to: edge.target,
type: toCableType(edge.type),
label: edge.label ?? undefined,
})
}
}
return hints
}

Some files were not shown because too many files have changed in this diff Show More