Compare commits

...
60 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
Pouzor f07f43686e chore: bump version to 3.1.2
ha-relevant: no
2026-08-01 01:26:23 +02:00
Pouzor 3bde741fd2 chore(deps): clear the seven open dependabot alerts
Lockfile only — every advisory is transitive and no direct dependency range
had to move:

- postcss 8.5.14 -> 8.5.25 (high, GHSA-r28c-9q8g-f849, path traversal in
  source-map auto-loading), pulled in by vite and shadcn
- fast-uri 3.1.2 -> 3.1.5 (two high advisories), via shadcn's ajv
- hono 4.12.25 -> 4.12.33 and @hono/node-server 1.19.14 -> 2.0.12 (three
  moderate advisories), via shadcn's @modelcontextprotocol/sdk, itself bumped
  1.29.0 -> 1.30.0

`npm audit` now reports 0 vulnerabilities. Build, lint and the full frontend
suite (1542 tests) pass unchanged.

ha-relevant: no
2026-08-01 01:14:21 +02:00
NebeszandPouzor - Rémy Jardient e8ea6857bc feat(mcp): add lightweight node lookup tools
Add a label substring filter to GET /api/v1/nodes, plus MCP tools
list_node_summaries (id/label/type/status only) and get_node
(by id -> single object, by label -> array) so an LLM client can look
up nodes without paying the token cost of the full node payload.
2026-07-30 01:57:34 +02:00
Pouzor - Rémy JardientandGitHub 42774fc138 Merge pull request #311 from Nebesz/fix/mcp-node-type-enum
fix: sync MCP node type enum with frontend NodeType
2026-07-30 01:37:33 +02:00
Pouzor 8f273e0201 fix: sync MCP edge type enum with frontend and gate both enums in CI
The node type fix left the same stale-enum bug in place for edges:
create_edge only advertised ethernet/wifi/iot/vlan/virtual, while the
frontend EdgeType union also defines cluster, fibre and electrical.
The backend's EdgeBase.type is a plain str, so those three were always
accepted server-side — only the MCP schema rejected them.

- Extract EDGE_TYPES in mcp/app/tools.py and use it for create_edge.
- Add mcp/tests/test_edge_types_sync.py, mirroring the NodeType guard:
  parses EdgeType out of frontend/src/types/index.ts and asserts set
  equality with EDGE_TYPES.
- Add create_edge cases for the three previously-rejected types plus a
  schema test pinning the enum contents and the ethernet default.
- Add an `mcp` job to quality.yml. Both sync guards were unreachable
  from CI before: quality.yml only ran frontend and backend, so the
  enums could drift again with nothing to catch it. The job runs from a
  full checkout because the guards read the frontend sources, which are
  not in the mcp Docker build context.
- Pin mcp[cli]>=1.26,<2. The bound is load-bearing: SDK 2.0 dropped the
  low-level Server.list_resources/list_tools decorator API that
  app/resources.py and app/tools.py are built on, so an unbounded spec
  breaks both the tests and the Docker image at import time.
- Move pytest/pytest-anyio/trio into mcp/requirements.txt, matching how
  backend/requirements.txt carries its own test deps. trio is required
  because pytest-anyio parametrises every async test over both backends.

Tested: 114 passed in mcp/, both in the existing venv and in a clean
Python 3.13 venv installed the way the new CI job does.

ha-relevant: no
2026-07-30 01:05:48 +02:00
Pouzor - Rémy JardientandGitHub 3c8e463dd9 Merge pull request #313 from Pouzor/feat/service-brand-icons
feat: brand icon picker for services, in a dedicated add/edit modal
2026-07-28 22:12:27 +02:00
Pouzor c4017c7634 feat(services): brand icon picker and dedicated add/edit modal
Services can now carry a brand logo (homarr dashboard-icons) or a generic
lucide icon, the same way nodes already could — closes the request in
discussion #299 where services stand in for docker images.

The add/edit form moves out of the right-hand DetailPanel into a proper
ServiceModal, which gives the icon picker room to breathe and matches how
nodes are edited.

- ServiceModal: name, port, protocol, path, icon; validates the name and
  clamps the port, and spreads over the original service on save so fields
  it doesn't own (category) survive an edit
- IconPickerPanel: Generic/Brand picker extracted from NodeModal so both
  surfaces share one implementation
- ServiceIcon: renders a lucide or brand:<slug> icon in the DetailPanel
  badge and on the canvas node, and nothing when the key can't be resolved
- Legacy scanner signature keys (bar-chart-2) still don't resolve to an
  icon, but the modal shows them verbatim and preserves them on save

ServiceInfo.icon already existed and the backend stores services as
list[Any], so no schema or migration change.

ha-relevant: yes
2026-07-28 22:00:45 +02:00
Nebesz a88688170a fix: sync MCP node type enum with frontend NodeType
The hardcoded NODE_TYPES list in mcp/app/tools.py only had 11 entries,
causing create_node/update_node/approve_device to reject valid device
types (firewall, docker_container, ups, etc.) that the backend and
frontend already support. Expanded the list to match
frontend/src/types/index.ts's NodeType union, excluding group/groupRect/text
which are canvas annotations, not devices. Added a test that parses the
frontend union and fails if the two lists drift apart again.
2026-07-28 18:17:52 +02:00
Pouzor - Rémy JardientandGitHub aaccb50b00 Merge pull request #306 from autogpt-elendir/feat/oidc-auth
feat(auth): add generic OIDC browser sessions
2026-07-28 00:29:41 +02:00
Pouzor 34758c9850 fix(ui): use official Homelable logo on login page
The login page rendered lucide's generic Network glyph instead of the
brand mark. Use the Logo component there, and realign Logo with the
canonical assets in docs/logo:

- match icon.svg geometry exactly (bg glow gradient, node glow filter)
- fix inverted wordmark colors (Home #e6edf3 / lable #00d4ff)
- scope gradient/filter ids per instance so the Sidebar and Toolbar
  logos don't share defs ids

ha-relevant: maybe
2026-07-25 00:02:39 +02:00
ypso 4d2284dc37 fix(auth): harden OIDC callback validation 2026-07-24 11:12:57 +02:00
Pouzor 9f974f2fcf docs(auth): document OIDC setup and configuration
- FEATURES.md: add "Authentication (Local / OpenID Connect)" section + TOC
- docs/oidc-auth.md: flow overview, env var reference, fail-closed validation
  rules, provider setup (Authentik/Keycloak/Authelia/Google), reverse-proxy
  notes, session/logout caveats, troubleshooting, revert-to-local

ha-relevant: no
2026-07-21 23:35:50 +02:00
Pouzor 37ad9c6933 fix(ci): scope dependency-audit npm audit to runtime deps
The dependency-audit job failed on vulnerabilities from `shadcn` and its
transitive tree (hono, fast-uri, body-parser, @modelcontextprotocol/sdk).
`shadcn` is a build-time scaffolding CLI plus a CSS `@import`, never shipped
in the browser bundle, so it belongs in devDependencies.

- move `shadcn` to devDependencies (regenerated lockfile: dev flags only,
  no version changes)
- run `npm audit` with `--omit=dev`, matching the runtime-only intent of the
  pip-audit step

npm audit --omit=dev --audit-level=high now reports 0 vulnerabilities; the
production build still resolves the shadcn CSS import.

ha-relevant: no
2026-07-21 23:34:34 +02:00
ypso 99e7b9a343 ci(security): enforce unsuppressed Python audit 2026-07-21 00:37:32 +02:00
ypso f061db645b chore(deps): refresh vulnerable frontend packages 2026-07-21 00:36:06 +02:00
ypso fd96b7d6ae feat(auth): add OIDC browser session flow 2026-07-21 00:36:02 +02:00
ypso 0e95d99868 feat(auth): add generic OIDC backend sessions 2026-07-21 00:35:57 +02:00
Pouzor - Rémy JardientandGitHub d9f3b4c18a Merge pull request #303 from Pouzor/release/3.1.1
chore: bump version to 3.1.1
2026-07-19 22:23:57 +02:00
Pouzor 80ebbca804 chore: bump version to 3.1.1 2026-07-19 22:17:59 +02:00
Pouzor - Rémy JardientandGitHub 959cd4a3ae Merge pull request #302 from Isilla/feat/services-drag-reorder
feat(detail-panel): make services list draggable to reorder
2026-07-19 22:07:20 +02:00
Marianne JOSEPH 084a8e23e7 feat(detail-panel): make services list draggable to reorder
Mirrors the existing properties drag-and-drop (native HTML5 drag)
so services can be reordered the same way.
2026-07-19 21:59:16 +02:00
Pouzor - Rémy JardientandGitHub f5dd46a729 Merge pull request #301 from Pouzor/fix/yaml-export-port-numbers
fix: round-trip Show Port Numbers on YAML export/import
2026-07-19 21:12:18 +02:00
Pouzor 121873f41c test(settings): wait for async config before asserting Save payload
The Proxmox/Zigbee auto-sync Save tests clicked Save after only
waiting for an unrelated field, so under CI load getConfig could
resolve after the click and Save fired with the default
sync_enabled=false. Wait for the toggle to reflect the loaded
state first.
2026-07-19 15:34:19 +02:00
Pouzor 16d6348cec fix(yaml): round-trip show_port_numbers on export/import
YAML export wrote per-side handle counts but never the
show_port_numbers toggle, so the option was lost on re-import
while connection points survived.

ha-relevant: maybe

Fixes #272
2026-07-19 15:15:13 +02:00
Pouzor - Rémy JardientandGitHub 28fc4d8a7f Merge pull request #300 from Pouzor/feat/getting-started-walkthrough
feat(walkthrough): Getting Started tour + new-user canvas detection
2026-07-19 11:52:27 +02:00
Pouzor 5726289b18 fix(toaster): enable richColors so error surface actually applies
Setting --error-bg alone had no effect: sonner only honors per-type
surfaces when richColors is enabled, so error toasts kept using
--normal-bg and looked identical to a normal toast. Enable richColors
and pin success/info/warning back to the neutral popover surface so only
errors turn red.
2026-07-19 11:44:47 +02:00
Pouzor e85dab8cfc fix(toaster): render error toasts on a red surface
Error toasts previously used the same neutral popover background as
success toasts, so a failure (e.g. a save while the backend is down)
read like a normal 'Canvas saved'. Set sonner's per-type --error-bg/
--error-text/--error-border so errors stand out in red.
2026-07-19 11:33:54 +02:00
Pouzor deccff15cf changing wording 2026-07-19 11:05:43 +02:00
Pouzor a1bf54c417 fix(walkthrough): keep sidebar crisp behind modal steps
Neutralize the app modal backdrop's dim/blur while the tour runs so a
ringed sidebar/toolbar control behind an open modal (steps 2, 10) stays
readable — the overlay's SVG spotlight is now the single source of
dimming. Add a walkthrough-running body class toggled by the overlay and
a CSS rule stripping [data-slot=dialog-overlay] background/backdrop-filter.

Also refine coach-card placement: dialogCardPosition drops the card into
the empty bottom-right corner for large modals (inventory, scan history)
and beside small modals, so it never covers modal content (steps 3, 4).
2026-07-19 01:32:08 +02:00
Pouzor 8c56921375 feat(walkthrough): add Getting Started tour with first-run invite
Bespoke spotlight walkthrough (no new deps) that guides new users through the
main features. A version-stamped invite bubble offers the tour to new users and
re-offers it once to existing users after an upgrade; restartable from Settings.

- Multi-hole SVG-mask overlay keeps a ringed control and any open modal lit
- Steps: scan, scan history, inventory, nodes, edit, text/zone, grouping,
  style, imports, and a closing recap with a GitHub link
- Scan/history/inventory/import steps inject demo data (no backend calls) and
  are filtered out in standalone, which ends on a full-mode recap
- Persisted invite state via localStorage (utils/walkthrough.ts), live tour
  state via zustand (stores/walkthroughStore.ts)

ha-relevant: maybe
2026-07-19 00:40:08 +02:00
Pouzor bf8551015c feat(canvas): distinguish new user from cleared canvas; stop demo on backend error
Cleared canvas re-showed the demo, and backend errors silently fell back to
demo — hiding real outages and forcing users to wipe demo nodes before bulk
edits. Now:
- backend load reports `initialized` (CanvasState row exists = ever saved)
- decideCanvasLoad() picks real | empty | demo; empty is kept empty
- backend down/error shows an error banner + toast, never the demo
- data-new-user flag reserved as the Getting Started walkthrough hook

ha-relevant: yes
2026-07-18 23:02:44 +02:00
Pouzor 3f030959be docs(readme): rework header with logo, badges, nav links 2026-07-18 20:06:22 +02:00
183 changed files with 18928 additions and 1292 deletions
+20 -2
View File
@@ -1,4 +1,5 @@
# Backend - server-side only (NEVER commit .env)
# Generate: python3 -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=change_me_in_production
SQLITE_PATH=./data/homelab.db
# Uploaded media (floor plans) folder. Optional — defaults to <SQLITE_PATH dir>/uploads,
@@ -7,13 +8,30 @@ SQLITE_PATH=./data/homelab.db
# Set this to the URL(s) you use to access Homelable in your browser.
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
# Auth — default credentials: admin / admin
# Auth — local mode is the backward-compatible default.
AUTH_MODE=local
# Local credentials: admin / admin by default.
# ⚠️ Change before exposing on a network.
# Generate a new hash: python3 -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"
# Generate a new hash: python3 -c "import bcrypt; print(bcrypt.hashpw(b'yourpassword', bcrypt.gensalt()).decode())"
# ⚠️ Keep the single quotes around the hash — bcrypt hashes contain \$ which Docker misinterprets without them.
AUTH_USERNAME=admin
AUTH_PASSWORD_HASH='$2b$12$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG'
# OpenID Connect (optional alternative to local auth).
# Set AUTH_MODE=oidc and configure every value below. The callback must be
# registered exactly at the identity provider. Keep CORS_ORIGINS restricted to
# the Homelable browser origin; wildcard CORS is rejected in OIDC mode. OIDC
# also requires SECRET_KEY to contain at least 32 bytes.
# AUTH_MODE=oidc
# OIDC_DISCOVERY_URL=https://idp.example/application/o/homelable/.well-known/openid-configuration
# OIDC_CLIENT_ID=homelable
# OIDC_CLIENT_SECRET=replace-with-a-secret
# OIDC_REDIRECT_URI=https://homelable.example/api/v1/auth/oidc/callback
# OIDC_SCOPES="openid profile email"
# OIDC_COOKIE_SECURE=true
# OIDC_SESSION_EXPIRE_MINUTES=480
# OIDC_TRANSACTION_EXPIRE_SECONDS=600
# Scanner — JSON array of CIDR ranges to scan
SCANNER_RANGES=["192.168.1.0/24"]
+17
View File
@@ -60,3 +60,20 @@ jobs:
- run: ruff check .
- run: mypy app/
- run: pytest
# Also guards the NODE_TYPES / EDGE_TYPES enums in mcp/app/tools.py against
# drift from frontend/src/types/index.ts (test_node_types_sync.py,
# test_edge_types_sync.py) — those tests need the frontend sources, so this
# job runs from a full checkout rather than the mcp Docker context.
mcp:
runs-on: ubuntu-latest
defaults:
run:
working-directory: mcp
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- run: pip install -r requirements.txt
- run: pytest
+11 -4
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
@@ -31,11 +37,12 @@ jobs:
with:
node-version: '20'
- name: NPM Audit
run: cd frontend && npm audit --audit-level=high
# --omit=dev: only ship-time deps gate the build. Dev-only tooling
# (shadcn CLI and its transitive tree: hono, fast-uri, …) is never
# bundled or served, so its advisories must not fail release CI.
run: cd frontend && npm audit --omit=dev --audit-level=high
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Pip audit
# CVE-2024-23342 (ecdsa): timing attack on ECDSA — not exploitable (app uses HS256/HMAC only).
# python-jose forces ecdsa as a dependency with no fixed version available.
run: pip install pip-audit && pip-audit -r backend/requirements.txt --ignore-vuln CVE-2024-23342
run: pip install pip-audit && pip-audit -r backend/requirements.txt
+62
View File
@@ -5,6 +5,68 @@ 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
- Generic OIDC authentication: backend sessions and browser login flow. Thanks @autogpt-elendir. (#306)
- Brand icon picker for services, in a dedicated add/edit modal. (#313)
- MCP: lightweight node lookup tools. Thanks @Nebesz. (#312)
### Fixes
- Sync the MCP node and edge type enums with the frontend, gated in CI against drift. Thanks @Nebesz. (#311)
- Harden OIDC callback validation. (#306)
- Use the official Homelable logo on the login page. (#306)
- Clear the seven open dependabot alerts and refresh vulnerable frontend packages. (#317, #306)
### Docs
- Document OIDC setup and configuration. (#306)
## [3.1.1] - 2026-07-19
### Features
- Drag to reorder services in the node detail panel. Thanks @Isilla. (#302)
- Getting Started walkthrough tour with first-run canvas detection. (#300)
### Fixes
- Round-trip Show Port Numbers on YAML export/import. (#301)
- Render error toasts on a red surface. (#300)
### Docs
- Rework README header with logo, badges, and nav links. (#300)
## [3.1.0] - 2026-07-18
### Features
+64 -26
View File
@@ -16,19 +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)
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-)
---
@@ -73,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.
@@ -84,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.
@@ -95,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.
@@ -115,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.
@@ -128,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.
@@ -141,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.
@@ -155,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.
@@ -166,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.
@@ -187,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.
@@ -199,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.
@@ -211,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.
@@ -223,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.
@@ -241,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.
@@ -252,4 +273,21 @@ The AI can list nodes/edges/canvas/pending/scans, add/update/delete nodes and ed
---
## 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.
- **`oidc`** — sign in through your own identity provider (Authentik, Keycloak, Authelia, Google, …) via **Authorization Code + PKCE**. Good for SSO, per-user accounts at the IdP, MFA, and central account management.
**Use (OIDC):**
1. Register Homelable as a **confidential** client at your IdP and set the redirect URI to `https://<your-host>/api/v1/auth/oidc/callback`.
2. In `.env` set `AUTH_MODE=oidc` and the `OIDC_*` values (discovery URL, client id/secret, redirect URI, scopes), keep `CORS_ORIGINS` pinned to your browser origin, and give `SECRET_KEY` at least 32 bytes.
3. Restart the backend. The login screen now shows **Sign in with OpenID Connect**.
**How it protects you:** provider tokens are exchanged server-side and never touch the browser — Homelable issues its own short-lived, `__Host-`, HttpOnly, `SameSite=Lax` session cookie. Discovery metadata, issuer, audience, signature, expiry, nonce, state and PKCE are all validated; cookie-authenticated writes require a CSRF token and an allowed `Origin`; the status WebSocket authenticates from the cookie and validates `Origin`. Local Bearer auth and the MCP service key keep working unchanged. Full config, provider examples and troubleshooting: [docs/oidc-auth.md](./docs/oidc-auth.md).
> OIDC is Full-mode only — the no-backend standalone build has no login.
---
*Installing (Docker, Proxmox LXC, source) is covered in [INSTALLATION.md](./INSTALLATION.md). Running Home Assistant? See [homelable-hacs](https://github.com/Pouzor/homelable-hacs).*
+26 -2
View File
@@ -13,7 +13,7 @@ Open **http://localhost:3000** — login with `admin` / `admin`.
>
Generate a new hash:
```bash
docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"
docker compose exec backend python -c 'import bcrypt; print(bcrypt.hashpw(b"yourpassword", bcrypt.gensalt()).decode())'
```
@@ -21,7 +21,7 @@ docker compose exec backend python -c "from passlib.context import CryptContext;
- **`.env` file** (recommended): wrap the hash in single quotes → `AUTH_PASSWORD_HASH='$2b$12$...'`
- **`docker-compose.yml` `environment:` block**: escape every `$` as `$$` — use this command to generate a pre-escaped hash:
```bash
docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword').replace('\$', '\$\$'))"
docker compose exec backend python -c 'import bcrypt; print(bcrypt.hashpw(b"yourpassword", bcrypt.gensalt()).decode().replace("$", "$$"))'
```
## Quick Start — Frontend only
@@ -69,6 +69,7 @@ bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/Proxmo
All configuration is done via `.env` (copied from `.env.example`):
```env
# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=change_me_in_production
# Auth — default: admin / admin
@@ -82,6 +83,29 @@ SCANNER_RANGES=["192.168.1.0/24"]
STATUS_CHECKER_INTERVAL=60
```
### OpenID Connect (optional)
OIDC is an exclusive alternative to the local password. Existing installs stay
in `AUTH_MODE=local` unless explicitly changed.
```env
AUTH_MODE=oidc
CORS_ORIGINS=["https://homelable.example"]
OIDC_DISCOVERY_URL=https://idp.example/application/o/homelable/.well-known/openid-configuration
OIDC_CLIENT_ID=homelable
OIDC_CLIENT_SECRET=replace-with-a-secret
OIDC_REDIRECT_URI=https://homelable.example/api/v1/auth/oidc/callback
OIDC_SCOPES="openid profile email"
OIDC_COOKIE_SECURE=true
OIDC_SESSION_EXPIRE_MINUTES=480
```
Register `OIDC_REDIRECT_URI` exactly at the identity provider. Production OIDC
requires HTTPS, a non-wildcard `CORS_ORIGINS`, and a confidential client. The
backend uses Authorization Code with PKCE and keeps provider tokens out of the
browser. `SECRET_KEY` must contain at least 32 bytes in OIDC mode. Do not expose
`OIDC_CLIENT_SECRET` in Compose YAML or commit it to Git.
All settings are also editable in-app via the **Scan Network** button.
---
+51 -3
View File
@@ -1,4 +1,35 @@
# Homelable
<h1 align="center"><img src="docs/logo/icon-inline.svg" alt="Homelable" width="58" align="middle" />&nbsp;Homelable</h1>
<p align="center">
<strong>Self-hosted homelab infrastructure visualization, scanning &amp; live monitoring</strong>
</p>
<p align="center">
<a href="https://github.com/Pouzor/homelable/releases/latest"><img src="https://img.shields.io/github/v/release/Pouzor/homelable" alt="Latest release" /></a>
<a href="https://github.com/Pouzor/homelable/actions/workflows/docker-ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/Pouzor/homelable/docker-ci.yml?branch=main&amp;label=build" alt="Build status" /></a>
<a href="https://github.com/Pouzor/homelable/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" /></a>
<a href="https://github.com/Pouzor/homelable/issues"><img src="https://img.shields.io/github/issues/Pouzor/homelable" alt="Issues" /></a>
<a href="https://github.com/Pouzor/homelable/stargazers"><img src="https://img.shields.io/github/stars/Pouzor/homelable?style=social" alt="Stars" /></a>
<a href="https://github.com/Pouzor/homelable/network/members"><img src="https://img.shields.io/github/forks/Pouzor/homelable?style=social" alt="Forks" /></a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/24461?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-24461" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/24461/daily?language=TypeScript" alt="Pouzor%2Fhomelable | Trendshift" width="130" /></a>
</p>
<p align="center">
<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> ·
<a href="#live-view-read-only-public-canvas">Live View</a> ·
<a href="#mcp-server-ai-integration-optional">MCP Server</a>
</p>
## About
Homelable is a self-hosted infrastructure visualization solution. It provides a network/zigbee scanning feature to accelerate the identification of machines, devices and services deployed on your local infrastructure.
@@ -10,7 +41,6 @@ If you just like the design, you can only run the frontend and export your desig
If you are running <img width="22" height="22" align="top" alt="New_Home_Assistant_logo" src="https://github.com/user-attachments/assets/3bb17686-c706-40ce-a2d3-57e02378f37c" /> Homeassistant, check the [Homelable HA version](https://github.com/Pouzor/homelable-hacs) (via HACS)
<a href="https://trendshift.io/repositories/24461?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-24461" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/24461/daily?language=TypeScript" alt="Pouzor%2Fhomelable | Trendshift" width="250" height="55"/></a>
---
## Screenshots
@@ -27,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)**.
@@ -39,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.0
3.2.0
+45 -10
View File
@@ -1,31 +1,66 @@
import hmac
from dataclasses import dataclass
from fastapi import Depends, Header, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.core.config import settings
from app.core.security import decode_token
from app.core.security import decode_token, get_oidc_session
bearer = HTTPBearer(auto_error=False)
def get_current_user(
@dataclass(frozen=True)
class AuthContext:
subject: str
display_name: str
auth_method: str
issuer: str | None = None
csrf_token: str | None = None
def get_auth_context(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
x_mcp_service_key: str | None = Header(default=None),
) -> str:
) -> AuthContext:
# 1. MCP service key (Docker-internal only — backend port is not externally exposed)
if x_mcp_service_key is not None:
if not settings.mcp_service_key:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="MCP service key not configured")
if not hmac.compare_digest(x_mcp_service_key.encode(), settings.mcp_service_key.encode()):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid MCP service key")
return "__mcp_service__"
return AuthContext(
subject="__mcp_service__",
display_name="MCP service",
auth_method="mcp",
)
# 2. Standard JWT bearer token
if credentials is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
username = decode_token(credentials.credentials)
if not username:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
return username
if credentials is not None:
username = decode_token(credentials.credentials)
if not username:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
return AuthContext(
subject=username,
display_name=username,
auth_method="local",
)
# 3. OIDC session cookie (only active in exclusive OIDC mode)
if settings.auth_mode == "oidc":
oidc_session = get_oidc_session(request)
if oidc_session is not None:
return AuthContext(
subject=oidc_session.subject,
display_name=oidc_session.display_name,
auth_method="oidc",
issuer=oidc_session.issuer,
csrf_token=oidc_session.csrf_token,
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
def get_current_user(context: AuthContext = Depends(get_auth_context)) -> str:
return context.subject
+97 -2
View File
@@ -1,12 +1,27 @@
import hmac
import logging
from collections.abc import Mapping
from typing import cast
from fastapi import APIRouter, HTTPException, status
from authlib.integrations.base_client.errors import OAuthError
from authlib.jose.errors import JoseError
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from pydantic import BaseModel
from starlette.responses import RedirectResponse
from app.api.deps import AuthContext, get_auth_context
from app.core.config import settings
from app.core.security import create_access_token, verify_password
from app.core.security import (
clear_oidc_session_cookie,
create_access_token,
create_oidc_session_token,
get_oidc_client,
set_oidc_session_cookie,
verify_password,
)
router = APIRouter()
logger = logging.getLogger(__name__)
class LoginRequest(BaseModel):
@@ -19,8 +34,36 @@ class TokenResponse(BaseModel):
token_type: str = "bearer"
class AuthConfigResponse(BaseModel):
mode: str
oidc_login_url: str | None = None
class CurrentUserResponse(BaseModel):
subject: str
display_name: str
auth_method: str
issuer: str | None = None
csrf_token: str | None = None
def _require_oidc_mode() -> None:
if settings.auth_mode != "oidc":
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="OIDC login is disabled")
@router.get("/config", response_model=AuthConfigResponse)
async def auth_config() -> AuthConfigResponse:
return AuthConfigResponse(
mode=settings.auth_mode,
oidc_login_url="/api/v1/auth/oidc/login" if settings.auth_mode == "oidc" else None,
)
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest) -> TokenResponse:
if settings.auth_mode != "local":
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Local login is disabled")
# Always run both checks to prevent timing-based username enumeration.
# hmac.compare_digest is constant-time; verify_password (bcrypt) always runs.
username_ok = hmac.compare_digest(body.username, settings.auth_username)
@@ -29,3 +72,55 @@ async def login(body: LoginRequest) -> TokenResponse:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
token = create_access_token(body.username)
return TokenResponse(access_token=token)
@router.get("/oidc/login")
async def oidc_login(request: Request) -> Response:
_require_oidc_mode()
client = get_oidc_client()
return cast(Response, await client.authorize_redirect(request, settings.oidc_redirect_uri))
@router.get("/oidc/callback")
async def oidc_callback(request: Request) -> Response:
_require_oidc_mode()
client = get_oidc_client()
try:
token = await client.authorize_access_token(request)
except (OAuthError, JoseError) as exc:
logger.warning("OIDC callback rejected: %s", exc.error)
request.session.clear()
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="OIDC authentication failed") from None
userinfo = token.get("userinfo")
if not isinstance(userinfo, Mapping):
request.session.clear()
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="OIDC authentication failed")
try:
session_token = create_oidc_session_token(userinfo)
except ValueError:
request.session.clear()
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="OIDC authentication failed") from None
request.session.clear()
response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
set_oidc_session_cookie(response, session_token)
return response
@router.get("/me", response_model=CurrentUserResponse)
async def current_user(context: AuthContext = Depends(get_auth_context)) -> CurrentUserResponse:
return CurrentUserResponse(
subject=context.subject,
display_name=context.display_name,
auth_method=context.auth_method,
issuer=context.issuer,
csrf_token=context.csrf_token,
)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(_: AuthContext = Depends(get_auth_context)) -> Response:
response = Response(status_code=status.HTTP_204_NO_CONTENT)
clear_oidc_session_cookie(response)
return response
+3
View File
@@ -37,6 +37,9 @@ async def load_canvas(
edges=[EdgeResponse.model_validate(e) for e in edges],
viewport=viewport,
custom_style=state.custom_style if state else None,
# A CanvasState row exists only after a save (or explicit design create),
# so its presence marks an intentional canvas vs. a never-touched one.
initialized=state is not None,
)
+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)
+10 -4
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -55,9 +55,15 @@ async def _find_free_position(db: AsyncSession, design_id: str | None) -> tuple[
@router.get("", response_model=list[NodeResponse])
async def list_nodes(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[Node]:
result = await db.execute(select(Node))
return list(result.scalars().all())
async def list_nodes(
label: str | None = Query(None, description="Case-insensitive substring filter on node label"),
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> list[Node]:
query = select(Node)
if label:
query = query.where(Node.label.ilike(f"%{label}%"))
return list((await db.execute(query)).scalars().all())
@router.post("", response_model=NodeResponse, status_code=status.HTTP_201_CREATED)
+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,
+28 -12
View File
@@ -3,7 +3,13 @@ import json
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.core.security import decode_token
from app.core.config import settings
from app.core.security import (
decode_oidc_session_token,
decode_token,
oidc_session_cookie_name,
origin_is_allowed,
)
router = APIRouter()
@@ -21,19 +27,29 @@ def _drop(websocket: WebSocket) -> None:
async def ws_status(websocket: WebSocket) -> None:
# Accept first so we can send a close frame with a reason code
await websocket.accept()
try:
# Expect the first message to be a JSON auth payload: {"token": "<jwt>"}
raw = await websocket.receive_text()
try:
payload = json.loads(raw)
token = payload.get("token", "")
except (json.JSONDecodeError, AttributeError):
token = ""
if not token or not decode_token(token):
if not origin_is_allowed(websocket.headers.get("origin")):
await websocket.close(code=1008) # Policy Violation
return
if settings.auth_mode == "oidc":
cookie_token = websocket.cookies.get(oidc_session_cookie_name(), "")
if not cookie_token or decode_oidc_session_token(cookie_token) is None:
await websocket.close(code=1008) # Policy Violation
return
except WebSocketDisconnect:
return
else:
# Local mode keeps the existing first-message bearer protocol.
try:
raw = await websocket.receive_text()
try:
payload = json.loads(raw)
token = payload.get("token", "")
except (json.JSONDecodeError, AttributeError):
token = ""
if not token or not decode_token(token):
await websocket.close(code=1008) # Policy Violation
return
except WebSocketDisconnect:
return
_connections.append(websocket)
try:
+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",
+43 -2
View File
@@ -1,8 +1,9 @@
import json
import logging
from pathlib import Path
from typing import Literal
from pydantic import model_validator
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
logger = logging.getLogger(__name__)
@@ -35,11 +36,22 @@ class Settings(BaseSettings):
access_token_expire_minutes: int = 1440 # 24h
# Auth — set AUTH_USERNAME and AUTH_PASSWORD_HASH in .env
auth_mode: Literal["local", "oidc"] = "local"
auth_username: str = "admin"
auth_password_hash: str = ""
# OpenID Connect — required only when AUTH_MODE=oidc.
oidc_discovery_url: str = ""
oidc_client_id: str = ""
oidc_client_secret: str = ""
oidc_redirect_uri: str = ""
oidc_scopes: str = "openid profile email"
oidc_cookie_secure: bool = True
oidc_session_expire_minutes: int = Field(default=480, ge=5, le=1440)
oidc_transaction_expire_seconds: int = Field(default=600, ge=60, le=3600)
@model_validator(mode="after")
def check_password_hash(self) -> "Settings":
def validate_auth_settings(self) -> "Settings":
h = self.auth_password_hash
if h and not h.startswith("$2"):
logger.error(
@@ -47,6 +59,35 @@ class Settings(BaseSettings):
"bcrypt hashes contain '$' signs — wrap the value in single quotes "
"in your .env file: AUTH_PASSWORD_HASH='$2b$12$...'"
)
if self.auth_mode == "oidc":
required = {
"OIDC_DISCOVERY_URL": self.oidc_discovery_url,
"OIDC_CLIENT_ID": self.oidc_client_id,
"OIDC_CLIENT_SECRET": self.oidc_client_secret,
"OIDC_REDIRECT_URI": self.oidc_redirect_uri,
}
missing = [name for name, value in required.items() if not value]
if missing:
raise ValueError(f"AUTH_MODE=oidc requires: {', '.join(missing)}")
if len(self.secret_key.encode("utf-8")) < 32:
raise ValueError("SECRET_KEY must be at least 32 bytes when AUTH_MODE=oidc")
if "openid" not in self.oidc_scopes.split():
raise ValueError("OIDC_SCOPES must contain 'openid'")
for name, value in {
"OIDC_DISCOVERY_URL": self.oidc_discovery_url,
"OIDC_REDIRECT_URI": self.oidc_redirect_uri,
}.items():
if not value.startswith(("https://", "http://")):
raise ValueError(f"{name} must be an HTTP(S) URL")
if self.oidc_cookie_secure:
for name, value in {
"OIDC_DISCOVERY_URL": self.oidc_discovery_url,
"OIDC_REDIRECT_URI": self.oidc_redirect_uri,
}.items():
if not value.startswith("https://"):
raise ValueError(f"{name} must use HTTPS when OIDC_COOKIE_SECURE=true")
if "*" in self.cors_origins:
raise ValueError("CORS_ORIGINS cannot contain '*' when AUTH_MODE=oidc")
return self
# Scanner
+181 -3
View File
@@ -1,10 +1,33 @@
import hmac
import secrets
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from functools import lru_cache
from typing import Any
import bcrypt
from jose import JWTError, jwt
import jwt
from authlib.integrations.starlette_client import OAuth, StarletteOAuth2App
from fastapi import Request, Response, status
from jwt import InvalidTokenError
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import JSONResponse
from app.core.config import settings
ACCESS_TOKEN_USE = "access"
OIDC_SESSION_TOKEN_USE = "oidc_session"
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE"})
@dataclass(frozen=True)
class OIDCSession:
subject: str
issuer: str
display_name: str
csrf_token: str
def verify_password(plain: str, hashed: str) -> bool:
if not plain or not hashed:
@@ -21,14 +44,169 @@ def hash_password(password: str) -> str:
def create_access_token(subject: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
payload = {"sub": subject, "exp": expire}
payload = {"sub": subject, "exp": expire, "token_use": ACCESS_TOKEN_USE}
return str(jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm))
def decode_token(token: str) -> str | None:
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
if payload.get("token_use") not in (None, ACCESS_TOKEN_USE):
return None
sub = payload.get("sub")
return str(sub) if sub is not None else None
except JWTError:
except InvalidTokenError:
return None
def create_oidc_session_token(userinfo: Mapping[str, Any]) -> str:
subject = userinfo.get("sub")
issuer = userinfo.get("iss")
if not isinstance(subject, str) or not subject or not isinstance(issuer, str) or not issuer:
raise ValueError("OIDC userinfo must contain non-empty 'sub' and 'iss' claims")
display_name = next(
(
value
for value in (
userinfo.get("preferred_username"),
userinfo.get("name"),
userinfo.get("email"),
subject,
)
if isinstance(value, str) and value
),
subject,
)
now = datetime.now(timezone.utc)
payload = {
"sub": subject,
"oidc_issuer": issuer,
"name": display_name,
"csrf": secrets.token_urlsafe(32),
"iat": now,
"exp": now + timedelta(minutes=settings.oidc_session_expire_minutes),
"token_use": OIDC_SESSION_TOKEN_USE,
}
return str(jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm))
def decode_oidc_session_token(token: str) -> OIDCSession | None:
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
if payload.get("token_use") != OIDC_SESSION_TOKEN_USE:
return None
subject = payload.get("sub")
issuer = payload.get("oidc_issuer")
display_name = payload.get("name")
csrf_token = payload.get("csrf")
if not isinstance(subject, str) or not subject:
return None
if not isinstance(issuer, str) or not issuer:
return None
if not isinstance(display_name, str) or not display_name:
return None
if not isinstance(csrf_token, str) or not csrf_token:
return None
return OIDCSession(
subject=subject,
issuer=issuer,
display_name=display_name,
csrf_token=csrf_token,
)
except InvalidTokenError:
return None
def oidc_session_cookie_name() -> str:
return "__Host-homelable-session" if settings.oidc_cookie_secure else "homelable-session"
def set_oidc_session_cookie(response: Response, token: str) -> None:
response.set_cookie(
key=oidc_session_cookie_name(),
value=token,
max_age=settings.oidc_session_expire_minutes * 60,
secure=settings.oidc_cookie_secure,
httponly=True,
samesite="lax",
path="/",
)
def clear_oidc_session_cookie(response: Response) -> None:
response.delete_cookie(
key=oidc_session_cookie_name(),
secure=settings.oidc_cookie_secure,
httponly=True,
samesite="lax",
path="/",
)
def get_oidc_session(request: Request) -> OIDCSession | None:
token = request.cookies.get(oidc_session_cookie_name())
return decode_oidc_session_token(token) if token else None
def origin_is_allowed(origin: str | None) -> bool:
if not origin:
return False
allowed_origins = {value.rstrip("/") for value in settings.cors_origins}
return origin.rstrip("/") in allowed_origins
class OIDCCSRFMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
if settings.auth_mode != "oidc" or request.method in SAFE_METHODS:
return await call_next(request)
# Explicit non-cookie credentials take precedence in get_current_user,
# so their requests keep the existing CSRF-free API behavior.
if request.headers.get("Authorization") or request.headers.get("X-MCP-Service-Key"):
return await call_next(request)
oidc_session = get_oidc_session(request)
if oidc_session is None:
return await call_next(request)
csrf_token = request.headers.get("X-Homelable-CSRF", "")
csrf_valid = hmac.compare_digest(csrf_token.encode(), oidc_session.csrf_token.encode())
if not csrf_valid or not origin_is_allowed(request.headers.get("Origin")):
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"detail": "CSRF validation failed"},
)
return await call_next(request)
@lru_cache(maxsize=8)
def _build_oidc_client(
discovery_url: str,
client_id: str,
client_secret: str,
scopes: str,
) -> StarletteOAuth2App:
oauth = OAuth()
client = oauth.register(
name="oidc",
client_id=client_id,
client_secret=client_secret,
server_metadata_url=discovery_url,
client_kwargs={
"scope": scopes,
"code_challenge_method": "S256",
},
)
if client is None:
raise RuntimeError("Failed to register OIDC client")
return client
def get_oidc_client() -> StarletteOAuth2App:
return _build_oidc_client(
settings.oidc_discovery_url,
settings.oidc_client_id,
settings.oidc_client_secret,
settings.oidc_scopes,
)
+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)
+14 -1
View File
@@ -6,6 +6,7 @@ from typing import Any
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware
from app.api.routes import (
auth,
@@ -16,6 +17,7 @@ from app.api.routes import (
media,
nodes,
proxmox,
racks,
scan,
stats,
status,
@@ -25,6 +27,7 @@ from app.api.routes import (
from app.api.routes import settings as settings_routes
from app.core.config import settings
from app.core.scheduler import start_scheduler, stop_scheduler
from app.core.security import OIDCCSRFMiddleware
from app.db.database import init_db
@@ -53,12 +56,21 @@ app = FastAPI(
lifespan=lifespan,
)
app.add_middleware(OIDCCSRFMiddleware)
app.add_middleware(
SessionMiddleware,
secret_key=settings.secret_key,
session_cookie=("__Host-homelable-oidc-flow" if settings.oidc_cookie_secure else "homelable-oidc-flow"),
max_age=settings.oidc_transaction_expire_seconds,
same_site="lax",
https_only=settings.oidc_cookie_secure,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
allow_headers=["Authorization", "Content-Type", "X-Homelable-CSRF", "X-MCP-Service-Key"],
)
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
@@ -66,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"])
+5
View File
@@ -84,3 +84,8 @@ class CanvasStateResponse(BaseModel):
edges: list[EdgeResponse]
viewport: dict[str, Any]
custom_style: dict[str, Any] | None = None
# True once this design's canvas has ever been persisted (a CanvasState row
# exists). Lets the frontend tell a brand-new user (show demo) apart from one
# who intentionally cleared their canvas (keep it empty). False also for a
# missing/uninitialized design.
initialized: bool = False
+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"),
+3 -1
View File
@@ -6,8 +6,10 @@ aiosqlite==0.20.0
alembic==1.13.3
pydantic==2.9.2
pydantic-settings==2.5.2
python-jose[cryptography]==3.5.0
PyJWT==2.13.0
bcrypt==4.2.1
authlib==1.7.2
itsdangerous==2.2.0
python-multipart==0.0.31
apscheduler==3.10.4
python-nmap==0.7.1
+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
+288 -3
View File
@@ -1,5 +1,56 @@
import time
from unittest.mock import AsyncMock, patch
from urllib.parse import parse_qs, urlparse
import pytest
from authlib.integrations.base_client.errors import OAuthError
from authlib.jose.errors import ExpiredTokenError, JoseError
from fastapi import WebSocketDisconnect
from httpx import AsyncClient
from starlette.responses import RedirectResponse
from starlette.testclient import TestClient
class FakeOIDCClient:
def __init__(self, token=None, error: OAuthError | JoseError | None = None):
self.token = token or {}
self.error = error
self.redirect_uri: str | None = None
async def authorize_redirect(self, _request, redirect_uri: str):
self.redirect_uri = redirect_uri
return RedirectResponse("https://idp.example/application/o/authorize/?state=test", status_code=302)
async def authorize_access_token(self, _request):
if self.error is not None:
raise self.error
return self.token
@pytest.fixture
def oidc_settings():
from app.core.config import settings
from app.core.security import _build_oidc_client
values = {
"auth_mode": "oidc",
"oidc_discovery_url": "https://idp.example/application/o/homelable/.well-known/openid-configuration",
"oidc_client_id": "homelable",
"oidc_client_secret": "test-client-secret",
"oidc_redirect_uri": "http://test/api/v1/auth/oidc/callback",
"oidc_scopes": "openid profile email",
"oidc_cookie_secure": False,
"oidc_session_expire_minutes": 60,
"cors_origins": ["http://test"],
}
original = {name: getattr(settings, name) for name in values}
for name, value in values.items():
setattr(settings, name, value)
_build_oidc_client.cache_clear()
yield settings
for name, value in original.items():
setattr(settings, name, value)
_build_oidc_client.cache_clear()
async def test_login_success(client: AsyncClient):
@@ -10,6 +61,12 @@ async def test_login_success(client: AsyncClient):
assert data["token_type"] == "bearer"
async def test_auth_config_defaults_to_local(client: AsyncClient):
res = await client.get("/api/v1/auth/config")
assert res.status_code == 200
assert res.json() == {"mode": "local", "oidc_login_url": None}
async def test_login_wrong_password(client: AsyncClient):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "wrong"})
assert res.status_code == 401
@@ -76,7 +133,7 @@ async def test_expired_token_rejected(client: AsyncClient):
"""A JWT whose `exp` is in the past must be refused."""
from datetime import datetime, timedelta, timezone
from jose import jwt
import jwt
from app.core.config import settings
payload = {
@@ -97,14 +154,14 @@ async def test_token_signed_with_wrong_secret_rejected(client: AsyncClient):
"""A token signed with a different key must not be accepted."""
from datetime import datetime, timedelta, timezone
from jose import jwt
import jwt
from app.core.config import settings
payload = {
"sub": "admin",
"exp": datetime.now(timezone.utc) + timedelta(minutes=5),
}
forged = jwt.encode(payload, "different-secret", algorithm=settings.algorithm)
forged = jwt.encode(payload, "different-secret-that-is-at-least-32-bytes", algorithm=settings.algorithm)
res = await client.get("/api/v1/nodes", headers={"Authorization": f"Bearer {forged}"})
assert res.status_code == 401
@@ -136,3 +193,231 @@ def test_verify_password_handles_empty_inputs():
assert verify_password("", h) is False
assert verify_password("hunter2", "") is False
assert verify_password("", "") is False
# --- OpenID Connect ---
async def test_oidc_config_exposes_login_url(client: AsyncClient, oidc_settings):
res = await client.get("/api/v1/auth/config")
assert res.status_code == 200
assert res.json() == {
"mode": "oidc",
"oidc_login_url": "/api/v1/auth/oidc/login",
}
async def test_local_login_is_disabled_in_oidc_mode(client: AsyncClient, oidc_settings):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
assert res.status_code == 404
async def test_oidc_login_is_hidden_in_local_mode(client: AsyncClient):
res = await client.get("/api/v1/auth/oidc/login")
assert res.status_code == 404
async def test_oidc_login_uses_configured_callback(client: AsyncClient, oidc_settings):
fake_client = FakeOIDCClient()
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
res = await client.get("/api/v1/auth/oidc/login")
assert res.status_code == 302
assert res.headers["location"].startswith("https://idp.example/")
assert fake_client.redirect_uri == "http://test/api/v1/auth/oidc/callback"
async def test_oidc_client_enables_pkce_s256_and_nonce(oidc_settings):
from app.core.security import get_oidc_client
oidc_client = get_oidc_client()
metadata = {"authorization_endpoint": "https://idp.example/application/o/authorize/"}
with patch.object(oidc_client, "load_server_metadata", AsyncMock(return_value=metadata)):
authorization = await oidc_client.create_authorization_url(oidc_settings.oidc_redirect_uri)
query = parse_qs(urlparse(authorization["url"]).query)
assert query["response_type"] == ["code"]
assert query["code_challenge_method"] == ["S256"]
assert "code_challenge" in query
assert "code_verifier" not in query
assert query["nonce"] == [authorization["nonce"]]
assert authorization["code_verifier"]
async def test_oidc_callback_creates_session_for_api(client: AsyncClient, oidc_settings):
fake_client = FakeOIDCClient(token={
"userinfo": {
"iss": "https://idp.example/application/o/homelable/",
"sub": "user-123",
"preferred_username": "alice",
},
})
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
callback = await client.get("/api/v1/auth/oidc/callback")
assert callback.status_code == 303
assert callback.headers["location"] == "/"
cookie = callback.headers["set-cookie"]
assert "homelable-session=" in cookie
assert "HttpOnly" in cookie
assert "SameSite=lax" in cookie
me = await client.get("/api/v1/auth/me")
assert me.status_code == 200
assert me.json()["subject"] == "user-123"
assert me.json()["display_name"] == "alice"
assert me.json()["auth_method"] == "oidc"
assert me.json()["issuer"] == "https://idp.example/application/o/homelable/"
assert me.json()["csrf_token"]
protected = await client.get("/api/v1/nodes")
assert protected.status_code == 200
@pytest.mark.parametrize("userinfo", [None, {}, {"sub": "user-123"}, {"iss": "https://idp.example/"}])
async def test_oidc_callback_rejects_missing_identity_claims(client: AsyncClient, oidc_settings, userinfo):
token = {} if userinfo is None else {"userinfo": userinfo}
fake_client = FakeOIDCClient(token=token)
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
res = await client.get("/api/v1/auth/oidc/callback")
assert res.status_code == 401
assert res.json() == {"detail": "OIDC authentication failed"}
async def test_oidc_callback_hides_protocol_error_details(client: AsyncClient, oidc_settings):
fake_client = FakeOIDCClient(error=OAuthError(error="invalid_grant", description="sensitive detail"))
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
res = await client.get("/api/v1/auth/oidc/callback")
assert res.status_code == 401
assert res.json() == {"detail": "OIDC authentication failed"}
assert "sensitive detail" not in res.text
async def test_oidc_callback_hides_claim_validation_error_details(client: AsyncClient, oidc_settings):
fake_client = FakeOIDCClient(error=ExpiredTokenError())
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
res = await client.get("/api/v1/auth/oidc/callback")
assert res.status_code == 401
assert res.json() == {"detail": "OIDC authentication failed"}
assert "expired" not in res.text.lower()
async def test_oidc_session_token_cannot_be_used_as_bearer(client: AsyncClient, oidc_settings):
from app.core.security import create_oidc_session_token
token = create_oidc_session_token({"iss": "https://idp.example/", "sub": "user-123"})
res = await client.get("/api/v1/nodes", headers={"Authorization": f"Bearer {token}"})
assert res.status_code == 401
async def test_oidc_cookie_mutations_require_csrf_and_allowed_origin(client: AsyncClient, oidc_settings):
fake_client = FakeOIDCClient(token={
"userinfo": {
"iss": "https://idp.example/application/o/homelable/",
"sub": "user-123",
"preferred_username": "alice",
},
})
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
await client.get("/api/v1/auth/oidc/callback")
csrf_token = (await client.get("/api/v1/auth/me")).json()["csrf_token"]
missing = await client.post("/api/v1/auth/logout")
assert missing.status_code == 403
wrong_origin = await client.post(
"/api/v1/auth/logout",
headers={"Origin": "https://evil.example", "X-Homelable-CSRF": csrf_token},
)
assert wrong_origin.status_code == 403
valid = await client.post(
"/api/v1/auth/logout",
headers={"Origin": "http://test", "X-Homelable-CSRF": csrf_token},
)
assert valid.status_code == 204
assert "Max-Age=0" in valid.headers["set-cookie"]
assert (await client.get("/api/v1/auth/me")).status_code == 401
async def test_local_bearer_logout_does_not_require_csrf(client: AsyncClient, headers):
res = await client.post("/api/v1/auth/logout", headers=headers)
assert res.status_code == 204
# --- WebSocket authentication ---
def _wait_for_connection_count(expected: int) -> None:
from app.api.routes import status as status_routes
deadline = time.monotonic() + 1
while len(status_routes._connections) != expected and time.monotonic() < deadline:
time.sleep(0.01)
assert len(status_routes._connections) == expected
def test_local_websocket_keeps_first_message_bearer_flow():
from app.core.security import create_access_token
from app.main import app
test_client = TestClient(app)
try:
with test_client.websocket_connect(
"/api/v1/status/ws/status",
headers={"Origin": "http://localhost:3000"},
) as websocket:
websocket.send_json({"token": create_access_token("admin")})
_wait_for_connection_count(1)
_wait_for_connection_count(0)
finally:
test_client.close()
def test_websocket_rejects_untrusted_origin_before_auth():
from app.main import app
test_client = TestClient(app)
try:
with (
pytest.raises(WebSocketDisconnect) as caught,
test_client.websocket_connect(
"/api/v1/status/ws/status",
headers={"Origin": "https://evil.example"},
) as websocket,
):
websocket.receive_text()
assert caught.value.code == 1008
finally:
test_client.close()
def test_oidc_websocket_uses_session_cookie_without_javascript_token(oidc_settings):
from app.core.security import create_oidc_session_token, oidc_session_cookie_name
from app.main import app
session_token = create_oidc_session_token({"iss": "https://idp.example/", "sub": "user-123"})
test_client = TestClient(app)
test_client.cookies.set(oidc_session_cookie_name(), session_token)
try:
with test_client.websocket_connect(
"/api/v1/status/ws/status",
headers={"Origin": "http://test"},
):
_wait_for_connection_count(1)
_wait_for_connection_count(0)
finally:
test_client.close()
def test_oidc_websocket_rejects_missing_session_cookie(oidc_settings):
from app.main import app
test_client = TestClient(app)
try:
with (
pytest.raises(WebSocketDisconnect) as caught,
test_client.websocket_connect(
"/api/v1/status/ws/status",
headers={"Origin": "http://test"},
) as websocket,
):
websocket.receive_text()
assert caught.value.code == 1008
finally:
test_client.close()
+17
View File
@@ -22,6 +22,23 @@ async def test_load_canvas_empty(client: AsyncClient, headers: dict):
assert data["viewport"] == {"x": 0, "y": 0, "zoom": 1}
async def test_load_canvas_uninitialized_reports_initialized_false(client: AsyncClient, headers: dict):
# A never-saved canvas has no CanvasState row → initialized False. The frontend
# uses this to show the demo canvas only to genuinely new users.
data = (await client.get("/api/v1/canvas", headers=headers)).json()
assert data["initialized"] is False
async def test_load_canvas_initialized_true_after_save(client: AsyncClient, headers: dict):
# Saving an EMPTY canvas still creates a CanvasState row, so a subsequently
# loaded empty canvas is reported initialized — the user cleared it on purpose
# and must not get the demo re-seeded.
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}}, headers=headers)
data = (await client.get("/api/v1/canvas", headers=headers)).json()
assert data["nodes"] == []
assert data["initialized"] is True
async def test_load_canvas_requires_auth(client: AsyncClient):
res = await client.get("/api/v1/canvas")
assert res.status_code == 401
+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()
+40
View File
@@ -363,3 +363,43 @@ async def test_create_node_explicit_zero_position_preserved(client: AsyncClient,
)
assert res.status_code == 201
assert (res.json()["pos_x"], res.json()["pos_y"]) == (0.0, 0.0)
async def test_list_nodes_filters_by_label_exact(client: AsyncClient, headers: dict):
await client.post("/api/v1/nodes", json={"type": "server", "label": "Proxmox", "status": "unknown"}, headers=headers)
await client.post("/api/v1/nodes", json={"type": "router", "label": "Router", "status": "unknown"}, headers=headers)
res = await client.get("/api/v1/nodes?label=Proxmox", headers=headers)
assert res.status_code == 200
assert [n["label"] for n in res.json()] == ["Proxmox"]
async def test_list_nodes_filters_by_label_substring(client: AsyncClient, headers: dict):
await client.post("/api/v1/nodes", json={"type": "server", "label": "pve1-node", "status": "unknown"}, headers=headers)
await client.post("/api/v1/nodes", json={"type": "server", "label": "pve2-node", "status": "unknown"}, headers=headers)
await client.post("/api/v1/nodes", json={"type": "router", "label": "Router", "status": "unknown"}, headers=headers)
res = await client.get("/api/v1/nodes?label=pve", headers=headers)
assert res.status_code == 200
assert sorted(n["label"] for n in res.json()) == ["pve1-node", "pve2-node"]
async def test_list_nodes_filters_by_label_case_insensitive(client: AsyncClient, headers: dict):
await client.post("/api/v1/nodes", json={"type": "server", "label": "Proxmox", "status": "unknown"}, headers=headers)
res = await client.get("/api/v1/nodes?label=PROXMOX", headers=headers)
assert res.status_code == 200
assert [n["label"] for n in res.json()] == ["Proxmox"]
async def test_list_nodes_filters_by_label_no_match(client: AsyncClient, headers: dict):
await client.post("/api/v1/nodes", json={"type": "server", "label": "Proxmox", "status": "unknown"}, headers=headers)
res = await client.get("/api/v1/nodes?label=nonexistent", headers=headers)
assert res.status_code == 200
assert res.json() == []
async def test_list_nodes_without_label_param_returns_all(client: AsyncClient, headers: dict):
"""Omitting label is fully backward compatible with the old unfiltered behavior."""
for label in ("A", "B", "C"):
await client.post("/api/v1/nodes", json={"type": "generic", "label": label, "status": "unknown"}, headers=headers)
res = await client.get("/api/v1/nodes", headers=headers)
assert res.status_code == 200
assert len(res.json()) == 3
+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
+61
View File
@@ -167,3 +167,64 @@ def test_save_overrides_omits_mesh_credentials(tmp_path):
assert written["zigbee_sync_enabled"] is True
assert written["zigbee_sync_interval"] == 900
assert written["zwave_sync_interval"] == 1200
def _valid_oidc_settings(**overrides):
values = {
"secret_key": "test-secret-key-that-is-at-least-32-bytes",
"auth_mode": "oidc",
"oidc_discovery_url": "https://idp.example/application/o/homelable/.well-known/openid-configuration",
"oidc_client_id": "homelable",
"oidc_client_secret": "client-secret",
"oidc_redirect_uri": "https://homelable.example/api/v1/auth/oidc/callback",
"cors_origins": ["https://homelable.example"],
}
values.update(overrides)
return Settings(_env_file=None, **values)
def test_oidc_mode_requires_complete_client_configuration():
with pytest.raises(ValueError, match="OIDC_DISCOVERY_URL.*OIDC_CLIENT_ID.*OIDC_CLIENT_SECRET.*OIDC_REDIRECT_URI"):
Settings(secret_key="test-secret", auth_mode="oidc", _env_file=None)
def test_oidc_mode_requires_openid_scope():
with pytest.raises(ValueError, match="OIDC_SCOPES must contain 'openid'"):
_valid_oidc_settings(oidc_scopes="profile email")
def test_oidc_mode_requires_strong_session_secret():
with pytest.raises(ValueError, match="SECRET_KEY must be at least 32 bytes"):
_valid_oidc_settings(secret_key="too-short")
def test_oidc_secure_cookie_requires_https_callback():
with pytest.raises(ValueError, match="must use HTTPS"):
_valid_oidc_settings(oidc_redirect_uri="http://localhost/api/v1/auth/oidc/callback")
def test_oidc_secure_cookie_requires_https_discovery():
with pytest.raises(ValueError, match="OIDC_DISCOVERY_URL must use HTTPS"):
_valid_oidc_settings(
oidc_discovery_url="http://idp.local/application/o/homelable/.well-known/openid-configuration"
)
def test_oidc_insecure_dev_mode_allows_http_urls():
configured = _valid_oidc_settings(
oidc_cookie_secure=False,
oidc_discovery_url="http://idp.local/.well-known/openid-configuration",
oidc_redirect_uri="http://localhost/api/v1/auth/oidc/callback",
)
assert configured.oidc_cookie_secure is False
def test_oidc_mode_rejects_wildcard_cors():
with pytest.raises(ValueError, match="CORS_ORIGINS cannot contain"):
_valid_oidc_settings(cors_origins=["*"])
def test_valid_oidc_configuration_is_accepted():
configured = _valid_oidc_settings()
assert configured.auth_mode == "oidc"
assert configured.oidc_cookie_secure is True
+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"
+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 82" width="64" height="82" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+192
View File
@@ -0,0 +1,192 @@
# OpenID Connect (OIDC) Authentication
Homelable can delegate login to your own identity provider (IdP) instead of the
built-in username/password. Sign-in uses the standard **Authorization Code flow
with PKCE**: the browser is redirected to your IdP, and on return Homelable
issues its own short-lived session cookie. Provider access/ID tokens are
exchanged server-side and **never** reach the browser.
Use it when you want single sign-on, per-user accounts managed at the IdP, MFA,
or central account revocation — Authentik, Keycloak, Authelia, Zitadel, Google,
Microsoft Entra ID, Okta, or any spec-compliant OpenID Connect provider.
> 🔒 **Server-dependent feature** — requires the Homelable backend. It is hidden
> in the no-backend standalone/demo build, which has no login at all.
---
## How it works
1. Homelable exposes its mode at `GET /api/v1/auth/config`. In OIDC mode the
login screen shows **Sign in with OpenID Connect**.
2. `GET /api/v1/auth/oidc/login` redirects to your IdP with `state`, `nonce`,
and a PKCE `code_challenge` (S256).
3. The IdP authenticates the user and redirects back to
`GET /api/v1/auth/oidc/callback`.
4. Homelable validates the response (signature via the IdP's JWKS, issuer,
audience, expiry, `nonce`, `state`, PKCE) and mints its **own** session token.
5. That token is stored in a `__Host-`, `HttpOnly`, `Secure`, `SameSite=Lax`
cookie. The browser never sees the provider tokens.
The display name shown in the app is the first present of
`preferred_username` → `name` → `email` → `sub`.
Local Bearer authentication and the MCP service key keep working unchanged, so
the API and the MCP server are unaffected.
---
## Prerequisites
1. A reachable **OpenID Connect** provider with a discovery document
(`.well-known/openid-configuration`).
2. Homelable served over **HTTPS** in production (required for the secure
session cookie).
3. A **confidential** OIDC client (client id **and** secret) registered at the
IdP, with Homelable's callback URL allow-listed as a redirect URI.
---
## Configuration
All settings live in `.env` (see `.env.example`). Switching to OIDC is
exclusive — it replaces local login. Existing installs stay on
`AUTH_MODE=local` until you change this.
```env
AUTH_MODE=oidc
# Pin CORS to the exact browser origin(s). Wildcard is rejected in OIDC mode.
CORS_ORIGINS=["https://homelable.example"]
# From your IdP:
OIDC_DISCOVERY_URL=https://idp.example/application/o/homelable/.well-known/openid-configuration
OIDC_CLIENT_ID=homelable
OIDC_CLIENT_SECRET=replace-with-a-secret
# Must EXACTLY match a redirect URI registered at the IdP.
OIDC_REDIRECT_URI=https://homelable.example/api/v1/auth/oidc/callback
OIDC_SCOPES="openid profile email"
OIDC_COOKIE_SECURE=true
OIDC_SESSION_EXPIRE_MINUTES=480
OIDC_TRANSACTION_EXPIRE_SECONDS=600
```
Then restart the backend (`docker compose restart backend`).
### Reference
| Variable | Required | Default | Notes |
|---|---|---|---|
| `AUTH_MODE` | yes | `local` | Set to `oidc` to enable. |
| `OIDC_DISCOVERY_URL` | yes | — | The IdP's `.well-known/openid-configuration` URL. |
| `OIDC_CLIENT_ID` | yes | — | Confidential client id. |
| `OIDC_CLIENT_SECRET` | yes | — | Client secret. Keep it out of Git and Compose YAML. |
| `OIDC_REDIRECT_URI` | yes | — | Must equal the registered redirect **exactly**, path included. |
| `OIDC_SCOPES` | no | `openid profile email` | Must contain `openid`. |
| `OIDC_COOKIE_SECURE` | no | `true` | `true` requires an HTTPS redirect URI and enables the `__Host-` cookie prefix. Set `false` only for local HTTP testing. |
| `OIDC_SESSION_EXPIRE_MINUTES` | no | `480` | App session lifetime (5–1440). |
| `OIDC_TRANSACTION_EXPIRE_SECONDS` | no | `600` | Lifetime of the short login-flow cookie holding `state`/`nonce`/PKCE (60–3600). |
| `SECRET_KEY` | yes | — | Must be **≥ 32 bytes** in OIDC mode. Generate: `python3 -c "import secrets; print(secrets.token_hex(32))"`. |
| `CORS_ORIGINS` | yes | — | Exact browser origin(s). `*` is rejected in OIDC mode. |
### Fail-closed validation
The backend refuses to start in OIDC mode unless the configuration is complete
and safe. It errors if any of the following is true:
- any of `OIDC_DISCOVERY_URL`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`,
`OIDC_REDIRECT_URI` is missing;
- `SECRET_KEY` is shorter than 32 bytes;
- `OIDC_SCOPES` does not contain `openid`;
- `OIDC_DISCOVERY_URL` or `OIDC_REDIRECT_URI` is not an HTTP(S) URL;
- `OIDC_COOKIE_SECURE=true` but `OIDC_REDIRECT_URI` is not HTTPS;
- `CORS_ORIGINS` contains `*`.
---
## Provider setup
The callback URL to register everywhere is:
```
https://<your-host>/api/v1/auth/oidc/callback
```
### Authentik
1. **Applications → Providers → Create → OAuth2/OpenID Provider.**
2. Client type **Confidential**; add the redirect URI above.
3. Note the client id/secret and the discovery URL:
`https://authentik.example/application/o/<app-slug>/.well-known/openid-configuration`.
4. Create an **Application** bound to that provider.
### Keycloak
1. In your realm: **Clients → Create client** (OpenID Connect), **Client
authentication ON** (confidential).
2. Set **Valid redirect URIs** to the callback URL.
3. Discovery URL:
`https://kc.example/realms/<realm>/.well-known/openid-configuration`.
4. Copy the secret from the **Credentials** tab.
### Authelia
1. Add Homelable to the `identity_providers.oidc.clients` list with a
`client_secret`, the redirect URI, and scopes `openid profile email`.
2. Discovery URL: `https://auth.example/.well-known/openid-configuration`.
### Google
1. **Google Cloud Console → APIs & Services → Credentials → OAuth client ID**
(type *Web application*).
2. Add the callback URL under **Authorized redirect URIs** (Google requires
HTTPS).
3. Discovery URL: `https://accounts.google.com/.well-known/openid-configuration`.
Any other spec-compliant provider works the same way — point
`OIDC_DISCOVERY_URL` at its discovery document.
---
## Behind a reverse proxy
Homelable derives the redirect target from `OIDC_REDIRECT_URI`, so set it to the
**public** HTTPS URL your users reach — not the internal container address. Make
sure the proxy:
- terminates TLS and forwards `/api/v1/auth/oidc/*` to the backend;
- passes the original `Host` and `X-Forwarded-Proto` headers;
- does not strip the `Cookie` / `Set-Cookie` headers on those routes.
---
## Logout & sessions
- **Logout** clears the Homelable session cookie (`POST /api/v1/auth/logout`).
- Sessions are stateless JWTs signed with `SECRET_KEY`; there is no server-side
revocation, so a session stays valid until `OIDC_SESSION_EXPIRE_MINUTES`
elapses. Keep the lifetime modest for sensitive deployments.
- Logout is local to Homelable; it does not sign the user out at the IdP.
---
## Troubleshooting
| Symptom | Likely cause / fix |
|---|---|
| IdP shows `redirect_uri_mismatch` | `OIDC_REDIRECT_URI` must equal the registered URI **exactly**, including scheme, host, port and the `/api/v1/auth/oidc/callback` path. |
| `OIDC authentication failed` (401) after IdP login | Wrong client secret, `openid` missing from scopes, or clock skew breaking `nonce`/`exp` — sync server time (NTP). |
| Logged out immediately / cookie not set | `OIDC_COOKIE_SECURE=true` while serving over HTTP. Serve HTTPS, or set `OIDC_COOKIE_SECURE=false` for local testing only. |
| `403 CSRF validation failed` on save/actions | The request `Origin` is not in `CORS_ORIGINS`, or a stale tab is missing the CSRF token — reload the page. |
| Backend won't start in OIDC mode | Re-read the error: a required `OIDC_*` value is missing, `SECRET_KEY` < 32 bytes, `CORS_ORIGINS` has `*`, or the redirect isn't HTTPS while the cookie is secure. |
| Login button doesn't appear | `AUTH_MODE` is still `local`, or the frontend can't reach `/api/v1/auth/config` — check the proxy and `CORS_ORIGINS`. |
---
## Reverting to local auth
Set `AUTH_MODE=local` (or remove it) and restart the backend. The
`AUTH_USERNAME` / `AUTH_PASSWORD_HASH` login is restored; the `OIDC_*` values are
ignored while in local mode.
+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.
+516 -122
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "3.1.0",
"version": "3.2.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -27,11 +27,10 @@
"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",
"shadcn": "^4.0.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
@@ -60,6 +59,7 @@
"globals": "^16.5.0",
"jsdom": "^28.1.0",
"lucide-react": "^1.7.0",
"shadcn": "^4.0.0",
"tailwindcss": "^4.2.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
+272 -73
View File
@@ -1,4 +1,4 @@
import { useEffect, useCallback, useRef, useState } from 'react'
import { useEffect, useCallback, useMemo, useRef, useState } from 'react'
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
import { type Node } from '@xyflow/react'
import { applyDagreLayout } from '@/utils/layout'
@@ -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'
@@ -45,8 +45,17 @@ import { useThemeStore } from '@/stores/themeStore'
import { canvasApi, designsApi, liveviewApi } from '@/api/client'
import * as standaloneStorage from '@/utils/standaloneStorage'
import { demoNodes, demoEdges } from '@/utils/demoData'
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_INVENTORY_DEVICES } from '@/walkthrough/demoTourData'
import { useStatusPolling } from '@/hooks/useStatusPolling'
import type { NodeData, EdgeData, CustomStyleDef, FloorMapConfig, NodeType } from '@/types'
import { bootstrapAuth } from '@/auth/bootstrap'
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'
@@ -57,12 +66,29 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
export default function App() {
const { loadCanvas, applyLayout, markSaved, markUnsaved, hasUnsavedChanges, editSeq, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer, floorMap, setFloorMap } = useCanvasStore()
const canvasRef = useRef<HTMLDivElement>(null)
const { isAuthenticated } = useAuthStore()
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()
useEffect(() => {
if (STANDALONE || authBootstrapStarted.current) return
authBootstrapStarted.current = true
void bootstrapAuth()
}, [])
const [autosave, setAutosave] = useState<AutosaveSettings>(readAutosaveSettings)
useEffect(() => subscribeAutosaveSettings(setAutosave), [])
@@ -74,18 +100,30 @@ export default function App() {
const loadedDesignIdRef = useRef<string | null>(loadedDesignId)
useEffect(() => { loadedDesignIdRef.current = loadedDesignId }, [loadedDesignId])
// True while the last canvas load failed (backend down/error). Drives the error
// banner and keeps us from masking a real failure with the demo canvas.
const [loadError, setLoadError] = useState(false)
// True when the loaded canvas is the demo (a brand-new user). Reserved as the
// entry signal for the upcoming Getting Started walkthrough.
const [isNewUser, setIsNewUser] = useState(false)
// Getting Started tour: when true, the corresponding modal renders injected demo
// data instead of hitting the backend.
const [tourScanHistoryDemo, setTourScanHistoryDemo] = useState(false)
const [tourInventoryDemo, setTourInventoryDemo] = useState(false)
const [themeModalOpen, setThemeModalOpen] = useState(false)
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)
@@ -109,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.
@@ -129,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)
@@ -140,70 +190,116 @@ 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 }) },
})
const loadCanvasFromApi = useCallback(async (designId?: string) => {
let res
try {
const res = await canvasApi.load(designId)
const { nodes: apiNodes, edges: apiEdges } = res.data
if (apiNodes.length > 0) {
const proxmoxContainerMap = new Map<string, boolean>(
(apiNodes as ApiNode[])
.filter((n) => n.type === 'group' || n.container_mode === true)
.map((n) => [n.id, true])
)
const { nodes: rfNodes, edges: rfEdges } = migrateClusterHandles(
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)),
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
)
const savedTheme = res.data.viewport?.theme_id
if (savedTheme) setTheme(savedTheme)
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
const savedFloorMap = res.data.viewport?.floor_map as FloorMapConfig | undefined
// Clear when the target design has no floor plan, so it doesn't bleed
// across canvases when switching designs.
setFloorMap(savedFloorMap ?? null)
loadCanvas(rfNodes, rfEdges)
} else {
setFloorMap(null)
loadCanvas(demoNodes, demoEdges)
}
res = await canvasApi.load(designId)
} catch {
// Backend down / errored. Surface it and STOP — never fall back to the demo
// canvas, which would hide the real error and look like a fresh account.
// Leave the on-screen canvas and provenance untouched so an autosave can't
// clobber real data with an empty canvas.
setLoadError(true)
toast.error('Could not load canvas — backend not responding')
return
}
const { nodes: apiNodes, edges: apiEdges } = res.data
const mode = decideCanvasLoad(apiNodes.length > 0, res.data.initialized === true)
if (mode === 'real') {
const proxmoxContainerMap = new Map<string, boolean>(
(apiNodes as ApiNode[])
.filter((n) => n.type === 'group' || n.container_mode === true)
.map((n) => [n.id, true])
)
const { nodes: rfNodes, edges: rfEdges } = migrateClusterHandles(
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)),
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
)
const savedTheme = res.data.viewport?.theme_id
if (savedTheme) setTheme(savedTheme)
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
const savedFloorMap = res.data.viewport?.floor_map as FloorMapConfig | undefined
// Clear when the target design has no floor plan, so it doesn't bleed
// across canvases when switching designs.
setFloorMap(savedFloorMap ?? null)
loadCanvas(rfNodes, rfEdges)
} else if (mode === 'empty') {
// Initialized but no nodes: the user cleared this canvas on purpose — respect
// it and keep it empty instead of re-seeding the demo.
const savedTheme = res.data.viewport?.theme_id
if (savedTheme) setTheme(savedTheme)
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
setFloorMap(null)
loadCanvas([], [])
} else {
// Brand-new, never-saved canvas: seed the demo.
setFloorMap(null)
loadCanvas(demoNodes, demoEdges)
} finally {
// Record provenance so autosave writes back under the design just loaded.
setLoadedDesignId(designId ?? null)
}
setLoadError(false)
setIsNewUser(isNewUserCanvas(mode))
// Record provenance so autosave writes back under the design just loaded.
setLoadedDesignId(designId ?? null)
}, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
// Standalone counterpart of loadCanvasFromApi — reads a design's canvas from
// localStorage, falling back to the demo canvas when it has never been saved.
const loadStandaloneCanvas = useCallback((designId: string) => {
const saved = standaloneStorage.loadCanvas(designId)
if (saved && saved.nodes.length > 0) {
// A stored entry (even with zero nodes) means the user has saved this canvas,
// so treat it as initialized and don't re-seed the demo on top of a canvas
// they deliberately cleared.
const mode = decideCanvasLoad((saved?.nodes.length ?? 0) > 0, saved !== null)
if (mode === 'real' && saved) {
if (saved.theme_id) setTheme(saved.theme_id)
if (saved.custom_style) setCustomStyle(saved.custom_style)
// Floor plans are backend-only; keep the store clear in standalone mode.
setFloorMap(null)
const migrated = migrateClusterHandles(saved.nodes, saved.edges)
loadCanvas(migrated.nodes, migrated.edges)
} else if (mode === 'empty' && saved) {
if (saved.theme_id) setTheme(saved.theme_id)
if (saved.custom_style) setCustomStyle(saved.custom_style)
setFloorMap(null)
loadCanvas([], [])
} else {
setFloorMap(null)
loadCanvas(demoNodes, demoEdges)
}
setLoadError(false)
setIsNewUser(isNewUserCanvas(mode))
// Record provenance so autosave writes back under the design just loaded.
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
@@ -216,7 +312,7 @@ export default function App() {
const targetId = fromUrl ?? activeDesignId ?? designs[0]?.id
if (targetId) {
setActiveDesign(targetId)
loadStandaloneCanvas(targetId)
void loadAnyDesign(targetId)
}
return
}
@@ -228,19 +324,76 @@ export default function App() {
const targetId = fromUrl ?? activeDesignId ?? loadedDesigns[0]?.id
if (targetId) {
setActiveDesign(targetId)
await loadCanvasFromApi(targetId)
await loadAnyDesign(targetId)
}
} catch {
// If API fails (e.g. fresh DB with no designs), fall back to demo data
loadCanvas(demoNodes, demoEdges)
// Backend unreachable/errored — surface it. Do NOT seed the demo: that would
// hide a real outage behind a fake "new account" canvas.
setLoadError(true)
toast.error('Could not reach backend — check the server and retry')
}
}, [setDesigns, setActiveDesign, loadCanvasFromApi, loadStandaloneCanvas, activeDesignId, loadCanvas])
}, [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).
const loadDesignsAndCanvasRef = useRef(loadDesignsAndCanvas)
useEffect(() => { loadDesignsAndCanvasRef.current = loadDesignsAndCanvas }, [loadDesignsAndCanvas])
// Retry a failed load from the error banner.
const handleRetryLoad = useCallback(() => {
setLoadError(false)
void loadDesignsAndCanvasRef.current()
}, [])
// Bridge the Getting Started tour steps to the App's modal controls. The overlay
// calls closeAll() before each step, then the step's action to open the target.
const walkthroughActions = useMemo<WalkthroughActionApi>(() => ({
closeAll: () => {
setScanConfigOpen(false)
setScanHistoryOpen(false)
setTourScanHistoryDemo(false)
setInventoryModalOpen(false)
setTourInventoryDemo(false)
setEditNodeId(null)
setThemeModalOpen(false)
setZigbeeImportOpen(false)
// Clear any tour-driven multi-selection so the DetailPanel closes.
useCanvasStore.setState((s) => ({
nodes: s.nodes.map((n) => (n.selected ? { ...n, selected: false } : n)),
selectedNodeIds: [],
selectedNodeId: null,
}))
},
openScanConfig: () => setScanConfigOpen(true),
openScanHistoryDemo: () => {
setTourScanHistoryDemo(true)
setScanHistoryOpen(true)
},
openInventoryDemo: () => {
setTourInventoryDemo(true)
openInventoryModal(undefined, 'pending')
},
editFirstNode: () => {
const first = useCanvasStore.getState().nodes.find((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
if (first) setEditNodeId(first.id)
},
selectTwoNodes: () => {
const ids = useCanvasStore.getState().nodes
.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
.slice(0, 2)
.map((n) => n.id)
if (ids.length < 2) return
const set = new Set(ids)
useCanvasStore.setState((s) => ({
nodes: s.nodes.map((n) => ({ ...n, selected: set.has(n.id) })),
selectedNodeIds: ids,
selectedNodeId: null,
}))
},
openStyle: () => setThemeModalOpen(true),
openZigbeeImport: () => setZigbeeImportOpen(true),
}), [openInventoryModal])
// Load designs + canvas on auth (or immediately in standalone mode, which has
// no auth gate).
useEffect(() => {
@@ -266,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
@@ -280,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
@@ -291,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) {
@@ -820,12 +978,22 @@ export default function App() {
const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null
const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null
if (!STANDALONE && !isInitialized) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-[#0d1117] text-sm text-muted-foreground">
Loading authentication…
</div>
)
}
if (!STANDALONE && !isAuthenticated) return <LoginPage />
return (
<WalkthroughActionsProvider value={walkthroughActions}>
<TooltipProvider>
<ReactFlowProvider>
<div className="flex h-screen w-screen overflow-hidden bg-[#0d1117]">
{/* data-new-user marks a first-time (demo) canvas — the hook the upcoming
Getting Started walkthrough keys off. */}
<div className="flex h-screen w-screen overflow-hidden bg-[#0d1117]" data-new-user={isNewUser}>
<Sidebar
onAddNode={() => setAddNodeOpen(true)}
onAddGroupRect={() => setAddGroupRectOpen(true)}
@@ -837,9 +1005,24 @@ 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 && (
<div
role="alert"
className="flex items-center justify-between gap-3 bg-[#3d1418] border-b border-[#f85149] px-4 py-2 text-sm text-[#ffa198]"
>
<span>Backend not responding — the canvas could not be loaded.</span>
<button
type="button"
onClick={handleRetryLoad}
className="rounded border border-[#f85149] px-2 py-0.5 text-[#ffa198] hover:bg-[#f85149]/20"
>
Retry
</button>
</div>
)}
<Toolbar
onSave={handleSave}
onAutoLayout={handleAutoLayout}
@@ -855,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>
@@ -943,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')
}}
/>
@@ -954,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')
}}
/>
@@ -965,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')
}}
/>
@@ -975,6 +1169,7 @@ export default function App() {
<ScanHistoryModal
open={scanHistoryOpen}
onClose={() => setScanHistoryOpen(false)}
demoRuns={tourScanHistoryDemo ? DEMO_SCAN_RUNS : undefined}
/>
)}
@@ -1063,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)} />
@@ -1094,11 +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}
<DeviceInventoryModal
open={inventoryModalOpen}
onClose={() => setInventoryModalOpen(false)}
highlightId={inventoryHighlightId}
initialStatus={inventoryModalStatus}
demoDevices={tourInventoryDemo ? DEMO_INVENTORY_DEVICES : undefined}
/>
<ExportModal
@@ -1108,7 +1304,10 @@ export default function App() {
/>
<Toaster theme="dark" position="bottom-right" />
<WalkthroughInvite />
<WalkthroughOverlay />
</ReactFlowProvider>
</TooltipProvider>
</WalkthroughActionsProvider>
)
}
+26 -4
View File
@@ -6,7 +6,7 @@ type Interceptor<T> = {
}
interface MockInstance {
defaults: { baseURL?: string }
defaults: { baseURL?: string; withCredentials?: boolean }
interceptors: {
request: { use: (f: Interceptor<unknown>['fulfilled'], r?: Interceptor<unknown>['rejected']) => void }
response: { use: (f: Interceptor<unknown>['fulfilled'], r?: Interceptor<unknown>['rejected']) => void }
@@ -25,9 +25,9 @@ const instances = hoisted.instances as MockInstance[]
vi.mock('axios', () => {
return {
default: {
create: (cfg: { baseURL?: string }) => {
create: (cfg: { baseURL?: string; withCredentials?: boolean }) => {
const inst: MockInstance = {
defaults: { baseURL: cfg?.baseURL },
defaults: { baseURL: cfg?.baseURL, withCredentials: cfg?.withCredentials },
interceptors: {
request: { use: (f: unknown, r?: unknown) => { inst.__req = { fulfilled: f as never, rejected: r as never } } },
response: { use: (f: unknown, r?: unknown) => { inst.__res = { fulfilled: f as never, rejected: r as never } } },
@@ -54,7 +54,7 @@ describe('api/client', () => {
const [api, publicApi] = instances
beforeEach(() => {
useAuthStore.setState({ token: null, isAuthenticated: false })
useAuthStore.setState({ token: null, csrfToken: null, isAuthenticated: false })
api.get.mockClear()
api.post.mockClear()
api.patch.mockClear()
@@ -66,6 +66,7 @@ describe('api/client', () => {
it('creates two axios instances with /api/v1 baseURL', () => {
expect(instances).toHaveLength(2)
expect(api.defaults.baseURL).toBe('/api/v1')
expect(api.defaults.withCredentials).toBe(true)
expect(publicApi.defaults.baseURL).toBe('/api/v1')
})
@@ -86,6 +87,18 @@ describe('api/client', () => {
expect((out as typeof cfg).headers.Authorization).toBeUndefined()
})
it('adds the in-memory OIDC CSRF token only to unsafe requests', () => {
useAuthStore.setState({ csrfToken: 'csrf-123' })
const post = { method: 'post', headers: {} as Record<string, string> }
const get = { method: 'get', headers: {} as Record<string, string> }
api.__req.fulfilled!(post)
api.__req.fulfilled!(get)
expect(post.headers['X-Homelable-CSRF']).toBe('csrf-123')
expect(get.headers['X-Homelable-CSRF']).toBeUndefined()
})
it('response interceptor passes through 2xx responses', () => {
const r = { status: 200, data: { ok: true } }
expect(api.__res.fulfilled!(r)).toBe(r)
@@ -125,6 +138,15 @@ describe('api/client', () => {
expect(api.post).toHaveBeenCalledWith('/auth/login', { username: 'u', password: 'p' })
})
it('authApi discovers auth publicly and manages the current session', () => {
mod.authApi.config()
expect(publicApi.get).toHaveBeenCalledWith('/auth/config')
mod.authApi.me()
expect(api.get).toHaveBeenCalledWith('/auth/me')
mod.authApi.logout()
expect(api.post).toHaveBeenCalledWith('/auth/logout')
})
it('canvasApi.load GETs /canvas', () => {
mod.canvasApi.load()
expect(api.get).toHaveBeenCalledWith('/canvas', expect.objectContaining({}))
+64 -1
View File
@@ -1,16 +1,22 @@
import axios from 'axios'
import { useAuthStore } from '@/stores/authStore'
import type { AuthMode, AuthUser } from '@/stores/authStore'
export const api = axios.create({
baseURL: '/api/v1',
withCredentials: true,
})
// Unauthenticated axios instance — no JWT, no 401 redirect (used for public endpoints)
const publicApi = axios.create({ baseURL: '/api/v1' })
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().token
const { token, csrfToken } = useAuthStore.getState()
if (token) config.headers.Authorization = `Bearer ${token}`
const method = config.method?.toUpperCase() ?? 'GET'
if (csrfToken && !['GET', 'HEAD', 'OPTIONS'].includes(method)) {
config.headers['X-Homelable-CSRF'] = csrfToken
}
return config
})
@@ -23,8 +29,11 @@ api.interceptors.response.use(
)
export const authApi = {
config: () => publicApi.get<{ mode: AuthMode; oidc_login_url: string | null }>('/auth/config'),
login: (username: string, password: string) =>
api.post<{ access_token: string }>('/auth/login', { username, password }),
me: () => api.get<AuthUser>('/auth/me'),
logout: () => api.post('/auth/logout'),
}
export const canvasApi = {
@@ -52,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}`),
@@ -97,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
@@ -211,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
@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '@/stores/authStore'
vi.mock('@/api/client', () => ({
authApi: {
config: vi.fn(),
me: vi.fn(),
},
}))
import { authApi } from '@/api/client'
import { bootstrapAuth } from '../bootstrap'
describe('bootstrapAuth', () => {
beforeEach(() => {
sessionStorage.clear()
useAuthStore.setState({
token: null,
isAuthenticated: false,
isInitialized: false,
authMode: null,
authMethod: null,
oidcLoginUrl: null,
displayName: null,
csrfToken: null,
})
vi.mocked(authApi.config).mockReset()
vi.mocked(authApi.me).mockReset()
})
it('restores an OIDC cookie session without storing an IdP token', async () => {
vi.mocked(authApi.config).mockResolvedValue({
data: { mode: 'oidc', oidc_login_url: '/api/v1/auth/oidc/login' },
} as never)
vi.mocked(authApi.me).mockResolvedValue({
data: {
subject: 'user-1',
display_name: 'Alice',
auth_method: 'oidc',
issuer: 'https://id.example/application/o/homelable/',
csrf_token: 'csrf-123',
},
} as never)
await bootstrapAuth()
const state = useAuthStore.getState()
expect(authApi.me).toHaveBeenCalledOnce()
expect(state.isAuthenticated).toBe(true)
expect(state.authMethod).toBe('oidc')
expect(state.token).toBeNull()
expect(state.csrfToken).toBe('csrf-123')
})
it('does not probe /auth/me for local mode without a persisted token', async () => {
vi.mocked(authApi.config).mockResolvedValue({
data: { mode: 'local', oidc_login_url: null },
} as never)
await bootstrapAuth()
expect(authApi.me).not.toHaveBeenCalled()
expect(useAuthStore.getState()).toMatchObject({
authMode: 'local',
isAuthenticated: false,
isInitialized: true,
})
})
it('validates and retains a persisted local bearer token', async () => {
useAuthStore.setState({ token: 'local-token' })
vi.mocked(authApi.config).mockResolvedValue({
data: { mode: 'local', oidc_login_url: null },
} as never)
vi.mocked(authApi.me).mockResolvedValue({
data: {
subject: 'admin',
display_name: 'admin',
auth_method: 'local',
issuer: null,
csrf_token: null,
},
} as never)
await bootstrapAuth()
expect(useAuthStore.getState()).toMatchObject({
token: 'local-token',
isAuthenticated: true,
authMethod: 'local',
})
})
it('fails closed when the backend rejects the session', async () => {
useAuthStore.setState({ token: 'expired-token', isAuthenticated: true })
vi.mocked(authApi.config).mockResolvedValue({
data: { mode: 'local', oidc_login_url: null },
} as never)
vi.mocked(authApi.me).mockRejectedValue({ response: { status: 401 } })
await bootstrapAuth()
expect(useAuthStore.getState()).toMatchObject({
token: null,
isAuthenticated: false,
isInitialized: true,
})
})
it('leaves the auth mode unknown when discovery is unavailable', async () => {
vi.mocked(authApi.config).mockRejectedValue(new Error('network down'))
await bootstrapAuth()
expect(useAuthStore.getState()).toMatchObject({
authMode: null,
isAuthenticated: false,
isInitialized: true,
})
})
})
+33
View File
@@ -0,0 +1,33 @@
import { authApi } from '@/api/client'
import { useAuthStore } from '@/stores/authStore'
/**
* Discover the configured authentication mode and restore a valid session.
* OIDC sessions are represented only by the backend's HttpOnly cookie; local
* sessions retain their existing bearer token until /auth/me validates it.
*/
export async function bootstrapAuth(): Promise<void> {
const store = useAuthStore.getState()
try {
const { data: config } = await authApi.config()
store.setAuthConfig(config.mode, config.oidc_login_url)
const token = useAuthStore.getState().token
if (config.mode === 'oidc' || token) {
try {
const { data: user } = await authApi.me()
useAuthStore.getState().authenticate(user)
} catch {
// The response interceptor clears invalid credentials on 401. Also
// clear them for malformed/unreachable session checks, failing closed.
useAuthStore.getState().logout()
}
}
} catch {
// Keep authMode unknown when discovery is unreachable. The login screen
// reports that state without guessing which authentication mode is active.
} finally {
useAuthStore.getState().finishInitialization()
}
}
+83 -51
View File
@@ -1,17 +1,19 @@
import { useState } from 'react'
import { Network, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Loader2 } from 'lucide-react'
import { Button, buttonVariants } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Logo } from '@/components/ui/Logo'
import { authApi } from '@/api/client'
import { useAuthStore } from '@/stores/authStore'
import { cn } from '@/lib/utils'
export function LoginPage() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const login = useAuthStore((s) => s.login)
const { authMode, oidcLoginUrl, login } = useAuthStore()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -42,62 +44,92 @@ export function LoginPage() {
<div className="relative w-full max-w-sm px-4">
{/* Logo */}
<div className="flex flex-col items-center gap-3 mb-8">
<div className="flex items-center justify-center w-14 h-14 rounded-xl bg-[#00d4ff]/10 border border-[#00d4ff]/20">
<Network size={28} className="text-[#00d4ff]" />
</div>
<Logo size={64} showText={false} />
<div className="text-center">
<h1 className="text-xl font-semibold text-foreground tracking-wide">Homelable</h1>
<h1 className="text-xl font-semibold tracking-tight">
<span className="text-[#e6edf3]">Home</span>
<span className="text-[#00d4ff]">lable</span>
</h1>
<p className="text-xs text-muted-foreground mt-0.5">HomeLab Visualizer</p>
</div>
</div>
{/* Form */}
<form
onSubmit={handleSubmit}
className="flex flex-col gap-4 bg-[#161b22] border border-[#30363d] rounded-xl p-6"
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="username" className="text-xs text-muted-foreground">Username</Label>
<Input
id="username"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="bg-[#21262d] border-[#30363d] focus-visible:ring-[#00d4ff]/50 text-sm"
placeholder="admin"
required
/>
{authMode === 'local' && (
<>
<form
onSubmit={handleSubmit}
className="flex flex-col gap-4 bg-[#161b22] border border-[#30363d] rounded-xl p-6"
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="username" className="text-xs text-muted-foreground">Username</Label>
<Input
id="username"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="bg-[#21262d] border-[#30363d] focus-visible:ring-[#00d4ff]/50 text-sm"
placeholder="admin"
required
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="password" className="text-xs text-muted-foreground">Password</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="bg-[#21262d] border-[#30363d] focus-visible:ring-[#00d4ff]/50 text-sm"
required
/>
</div>
{error && (
<p className="text-xs text-[#f85149] text-center">{error}</p>
)}
<Button
type="submit"
disabled={loading}
className="mt-1 bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 font-medium"
>
{loading ? <Loader2 size={15} className="animate-spin" /> : 'Sign in'}
</Button>
</form>
<p className="text-center text-[10px] text-muted-foreground/40 mt-4">
Credentials configured in <span className="font-mono">.env</span>
</p>
</>
)}
{authMode === 'oidc' && (
<div className="flex flex-col gap-4 bg-[#161b22] border border-[#30363d] rounded-xl p-6 text-center">
<p className="text-sm text-muted-foreground">
Continue through the configured OpenID Connect provider.
</p>
<a
href={oidcLoginUrl ?? '/api/v1/auth/oidc/login'}
className={cn(
buttonVariants(),
'bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 font-medium',
)}
>
Sign in with OpenID Connect
</a>
</div>
)}
<div className="flex flex-col gap-1.5">
<Label htmlFor="password" className="text-xs text-muted-foreground">Password</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="bg-[#21262d] border-[#30363d] focus-visible:ring-[#00d4ff]/50 text-sm"
required
/>
{authMode === null && (
<div className="flex flex-col gap-4 bg-[#161b22] border border-[#30363d] rounded-xl p-6 text-center">
<p className="text-sm text-[#f85149]">Could not load authentication configuration.</p>
<Button type="button" variant="outline" onClick={() => window.location.reload()}>
Retry
</Button>
</div>
{error && (
<p className="text-xs text-[#f85149] text-center">{error}</p>
)}
<Button
type="submit"
disabled={loading}
className="mt-1 bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 font-medium"
>
{loading ? <Loader2 size={15} className="animate-spin" /> : 'Sign in'}
</Button>
</form>
<p className="text-center text-[10px] text-muted-foreground/40 mt-4">
Credentials configured in <span className="font-mono">.env</span>
</p>
)}
</div>
</div>
)
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { act, render, screen, fireEvent, waitFor } from '@testing-library/react'
import { LoginPage } from '../LoginPage'
import { useAuthStore } from '@/stores/authStore'
@@ -13,7 +13,12 @@ import { authApi } from '@/api/client'
describe('LoginPage', () => {
beforeEach(() => {
useAuthStore.setState({ token: null, isAuthenticated: false })
useAuthStore.setState({
token: null,
isAuthenticated: false,
authMode: 'local',
oidcLoginUrl: null,
})
vi.mocked(authApi.login).mockReset()
})
@@ -30,6 +35,41 @@ describe('LoginPage', () => {
expect(screen.getByRole('button', { name: /sign in/i })).toBeDefined()
})
it('renders the official Homelable logo mark — not a generic lucide icon', () => {
render(<LoginPage />)
const svg = screen.getByTestId('logo-mark')
// The official mark from docs/logo/icon.svg, not lucide's Network glyph
expect(svg.querySelector('path')?.getAttribute('d'))
.toBe('M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z')
expect(svg.classList.contains('lucide')).toBe(false)
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Homelable')
expect(screen.getByText('HomeLab Visualizer')).toBeDefined()
})
it('renders only the provider redirect in OIDC mode', () => {
useAuthStore.setState({
authMode: 'oidc',
oidcLoginUrl: '/api/v1/auth/oidc/login',
})
render(<LoginPage />)
expect(screen.queryByLabelText('Username')).toBeNull()
expect(screen.queryByLabelText('Password')).toBeNull()
expect(screen.getByRole('link', { name: 'Sign in with OpenID Connect' }))
.toHaveAttribute('href', '/api/v1/auth/oidc/login')
})
it('does not guess an auth mode when discovery failed', () => {
useAuthStore.setState({ authMode: null })
render(<LoginPage />)
expect(screen.getByText('Could not load authentication configuration.')).toBeDefined()
expect(screen.queryByLabelText('Username')).toBeNull()
expect(screen.getByRole('button', { name: 'Retry' })).toBeDefined()
})
// ── Security checks ──────────────────────────────────────────────────────
it('password field type is "password" — not rendered as plain text', () => {
@@ -102,7 +142,9 @@ describe('LoginPage', () => {
await waitFor(() => {
expect((screen.getByRole('button', { name: '' }) as HTMLButtonElement).disabled).toBe(true)
})
resolve({ data: { access_token: 'tok' } })
await act(async () => {
resolve({ data: { access_token: 'tok' } })
})
})
it('calls authApi.login with credentials via POST body (not URL params)', async () => {
@@ -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',
@@ -44,9 +44,11 @@ vi.mock('@/utils/nodeColors', () => ({
resolveNodeColors: () => ({ background: '#161b22', border: '#30363d', icon: '#00d4ff' }),
}))
vi.mock('@/utils/nodeIcons', () => ({
// Partial mock: node-type icon resolution is stubbed, but the brand/lucide
// resolver stays real so service icons render as they do in the app.
vi.mock('@/utils/nodeIcons', async (importActual) => ({
...(await importActual<typeof import('@/utils/nodeIcons')>()),
resolveNodeIcon: (_typeIcon: unknown) => _typeIcon,
isBrandIconKey: (k: string | undefined) => !!k && k.startsWith('brand:'),
}))
vi.mock('@/utils/maskIp', () => ({
@@ -252,6 +254,27 @@ describe('BaseNode — services visibility toggle', () => {
expect(screen.queryByRole('link', { name: /ssh/i })).toBeNull()
})
it('renders the brand icon of a service that has one', () => {
renderBaseNode({
ip: '192.168.1.10',
custom_colors: { show_services: true },
services: [{ service_name: 'plex', port: 32400, protocol: 'tcp', icon: 'brand:plex' }],
})
const img = screen.getByAltText('plex') as HTMLImageElement
expect(img.src).toContain('dashboard-icons/svg/plex.svg')
})
it('renders no service icon when the service has none', () => {
renderBaseNode({
ip: '192.168.1.10',
custom_colors: { show_services: true },
services: [{ service_name: 'ssh', port: 22, protocol: 'tcp' }],
})
expect(screen.queryByRole('img')).toBeNull()
})
})
describe('BaseNode — legacy hardware fallback', () => {
@@ -5,6 +5,7 @@ import type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors'
import { resolveNodeIcon, isBrandIconKey } from '@/utils/nodeIcons'
import { NodeIcon } from '@/components/ui/NodeIcon'
import { ServiceIcon } from '@/components/ui/ServiceIcon'
import { resolvePropertyIcon } from '@/utils/propertyIcons'
import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
@@ -172,13 +173,16 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
>
<div className="flex items-center justify-between gap-2 w-full min-w-0">
{/* LEFT: service name */}
<span
className="font-medium truncate"
style={{ minWidth: 0, color: svcOffline ? '#f85149' : undefined }}
title={svc.service_name}
>
{svc.service_name}
{/* LEFT: icon + service name */}
<span className="flex items-center gap-1 min-w-0">
<ServiceIcon iconKey={svc.icon} size={11} />
<span
className="font-medium truncate"
style={{ minWidth: 0, color: svcOffline ? '#f85149' : undefined }}
title={svc.service_name}
>
{svc.service_name}
</span>
</span>
{/* RIGHT: path + port */}
@@ -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,13 +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?: 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> = {
@@ -80,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,
)
@@ -89,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'
}
@@ -113,10 +124,10 @@ function injectAutoEdges(edges: AutoEdge[] | undefined) {
}))
}
export function PendingDevicesModal({ open, onClose, highlightId, initialStatus = 'pending' }: 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('')
@@ -127,15 +138,20 @@ 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.
if (demoDevices) { setDevices(statusFilter === 'pending' ? demoDevices : []); return }
setLoading(true)
try {
const res = statusFilter === 'pending' ? await scanApi.pending() : await scanApi.hidden()
@@ -145,7 +161,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
} finally {
setLoading(false)
}
}, [statusFilter])
}, [statusFilter, demoDevices])
useEffect(() => { if (open) load() }, [open, load])
useEffect(() => { if (open && scanEventTs > 0) load() }, [scanEventTs, open, load])
@@ -158,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>()
@@ -175,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,
@@ -184,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
@@ -199,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))
@@ -271,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)
@@ -325,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)
@@ -334,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))
@@ -345,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))
@@ -356,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)
@@ -442,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) {
@@ -457,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 (
<>
@@ -469,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>
@@ -478,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"
@@ -547,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}
@@ -591,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'}`}
@@ -598,6 +655,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
>
{selectMode ? 'Exit select' : 'Select mode'}
</button>
)}
</div>
{/* Body */}
@@ -734,7 +792,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
</DialogContent>
</Dialog>
<PendingDeviceModal
<InventoryDeviceModal
device={selected}
onClose={() => setSelected(null)}
onApprove={handleApprove}
@@ -746,7 +804,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
}
interface DeviceCardProps {
device: PendingDevice
device: InventoryEntry
selected: boolean
selectMode: boolean
highlighted: boolean
@@ -0,0 +1,93 @@
import { createElement, useState } from 'react'
import modalStyles from './modal-interactive.module.css'
import { Input } from '@/components/ui/input'
import { ICON_REGISTRY, ICON_CATEGORIES, isBrandIconKey } from '@/utils/nodeIcons'
import { BrandIconPicker } from './BrandIconPicker'
interface IconPickerPanelProps {
/** Current icon key — a lucide registry key or `brand:<slug>`. */
value?: string
/** Called with the new key, or `undefined` when the current generic icon is toggled off. */
onSelect: (key: string | undefined) => void
/** Focus the generic-icon search field on mount. */
autoFocus?: boolean
}
/**
* Shared Generic/Brand icon picker body. Used by NodeModal and ServiceModal so
* both surfaces offer the same lucide registry + dashboard-icons brand catalog.
*/
export function IconPickerPanel({ value, onSelect, autoFocus }: IconPickerPanelProps) {
const [search, setSearch] = useState('')
const [tab, setTab] = useState<'generic' | 'brand'>(isBrandIconKey(value) ? 'brand' : 'generic')
const tabClass = (active: boolean) =>
`text-[11px] px-2 py-1 rounded transition-colors cursor-pointer ${
active ? 'bg-[#21262d] text-foreground border border-[#30363d]' : 'text-muted-foreground hover:text-foreground'
}`
return (
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d]">
<div className="flex gap-1 mb-1" role="tablist" aria-label="Icon source">
<button type="button" role="tab" aria-selected={tab === 'generic'} onClick={() => setTab('generic')} className={tabClass(tab === 'generic')}>
Generic
</button>
<button type="button" role="tab" aria-selected={tab === 'brand'} onClick={() => setTab('brand')} className={tabClass(tab === 'brand')}>
Brand
</button>
</div>
{tab === 'brand' ? (
<BrandIconPicker value={value} onSelect={onSelect} />
) : (
<>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search icons…"
className={`bg-[#21262d] border-[#30363d] text-xs h-7 ${modalStyles['modal-radius']}`}
autoFocus={autoFocus}
/>
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
{ICON_CATEGORIES.map((cat) => {
const q = search.toLowerCase()
const entries = ICON_REGISTRY.filter(
(e) => e.category === cat && (search === '' || e.label.toLowerCase().includes(q) || e.key.includes(q))
)
if (entries.length === 0) return null
return (
<div key={cat}>
<p className="text-[9px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1">{cat}</p>
<div className="grid grid-cols-7 gap-1">
{entries.map((entry) => {
const isSelected = value === entry.key
return (
<button
key={entry.key}
type="button"
title={entry.label}
onClick={() => onSelect(isSelected ? undefined : entry.key)}
className={`flex items-center justify-center w-7 h-7 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
aria-label={`Select icon ${entry.label}`}
style={{
background: isSelected ? '#00d4ff22' : 'transparent',
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
color: isSelected ? '#00d4ff' : '#8b949e',
}}
onMouseEnter={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = '#21262d' }}
onMouseLeave={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
{createElement(entry.icon, { size: 13 })}
</button>
)
})}
</div>
</div>
)
})}
</div>
</>
)}
</div>
)
}
@@ -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"
+6 -81
View File
@@ -10,8 +10,8 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSepa
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod, type NodeTypeStyle } from '@/types'
import { useThemeStore } from '@/stores/themeStore'
import { resolveNodeColors } from '@/utils/nodeColors'
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
import { BrandIconPicker } from './BrandIconPicker'
import { ICON_REGISTRY, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
import { IconPickerPanel } from './IconPickerPanel'
import { MAX_HANDLES, clampHandles, sideDefault, handleCountField, type Side } from '@/utils/handleUtils'
import { getValidParentTypes } from '@/utils/virtualEdgeParent'
@@ -142,9 +142,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
const merged = { ...DEFAULT_DATA, ...initial }
if (MESH_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
const [form, setForm] = useState<Partial<NodeData>>(merged)
const [iconSearch, setIconSearch] = useState('')
const [iconPickerOpen, setIconPickerOpen] = useState(false)
const [iconTab, setIconTab] = useState<'generic' | 'brand'>(isBrandIconKey(initial?.custom_icon) ? 'brand' : 'generic')
const [labelError, setLabelError] = useState(false)
const resolvedNodeColors = resolveNodeColors({ type: form.type ?? 'generic', custom_colors: form.custom_colors })
const showServicesEnabled = form.custom_colors?.show_services === true
@@ -298,85 +296,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
{/* Inline icon picker - full width, shown below the type+icon row */}
{iconPickerOpen && (
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d] col-span-2">
<div className="flex gap-1 mb-1" role="tablist" aria-label="Icon source">
<button
type="button"
role="tab"
aria-selected={iconTab === 'generic'}
onClick={() => setIconTab('generic')}
className={`text-[11px] px-2 py-1 rounded transition-colors cursor-pointer ${
iconTab === 'generic' ? 'bg-[#21262d] text-foreground border border-[#30363d]' : 'text-muted-foreground hover:text-foreground'
}`}
>
Generic
</button>
<button
type="button"
role="tab"
aria-selected={iconTab === 'brand'}
onClick={() => setIconTab('brand')}
className={`text-[11px] px-2 py-1 rounded transition-colors cursor-pointer ${
iconTab === 'brand' ? 'bg-[#21262d] text-foreground border border-[#30363d]' : 'text-muted-foreground hover:text-foreground'
}`}
>
Brand
</button>
</div>
{iconTab === 'brand' ? (
<BrandIconPicker
value={form.custom_icon}
onSelect={(key) => { set('custom_icon', key); setIconPickerOpen(false) }}
/>
) : (
<>
<Input
value={iconSearch}
onChange={(e) => setIconSearch(e.target.value)}
placeholder="Search icons…"
className={`bg-[#21262d] border-[#30363d] text-xs h-7 ${modalStyles['modal-radius']}`}
<div className="col-span-2">
<IconPickerPanel
value={form.custom_icon}
onSelect={(key) => { set('custom_icon', key); setIconPickerOpen(false) }}
autoFocus
/>
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
{ICON_CATEGORIES.map((cat) => {
const entries = ICON_REGISTRY.filter(
(e) => e.category === cat &&
(iconSearch === '' || e.label.toLowerCase().includes(iconSearch.toLowerCase()) || e.key.includes(iconSearch.toLowerCase()))
)
if (entries.length === 0) return null
return (
<div key={cat}>
<p className="text-[9px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1">{cat}</p>
<div className="grid grid-cols-7 gap-1">
{entries.map((entry) => {
const isSelected = form.custom_icon === entry.key
return (
<button
key={entry.key}
type="button"
title={entry.label}
onClick={() => { set('custom_icon', isSelected ? undefined : entry.key); setIconPickerOpen(false) }}
className={`flex items-center justify-center w-7 h-7 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
aria-label={`Select icon ${entry.label}`}
style={{
background: isSelected ? '#00d4ff22' : 'transparent',
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
color: isSelected ? '#00d4ff' : '#8b949e',
}}
onMouseEnter={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = '#21262d' }}
onMouseLeave={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
{createElement(entry.icon, { size: 13 })}
</button>
)
})}
</div>
</div>
)
})}
</div>
</>
)}
</div>
)}
@@ -20,6 +20,8 @@ export interface ScanRun {
interface ScanHistoryModalProps {
open: boolean
onClose: () => void
/** Getting Started tour: render these canned runs and skip the backend fetch. */
demoRuns?: ScanRun[]
}
type KindFilter = 'all' | 'ip' | 'zigbee' | 'zwave' | 'proxmox'
@@ -85,7 +87,7 @@ function runDuration(r: ScanRun, now: number): string {
return formatDuration(end - start)
}
export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
export function ScanHistoryModal({ open, onClose, demoRuns }: ScanHistoryModalProps) {
const [runs, setRuns] = useState<ScanRun[]>([])
const [loading, setLoading] = useState(false)
const [stopping, setStopping] = useState<string | null>(null)
@@ -95,6 +97,8 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
const prevRunsRef = useRef<ScanRun[]>([])
const load = useCallback(async () => {
// Tour/demo mode: show injected runs, never touch the backend.
if (demoRuns) { setRuns(demoRuns); prevRunsRef.current = demoRuns; return }
setLoading(true)
try {
const res = await scanApi.runs()
@@ -127,7 +131,7 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
} finally {
setLoading(false)
}
}, [])
}, [demoRuns])
// Load when opened; reset prior-state tracker so we don't replay old transitions
useEffect(() => {
@@ -155,6 +159,7 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
}, [open, runs])
const handleStop = async (runId: string) => {
if (demoRuns) return // tour mode — no backend
setStopping(runId)
try {
await scanApi.stop(runId)
+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>
@@ -0,0 +1,191 @@
import { createElement, useState } from 'react'
import { ChevronDown, RotateCcw, Circle } from 'lucide-react'
import modalStyles from './modal-interactive.module.css'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ICON_REGISTRY, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
import { IconPickerPanel } from './IconPickerPanel'
import { EMPTY_SERVICE_FORM, type ServiceFormData, type ServiceSubmitData } from '@/utils/serviceForm'
interface ServiceModalProps {
open: boolean
onClose: () => void
onSubmit: (data: ServiceSubmitData) => void
initial?: ServiceFormData
title?: string
confirmLabel?: string
}
export function ServiceModal({ open, onClose, onSubmit, initial, title = 'Add Service', confirmLabel = 'Add' }: ServiceModalProps) {
const [form, setForm] = useState<ServiceFormData>(initial ?? EMPTY_SERVICE_FORM)
const [iconPickerOpen, setIconPickerOpen] = useState(false)
const [nameError, setNameError] = useState(false)
const set = <K extends keyof ServiceFormData>(key: K, value: ServiceFormData[K]) =>
setForm((f) => ({ ...f, [key]: value }))
const setPort = (value: string) => set('port', value.replace(/\D/g, '').slice(0, 5))
const clampPort = () => {
if (!form.port) return
const parsed = Number.parseInt(form.port, 10)
set('port', Number.isFinite(parsed) ? String(Math.max(1, Math.min(65535, parsed))) : '')
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const name = form.service_name.trim()
if (!name) {
setNameError(true)
return
}
const trimmedPort = form.port.trim()
const port = trimmedPort === '' ? undefined : Number.parseInt(trimmedPort, 10)
if (port != null && (Number.isNaN(port) || port < 1 || port > 65535)) return
const path = form.path.trim()
onSubmit({
service_name: name,
protocol: form.protocol,
port,
path: path || undefined,
icon: form.icon,
})
onClose()
}
const iconPreview = () => {
if (isBrandIconKey(form.icon)) {
const slug = brandIconSlug(form.icon!)
return (
<>
<img src={brandIconUrl(slug)} alt={slug} width={13} height={13} className="shrink-0" style={{ width: 13, height: 13, objectFit: 'contain' }} />
<span className="text-foreground truncate">{slug}</span>
</>
)
}
const entry = ICON_REGISTRY.find((e) => e.key === form.icon)
if (entry) {
return (
<>
{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff] shrink-0' })}
<span className="text-foreground truncate">{entry.label}</span>
</>
)
}
// A key we can't resolve (e.g. a legacy scanner signature name) is still
// shown verbatim so saving doesn't look like it silently dropped it.
return (
<>
{createElement(Circle, { size: 13, className: 'text-muted-foreground shrink-0' })}
<span className="text-muted-foreground truncate">{form.icon ?? 'None'}</span>
</>
)
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-sm">
<DialogHeader>
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
{/* Name */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Name *</Label>
<Input
value={form.service_name}
onChange={(e) => { set('service_name', e.target.value); if (nameError) setNameError(false) }}
placeholder="Service name"
autoFocus
className={`bg-[#21262d] text-sm h-8 ${nameError ? 'border-[#f85149]' : 'border-[#30363d]'}`}
/>
{nameError && <span className="text-[10px] text-[#f85149]">Name is required</span>}
</div>
{/* Port + protocol */}
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Port</Label>
<Input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.port}
onChange={(e) => setPort(e.target.value)}
onBlur={clampPort}
placeholder="Port"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Protocol</Label>
<select
value={form.protocol}
aria-label="Protocol"
onChange={(e) => set('protocol', e.target.value as 'tcp' | 'udp')}
className={`bg-[#21262d] border border-[#30363d] text-sm h-8 px-2 text-foreground ${modalStyles['modal-radius']}`}
>
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
</div>
</div>
{/* Path */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Path</Label>
<Input
value={form.path}
onChange={(e) => set('path', e.target.value)}
placeholder="Path (/admin)"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
</div>
{/* Icon */}
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Icon</Label>
{form.icon && (
<button
type="button"
onClick={() => { set('icon', undefined); setIconPickerOpen(false) }}
className="flex items-center gap-1 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
>
<RotateCcw size={10} /> Reset
</button>
)}
</div>
<button
type="button"
onClick={() => setIconPickerOpen((o) => !o)}
className={`flex items-center justify-between gap-2 h-8 px-3 bg-[#21262d] border border-[#30363d] text-sm transition-colors w-full cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`}
aria-label="Icon picker trigger"
>
<span className="flex items-center gap-2 min-w-0">{iconPreview()}</span>
<ChevronDown size={12} className="text-muted-foreground shrink-0" style={{ transform: iconPickerOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} />
</button>
{iconPickerOpen && (
<IconPickerPanel
value={form.icon}
onSelect={(key) => { set('icon', key); setIconPickerOpen(false) }}
/>
)}
</div>
<div className="flex justify-end gap-2 pt-1">
<Button type="button" variant="ghost" size="sm" className={`cursor-pointer ${modalStyles['modal-cancel-hover']}`} onClick={onClose}>
Cancel
</Button>
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer">
{confirmLabel}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}
@@ -11,6 +11,7 @@ import {
type ZwaveConfigData,
} from '@/api/client'
import { useCanvasStore } from '@/stores/canvasStore'
import { useWalkthroughStore } from '@/stores/walkthroughStore'
import { toast } from 'sonner'
import {
type AlignmentSettings,
@@ -401,6 +402,18 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
Saves silently after this many seconds with no changes. Manual Ctrl+S still works.
</p>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-foreground">Getting started tour</span>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => { useWalkthroughStore.getState().start(); onClose() }}
>
Restart walkthrough
</Button>
</div>
</div>
</div>
@@ -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()
})
})
})
@@ -0,0 +1,51 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { IconPickerPanel } from '../IconPickerPanel'
describe('IconPickerPanel', () => {
it('opens on the Generic tab by default', () => {
render(<IconPickerPanel onSelect={vi.fn()} />)
expect(screen.getByRole('tab', { name: 'Generic' }).getAttribute('aria-selected')).toBe('true')
})
it('opens on the Brand tab when the current value is a brand key', () => {
render(<IconPickerPanel value="brand:plex" onSelect={vi.fn()} />)
expect(screen.getByRole('tab', { name: 'Brand' }).getAttribute('aria-selected')).toBe('true')
})
it('emits the lucide key when a generic icon is picked', () => {
const onSelect = vi.fn()
render(<IconPickerPanel onSelect={onSelect} />)
fireEvent.click(screen.getByLabelText('Select icon Database (SQL/NoSQL)'))
expect(onSelect).toHaveBeenCalledWith('database')
})
it('emits undefined when the already-selected generic icon is clicked again', () => {
const onSelect = vi.fn()
render(<IconPickerPanel value="database" onSelect={onSelect} />)
fireEvent.click(screen.getByLabelText('Select icon Database (SQL/NoSQL)'))
expect(onSelect).toHaveBeenCalledWith(undefined)
})
it('filters the generic grid by search text', () => {
render(<IconPickerPanel onSelect={vi.fn()} />)
fireEvent.change(screen.getByPlaceholderText('Search icons…'), { target: { value: 'grafana' } })
expect(screen.getByLabelText('Select icon Grafana / Kibana')).toBeDefined()
expect(screen.queryByLabelText('Select icon Database (SQL/NoSQL)')).toBeNull()
})
it('emits a prefixed key when a brand icon is picked', () => {
const onSelect = vi.fn()
render(<IconPickerPanel onSelect={onSelect} />)
fireEvent.click(screen.getByRole('tab', { name: 'Brand' }))
fireEvent.change(screen.getByLabelText('Brand icon search'), { target: { value: 'plex' } })
fireEvent.click(screen.getByRole('button', { name: 'plex' }))
expect(onSelect).toHaveBeenCalledWith('brand:plex')
})
it('keeps the generic search box out of the Brand tab', () => {
render(<IconPickerPanel onSelect={vi.fn()} />)
fireEvent.click(screen.getByRole('tab', { name: 'Brand' }))
expect(screen.queryByPlaceholderText('Search icons…')).toBeNull()
})
})
@@ -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()
})
@@ -0,0 +1,182 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { ServiceModal } from '../ServiceModal'
import { serviceToForm } from '@/utils/serviceForm'
function setup(props: Partial<React.ComponentProps<typeof ServiceModal>> = {}) {
const onSubmit = vi.fn()
const onClose = vi.fn()
render(<ServiceModal open onClose={onClose} onSubmit={onSubmit} {...props} />)
return { onSubmit, onClose }
}
const submit = (label = 'Add') =>
fireEvent.click(screen.getByRole('button', { name: label }))
describe('ServiceModal', () => {
describe('form submission', () => {
it('submits name, port, protocol and path', () => {
const { onSubmit, onClose } = setup()
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } })
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } })
fireEvent.change(screen.getByLabelText('Protocol'), { target: { value: 'udp' } })
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '/admin' } })
submit()
expect(onSubmit).toHaveBeenCalledWith({
service_name: 'nginx',
protocol: 'udp',
port: 80,
path: '/admin',
icon: undefined,
})
expect(onClose).toHaveBeenCalledOnce()
})
it('submits without a port', () => {
const { onSubmit } = setup()
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'health' } })
submit()
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ service_name: 'health', port: undefined, path: undefined }))
})
it('trims whitespace off the name and path', () => {
const { onSubmit } = setup()
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: ' nginx ' } })
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: ' /admin ' } })
submit()
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ service_name: 'nginx', path: '/admin' }))
})
it('blocks submission and flags the field when the name is blank', () => {
const { onSubmit, onClose } = setup()
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: ' ' } })
submit()
expect(onSubmit).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
expect(screen.getByText('Name is required')).toBeDefined()
})
it('clears the name error once the user types', () => {
setup()
submit()
expect(screen.getByText('Name is required')).toBeDefined()
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'n' } })
expect(screen.queryByText('Name is required')).toBeNull()
})
it('strips non-digits from the port field', () => {
setup()
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '8a0b80' } })
expect((screen.getByPlaceholderText('Port') as HTMLInputElement).value).toBe('8080')
})
it('clamps an out-of-range port on blur', () => {
setup()
const port = screen.getByPlaceholderText('Port')
fireEvent.change(port, { target: { value: '99999' } })
fireEvent.blur(port)
expect((port as HTMLInputElement).value).toBe('65535')
})
it('calls onClose without submitting on Cancel', () => {
const { onSubmit, onClose } = setup()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onSubmit).not.toHaveBeenCalled()
expect(onClose).toHaveBeenCalledOnce()
})
})
describe('edit mode', () => {
const initial = serviceToForm({ port: 80, protocol: 'tcp', service_name: 'nginx', path: '/admin', icon: 'brand:nginx' })
it('prefills the form from an existing service', () => {
setup({ initial, title: 'Edit Service', confirmLabel: 'Save' })
expect((screen.getByPlaceholderText('Service name') as HTMLInputElement).value).toBe('nginx')
expect((screen.getByPlaceholderText('Port') as HTMLInputElement).value).toBe('80')
expect((screen.getByPlaceholderText('Path (/admin)') as HTMLInputElement).value).toBe('/admin')
expect(screen.getByText('Edit Service')).toBeDefined()
})
it('keeps the existing icon when nothing else changes', () => {
const { onSubmit } = setup({ initial, confirmLabel: 'Save' })
submit('Save')
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ icon: 'brand:nginx' }))
})
it('clears a port that the user emptied', () => {
const { onSubmit } = setup({ initial, confirmLabel: 'Save' })
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '' } })
submit('Save')
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ port: undefined }))
})
it('clears a path that the user emptied', () => {
const { onSubmit } = setup({ initial, confirmLabel: 'Save' })
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '' } })
submit('Save')
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ path: undefined }))
})
})
describe('icon picker', () => {
it('shows "None" until an icon is picked', () => {
setup()
expect(screen.getByLabelText('Icon picker trigger').textContent).toContain('None')
})
it('shows an unresolvable legacy key verbatim rather than "None"', () => {
setup({ initial: serviceToForm({ protocol: 'tcp', service_name: 'grafana', icon: 'bar-chart-2' }) })
expect(screen.getByLabelText('Icon picker trigger').textContent).toContain('bar-chart-2')
})
it('keeps an unresolvable legacy key on save instead of dropping it', () => {
const { onSubmit } = setup({ initial: serviceToForm({ protocol: 'tcp', service_name: 'grafana', icon: 'bar-chart-2' }), confirmLabel: 'Save' })
submit('Save')
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ icon: 'bar-chart-2' }))
})
it('selects a generic lucide icon', () => {
const { onSubmit } = setup()
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'db' } })
fireEvent.click(screen.getByLabelText('Icon picker trigger'))
fireEvent.click(screen.getByLabelText('Select icon Database (SQL/NoSQL)'))
submit()
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ icon: 'database' }))
})
it('selects a brand icon from the Brand tab', () => {
const { onSubmit } = setup()
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'plex' } })
fireEvent.click(screen.getByLabelText('Icon picker trigger'))
fireEvent.click(screen.getByRole('tab', { name: 'Brand' }))
fireEvent.change(screen.getByLabelText('Brand icon search'), { target: { value: 'plex' } })
fireEvent.click(screen.getByRole('button', { name: 'plex' }))
submit()
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ icon: 'brand:plex' }))
})
it('opens on the Brand tab when the service already carries a brand icon', () => {
setup({ initial: serviceToForm({ protocol: 'tcp', service_name: 'plex', icon: 'brand:plex' }) })
fireEvent.click(screen.getByLabelText('Icon picker trigger'))
expect(screen.getByRole('tab', { name: 'Brand' }).getAttribute('aria-selected')).toBe('true')
})
it('previews the selected brand icon on the trigger', () => {
setup({ initial: serviceToForm({ protocol: 'tcp', service_name: 'plex', icon: 'brand:plex' }) })
expect(screen.getByLabelText('Icon picker trigger').textContent).toContain('plex')
})
it('resets the icon back to none', () => {
const { onSubmit } = setup({ initial: serviceToForm({ protocol: 'tcp', service_name: 'plex', icon: 'brand:plex' }) })
fireEvent.click(screen.getByRole('button', { name: /Reset/ }))
submit()
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ icon: undefined }))
})
it('hides the Reset button when no icon is set', () => {
setup()
expect(screen.queryByRole('button', { name: /Reset/ })).toBeNull()
})
})
})
@@ -131,6 +131,9 @@ describe('SettingsModal', () => {
vi.mocked(proxmoxApi.saveConfig).mockResolvedValue({ data: {} } as never)
render(<SettingsModal open onClose={vi.fn()} />)
await screen.findByDisplayValue('60')
// Wait for the async proxmox getConfig to hydrate the toggle before saving —
// otherwise Save can fire with the default sync_enabled=false under CI load.
await waitFor(() => expect(screen.getByLabelText('Toggle Proxmox auto-sync')).toBeChecked())
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(proxmoxApi.saveConfig).toHaveBeenCalledWith({ sync_enabled: true, sync_interval: 3600 })
@@ -174,6 +177,9 @@ describe('SettingsModal', () => {
render(<SettingsModal open onClose={vi.fn()} />)
await screen.findByDisplayValue('60')
await screen.findByText('Zigbee auto-sync')
// Wait for the async zigbee getConfig to hydrate the toggle before saving —
// otherwise Save can fire with the default sync_enabled=false under CI load.
await waitFor(() => expect(screen.getByLabelText('Toggle Zigbee auto-sync')).toBeChecked())
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(zigbeeApi.saveConfig).toHaveBeenCalledWith({ sync_enabled: true, sync_interval: 1800 })
+105 -349
View File
@@ -7,38 +7,30 @@ 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'
import { ServiceIcon } from '@/components/ui/ServiceIcon'
import type { Node } from '@xyflow/react'
interface DetailPanelProps {
onEdit: (id: string) => void
}
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '', path: '' }
type PropForm = { key: string; value: string; icon: string | null; visible: boolean }
const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true }
/** Open service editor: `index === null` means "add a new service". */
type SvcModalState = { nodeId: string; index: number | null; form?: ServiceFormData }
export function DetailPanel({ onEdit }: DetailPanelProps) {
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup, removeFromGroup, setNodeSize } = useCanvasStore()
const serviceStatuses = useCanvasStore((s) => s.serviceStatuses)
const [addingForNode, setAddingForNode] = useState<string | null>(null)
const [newSvc, setNewSvc] = useState<SvcForm>(EMPTY_FORM)
const [editingFor, setEditingFor] = useState<{ nodeId: string; index: number } | null>(null)
const [editSvc, setEditSvc] = useState<SvcForm>(EMPTY_FORM)
const [svcModal, setSvcModal] = useState<SvcModalState | null>(null)
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)
// Multi-select panel
const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id))
@@ -87,8 +79,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
}
// Normal single-node panel
const addingService = addingForNode === node.id
const editingIndex = editingFor?.nodeId === node.id ? editingFor.index : null
const openSvcModal = svcModal?.nodeId === node.id ? svcModal : null
const { data } = node
const services = data.services ?? []
const statusColor = STATUS_COLORS[data.status]
@@ -102,115 +93,63 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
}
}
const handleAddService = () => {
const trimmedPort = newSvc.port.trim()
const port = trimmedPort === '' ? undefined : parseInt(trimmedPort, 10)
if (!newSvc.service_name.trim()) return
if (trimmedPort !== '' && (port == null || Number.isNaN(port) || port < 1 || port > 65535)) return
const handleSubmitService = (data: ServiceSubmitData) => {
if (!openSvcModal) return
snapshotHistory()
const path = newSvc.path.trim()
const svc: ServiceInfo = {
...(port != null ? { port } : {}),
protocol: newSvc.protocol,
service_name: newSvc.service_name.trim(),
...(path ? { path } : {}),
if (openSvcModal.index === null) {
const svc: ServiceInfo = {
...(data.port != null ? { port: data.port } : {}),
protocol: data.protocol,
service_name: data.service_name,
...(data.path ? { path: data.path } : {}),
...(data.icon ? { icon: data.icon } : {}),
}
updateNode(node.id, { services: [...services, svc] })
return
}
updateNode(node.id, { services: [...services, svc] })
setNewSvc(EMPTY_FORM)
setAddingForNode(null)
const updated = services.map((svc, i) =>
i === openSvcModal.index
? {
...svc,
protocol: data.protocol,
service_name: data.service_name,
port: data.port,
path: data.path,
icon: data.icon,
}
: svc
)
updateNode(node.id, { services: updated })
}
const handleRemoveService = (index: number) => {
snapshotHistory()
const updated = services.filter((_, i) => i !== index)
updateNode(node.id, { services: updated })
if (editingIndex === index) setEditingFor(null)
if (openSvcModal?.index === index) setSvcModal(null)
}
const handleStartEdit = (index: number) => {
const svc = services[index]
if (!svc) return
setEditSvc({ port: svc.port != null ? String(svc.port) : '', protocol: svc.protocol, service_name: svc.service_name, path: svc.path ?? '' })
setEditingFor({ nodeId: node.id, index })
setAddingForNode(null)
setSvcModal({ nodeId: node.id, index, form: serviceToForm(svc) })
}
const handleSaveEdit = () => {
if (editingIndex === null) return
const trimmedPort = editSvc.port.trim()
const port = trimmedPort === '' ? undefined : parseInt(trimmedPort, 10)
if (!editSvc.service_name.trim()) return
if (trimmedPort !== '' && (port == null || Number.isNaN(port) || port < 1 || port > 65535)) return
const handleReorderService = (from: number, to: number) => {
if (from === to || from < 0 || to < 0 || from >= services.length || to >= services.length) return
snapshotHistory()
const path = editSvc.path.trim()
const updated = services.map((svc, i) =>
i === editingIndex
? {
...svc,
protocol: editSvc.protocol,
service_name: editSvc.service_name.trim(),
...(port != null ? { port } : { port: undefined }),
...(path ? { path } : { path: undefined }),
}
: svc
)
updateNode(node.id, { services: updated })
setEditingFor(null)
const reordered = [...services]
const [moved] = reordered.splice(from, 1)
reordered.splice(to, 0, moved)
updateNode(node.id, { services: reordered })
}
// --- 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 (
@@ -278,87 +217,55 @@ 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">
<span className="text-xs text-muted-foreground">Services{services.length > 0 ? ` (${services.length})` : ''}</span>
<button onClick={() => { setAddingForNode((v) => v === node.id ? null : node.id); setEditingFor(null) }} className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer">
<button onClick={() => setSvcModal({ nodeId: node.id, index: 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>
{addingService && <ServiceForm form={newSvc} onChange={setNewSvc} onConfirm={handleAddService} onCancel={() => setAddingForNode(null)} confirmLabel="Add" autoFocus />}
{services.length > 0 && (
<div className="flex flex-col gap-1.5">
{services.map((svc, i) =>
editingIndex === i ? (
<ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus />
) : (
<ServiceBadge key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`} svc={svc} host={host} status={serviceStatuses[serviceStatusKey(node.id, svc.port, svc.protocol)]} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
)
)}
{services.map((svc, i) => (
<ServiceBadge
key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`}
svc={svc}
host={host}
status={serviceStatuses[serviceStatusKey(node.id, svc.port, svc.protocol)]}
draggable={services.length > 1}
isDragging={dragSvcIndex === i}
isDragOver={dragOverSvcIndex === i && dragSvcIndex !== i}
onDragStart={() => setDragSvcIndex(i)}
onDragEnter={() => { if (dragSvcIndex !== null) setDragOverSvcIndex(i) }}
onDragEnd={() => { setDragSvcIndex(null); setDragOverSvcIndex(null) }}
onDrop={() => {
if (dragSvcIndex !== null) handleReorderService(dragSvcIndex, i)
setDragSvcIndex(null)
setDragOverSvcIndex(null)
}}
onEdit={() => handleStartEdit(i)}
onRemove={() => handleRemoveService(i)}
/>
))}
</div>
)}
{services.length === 0 && !addingService && <p className="text-[10px] text-muted-foreground/50">No services — click Add to register one.</p>}
{services.length === 0 && <p className="text-[10px] text-muted-foreground/50">No services — click Add to register one.</p>}
</div>
{openSvcModal && (
<ServiceModal
key={`svc-${openSvcModal.index ?? 'new'}`}
open
onClose={() => setSvcModal(null)}
onSubmit={handleSubmitService}
initial={openSvcModal.form}
title={openSvcModal.index === null ? 'Add Service' : 'Edit Service'}
confirmLabel={openSvcModal.index === null ? 'Add' : 'Save'}
/>
)}
{data.notes && (
<div className="px-4 py-3 border-t border-border">
<div className="text-xs text-muted-foreground mb-1">Notes</div>
@@ -516,6 +423,7 @@ function MultiSelectPanel({ nodeIds, nodes, groupName, setGroupName, creatingGro
</>
) : (
<Button
data-tour="create-group"
size="sm"
className="w-full gap-2 bg-[#00d4ff]/10 text-[#00d4ff] border border-[#00d4ff]/30 hover:bg-[#00d4ff]/20"
variant="ghost"
@@ -667,131 +575,14 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono?
)
}
function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFocus }: {
form: { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }
onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }) => void
onConfirm: () => void
onCancel: () => void
confirmLabel: string
autoFocus?: boolean
}) {
const setPort = (value: string) => {
const digitsOnly = value.replace(/\D/g, '').slice(0, 5)
onChange({ ...form, port: digitsOnly })
}
const clampPort = (value: string) => {
if (!value) return ''
const parsed = Number.parseInt(value, 10)
if (!Number.isFinite(parsed)) return ''
return String(Math.max(1, Math.min(65535, parsed)))
}
return (
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
<Input value={form.service_name} onChange={(e) => onChange({ ...form, service_name: e.target.value })} placeholder="Service name" className="bg-[#21262d] border-[#30363d] text-xs h-7" autoFocus={autoFocus} onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
<div className="flex gap-1.5">
<Input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.port}
onChange={(e) => setPort(e.target.value)}
onBlur={() => onChange({ ...form, port: clampPort(form.port) })}
placeholder="Port"
className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-28 shrink-0"
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
/>
<select value={form.protocol} onChange={(e) => onChange({ ...form, protocol: e.target.value as 'tcp' | 'udp' })} className="flex-1 bg-[#21262d] border border-[#30363d] rounded-md text-xs h-7 px-1.5 text-foreground">
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
</div>
<Input value={form.path} onChange={(e) => onChange({ ...form, path: e.target.value })} placeholder="Path (/admin)" className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
<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>
)
const CATEGORY_COLORS: Record<string, string> = {
web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
}
// --- 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
function ServiceBadge({ svc, host, status, draggable, isDragging, isDragOver, onDragStart, onDragEnter, onDragEnd, onDrop, onEdit, onRemove }: {
svc: ServiceInfo
host?: string
status?: ServiceStatus
draggable: boolean
isDragging: boolean
isDragOver: boolean
@@ -799,60 +590,9 @@ function PropertyBadge({ prop, draggable, isDragging, isDragOver, onDragStart, o
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',
}
function ServiceBadge({ svc, host, status, onEdit, onRemove }: { svc: ServiceInfo; host?: string; status?: ServiceStatus; onEdit: () => void; onRemove: () => void }) {
const url = getServiceUrl(svc, host)
// Manually-added services carry no category, so they fell back to grey even
// when they're reachable HTTP/HTTPS. Treat any resolvable web URL as `web`.
@@ -863,11 +603,27 @@ function ServiceBadge({ svc, host, status, onEdit, onRemove }: { svc: ServiceInf
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 min-w-0"
style={{ background: '#21262d', borderColor: '#30363d' }}
style={{
background: '#21262d',
borderColor: isDragOver ? '#00d4ff' : '#30363d',
opacity: isDragging ? 0.4 : 1,
}}
>
<div className="flex items-center gap-1.5 min-w-0 flex-1">
{draggable && (
<span className="shrink-0 cursor-grab active:cursor-grabbing text-[#8b949e] hover:text-[#00d4ff]" title="Drag to reorder">
{createElement(GripVertical, { size: 11 })}
</span>
)}
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
<ServiceIcon iconKey={svc.icon} size={12} className="shrink-0" color={color} />
{url ? (
<a
@@ -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>
)}
</>
)
}
+84 -23
View File
@@ -1,14 +1,17 @@
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'
import { useDesignStore } from '@/stores/designStore'
import { useAuthStore } from '@/stores/authStore'
import { designsApi, mediaApi } from '@/api/client'
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,34 @@ 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 {
await authApi.logout()
logout()
} catch {
toast.error('Could not end the current session')
}
}, [logout])
const openDesignModal = useCallback((m: { mode: 'create' | 'edit'; design?: Design }) => {
setOpenSeq((s) => s + 1)
@@ -63,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) {
@@ -155,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 ? (() => {
@@ -222,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
/>
@@ -233,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 && (
@@ -246,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>
@@ -268,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} />
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} />
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} />
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
{!STANDALONE && <SidebarItem icon={RadioTower} label="Z-Wave Import" collapsed={collapsed} onClick={onZwaveImport} />}
{!STANDALONE && <SidebarItem icon={Server} label="Proxmox Import" collapsed={collapsed} onClick={onProxmoxImport} />}
{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}
@@ -294,7 +321,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
icon={LogOut}
label="Logout"
collapsed={collapsed}
onClick={logout}
onClick={() => void handleLogout()}
/>
)}
</div>
@@ -324,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)
@@ -360,12 +418,15 @@ interface SidebarItemProps {
badge?: boolean
accent?: boolean
onClick?: () => void
/** Stable anchor for the Getting Started walkthrough spotlight. */
dataTour?: string
}
function SidebarItem({ icon: Icon, label, collapsed, active, badge, accent, onClick }: SidebarItemProps) {
function SidebarItem({ icon: Icon, label, collapsed, active, badge, accent, onClick, dataTour }: SidebarItemProps) {
const btn = (
<button
onClick={onClick}
data-tour={dataTour}
className={`relative flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm transition-colors cursor-pointer ${
active
? 'bg-[#00d4ff]/10 text-[#00d4ff]'
+45 -25
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,29 +46,37 @@ 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>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onChangeStyle}>
<Button data-tour="style" size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onChangeStyle}>
<Palette size={14} /> Style
</Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={() => fileInputRef.current?.click()} title="Import from YAML">
@@ -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>
@@ -343,15 +343,58 @@ describe('DetailPanel', () => {
})
describe('Services — add/remove', () => {
it('shows add form when Add is clicked', () => {
it('opens the Add Service modal when Add is clicked', () => {
setupStore({})
render(<DetailPanel onEdit={vi.fn()} />)
// Two "Add" buttons: first = properties, second = services
const addButtons = screen.getAllByText('Add')
fireEvent.click(addButtons[addButtons.length - 1])
expect(screen.getByText('Add Service')).toBeDefined()
expect(screen.getByPlaceholderText('Service name')).toBeDefined()
})
it('renders no service form until the modal is opened', () => {
setupStore({})
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.queryByPlaceholderText('Service name')).toBeNull()
})
it('persists the icon chosen in the modal', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({})],
selectedNodeId: 'n1',
selectedNodeIds: [],
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode,
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
const addHeaders = screen.getAllByText('Add')
fireEvent.click(addHeaders[addHeaders.length - 1])
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'plex' } })
fireEvent.click(screen.getByLabelText('Icon picker trigger'))
fireEvent.click(screen.getByRole('tab', { name: 'Brand' }))
fireEvent.change(screen.getByLabelText('Brand icon search'), { target: { value: 'plex' } })
fireEvent.click(screen.getByRole('button', { name: 'plex' }))
fireEvent.click(screen.getAllByRole('button', { name: 'Add' }).at(-1) as HTMLButtonElement)
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'plex', icon: 'brand:plex' })
})
it('closes the modal after adding', () => {
setupStore({})
render(<DetailPanel onEdit={vi.fn()} />)
const addHeaders = screen.getAllByText('Add')
fireEvent.click(addHeaders[addHeaders.length - 1])
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } })
fireEvent.click(screen.getAllByRole('button', { name: 'Add' }).at(-1) as HTMLButtonElement)
expect(screen.queryByPlaceholderText('Service name')).toBeNull()
})
it('calls updateNode with new service on Add confirm', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
@@ -372,7 +415,7 @@ describe('DetailPanel', () => {
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } })
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } })
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '/admin' } })
fireEvent.keyDown(screen.getByPlaceholderText('Port'), { key: 'Enter' })
fireEvent.click(screen.getAllByRole('button', { name: 'Add' }).at(-1) as HTMLButtonElement)
expect(updateNode).toHaveBeenCalledOnce()
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp', path: '/admin' })
})
@@ -430,17 +473,56 @@ describe('DetailPanel', () => {
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
expect(() => render(<DetailPanel onEdit={vi.fn()} />)).not.toThrow()
})
it('reorders services on drag and drop', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ services: [
{ port: 80, protocol: 'tcp', service_name: 'A' },
{ port: 81, protocol: 'tcp', service_name: 'B' },
{ port: 82, protocol: 'tcp', service_name: 'C' },
] })],
selectedNodeId: 'n1',
selectedNodeIds: [],
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode,
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
const rows = screen.getAllByTitle('Drag to reorder').map(
(g) => g.closest('[draggable="true"]') as HTMLElement,
)
// Drag last (C) onto first (A) position
fireEvent.dragStart(rows[2])
fireEvent.dragEnter(rows[0])
fireEvent.drop(rows[0])
expect(updateNode).toHaveBeenCalledOnce()
const [, payload] = updateNode.mock.calls[0]
expect(payload.services.map((s: { service_name: string }) => s.service_name)).toEqual(['C', 'A', 'B'])
})
it('is not draggable with a single service', () => {
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'A' }] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.queryByTitle('Drag to reorder')).toBeNull()
})
})
describe('Services — edit', () => {
const svc = { port: 80, protocol: 'tcp' as const, service_name: 'nginx' }
it('shows edit form pre-filled when pencil is clicked', () => {
it('opens the Edit Service modal pre-filled when pencil is clicked', () => {
setupStore({ services: [{ ...svc, path: '/admin' }] })
render(<DetailPanel onEdit={vi.fn()} />)
// Hover to reveal edit button (fireEvent.mouseOver isn't needed — opacity is CSS only)
const editBtn = screen.getByTitle('Edit service')
fireEvent.click(editBtn)
expect(screen.getByText('Edit Service')).toBeDefined()
const nameInput = screen.getByPlaceholderText('Service name') as HTMLInputElement
expect(nameInput.value).toBe('nginx')
const portInput = screen.getByPlaceholderText('Port') as HTMLInputElement
@@ -474,6 +556,46 @@ describe('DetailPanel', () => {
expect(updateNode.mock.calls[0][1].services[0].path).toBe('/admin')
})
it('keeps fields the modal does not own, like category', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ services: [{ ...svc, category: 'web' }] })],
selectedNodeId: 'n1',
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode,
snapshotHistory: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
fireEvent.click(screen.getByTitle('Edit service'))
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(updateNode.mock.calls[0][1].services[0].category).toBe('web')
})
it('saves a brand icon picked while editing', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ services: [svc] })],
selectedNodeId: 'n1',
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode,
snapshotHistory: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
fireEvent.click(screen.getByTitle('Edit service'))
fireEvent.click(screen.getByLabelText('Icon picker trigger'))
fireEvent.click(screen.getByRole('tab', { name: 'Brand' }))
fireEvent.change(screen.getByLabelText('Brand icon search'), { target: { value: 'nginx' } })
fireEvent.click(screen.getByRole('button', { name: 'nginx' }))
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(updateNode.mock.calls[0][1].services[0].icon).toBe('brand:nginx')
})
it('cancels edit without updating', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
@@ -548,6 +670,25 @@ describe('DetailPanel', () => {
expect(screen.getByText('8080/tcp')).toBeDefined()
})
it('renders a brand icon image when the service carries one', () => {
setupStore({ services: [{ port: 32400, protocol: 'tcp', service_name: 'plex', icon: 'brand:plex' }] })
render(<DetailPanel onEdit={vi.fn()} />)
const img = screen.getByAltText('plex') as HTMLImageElement
expect(img.src).toContain('dashboard-icons/svg/plex.svg')
})
it('renders no icon image when the service has no icon', () => {
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'nginx' }] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.queryByRole('img')).toBeNull()
})
it('renders no icon image for an unresolvable legacy icon key', () => {
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'nginx', icon: 'bar-chart-2' }] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.queryByRole('img')).toBeNull()
})
it('renders path label when path is set', () => {
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'web', path: '/admin' }] })
render(<DetailPanel onEdit={vi.fn()} />)
@@ -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 } 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'
@@ -13,6 +13,9 @@ vi.mock('@/stores/canvasStore')
vi.mock('@/stores/authStore')
vi.mock('@/api/client', () => ({
authApi: {
logout: vi.fn().mockResolvedValue({}),
},
scanApi: {
trigger: vi.fn().mockResolvedValue({}),
runs: vi.fn().mockResolvedValue({ data: [] }),
@@ -75,7 +78,7 @@ const defaultProps = {
onSave: vi.fn(),
onOpenSettings: vi.fn(),
onOpenHistory: vi.fn(),
onOpenPending: vi.fn(),
onOpenInventory: vi.fn(),
}
// ── Tests ─────────────────────────────────────────────────────────────────────
@@ -241,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', () => {
@@ -272,10 +275,130 @@ describe('Sidebar', () => {
expect(screen.getByText('Logout')).toBeInTheDocument()
})
it('calls logout when Logout is clicked', () => {
it('ends the backend session before clearing local auth state', async () => {
const { authApi } = await import('@/api/client')
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Logout'))
expect(mockLogout).toHaveBeenCalledOnce()
await waitFor(() => {
expect(authApi.logout).toHaveBeenCalledOnce()
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()
})
})
@@ -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()
})
+64 -12
View File
@@ -1,10 +1,21 @@
import { useId } from 'react';
interface LogoProps {
size?: number;
showText?: boolean;
className?: string;
}
/**
* Official Homelable mark — kept in sync with `docs/logo/icon.svg`.
* Wordmark colors follow `docs/logo/logo-md.svg` (Home = #e6edf3, lable = #00d4ff).
*/
export function Logo({ size = 32, showText = true, className = '' }: LogoProps) {
// Colons are legal in a URL fragment but confuse some tooling — strip them.
const uid = useId().replace(/:/g, '');
const bgGlow = `homelable-bg-glow-${uid}`;
const nodeGlow = `homelable-node-glow-${uid}`;
return (
<div className={`flex items-center gap-2 ${className}`}>
<svg
@@ -13,32 +24,73 @@ export function Logo({ size = 32, showText = true, className = '' }: LogoProps)
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
data-testid="logo-mark"
>
<defs>
<radialGradient id={bgGlow} cx="50%" cy="35%" r="55%">
<stop offset="0%" stopColor="#00d4ff" stopOpacity="0.08" />
<stop offset="100%" stopColor="#0d1117" stopOpacity="0" />
</radialGradient>
<filter id={nodeGlow}>
<feGaussianBlur stdDeviation="1.5" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Background */}
<circle cx="32" cy="32" r="32" fill="#0d1117" />
<circle cx="32" cy="32" r="32" fill={`url(#${bgGlow})`} />
{/* House body */}
<path
d="M32 12 L52 30 L48 30 L48 52 L16 52 L16 30 L12 30 Z"
d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22"
stroke="#00d4ff"
strokeWidth="1.5"
strokeLinejoin="round"
/>
<rect x="27" y="40" width="10" height="12" rx="1" fill="#0d1117" stroke="#00d4ff" strokeWidth="1" />
<circle cx="32" cy="33" r="3" fill="#00d4ff" />
<circle cx="22" cy="38" r="2" fill="#39d353" />
<line x1="22" y1="38" x2="29" y2="33" stroke="#39d353" strokeWidth="1" opacity="0.7" />
<circle cx="42" cy="38" r="2" fill="#39d353" />
<line x1="42" y1="38" x2="35" y2="33" stroke="#39d353" strokeWidth="1" opacity="0.7" />
<circle cx="32" cy="24" r="2" fill="#a855f7" />
<line x1="32" y1="24" x2="32" y2="30" stroke="#a855f7" strokeWidth="1" opacity="0.7" />
<circle cx="32" cy="33" r="3" fill="none" stroke="#00d4ff" strokeWidth="1.5" opacity="0.4" />
{/* Floor line (subtle) */}
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" strokeWidth="0.5" opacity="0.25" />
{/* Door */}
<rect
x="27"
y="40"
width="10"
height="13"
rx="1.5"
fill="#0d1117"
stroke="#00d4ff"
strokeWidth="1"
opacity="0.9"
/>
{/* Network lines (drawn under nodes) */}
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" strokeWidth="1.2" opacity="0.7" strokeLinecap="round" />
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" strokeWidth="1.2" opacity="0.7" strokeLinecap="round" />
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" strokeWidth="1.2" opacity="0.7" strokeLinecap="round" />
{/* Center hub */}
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12" />
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter={`url(#${nodeGlow})`} />
{/* Peripheral nodes */}
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter={`url(#${nodeGlow})`} />
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter={`url(#${nodeGlow})`} />
<circle cx="32" cy="23" r="2" fill="#a855f7" filter={`url(#${nodeGlow})`} />
</svg>
{showText && (
<span
className="font-bold tracking-tight"
style={{ fontSize: size * 0.55, fontFamily: 'Inter, sans-serif' }}
>
<span style={{ color: '#00d4ff' }}>Home</span>
<span style={{ color: '#ffffff' }}>lable</span>
<span style={{ color: '#e6edf3' }}>Home</span>
<span style={{ color: '#00d4ff' }}>lable</span>
</span>
)}
</div>
@@ -0,0 +1,33 @@
import { createElement } from 'react'
import { resolveCustomIcon } from '@/utils/nodeIcons'
interface ServiceIconProps {
/** Icon key on the service — a lucide registry key or `brand:<slug>`. */
iconKey?: string
size?: number
className?: string
color?: string
}
/**
* Renders a service's icon, or nothing when the service has no icon (or carries
* a key we can't resolve — e.g. a legacy scanner signature name).
*/
export function ServiceIcon({ iconKey, size = 12, className, color }: ServiceIconProps) {
const resolved = resolveCustomIcon(iconKey)
if (!resolved) return null
if (resolved.kind === 'brand') {
return (
<img
src={resolved.url}
alt={resolved.slug}
width={size}
height={size}
loading="lazy"
className={className}
style={{ width: size, height: size, objectFit: 'contain', flexShrink: 0 }}
/>
)
}
return createElement(resolved.icon, { size, className, color })
}
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Logo } from '../Logo'
describe('Logo', () => {
it('renders the official mark (house + network nodes), not a placeholder icon', () => {
render(<Logo />)
const svg = screen.getByTestId('logo-mark')
// Canonical house outline from docs/logo/icon.svg
expect(svg.querySelector('path')?.getAttribute('d'))
.toBe('M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z')
// Center hub + 3 peripheral nodes + hub glow + 2 background circles
expect(svg.querySelectorAll('circle').length).toBe(7)
})
it('uses the canonical wordmark colors (Home #e6edf3 / lable #00d4ff)', () => {
render(<Logo showText />)
expect(screen.getByText('Home')).toHaveStyle({ color: '#e6edf3' })
expect(screen.getByText('lable')).toHaveStyle({ color: '#00d4ff' })
})
it('hides the wordmark when showText is false', () => {
render(<Logo showText={false} />)
expect(screen.queryByText('Home')).toBeNull()
expect(screen.getByTestId('logo-mark')).toBeInTheDocument()
})
it('scales the svg to the requested size', () => {
render(<Logo size={64} />)
const svg = screen.getByTestId('logo-mark')
expect(svg.getAttribute('width')).toBe('64')
expect(svg.getAttribute('height')).toBe('64')
})
it('generates unique gradient/filter ids per instance — two logos can coexist', () => {
// Sidebar and Toolbar both render a Logo at the same time; duplicate SVG
// defs ids would make one instance reference the other's gradient.
render(<><Logo /><Logo /></>)
const [first, second] = screen.getAllByTestId('logo-mark')
const idOf = (svg: HTMLElement) => svg.querySelector('radialGradient')!.getAttribute('id')
expect(idOf(first)).not.toBe(idOf(second))
// Each instance must point at its own gradient
const gradientRef = (svg: HTMLElement) =>
svg.querySelectorAll('circle')[1].getAttribute('fill')
expect(gradientRef(first)).toBe(`url(#${idOf(first)!})`)
expect(gradientRef(second)).toBe(`url(#${idOf(second)!})`)
})
it('marks the svg aria-hidden — the adjacent wordmark carries the name', () => {
render(<Logo />)
expect(screen.getByTestId('logo-mark')).toHaveAttribute('aria-hidden', 'true')
})
it('forwards className to the wrapper', () => {
const { container } = render(<Logo className="my-class" />)
expect(container.firstElementChild).toHaveClass('my-class')
})
})
@@ -0,0 +1,46 @@
import { describe, it, expect, afterEach, beforeAll, vi } from 'vitest'
import { render, cleanup, waitFor } from '@testing-library/react'
import { toast } from 'sonner'
import { Toaster } from '../sonner'
beforeAll(() => {
// sonner reads matchMedia for theme detection; jsdom doesn't provide it.
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}))
})
afterEach(() => cleanup())
describe('Toaster', () => {
it('renders error toasts on a red surface so failures are visible', async () => {
render(<Toaster />)
toast.error('Save failed')
// The style (with the CSS vars) lands on the toast list, mounted on first toast.
const list = await waitFor(() => {
const el = document.querySelector('[data-sonner-toaster]') as HTMLElement | null
expect(el).not.toBeNull()
return el!
})
const style = list.getAttribute('style') ?? ''
expect(style).toContain('--error-bg: #f85149')
expect(style).toContain('--error-text: #ffffff')
// richColors must be on for sonner to apply the per-type surface, and the
// toast itself must be tagged as an error so it picks up --error-bg.
const item = await waitFor(() => {
const el = document.querySelector('[data-sonner-toast]') as HTMLElement | null
expect(el).not.toBeNull()
return el!
})
expect(item.getAttribute('data-rich-colors')).toBe('true')
expect(item.getAttribute('data-type')).toBe('error')
})
})
+18
View File
@@ -11,6 +11,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
richColors
icons={{
success: (
<CircleCheckIcon className="size-4" />
@@ -34,6 +35,23 @@ const Toaster = ({ ...props }: ToasterProps) => {
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
// richColors (below) is required for sonner to honor per-type surfaces.
// Errors get a red surface so failures (e.g. a save while the backend
// is down) stand out instead of reading like a normal toast...
"--error-bg": "#f85149",
"--error-text": "#ffffff",
"--error-border": "#da3633",
// ...while success/info/warning stay on the neutral popover surface so
// the rest of the toast UX is unchanged.
"--success-bg": "var(--popover)",
"--success-text": "var(--popover-foreground)",
"--success-border": "var(--border)",
"--info-bg": "var(--popover)",
"--info-text": "var(--popover-foreground)",
"--info-border": "var(--border)",
"--warning-bg": "var(--popover)",
"--warning-text": "var(--popover-foreground)",
"--warning-border": "var(--border)",
} as React.CSSProperties
}
toastOptions={{
@@ -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())

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