Compare commits

..

139 Commits

Author SHA1 Message Date
Pouzor 8541922386 bump: version 2.0.3 2026-05-13 11:37:06 +02:00
Rémy 3ed9cb0d4f Merge pull request #146 from Pouzor/fix/zigbee-no-check-method
fix(zigbee): hide check method in modal, force none/online for zigbee nodes
2026-05-13 11:35:10 +02:00
Pouzor fff11a4b6a fix(zigbee): hide check method in modal, force none/online for zigbee nodes
Zigbee nodes have no IP-based check — hide Check Method and Check Target
fields in NodeModal for all three zigbee types. Default and force
check_method='none' so the scheduler marks them always online.
Backend approve and zigbee import routes also set status='online' and
check_method='none' for zigbee node types.
2026-05-13 11:14:35 +02:00
Pouzor 0680566081 bump: 2.0.2 2026-05-11 19:55:21 +02:00
Remy 2f5a90a00e Merge pull request #142 from Pouzor/fix/visual
Node modal polish + Smart Home and Brand icon pickers
2026-05-11 19:53:48 +02:00
Pouzor 928f63df0f fix(icons): narrow ICON_MAP lookup type for strict build
tsc -b (used in npm run build) flagged TS2774 because LucideIcon is a
function and therefore always truthy. Cast the lookup result to
LucideIcon | undefined so the falsy branch becomes meaningful.
2026-05-11 19:44:20 +02:00
Pouzor e84a4e0eb3 merge feat/brand-icons into fix/visual 2026-05-11 19:20:10 +02:00
Pouzor 3b0dbd7a8b feat(icons): add brand icon picker from dashboard-icons
Add a second tab in the node Icon picker to choose from the ~2250 brand
icons hosted by homarr-labs/dashboard-icons (Plex, Sonarr, Home
Assistant, etc.) served via jsDelivr CDN. The Generic tab keeps the
existing lucide picker unchanged.

Storage uses a 'brand:<slug>' prefix on custom_icon, so existing nodes
referencing lucide keys keep working with zero migration. A new
resolveCustomIcon helper returns a discriminated union (lucide | brand)
and a NodeIcon component centralizes rendering for both kinds.

Includes a manifest fetch script (scripts/fetch-dashboard-icons.mjs)
and a checked-in dashboardIcons.json snapshot.
2026-05-11 19:18:01 +02:00
Pouzor 3a9b3b2650 feat(icons): add Smart Home / Sensors icon category
Add 27 new icons covering common IoT/Zigbee endpoints: smart plug,
relay, energy meter, solar, door/window sensor, smart lock, smoke
detector, siren, motion radar, presence, vibration, water leak,
humidity, air quality, HVAC vent, fan, AC, smart light, blinds,
doorbell, speaker, remote, garage, valve, weather station, plus
voice assistant and webhook in the existing Automation category.
2026-05-11 17:08:11 +02:00
Pouzor ff02f3b5db fix(node-modal): cap height at 90vh with scroll
Modal grew taller than viewport when icon picker expanded, hiding
header and footer buttons. Constrain DialogContent to 90vh and add
overflow-y-auto so all controls stay reachable.
2026-05-11 16:54:33 +02:00
Pouzor a66e6aa906 bump: version 2.0.1 2026-05-11 16:11:51 +02:00
Remy 8e59d9a0d1 Merge pull request #141 from Pouzor/fix/zigbee-node-types-in-modal
fix(node-modal): expose Zigbee device types in selector
2026-05-11 16:09:07 +02:00
Pouzor ee4136b506 fix(node-modal): expose Zigbee device types in selector
Add Zigbee section (coordinator, router, end device) to NodeModal
type group list, matching types used by zigbee2mqtt import.
2026-05-11 15:55:34 +02:00
Pouzor 8e8f01d6ca bump version 2.0.0 2026-05-11 02:23:48 +02:00
Pouzor aba4f34168 feat(style-modal): expose Zigbee node types for customization
Add zigbee_coordinator, zigbee_router, zigbee_enddevice to the
CustomStyleModal editable list with Radio/Zap/Lightbulb icons so users
can tweak color, opacity, and size like any other node type.
2026-05-11 01:44:24 +02:00
Remy 6524efe126 Merge pull request #139 from Pouzor/feature/node-alignment-guides
feat(canvas): alignment guides + snap while dragging
2026-05-11 01:29:44 +02:00
Pouzor 062a08f116 fix(alignment): only snap top-level dragged nodes
Code review flagged silent corruption when dragging a mixed selection
of a parent + one of its children: the child's ids stayed in
pendingSnap even though nodeBox excluded it from the bbox. On drag
stop we shifted the child's parent-relative position by the same delta
the parent already moves by — double-snapping the child off-screen.

Restrict the pendingSnap id set to nodes that contributed a box
(top-level only). Children follow their parent's move automatically;
no extra shift is needed.
2026-05-11 01:23:37 +02:00
Pouzor 5e9963db4d refactor(alignment): defer snap to drag stop, stabilise callback deps
Code review flagged a drag race + deps issues:

- Race: setNodes during onNodeDrag fights React Flow's internal drag
  handler, which derives positions from the cursor offset captured at
  drag start. Snap delta could be overwritten on the next frame.
- Deps: useCallback closed over guides.length, causing recreation on
  every guide change and pulling stale references into onNodeDrag.

Fix: compute guides live during drag for visual feedback, store the
pending snap delta in a ref, and apply it via setNodes only on
onNodeDragStop. Replace the guides.length dep with a ref mirror updated
via useEffect (not during render).
2026-05-11 01:14:47 +02:00
Pouzor 82203f67d8 fix(alignment): use OnNodeDrag (NodeDragHandler doesn't exist in @xyflow/react)
CI build failed with TS2305: NodeDragHandler is not exported. Local
typecheck missed it because tsc-noEmit is more permissive than tsc -b.

Use the exported OnNodeDrag<NodeType> type and alias it with our
NodeData so the callback params are inferred (no implicit any).
2026-05-11 01:00:36 +02:00
Pouzor 004a8f19c1 feat(canvas): alignment guides + snap while dragging nodes
Draw.io / Figma style: while dragging a node, show dashed cyan guide
lines when its edges (left / center / right / top / middle / bottom)
align with another node's, and snap the position to the matched line
within a configurable threshold.

- utils/alignment.ts: pure snap math, returns delta + guide segments.
  Same-size boxes show all aligned guides simultaneously.
- canvas/AlignmentGuides.tsx: SVG overlay locked to the React Flow
  viewport (panned/zoomed correctly).
- hooks/useAlignmentGuides.ts: wires onNodeDrag/onNodeDragStop, applies
  snap via setNodes, listens for Alt to temporarily disable.
- utils/alignmentSettings.ts: localStorage-backed prefs (enabled,
  threshold 2-16px) with a tiny CustomEvent pub-sub so the SettingsPanel
  and the drag hook stay in sync without a global store.
- Sidebar settings panel: toggle + threshold slider.

Multi-selection drag uses the union bounding box. Children with
parentId are skipped for v1 to avoid mixing absolute and parent-relative
coordinates. Hold Alt to escape snap.
2026-05-11 00:35:50 +02:00
Pouzor 5ab0bdeb7f fix(text-node): persist text in label so it survives reload
text_content is not in the API serializer schema, so text node content
was dropped on save and the node came back empty after reload.

Store text in label instead — already persisted, and TextNode + the
edit modal already fall back to label, so existing data stays
compatible. Clear stale text_content on update.

Regression test added for the text-node save/load roundtrip.
2026-05-11 00:34:38 +02:00
Pouzor 3f9866e8a1 fix(detail): handle non-Z timezone offsets in Last Seen
Backend sends ISO timestamps with +00:00 offset. Code blindly appended
'Z' when the string didn't end with 'Z', producing '...+00:00Z' which
parses to Invalid Date.

Detect any timezone marker (Z, z, or numeric offset like +00:00) and
only append 'Z' for naive datetimes.

Co-authored-by:
2026-05-10 21:45:22 +02:00
Pouzor ebdf6cb55b fix(scan): default check_method='ping' for approved devices
Approving a pending device created a Node with check_method=NULL, so
the status scheduler silently skipped it (scheduler.py:64 filters
falsy check_method) and the bubble stayed grey forever.

Default to 'ping' when the device has an IP, in both single and bulk
approve paths. Also persist caller-supplied check_method/check_target
in the single-approve path.

Add regression tests asserting default check_method='ping' after
approval for both endpoints.
2026-05-10 20:33:49 +02:00
Pouzor ec8f1c87f1 fix(status): use ms timeout for ping on macOS
ping(8) -W flag is milliseconds on macOS, seconds on Linux. Code used
-W 1 for both, giving macOS a 1ms timeout that fails any host with
RTT >1ms (typical wifi/mesh is 2-10ms).

Use -W 1000 on darwin to match the intended 1s timeout. Add regression
test asserting the platform-specific value.
2026-05-10 19:45:36 +02:00
Pouzor 2b91f31807 fix(group): preserve children + size on edit, fix status WS proxy
- Skip NodeModal on double-click for 'group'/'groupRect' (use inline rename)
- Exempt 'group' from properties-clears-height rule in updateNode
  (was collapsing group + hiding children with extent: 'parent')
- Enable ws: true on Vite /api proxy so /api/v1/status/ws/status upgrades
  (status bubbles stayed grey because WS handshake failed)
- Add regression tests for group/groupRect height preservation
2026-05-10 19:30:04 +02:00
Remy c6965b827b Merge pull request #138 from Pouzor/feature/text-node
feat: add Text node type to canvas
2026-05-10 16:58:41 +02:00
Pouzor afdea65d4e feat: add demo text nodes with onboarding hints
Top-right banner: 'This is demo canvas, start with fresh scan'.
Bottom-left hint: 'You can remove all nodes'.
2026-05-10 16:50:39 +02:00
Pouzor 32eb9fbe23 fix: add 'text' entry to nodeIcons + all themes
Build failed because Record<NodeType, ...> in nodeIcons.ts and
themes.ts requires every NodeType key. Add Type icon for text and
neutral border/icon palette per theme.
2026-05-10 16:31:38 +02:00
Pouzor 27fd78ece6 feat: add Text node type to canvas
New canvas object Text with edit/create modal: text content (single
or multi-line), font (Inter, JetBrains Mono, Serif, System Sans),
size, color, border (style + width + color), background. Double-click
on node opens edit modal. Sidebar gets "Add Text" entry below "Add Zone".

Refs #124
2026-05-10 15:58:35 +02:00
Remy e672cb086a Merge pull request #107 from findthelorax/feature/services-node-toggle
feature: services node toggle
2026-05-10 03:29:31 +02:00
findthelorax 3cddcf5b81 added cursor pointer to edit and delete buttons on services 2026-05-10 03:15:57 +02:00
findthelorax 19791abf5b adjusted show services toggle for alignment 2026-05-10 03:15:57 +02:00
findthelorax d9d4be9a53 services styling in node and detail panel 2026-05-10 03:15:57 +02:00
findthelorax c165b8b016 rebase with new main branch 2026-05-10 03:15:57 +02:00
findthelorax b16e81cf29 fixed some styling inconsistencies, removed show services toggle from affecting custom colors 2026-05-10 03:15:57 +02:00
findthelorax 3028e5a3f7 fix: cleanup 2026-05-10 03:15:57 +02:00
findthelorax 340bd15099 fix: external link icon on the detail panel for non URL services 2026-05-10 03:15:57 +02:00
findthelorax d181cef4c1 fix: external link icon set to opacity 0 when no URL 2026-05-10 03:15:57 +02:00
findthelorax 4a048d0ab5 fix: styling 2026-05-10 03:15:57 +02:00
findthelorax f32c32f6a5 feature: added a toggle to show the services on a node 2026-05-10 03:15:57 +02:00
Remy d1f170d5db Merge pull request #128 from pranjal-joshi/feat/zigbee
feat: add Zigbee2MQTT network map importer
2026-05-10 02:35:16 +02:00
Pouzor 0a680fb9f2 fix(deps): bump fast-uri to patch GHSA-q3j6-qgpj-74h6 / GHSA-v39h-62p7-jpjc
Resolves the npm audit high-severity findings that broke the
dependency-audit job in the Security workflow.
2026-05-10 02:16:52 +02:00
Pouzor 9db41e886a fix(pending): respect active filters when clearing pending devices
Trash button in PendingDevicesModal now removes only filtered devices
instead of wiping the whole pending list. Falls back to clearPending
when no filters are active.
2026-05-10 01:51:11 +02:00
Pouzor 0863c2db94 feat(zigbee): import as background scan run
Mirrors IP scan flow: POST /zigbee/import-pending now creates a
ScanRun(kind=zigbee, status=running) and returns immediately.
Networkmap fetch + pending upsert run in the background, status
transitions to done/error when finished.

Frontend: import modal closes on submit, scan history shows the
run with a ZIG/IP kind chip and toasts on completion. Pending
modal auto-refreshes when run finishes.

scan_runs.kind column added (default 'ip', idempotent migration).
Existing zigbee tests refactored to exercise _persist_pending_import
directly (background tasks don't see the test session); route test
verifies the run is created with kind=zigbee.
2026-05-10 00:45:21 +02:00
Pouzor b17299f531 fix(pending): drop dangling onNodeApproved call + add keydown deps
Single approve was calling a removed prop ref (caught by review).
Keydown effect now has explicit deps so it doesn't re-bind every render.
2026-05-09 23:53:33 +02:00
Pouzor 277879488f feat(pending): full-screen modal with grid cards, filters, bulk restore
Replaces sidebar pending/hidden panels with a wide modal showing
devices as cards. Adds search, segmented source/status filters,
type filter, select mode for bulk approve/hide/restore, and
keyboard shortcuts. Hidden cards click-to-restore (no approval
detour); approval no longer pops the edit modal.

Backend: new restore + bulk-restore endpoints (hidden -> pending).
2026-05-09 23:40:44 +02:00
Pouzor 456f26a85a fix(ci): null-safe pending IP in SearchModal + bump python-multipart
- SearchModal.tsx: same null-IP guard as SearchBar; also search by
  friendly_name and ieee_address. Fixes Docker CI typecheck.
- python-multipart 0.0.26 -> 0.0.27 (CVE-2026-42561). Fixes
  Security pip-audit.
2026-05-08 00:53:18 +02:00
Pouzor 2a6140a6b1 chore(db): log instead of silently suppress new Zigbee migrations
Pre-push code review flagged the inline `with suppress(OperationalError)`
ALTERs as silently eating real errors. The project still uses inline
init_db migrations (CLAUDE.md aspires to Alembic but the runtime path
is inline), so as a minimum: add a `_try_migrate` helper that
distinguishes already-applied (debug log) from genuine failures
(warning log). Apply it to the Zigbee-related migrations only — the
legacy ALTERs above remain unchanged in this commit's scope.
2026-05-07 23:57:52 +02:00
Pouzor 3ae159d8d6 feat(zigbee): import to pending section with edge persistence
Coordinator auto-approves to a canvas Node; routers/end devices land in
pending_devices keyed by IEEE. Discovered parent->child edges are stored
in pending_device_links so that approving a pending device later
auto-creates the Edge once both endpoints exist as canvas Nodes.

- new POST /api/v1/zigbee/import-pending (default mode in modal)
- new pending_device_links table; ieee_address on nodes + pending_devices
- pending_devices.ip migrated to nullable (table rebuild on existing DBs)
- approve / bulk-approve return auto-created edges; sidebar pushes them
  into the canvas store with bottom -> top-t handles
- ZigbeeImportModal: radio toggle pending vs canvas; reset on close
- PendingDeviceModal: zigbee badge, IEEE/LQI/vendor/model rows, services
  hidden for zigbee
- Sidebar pending row: ZIG source badge, LQI badge, friendly_name fallback
- SearchBar: null-safe IP, also searches friendly_name and ieee_address
- Tooltip trigger uses asChild to avoid nested-button hydration error
2026-05-07 23:18:29 +02:00
Pouzor 5e567d4628 feat(zigbee): add slow-fetch hint in modal + bump timeout to 300s
- Modal: small italic note under Test/Fetch buttons warning users that
  large meshes can take several minutes
- Service: _NETWORKMAP_TIMEOUT 180s -> 300s (5 min) for very large meshes
2026-05-07 00:27:48 +02:00
Pouzor 05db9a59f5 fix(zigbee): strict tree edges + auto-select imported nodes
Edges
- Z2M `links` is bidirectional and includes router mesh paths, which
  caused duplicate edges and edges entering the coordinator from the
  bottom. Walk `links` only to derive parent_id + LQI; build final
  edges strictly from the parent->child hierarchy (one edge per
  non-coordinator node). Result: parent bottom -> child top, every time.
- Tests: bidirectional pair collapses to one edge, router-mesh siblings
  dropped, coordinator never receives an edge.

Auto-select
- After import, deselect existing canvas nodes and mark only the
  freshly-imported ones as selected, so the user can drag the whole
  subtree as a group.
2026-05-06 23:53:31 +02:00
Pouzor a46e505505 fix(zigbee): parse real Z2M networkmap shape (data.value.nodes/links)
The previous parser read `data.routes` which is just an echo of the
`routes` request flag (a boolean). On real brokers this caused
`TypeError: 'bool' object is not iterable` and 500s during /import.

- Rewrite parse_networkmap to read data.value.nodes + data.value.links
  with fallback to data.{nodes,links} for legacy variants
- Defensive: drop links to unknown nodes, propagate lqi from link to
  target node, extract model/vendor from definition block
- Bump networkmap timeout 10s -> 180s (large meshes are slow)
- Tests: rewrite fixture builders + sample payload to real Z2M shape;
  add cases for legacy shape, routes:false echo (regression), malformed
  list, link to unknown node, lqi propagation, definition extraction
- Update docs to mention 60s+ wait window

53 backend tests pass, mypy + ruff clean.
2026-05-06 22:38:15 +02:00
pranjal-joshi 5b5866d8d6 fix: update axios to ^1.15.2 and audit fix
- Update axios from ^1.13.6 to ^1.15.2 to fix high-severity vulnerabilities (SSRF, prototype pollution, CRLF injection)
- Run npm audit fix to patch transitive dependencies
- Resolves dependency-audit CI failure

Co-authored-by: CyberClaw <noreply@openclaw.ai>
2026-05-06 16:56:03 +02:00
Pouzor 9970780e7a fix(zigbee): sanitize MQTT error messages to prevent credential leakage
aiomqtt/paho exception strings can include the broker URI with embedded
credentials (mqtt://user:pass@host) or auth detail. The 502 response
from /import and the message field on /test-connection echoed these
verbatim via str(exc).

- Add _sanitize_mqtt_error() that maps known patterns (auth, refused,
  DNS, TLS, timeout) to coarse, credential-free categories
- Original exception still logged at WARNING level for operator debug
- Drop hostname:port from TimeoutError messages
- /test-connection unexpected-error path no longer interpolates exc

Tests: 6 new (auth/refused/DNS/TLS/unknown sanitization, end-to-end
fetch_networkmap leak check).
2026-05-06 14:55:51 +00:00
Pouzor 53aeb82af1 bump: dependancy 2026-05-06 16:37:07 +02:00
Pouzor d49610163f feat(zigbee): add MQTT TLS support with optional cert verify skip
- Schema: mqtt_tls + mqtt_tls_insecure flags on import + test-connection
  requests; model_validator enforces insecure requires tls
- Service: _build_tls_context() using ssl.create_default_context();
  logger.warning when verification disabled; tls_context plumbed into
  aiomqtt.Client for both fetch_networkmap and test_mqtt_connection
- Route: passes tls flags through to service
- Frontend: TLS checkbox auto-toggles port 1883<->8883 unless user
  edited; insecure checkbox disabled until TLS on, red-tinted; password
  field marked autocomplete=new-password
- Tests: 7 new backend tests (TLS context build, client kwargs assertion,
  router happy path, insecure-without-tls 422)
2026-05-06 16:30:23 +02:00
Pouzor 517130a9ef fix(zigbee): add zigbee node types to icons and themes maps
TS build failed in smoke-and-integration: NODE_TYPE_DEFAULT_ICONS and
all theme nodeAccents were missing zigbee_coordinator, zigbee_router,
zigbee_enddevice entries required by NodeType union.
2026-05-06 15:41:29 +02:00
pranjal-joshi 67b0620be2 fix: resolve all mypy errors in zigbee_service
- Add dict[str, Any] type parameters throughout (mypy type-arg)
- Fix str | None returns with explicit 'or None' coercion (no-any-return)
- Remove unused type: ignore[import] comment (unused-ignore)
- Decode message.payload bytes before json.loads (arg-type)
- Use X | Y syntax in isinstance check (ruff UP038)
- All 33 tests still passing, ruff + mypy clean

Co-authored-by: CyberKeys <noreply@openclaw.ai>
2026-05-04 14:25:15 +00:00
pranjal-joshi cc9c010002 fix: adhere to CONTRIBUTING.md — ruff clean + all tests passing
- Move aiomqtt to module-level import (enables proper patch() in tests)
- Remove unused variable (description) in zigbee_service — ruff F841
- Split long line (132 chars) to fit 120 char limit — ruff E501
- Fix import sort order in test files — ruff I001
- Remove unused imports (asyncio, AsyncMock, MagicMock) — ruff F401
- Rename test_mqtt_connection import alias to _test_mqtt_connection
  to avoid pytest fixture name collision (ERROR at setup)
- All 33 backend tests now pass (21 service + 12 router)
- TypeScript typecheck: 0 errors

Co-authored-by: CyberKeys <noreply@openclaw.ai>
2026-05-04 14:12:38 +00:00
pranjal-joshi 103e24e5fa feat: add Zigbee2MQTT network map importer
- Backend: async MQTT service (aiomqtt) to fetch Z2M networkmap via bridge API
- Backend: FastAPI router at /api/v1/zigbee with /import and /test-connection
- Backend: Pydantic v2 schemas for request/response validation
- Backend: coordinator → router → end-device parent_id hierarchy builder
- Frontend: ZigbeeImportModal with MQTT config form, Test Connection, Fetch Devices
- Frontend: device list grouped by type (coordinator/router/enddevice) with checkboxes
- Frontend: ZigbeeCoordinatorNode, ZigbeeRouterNode, ZigbeeEndDeviceNode canvas nodes
- Frontend: Zigbee Import button in sidebar alongside Scan Network
- Frontend: handleZigbeeAddToCanvas wires selected devices + edges onto canvas
- Tests: full unit test suite for parser, hierarchy builder, MQTT mocks
- Tests: API endpoint tests for /zigbee/import and /zigbee/test-connection
- Tests: Vitest component tests for ZigbeeImportModal
- Docs: docs/zigbee-import.md with full usage, MQTT config, troubleshooting guide
- Docs: README.md Zigbee2MQTT Import section

Co-authored-by: CyberKeys <noreply@openclaw.ai>
2026-05-04 13:58:58 +00:00
Remy 5fb77ab00b Merge pull request #122 from Pouzor/1.12.1
1.12.1: firewall node + 1–48 bottom connection points
2026-05-03 22:50:09 +02:00
Pouzor 56cfbd1e76 bump version 1.13.0 2026-05-03 22:44:38 +02:00
Pouzor 43761c60cb feat(handles): allow 1-48 bottom connection points via slider
Replaces the 4-option dropdown with a 1..48 slider. Existing handle IDs
('bottom', 'bottom-2'..'bottom-4') are preserved and the hand-tuned pixel
positions for counts 1..4 are locked in (regression-tested) so saved
canvases render identically. Counts >=5 use uniform spacing; node card
auto-grows in width so handles stay clickable. Existing edge-remap on
shrink scales unchanged thanks to its index-based loop.

ha-relevant: yes
2026-05-03 21:09:14 +02:00
Pouzor ad958feabd fix(node-modal): keep modal open when delete confirm is cancelled
Bare if without braces meant onClose() ran unconditionally after the
window.confirm — clicking Cancel still closed the modal and dropped
unsaved edits. Wrap the confirm body so onClose only fires on accept.
2026-05-03 17:16:05 +02:00
Pouzor e3876e934c feat(nodes): add firewall node type
New 'firewall' NodeType with Flame icon. Wired through node registry,
icon registry, all six themes (red-tinted accent per palette), NodeModal
Hardware group, and CustomStyleModal editable types.
2026-05-03 16:48:15 +02:00
Pouzor dfa4a9c849 fix(sidebar): unfreeze view after scan triggers history panel
forceView prop was overriding local activeView on every render. Once parent
set sidebarForceView='history' after starting a scan, it never cleared, so
clicks on other nav items were ignored. Treat forceView as a one-shot
trigger via the previous-prop pattern; user clicks afterward control view.
2026-05-03 16:03:30 +02:00
Remy 785be6a5dd Create FUNDING.yml 2026-04-28 14:52:25 +02:00
Remy 39f8d16ef1 Merge pull request #113 from jackspiering/patch-1
Fix typo in 'optional' section header
2026-04-25 02:14:55 +02:00
Jack Spiering 0095bf8425 Fix typo in 'optional' section header 2026-04-24 22:43:26 +02:00
Pouzor 2cc97a6de9 bump: version 1.12.0 2026-04-24 15:33:09 +02:00
Remy 29a2ef1b20 Merge pull request #94 from findthelorax/bug/bezier-label
fix: bezier label
2026-04-24 15:23:54 +02:00
Pouzor 0c836e0575 Merge remote-tracking branch 'origin/main' into bug/bezier-label 2026-04-24 15:04:05 +02:00
Remy 6518eb313b Merge pull request #108 from findthelorax/fix/modal-accessibility
fix: modal accessibility
2026-04-24 14:48:52 +02:00
Pouzor 09a591f5f4 fix: move IP address comma-separated hint below input to prevent label wrapping 2026-04-24 12:08:56 +02:00
Pouzor 988b804b90 fix: subtle hover border (#8b949e) and remove redundant tabIndex on button elements 2026-04-24 12:01:30 +02:00
Pouzor 6fa0ada325 merge: resolve EdgeModal conflict — keep CSS module classes + SelectValue label fix 2026-04-24 11:50:30 +02:00
Pouzor 17613f42d1 fix: style apply-to-existing buttons as primary blue actions 2026-04-24 11:42:31 +02:00
Pouzor 0a44b69c4e fix: add default size hint (min 140x50, auto) in custom style editor 2026-04-24 11:42:31 +02:00
Pouzor 84235d81bf fix: show edge type label in select trigger after selection 2026-04-24 11:42:31 +02:00
Pouzor 31b61904ac fix: add setCustomStyle to useEffect dependency array 2026-04-24 11:42:31 +02:00
Pouzor babbcb1dc5 feat: add custom style theme with per-type node/edge style editor
- New 'custom' ThemeId with dedicated ThemeCard + pencil edit button
- CustomStyleModal: two-column editor for all node types (excl. groupRect/group) and edge types
  - Per node: border, background, icon color + opacity, default size (w/h)
  - Per edge: color + opacity, path style, animation
  - 'Apply to existing [Type]' per type, 'Apply All to Canvas' footer action
- canvasStore: applyTypeNodeStyle, applyTypeEdgeStyle, applyAllCustomStyles actions
- themeStore: customStyle state + setCustomStyle action
- Backend: custom_style JSON column on canvas_state (ALTER TABLE migration), saved/loaded with canvas
- App.tsx: custom_style included in save payload, restored on load (API + standalone)
- Tests: +8 frontend (themeStore + canvasStore), +3 backend (canvas API)
2026-04-24 11:42:31 +02:00
findthelorax 12fde681ba removed closeIconProps on edge modal 2026-04-23 22:57:36 -04:00
Brett Ferrante 63922f0841 Merge branch 'Pouzor:main' into bug/bezier-label 2026-04-23 20:06:50 -04:00
findthelorax 896cd4fa21 made a lot of accessibility fixes for the modalsincluding pointers, keyboard navigation and consistent border radius 2026-04-23 20:06:10 -04:00
findthelorax 18f9bb7bdf allow the enter key to apply the currently selected style 2026-04-24 00:42:00 +02:00
findthelorax da287d459c allow for keyboard navigation and adjusted card height to match regardless of text inside 2026-04-24 00:42:00 +02:00
findthelorax adb2088752 revert: restore workflow to upstream version 2026-04-24 00:42:00 +02:00
findthelorax cd0e08fb91 fixed canvas style options to fit better and be max 50vw 2026-04-24 00:42:00 +02:00
Brett Ferrante 3ccdde0bea Update Docker image references to use repository owner 2026-04-24 00:42:00 +02:00
Lucas Van Vonderen d0a49d0a0d fix(mcp): mount session manager via Starlette Mount to avoid double response.start
StreamableHTTPSessionManager is an ASGI app — it sends its own
http.response.start and http.response.body messages via the scope/receive/send
triple. Wrapping it inside a @app.api_route FastAPI handler causes FastAPI
to try to finalize the response after the handler returns, emitting a
second http.response.start. uvicorn rejects this with:

    RuntimeError: Unexpected ASGI message 'http.response.start' sent,
    after response already completed.

Every POST /mcp raises, making the server unreachable from any MCP client
(tested with Claude Code 2.x against mcp==1.27.0).

Fix: mount the session manager as a Starlette Mount so it owns the
response cycle directly. Auth middleware still applies because
add_middleware attaches at the app level, wrapping all mounted sub-apps.
2026-04-24 00:34:55 +02:00
findthelorax 31b5bc4515 fixed accessibility and keyboard navigation for edit node and connection modals 2026-04-23 16:00:55 -04:00
Brett Ferrante b05d70663c Merge branch 'Pouzor:main' into bug/bezier-label 2026-04-22 20:49:11 -04:00
Brett Ferrante 4e68af7cac Merge branch 'Pouzor:main' into main 2026-04-22 20:48:47 -04:00
Pouzor 528c362633 bump: version 1.11.0 2026-04-23 02:38:31 +02:00
Pouzor bfe520cd49 fix: use absolute positioning for status dot to prevent IP truncation 2026-04-23 02:35:23 +02:00
Pouzor 29c97ae501 fix: align status dot to top and restore gap in ProxmoxGroupNode header 2026-04-23 02:35:23 +02:00
findthelorax 4ecd241bf4 enlarged node resizer border for easier resizing 2026-04-23 02:35:23 +02:00
findthelorax 96786f155b aligned all status icon styling 2026-04-23 02:35:23 +02:00
Pouzor 1d6127fed3 fix(detail-panel): replace pixel math and fixed tooltip in ServiceBadge
- Remove magic-number width calculations (maxTotalWidth, nameCharWidth)
- Remove useState tooltip + position:fixed custom tooltip div
- Use CSS flex/truncate for layout and Shadcn Tooltip for path hover
- Add ServiceBadge rendering tests (name, port, path, link vs plain text)
2026-04-23 00:55:14 +02:00
findthelorax 26633f760d aligned services and path styling 2026-04-23 00:55:14 +02:00
findthelorax 745002593f reduces clickable space to the external link icon and the service name and added a tooltip to show truncated paths at the pointer 2026-04-23 00:55:14 +02:00
findthelorax 1a978c5e51 prioritize service name and port, truncate path or hide when necessary 2026-04-23 00:55:14 +02:00
findthelorax 16adff5cff prioritize service name, truncate path or hide when necessary 2026-04-23 00:55:14 +02:00
findthelorax 45b0965fb7 path max width set to 80px 2026-04-23 00:55:14 +02:00
findthelorax f1bcd6ef78 restore: tooltip.tsx 2026-04-23 00:55:14 +02:00
findthelorax 26be37f731 aligned services and path styling 2026-04-23 00:55:14 +02:00
Pouzor ebc1d41d5c feat: add docker_container node type and fix container mode for non-proxmox types
- Add docker_container node type (Package icon, sky-blue accent) as child of docker_host
- Parent selector for docker_container filters to docker_host only via nodeType field
- Virtual edge drag-connect syncs parent_id for docker_container <-> docker_host
- Fix setProxmoxContainerMode: remove proxmox-only guard so width/height are properly
  set/cleared for all container-capable types (docker_host, vm, lxc)
- Fix handleAddNode: only give group size when container_mode=true, making create
  and reload behavior consistent (was giving size unconditionally for CONTAINER_MODE_TYPES)
- Add regression tests for docker_host container mode toggle and docker_container nesting
2026-04-22 22:41:01 +02:00
findthelorax fe5e3c9858 revert: restore workflow to upstream version 2026-04-22 22:41:01 +02:00
findthelorax 78b47384da fixed the container mode toggle styling for backward compatability and proper padding to remain centered in it's parent element 2026-04-22 22:41:01 +02:00
findthelorax 38e6604f50 align canvasStore expectations with container-mode behavior 2026-04-22 22:41:01 +02:00
findthelorax 0e70b45e8a code aligned cleanup 2026-04-22 22:41:01 +02:00
findthelorax 7312132767 adjusted test to be more exhaustive of all container and parent node types 2026-04-22 22:41:01 +02:00
findthelorax cb5b1bd2e2 adjusting wording by replacing proxmox with generics since multiple node types can be a parent node 2026-04-22 22:41:01 +02:00
findthelorax c9a402fa0a fix: shows the proper parent node names once selected in the edit node modal 2026-04-22 22:41:01 +02:00
findthelorax 9f880395da fix: adjusted deserialize for all virtualization node types 2026-04-22 22:41:01 +02:00
findthelorax 585df726e7 feature: added a Container Mode toggle to the Virtualization group of node types 2026-04-22 22:41:01 +02:00
Brett Ferrante 1a3cde3a02 Update Docker image references to use repository owner 2026-04-22 22:41:01 +02:00
Brett Ferrante bfd7ccd36c Merge branch 'Pouzor:main' into bug/bezier-label 2026-04-21 10:47:39 -04:00
Brett Ferrante ada30311ed Merge branch 'Pouzor:main' into main 2026-04-21 10:11:55 -04:00
findthelorax a8ca4f1cca revert: restore workflow to upstream version 2026-04-21 16:08:40 +02:00
findthelorax feb05a1df5 updated node modal with consistent casing for type and check method following standard conventions 2026-04-21 16:08:40 +02:00
Brett Ferrante 0204a7ddeb Update Docker image references to use repository owner 2026-04-21 16:08:40 +02:00
Brett Ferrante 6ead20125d Merge branch 'Pouzor:main' into main 2026-04-21 09:51:22 -04:00
Remy 03e1e32af1 Create LICENSE 2026-04-21 12:29:18 +02:00
Pouzor c9cd6a08fc add CONTRIBUTING.md 2026-04-21 12:23:50 +02:00
Pouzor daa78a036a bump: version 1.10.2 2026-04-21 11:52:43 +02:00
Pouzor 3deb750441 fix: NaN guard on settings interval input and validate release URL scheme 2026-04-21 11:43:15 +02:00
Pouzor 88554ef952 fix: stop click propagation on pending device checkbox to prevent modal opening 2026-04-21 11:37:43 +02:00
Pouzor 110592f89e fix: checkbox onChange anti-pattern in PendingDevicesPanel 2026-04-21 11:28:55 +02:00
Pouzor 8b8da5584c feat: add logout button to sidebar 2026-04-21 11:23:52 +02:00
findthelorax 8626fb2ca4 revert: restore workflow to upstream version 2026-04-20 23:28:11 -04:00
findthelorax 5952274c27 adding in path geometry for the add point + 2026-04-20 23:26:25 -04:00
findthelorax 9c035e2be2 adding in path geometry for the add point + 2026-04-20 23:24:26 -04:00
findthelorax cad3add223 bug: fixed an issue with the label and a bezier path with multiple points 2026-04-20 23:24:26 -04:00
Brett Ferrante fc888629c3 Merge branch 'Pouzor:main' into bug/bezier-label 2026-04-20 23:17:05 -04:00
Brett Ferrante 6a21cc729e Merge pull request #16 from findthelorax/findthelorax-workflow-owner-patch
Update Docker image references to use repository owner
2026-04-20 09:36:24 -04:00
Brett Ferrante 06370529c6 Update Docker image references to use repository owner 2026-04-20 09:35:53 -04:00
Brett Ferrante 892710faac Merge branch 'Pouzor:main' into bug/bezier-label 2026-04-18 22:27:02 -04:00
findthelorax 431fb47498 bug: fixed an issue with the label and a bezier path with multiple points 2026-04-17 20:28:51 -04:00
92 changed files with 9352 additions and 1300 deletions
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: pouzor
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+276
View File
@@ -0,0 +1,276 @@
# Contributing to Homelable
Thanks for taking the time to contribute! This document covers everything you need to get started.
---
## Table of Contents
- [Ways to Contribute](#ways-to-contribute)
- [Reporting Bugs](#reporting-bugs)
- [Suggesting Features](#suggesting-features)
- [Development Setup](#development-setup)
- [Project Structure](#project-structure)
- [Coding Standards](#coding-standards)
- [Testing](#testing)
- [Submitting a Pull Request](#submitting-a-pull-request)
- [Commit Message Format](#commit-message-format)
---
## Ways to Contribute
- Report bugs or unexpected behavior
- Suggest new features or improvements
- Fix open issues (check the [issue tracker](https://github.com/Pouzor/homelable/issues))
- Improve documentation
- Add service signatures to `service_signatures.json`
---
## Reporting Bugs
Before opening an issue, search existing ones to avoid duplicates.
When filing a bug, include:
- **Homelable version** (visible in the sidebar bottom-left)
- **Deployment method** (Docker Compose, Proxmox LXC, source)
- **Steps to reproduce**
- **Expected vs actual behavior**
- **Relevant logs** (`docker compose logs backend` / `docker compose logs frontend`)
- **Browser console errors** if it's a UI issue
---
## Suggesting Features
Open an issue with the `enhancement` label. Describe:
- The problem you're trying to solve
- Your proposed solution
- Any alternatives you considered
For large changes, discuss first before writing code — it avoids wasted effort.
---
## Development Setup
### Prerequisites
- **Node.js 20+** and **npm**
- **Python 3.113.13** (3.14 not yet supported by all dependencies)
- **nmap** installed on your system (required for scanner)
- **Docker + Docker Compose** (optional, for full-stack testing)
### 1. Clone the repo
```bash
git clone https://github.com/Pouzor/homelable.git
cd homelable
```
### 2. Backend
```bash
cd backend
python3.13 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
# Copy and configure environment
cp .env.example .env # edit AUTH_PASSWORD_HASH, SECRET_KEY, etc.
# Start the backend (auto-reloads on change)
uvicorn app.main:app --reload --port 8000
```
API docs available at `http://localhost:8000/docs`.
### 3. Frontend
```bash
cd frontend
npm install
npm run dev # http://localhost:5173
```
Vite proxies `/api` to `localhost:8000` — the backend must be running.
### 4. Verify tooling
```bash
./scripts/verify-tooling.sh
```
---
## Project Structure
```
homelable/
├── frontend/src/
│ ├── components/
│ │ ├── canvas/ # React Flow canvas, custom nodes & edges
│ │ ├── panels/ # Sidebar, detail panel, toolbar
│ │ ├── modals/ # Add/edit node, scan config, pending devices
│ │ └── ui/ # Shadcn/ui base components
│ ├── stores/ # Zustand state (canvas, auth, scan)
│ ├── hooks/ # Custom React hooks
│ ├── types/ # TypeScript interfaces & enums
│ ├── api/ # Axios client & typed endpoints
│ └── utils/ # Layout, export, color helpers
├── backend/app/
│ ├── api/routes/ # FastAPI route handlers
│ ├── services/ # Scanner, status checker, canvas service
│ ├── db/ # SQLAlchemy models, Alembic migrations
│ ├── schemas/ # Pydantic request/response schemas
│ └── core/ # Config, JWT, scheduler
├── docker/ # Nginx configs
├── scripts/ # LXC bootstrap, dev helpers
└── mcp/ # MCP server (AI integration)
```
---
## Coding Standards
### General
- No untested code merged — every feature or fix must include tests
- Keep changes focused — one concern per PR
### Frontend (TypeScript + React)
- Strict TypeScript — no `any`, no type assertions unless truly necessary
- React Flow node domain fields go in `node.data`, never on the node root
- State management via Zustand stores — no prop drilling beyond 2 levels
- Styling via TailwindCSS utility classes — follow the existing [design system](#design-system)
- Run before committing:
```bash
cd frontend
npm run lint
npm run typecheck
npm test
```
### Backend (Python + FastAPI)
- Python 3.11+ syntax
- Pydantic v2 schemas for all request/response types
- SQLAlchemy async sessions — never block the event loop
- Scanner logic runs in a background thread — never in an async route directly
- All schema changes via Alembic migrations — never modify tables directly
- Run before committing:
```bash
cd backend
source .venv/bin/activate
ruff check .
pytest
```
### Design System
| Token | Value |
|---|---|
| Background | `#0d1117` |
| Surface | `#161b22` |
| Card | `#21262d` |
| Accent cyan | `#00d4ff` |
| Online | `#39d353` |
| Offline | `#f85149` |
| Pending | `#e3b341` |
| Font (UI) | Inter |
| Font (IPs/ports) | JetBrains Mono |
---
## Testing
Tests run automatically via a pre-commit hook when frontend or backend files are staged.
### Frontend
```bash
cd frontend
npm test # run all tests
npm run test:coverage # with coverage report
```
Test files live in `__tests__/` next to their module, named `*.test.ts(x)`.
**What to test:** Zustand store actions, utility functions, non-trivial component logic.
### Backend
```bash
cd backend
source .venv/bin/activate
pytest # run all tests
pytest -v tests/test_nodes.py # single file
```
Test files live in `backend/tests/test_*.py`.
**What to test:** all API routes (happy path + error cases), auth flows, service logic.
Use the `client` and `headers` fixtures from `conftest.py` — they provide an in-memory SQLite database so tests are isolated and fast.
---
## Submitting a Pull Request
1. **Fork** the repo and create a branch from `main`:
```bash
git checkout -b feat/my-feature
```
2. **Make your changes** — include tests.
3. **Run the full test suite** (frontend + backend) and make sure everything passes.
4. **Open a PR** against `main`:
- Use a clear title (see commit format below)
- Describe what changed and why
- Reference any related issues (`Closes #123`)
- Include screenshots for UI changes
5. Keep the PR focused — one feature or fix per PR. Large refactors should be discussed in an issue first.
---
## Commit Message Format
Follow [Conventional Commits](https://www.conventionalcommits.org/):
```
<type>: <short description>
[optional body]
```
| Type | When to use |
|---|---|
| `feat` | New feature |
| `fix` | Bug fix |
| `docs` | Documentation only |
| `refactor` | Code change with no behavior change |
| `test` | Adding or fixing tests |
| `chore` | Build, deps, tooling |
**Examples:**
```
feat: add logout button to sidebar
fix: stop click propagation on pending device checkbox
docs: add CONTRIBUTING.md
```
---
## Questions?
Open a [GitHub Discussion](https://github.com/Pouzor/homelable/discussions) or drop a comment on a relevant issue.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Remy Jardinet
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+33 -1
View File
@@ -74,6 +74,38 @@ Homelable continuously monitors your nodes and displays their live status (onlin
---
## Zigbee2MQTT Import
Homelable can connect directly to your MQTT broker and import your Zigbee network topology from **Zigbee2MQTT**, placing each device on the canvas as a typed node.
### Prerequisites
- A running **MQTT broker** (e.g. Mosquitto) accessible from the Homelable host
- **Zigbee2MQTT** connected to the broker with at least one device paired
### Usage
1. Click **Zigbee Import** in the left sidebar (below "Scan Network")
2. Enter your broker host, port (default `1883`), optional credentials, and base topic (default `zigbee2mqtt`)
3. Click **Test Connection** to verify reachability, then **Fetch Devices**
4. Select the devices you want from the grouped list (Coordinator / Router / End Device)
5. Click **Add N to Canvas** — devices are placed in a grid with IoT edges
### Node Types
| Type | Z2M Device | Icon |
|------|-----------|------|
| `zigbee_coordinator` | Coordinator | Network hub |
| `zigbee_router` | Router (mains-powered) | Radio |
| `zigbee_enddevice` | End Device (battery) | Antenna |
Hierarchy is set automatically: coordinator → routers → end devices (`parent_id`).
LQI (Link Quality Indicator) is stored as a node property.
> **Full documentation:** [docs/zigbee-import.md](./docs/zigbee-import.md)
---
## Live View (read-only public canvas)
Live View lets you share a read-only snapshot of your canvas with anyone on your network — no login required. It is disabled by default.
@@ -99,7 +131,7 @@ The page shows your canvas in pan/zoom-only mode — no editing, no credentials
---
## MCP Server (AI Integration) (optionnal)
## MCP Server (AI Integration) (optional)
Homelable can exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so any MCP-compatible AI client (Claude Code, Claude Desktop, Open WebUI…) can read your homelab topology and act on it.
+1 -1
View File
@@ -1 +1 @@
1.10.1
2.0.3
+4 -2
View File
@@ -25,6 +25,7 @@ async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_c
nodes=[NodeResponse.model_validate(n) for n in nodes],
edges=[EdgeResponse.model_validate(e) for e in edges],
viewport=viewport,
custom_style=state.custom_style if state else None,
)
@@ -67,13 +68,14 @@ async def save_canvas(
else:
db.add(Edge(**edge_data.model_dump()))
# Upsert viewport
# Upsert viewport + custom style
state = await db.get(CanvasState, 1)
if state:
state.viewport = body.viewport
state.custom_style = body.custom_style
state.saved_at = datetime.now(timezone.utc)
else:
db.add(CanvasState(id=1, viewport=body.viewport))
db.add(CanvasState(id=1, viewport=body.viewport, custom_style=body.custom_style))
await db.commit()
return {"saved": True}
+144 -4
View File
@@ -11,7 +11,7 @@ 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 Node, PendingDevice, ScanRun
from app.db.models import Edge, Node, PendingDevice, PendingDeviceLink, ScanRun
from app.schemas.nodes import NodeCreate
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
from app.services.scanner import request_cancel, run_scan
@@ -126,23 +126,34 @@ async def bulk_approve_devices(
for device in devices:
device.status = "approved"
node = Node(
label=device.hostname or device.ip,
label=device.hostname or device.friendly_name or device.ip or "device",
type=device.suggested_type or "generic",
ip=device.ip,
hostname=device.hostname,
status="unknown",
services=device.services or [],
ieee_address=device.ieee_address,
# Default to ping so the status checker actually polls the new node.
# Without this the scheduler skips it (check_method NULL → no check).
check_method="ping" if device.ip else None,
)
db.add(node)
created_nodes.append(node)
await db.flush() # populates node.id from Python-side default before reading
node_ids = [n.id for n in created_nodes]
approved_device_ids = [d.id for d in devices]
all_edges: list[dict[str, str]] = []
for device in devices:
all_edges.extend(await _resolve_pending_links_for_ieee(db, device.ieee_address))
await db.commit()
return {
"approved": len(node_ids),
"node_ids": node_ids,
"device_ids": approved_device_ids,
"edges_created": len(all_edges),
"edges": all_edges,
"skipped": len(payload.device_ids) - len(node_ids),
}
@@ -166,6 +177,41 @@ async def bulk_hide_devices(
return {"hidden": len(devices), "skipped": len(payload.device_ids) - len(devices)}
@router.post("/pending/{device_id}/restore", response_model=dict)
async def restore_device(
device_id: str,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, Any]:
device = await db.get(PendingDevice, device_id)
if not device:
raise HTTPException(status_code=404, detail="Device not found")
if device.status != "hidden":
raise HTTPException(status_code=409, detail="Device is not hidden")
device.status = "pending"
await db.commit()
return {"restored": True, "device_id": device_id}
@router.post("/pending/bulk-restore", response_model=dict)
async def bulk_restore_devices(
payload: BulkActionRequest,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, Any]:
result = await db.execute(
select(PendingDevice).where(
PendingDevice.id.in_(payload.device_ids),
PendingDevice.status == "hidden",
)
)
devices = result.scalars().all()
for device in devices:
device.status = "pending"
await db.commit()
return {"restored": len(devices), "skipped": len(payload.device_ids) - len(devices)}
@router.post("/pending/{device_id}/approve", response_model=dict)
async def approve_device(
device_id: str,
@@ -179,19 +225,113 @@ async def approve_device(
if device.status != "pending":
raise HTTPException(status_code=409, detail="Device already processed")
device.status = "approved"
_zigbee_types = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"}
_is_zigbee = node_data.type in _zigbee_types
node = Node(
label=node_data.label,
type=node_data.type,
ip=node_data.ip,
hostname=node_data.hostname,
status=node_data.status,
status="online" if _is_zigbee else node_data.status,
services=node_data.services or [],
ieee_address=device.ieee_address,
check_method="none" if _is_zigbee else (node_data.check_method or ("ping" if node_data.ip else None)),
check_target=None if _is_zigbee else node_data.check_target,
)
db.add(node)
await db.flush()
node_id = node.id
edges = await _resolve_pending_links_for_ieee(db, device.ieee_address)
await db.commit()
return {"approved": True, "node_id": node_id}
return {
"approved": True,
"node_id": node_id,
"edges_created": len(edges),
"edges": edges,
}
async def _resolve_pending_links_for_ieee(
db: AsyncSession, ieee: str | None
) -> list[dict[str, str]]:
"""Materialize edges for any pending_device_links involving ``ieee``.
For each link where the other endpoint already exists as a canvas Node
(matched by ``Node.ieee_address``), create the Edge and drop the link
row. Links where the other endpoint is still pending are kept so they
can resolve when that endpoint is approved later.
"""
if not ieee:
return []
links_q = await db.execute(
select(PendingDeviceLink).where(
(PendingDeviceLink.source_ieee == ieee)
| (PendingDeviceLink.target_ieee == ieee)
)
)
links = list(links_q.scalars().all())
if not links:
return []
# Map every relevant ieee → Node (single query).
other_ieees = {
link.target_ieee if link.source_ieee == ieee else link.source_ieee
for link in links
}
other_ieees.add(ieee)
nodes_q = await db.execute(
select(Node).where(Node.ieee_address.in_(other_ieees))
)
by_ieee = {n.ieee_address: n for n in nodes_q.scalars().all() if n.ieee_address}
self_node = by_ieee.get(ieee)
if self_node is None:
return []
# Pre-fetch existing edges between these node ids so we don't create dups
# if the user re-approves a device or had drawn the link manually.
candidate_node_ids = [n.id for n in by_ieee.values()]
existing_q = await db.execute(
select(Edge).where(
Edge.source.in_(candidate_node_ids),
Edge.target.in_(candidate_node_ids),
)
)
existing_pairs = {(e.source, e.target) for e in existing_q.scalars().all()}
created: list[dict[str, str]] = []
for link in links:
other_ieee = (
link.target_ieee if link.source_ieee == ieee else link.source_ieee
)
other_node = by_ieee.get(other_ieee)
if other_node is None:
continue
if link.source_ieee == ieee:
src_id, tgt_id = self_node.id, other_node.id
else:
src_id, tgt_id = other_node.id, self_node.id
# Skip if either direction already exists.
if (src_id, tgt_id) in existing_pairs or (tgt_id, src_id) in existing_pairs:
await db.delete(link)
continue
edge = Edge(
source=src_id,
target=tgt_id,
type="iot",
source_handle="bottom",
target_handle="top-t",
)
db.add(edge)
await db.flush()
existing_pairs.add((src_id, tgt_id))
created.append({"id": edge.id, "source": src_id, "target": tgt_id})
await db.delete(link)
return created
@router.post("/pending/{device_id}/hide")
+261
View File
@@ -0,0 +1,261 @@
"""FastAPI router for Zigbee2MQTT import."""
import logging
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy import delete as sa_delete
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import AsyncSessionLocal, get_db
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
from app.schemas.scan import ScanRunResponse
from app.schemas.zigbee import (
ZigbeeCoordinatorOut,
ZigbeeEdgeOut,
ZigbeeImportPendingResponse,
ZigbeeImportRequest,
ZigbeeImportResponse,
ZigbeeNodeOut,
ZigbeeTestConnectionRequest,
ZigbeeTestConnectionResponse,
)
from app.services.zigbee_service import fetch_networkmap, test_mqtt_connection
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/import", response_model=ZigbeeImportResponse)
async def import_zigbee_network(
payload: ZigbeeImportRequest,
_: str = Depends(get_current_user),
) -> ZigbeeImportResponse:
"""Fetch the Zigbee2MQTT network map and return nodes + edges ready for canvas drop.
Connects to the specified MQTT broker, publishes a networkmap request to
``<base_topic>/bridge/request/networkmap``, and waits up to 60 s for the
response (large meshes can take 30 s+). The devices are returned as typed homelable nodes with a
coordinator → router → end-device hierarchy.
"""
try:
nodes_raw, edges_raw = await fetch_networkmap(
mqtt_host=payload.mqtt_host,
mqtt_port=payload.mqtt_port,
base_topic=payload.base_topic,
username=payload.mqtt_username,
password=payload.mqtt_password,
tls=payload.mqtt_tls,
tls_insecure=payload.mqtt_tls_insecure,
)
except ImportError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
except ConnectionError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
except TimeoutError as exc:
raise HTTPException(status_code=504, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except Exception as exc:
logger.exception("Unexpected error during Zigbee import")
raise HTTPException(status_code=500, detail="Unexpected error during Zigbee import") from exc
nodes = [ZigbeeNodeOut(**n) for n in nodes_raw]
edges = [ZigbeeEdgeOut(**e) for e in edges_raw]
return ZigbeeImportResponse(nodes=nodes, edges=edges, device_count=len(nodes))
@router.post("/import-pending", response_model=ScanRunResponse)
async def import_zigbee_to_pending(
payload: ZigbeeImportRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> ScanRun:
"""Queue a Zigbee2MQTT pending import as a background scan run.
Returns the ScanRun row immediately so the UI can close the import
modal and surface progress under Scan History (kind=zigbee). The
actual MQTT fetch + pending upsert happens in the background.
"""
run = ScanRun(
status="running",
kind="zigbee",
ranges=[f"{payload.mqtt_host}:{payload.mqtt_port}"],
)
db.add(run)
await db.commit()
await db.refresh(run)
background_tasks.add_task(_background_zigbee_import, run.id, payload)
return run
async def _background_zigbee_import(run_id: str, payload: ZigbeeImportRequest) -> None:
async with AsyncSessionLocal() as db:
try:
nodes_raw, edges_raw = await fetch_networkmap(
mqtt_host=payload.mqtt_host,
mqtt_port=payload.mqtt_port,
base_topic=payload.base_topic,
username=payload.mqtt_username,
password=payload.mqtt_password,
tls=payload.mqtt_tls,
tls_insecure=payload.mqtt_tls_insecure,
)
result = await _persist_pending_import(db, nodes_raw, edges_raw)
run = await db.get(ScanRun, run_id)
if run:
run.status = "done"
run.devices_found = result.device_count
run.finished_at = datetime.now(timezone.utc)
await db.commit()
except Exception as exc:
logger.exception("Zigbee import %s failed", run_id)
await db.rollback()
run = await db.get(ScanRun, run_id)
if run:
run.status = "error"
run.error = str(exc)[:500]
run.finished_at = datetime.now(timezone.utc)
await db.commit()
async def _persist_pending_import(
db: AsyncSession,
nodes_raw: list[dict[str, Any]],
edges_raw: list[dict[str, Any]],
) -> ZigbeeImportPendingResponse:
"""Upsert nodes/edges into pending_devices + pending_device_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.
"""
coordinator_out: ZigbeeCoordinatorOut | None = None
coordinator_existed = False
pending_created = 0
pending_updated = 0
for n in nodes_raw:
ieee = n.get("ieee_address")
if not ieee:
continue
if n.get("device_type") == "Coordinator":
existing = await db.execute(select(Node).where(Node.ieee_address == ieee))
existing_node = existing.scalar_one_or_none()
if existing_node:
coordinator_out = ZigbeeCoordinatorOut(
id=existing_node.id,
label=existing_node.label,
ieee_address=ieee,
)
coordinator_existed = True
continue
label = n.get("friendly_name") or ieee
node = Node(
label=label,
type=n.get("type") or "zigbee_coordinator",
status="online",
check_method="none",
ieee_address=ieee,
services=[],
)
db.add(node)
await db.flush()
coordinator_out = ZigbeeCoordinatorOut(
id=node.id, label=label, ieee_address=ieee
)
continue
result = await db.execute(
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
)
pending = result.scalar_one_or_none()
if pending is None:
db.add(
PendingDevice(
ieee_address=ieee,
friendly_name=n.get("friendly_name"),
hostname=n.get("friendly_name"),
suggested_type=n.get("type"),
device_subtype=n.get("device_type"),
model=n.get("model"),
vendor=n.get("vendor"),
lqi=n.get("lqi"),
status="pending",
discovery_source="zigbee",
)
)
pending_created += 1
else:
pending.friendly_name = n.get("friendly_name") or pending.friendly_name
pending.suggested_type = n.get("type") or pending.suggested_type
pending.device_subtype = n.get("device_type") or pending.device_subtype
pending.model = n.get("model") or pending.model
pending.vendor = n.get("vendor") or pending.vendor
if n.get("lqi") is not None:
pending.lqi = n.get("lqi")
if pending.status == "hidden":
# Re-imported a hidden device → leave it hidden, just refresh fields.
pass
pending_updated += 1
# Replace all zigbee-source links with the freshly discovered set.
await db.execute(
sa_delete(PendingDeviceLink).where(PendingDeviceLink.discovery_source == "zigbee")
)
links_recorded = 0
seen: set[tuple[str, str]] = set()
for e in edges_raw:
src = e.get("source")
tgt = e.get("target")
if not src or not tgt or (src, tgt) in seen:
continue
seen.add((src, tgt))
db.add(
PendingDeviceLink(
source_ieee=src,
target_ieee=tgt,
discovery_source="zigbee",
)
)
links_recorded += 1
await db.commit()
return ZigbeeImportPendingResponse(
pending_created=pending_created,
pending_updated=pending_updated,
coordinator=coordinator_out,
coordinator_already_existed=coordinator_existed,
links_recorded=links_recorded,
device_count=len(nodes_raw),
)
@router.post("/test-connection", response_model=ZigbeeTestConnectionResponse)
async def test_zigbee_connection(
payload: ZigbeeTestConnectionRequest,
_: str = Depends(get_current_user),
) -> ZigbeeTestConnectionResponse:
"""Quick MQTT ping to validate broker connection before importing."""
try:
await test_mqtt_connection(
mqtt_host=payload.mqtt_host,
mqtt_port=payload.mqtt_port,
username=payload.mqtt_username,
password=payload.mqtt_password,
tls=payload.mqtt_tls,
tls_insecure=payload.mqtt_tls_insecure,
)
return ZigbeeTestConnectionResponse(connected=True, message="Connection successful")
except ImportError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
except (ConnectionError, TimeoutError) as exc:
return ZigbeeTestConnectionResponse(connected=False, message=str(exc))
except Exception:
logger.exception("Unexpected error during connection test")
return ZigbeeTestConnectionResponse(connected=False, message="Unexpected error")
+91 -1
View File
@@ -5,13 +5,30 @@ from contextlib import suppress
from pathlib import Path
from sqlalchemy.exc import OperationalError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.core.config import APP_VERSION, settings
logger = logging.getLogger(__name__)
async def _try_migrate(conn: AsyncConnection, sql: str, *, label: str) -> None:
"""Run an idempotent migration statement, logging any error.
Distinguishes 'already applied' errors (debug) from genuine failures
(warning) so silent corruption is avoided. Used for new in-commit
migrations; existing legacy ALTERs above remain wrapped in suppress.
"""
try:
await conn.exec_driver_sql(sql)
except OperationalError as exc:
msg = str(exc).lower()
if "duplicate column" in msg or "already exists" in msg:
logger.debug("Migration %s skipped (already applied): %s", label, exc)
else:
logger.warning("Migration %s failed: %s", label, exc)
# Ensure the data directory exists before SQLite tries to open the file
Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True)
@@ -80,10 +97,83 @@ async def init_db() -> None:
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN bottom_handles INTEGER NOT NULL DEFAULT 1")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_source TEXT")
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) ---
zigbee_migrations: list[tuple[str, str]] = [
("nodes.ieee_address", "ALTER TABLE nodes ADD COLUMN ieee_address TEXT"),
(
"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"),
(
"pending_devices.ieee_address.index",
"CREATE INDEX IF NOT EXISTS ix_pending_devices_ieee_address "
"ON pending_devices(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"),
]
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).
# SQLite can't ALTER column nullability — rebuild the table if needed.
try:
info = await conn.exec_driver_sql("PRAGMA table_info(pending_devices)")
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")
await conn.exec_driver_sql("PRAGMA foreign_keys = OFF")
await conn.exec_driver_sql(
"CREATE TABLE pending_devices_new ("
"id VARCHAR PRIMARY KEY,"
"ip VARCHAR,"
"mac VARCHAR, hostname VARCHAR, os VARCHAR, services JSON,"
"suggested_type VARCHAR,"
"status VARCHAR,"
"discovery_source VARCHAR,"
"ieee_address VARCHAR,"
"friendly_name VARCHAR,"
"device_subtype VARCHAR,"
"model VARCHAR,"
"vendor VARCHAR,"
"lqi INTEGER,"
"discovered_at DATETIME"
")"
)
await conn.exec_driver_sql(
"INSERT INTO pending_devices_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"
)
await conn.exec_driver_sql("DROP TABLE pending_devices")
await conn.exec_driver_sql(
"ALTER TABLE pending_devices_new RENAME TO pending_devices"
)
await conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_pending_devices_ieee_address "
"ON pending_devices(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)
# --- end Zigbee schema migrations -------------------------------------
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN properties JSON")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE canvas_state ADD COLUMN custom_style JSON")
# Migrate hardware columns → properties JSON (idempotent: only runs on nodes where properties IS NULL)
with suppress(OperationalError):
rows = await conn.exec_driver_sql(
+29 -1
View File
@@ -46,6 +46,7 @@ class Node(Base):
width: Mapped[float | None] = mapped_column(Float, nullable=True)
height: Mapped[float | None] = mapped_column(Float, nullable=True)
bottom_handles: Mapped[int] = mapped_column(Integer, default=1)
ieee_address: Mapped[str | None] = mapped_column(String, index=True, nullable=True)
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
response_time_ms: Mapped[int | None] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
@@ -78,6 +79,7 @@ class CanvasState(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
viewport: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
custom_style: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
@@ -85,7 +87,7 @@ class PendingDevice(Base):
__tablename__ = "pending_devices"
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
ip: Mapped[str] = mapped_column(String, nullable=False)
ip: Mapped[str | None] = mapped_column(String, nullable=True)
mac: Mapped[str | None] = mapped_column(String)
hostname: Mapped[str | None] = mapped_column(String)
os: Mapped[str | None] = mapped_column(String)
@@ -93,6 +95,31 @@ class PendingDevice(Base):
suggested_type: Mapped[str | None] = mapped_column(String)
status: Mapped[str] = mapped_column(String, default="pending")
discovery_source: Mapped[str | None] = mapped_column(String)
ieee_address: Mapped[str | None] = mapped_column(String, index=True, nullable=True, unique=True)
friendly_name: Mapped[str | None] = mapped_column(String, nullable=True)
device_subtype: Mapped[str | None] = mapped_column(String, nullable=True)
model: Mapped[str | None] = mapped_column(String, nullable=True)
vendor: Mapped[str | None] = mapped_column(String, nullable=True)
lqi: Mapped[int | None] = mapped_column(Integer, nullable=True)
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class PendingDeviceLink(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
endpoints exist as canvas Nodes.
"""
__tablename__ = "pending_device_links"
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
source_ieee: Mapped[str] = mapped_column(String, nullable=False, index=True)
target_ieee: Mapped[str] = mapped_column(String, nullable=False, index=True)
lqi: Mapped[int | None] = mapped_column(Integer, nullable=True)
discovery_source: Mapped[str] = mapped_column(String, nullable=False, default="zigbee")
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
@@ -101,6 +128,7 @@ class ScanRun(Base):
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
status: Mapped[str] = mapped_column(String, default="running")
kind: Mapped[str] = mapped_column(String, default="ip", server_default="ip")
ranges: Mapped[list[str]] = mapped_column(JSON, default=list)
devices_found: Mapped[int] = mapped_column(Integer, default=0)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+2 -1
View File
@@ -7,7 +7,7 @@ from typing import Any
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import auth, canvas, edges, liveview, nodes, scan, status
from app.api.routes import auth, canvas, edges, liveview, nodes, scan, status, zigbee
from app.api.routes import settings as settings_routes
from app.core.config import settings
from app.core.scheduler import start_scheduler, stop_scheduler
@@ -55,6 +55,7 @@ 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"])
app.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"])
app.include_router(zigbee.router, prefix="/api/v1/zigbee", tags=["zigbee"])
@app.get("/api/v1/health")
+2
View File
@@ -62,9 +62,11 @@ class CanvasSaveRequest(BaseModel):
nodes: list[NodeSave] = []
edges: list[EdgeSave] = []
viewport: dict[str, Any] = {}
custom_style: dict[str, Any] | None = None
class CanvasStateResponse(BaseModel):
nodes: list[NodeResponse]
edges: list[EdgeResponse]
viewport: dict[str, Any]
custom_style: dict[str, Any] | None = None
+8 -1
View File
@@ -6,7 +6,7 @@ from pydantic import BaseModel
class PendingDeviceResponse(BaseModel):
id: str
ip: str
ip: str | None
mac: str | None
hostname: str | None
os: str | None
@@ -14,6 +14,12 @@ class PendingDeviceResponse(BaseModel):
suggested_type: str | None
status: str
discovery_source: str | None
ieee_address: str | None = None
friendly_name: str | None = None
device_subtype: str | None = None
model: str | None = None
vendor: str | None = None
lqi: int | None = None
discovered_at: datetime
model_config = {"from_attributes": True}
@@ -22,6 +28,7 @@ class PendingDeviceResponse(BaseModel):
class ScanRunResponse(BaseModel):
id: str
status: str
kind: str = "ip"
ranges: list[str]
devices_found: int
started_at: datetime
+95
View File
@@ -0,0 +1,95 @@
"""Pydantic v2 schemas for Zigbee2MQTT import."""
from pydantic import BaseModel, Field, model_validator
class ZigbeeImportRequest(BaseModel):
mqtt_host: str = Field(..., description="MQTT broker hostname or IP address")
mqtt_port: int = Field(1883, ge=1, le=65535, description="MQTT broker port")
mqtt_username: str | None = Field(None, description="MQTT username (optional)")
mqtt_password: str | None = Field(None, description="MQTT password (optional)")
base_topic: str = Field("zigbee2mqtt", description="Zigbee2MQTT base topic")
mqtt_tls: bool = Field(False, description="Enable TLS (typically port 8883)")
mqtt_tls_insecure: bool = Field(
False, description="Skip TLS certificate verification (self-signed only)"
)
@model_validator(mode="after")
def _insecure_requires_tls(self) -> "ZigbeeImportRequest":
if self.mqtt_tls_insecure and not self.mqtt_tls:
raise ValueError("mqtt_tls_insecure requires mqtt_tls=true")
return self
class ZigbeeTestConnectionRequest(BaseModel):
mqtt_host: str
mqtt_port: int = Field(1883, ge=1, le=65535)
mqtt_username: str | None = None
mqtt_password: str | None = None
mqtt_tls: bool = False
mqtt_tls_insecure: bool = False
@model_validator(mode="after")
def _insecure_requires_tls(self) -> "ZigbeeTestConnectionRequest":
if self.mqtt_tls_insecure and not self.mqtt_tls:
raise ValueError("mqtt_tls_insecure requires mqtt_tls=true")
return self
class ZigbeeDeviceData(BaseModel):
ieee_address: str
friendly_name: str
device_type: str # Coordinator, Router, EndDevice
model: str | None = None
vendor: str | None = None
description: str | None = None
lqi: int | None = None
last_seen: str | None = None
class ZigbeeNodeOut(BaseModel):
"""A homelable-ready node representation of a Zigbee device."""
id: str
label: str
type: str # zigbee_coordinator | zigbee_router | zigbee_enddevice
ieee_address: str
friendly_name: str
device_type: str
model: str | None = None
vendor: str | None = None
lqi: int | None = None
parent_id: str | None = None
class ZigbeeEdgeOut(BaseModel):
source: str
target: str
class ZigbeeImportResponse(BaseModel):
nodes: list[ZigbeeNodeOut]
edges: list[ZigbeeEdgeOut]
device_count: int
class ZigbeeTestConnectionResponse(BaseModel):
connected: bool
message: str
class ZigbeeCoordinatorOut(BaseModel):
id: str
label: str
ieee_address: str
class ZigbeeImportPendingResponse(BaseModel):
"""Result of importing a Z2M network into the pending section."""
pending_created: int
pending_updated: int
coordinator: ZigbeeCoordinatorOut | None = None
coordinator_already_existed: bool = False
links_recorded: int
device_count: int
+6
View File
@@ -60,8 +60,14 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
async def _ping(host: str) -> bool:
# ping(8) -W flag units differ by OS:
# Linux: seconds (-W 1 = 1s)
# macOS: milliseconds (-W 1 = 1ms — fails for any RTT >1ms)
# Windows: -w in ms (-w 1000 = 1s)
if sys.platform == "win32":
args = ["ping", "-n", "1", "-w", "1000", host]
elif sys.platform == "darwin":
args = ["ping", "-c", "1", "-W", "1000", host]
else:
args = ["ping", "-c", "1", "-W", "1", host]
proc = await asyncio.create_subprocess_exec(
+326
View File
@@ -0,0 +1,326 @@
"""Zigbee2MQTT service: connects to MQTT broker and fetches the network map."""
from __future__ import annotations
import asyncio
import json
import logging
import ssl
from typing import Any
logger = logging.getLogger(__name__)
try:
import aiomqtt
except ImportError: # pragma: no cover
aiomqtt = None # type: ignore[assignment]
_NETWORKMAP_REQUEST_TOPIC = "{base_topic}/bridge/request/networkmap"
_NETWORKMAP_RESPONSE_TOPIC = "{base_topic}/bridge/response/networkmap"
_CONNECTION_TIMEOUT = 5.0 # seconds to verify broker reachability
_NETWORKMAP_TIMEOUT = 300.0 # seconds to wait for the networkmap response (large meshes can be slow)
def _sanitize_mqtt_error(exc: BaseException) -> str:
"""Return a generic, credential-free message for an MQTT error.
The raw aiomqtt/paho error string can include the broker URI with
embedded credentials (e.g. ``mqtt://user:pass@host``) or auth-related
detail that should not leak to API clients. Map known patterns to
coarse categories; default to a generic failure message. The original
exception is logged at WARNING level for operator debugging.
"""
logger.warning("MQTT error (sanitized for client): %r", exc)
raw = str(exc).lower()
if "not authoriz" in raw or "bad user" in raw or "bad username" in raw:
return "Authentication failed"
if "refused" in raw:
return "Connection refused by broker"
if "name or service not known" in raw or "getaddrinfo" in raw or "nodename nor servname" in raw:
return "Broker hostname could not be resolved"
if "ssl" in raw or "tls" in raw or "certificate" in raw:
return "TLS handshake failed"
if "timed out" in raw or "timeout" in raw:
return "Connection to broker timed out"
return "MQTT connection failed"
def _build_tls_context(insecure: bool) -> ssl.SSLContext:
"""Build an SSL context for MQTT TLS. If insecure, skip verification."""
ctx = ssl.create_default_context()
if insecure:
logger.warning(
"MQTT TLS certificate verification is DISABLED — "
"use only with self-signed brokers on trusted networks."
)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
def _z2m_type_to_homelable(device_type: str) -> str:
"""Map a Z2M device type string to a homelable node type."""
mapping = {
"Coordinator": "zigbee_coordinator",
"Router": "zigbee_router",
"EndDevice": "zigbee_enddevice",
}
return mapping.get(device_type, "zigbee_enddevice")
def _node_from_z2m(raw: dict[str, Any]) -> dict[str, Any] | None:
"""Build a homelable node dict from a Z2M raw networkmap node entry."""
ieee: str = raw.get("ieeeAddr") or raw.get("ieee_address") or ""
if not ieee:
return None
device_type: str = raw.get("type") or "EndDevice"
friendly_name: str = (
raw.get("friendlyName") or raw.get("friendly_name") or ieee
)
definition: dict[str, Any] = raw.get("definition") or {}
model: str | None = (
raw.get("modelID")
or raw.get("model")
or definition.get("model")
or None
)
vendor: str | None = raw.get("vendor") or definition.get("vendor") or None
return {
"id": ieee,
"label": friendly_name,
"type": _z2m_type_to_homelable(device_type),
"ieee_address": ieee,
"friendly_name": friendly_name,
"device_type": device_type,
"model": model,
"vendor": vendor,
"lqi": None,
"parent_id": None,
}
def parse_networkmap(
payload: dict[str, Any],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Parse a Z2M ``bridge/response/networkmap`` payload into node + edge lists.
Z2M raw response shape::
{
"data": {
"type": "raw",
"routes": false,
"value": {
"nodes": [{"ieeeAddr": ..., "type": "Coordinator|Router|EndDevice",
"friendlyName": ..., "definition": {"model": ..., "vendor": ...}}],
"links": [{"source": {"ieeeAddr": ...}, "target": {"ieeeAddr": ...},
"lqi": 200, "depth": 1}]
}
},
"status": "ok"
}
Older or alternate shapes may put nodes/links directly under ``data``.
Both are accepted.
"""
data: dict[str, Any] = payload.get("data") or {}
value = data.get("value")
container: dict[str, Any] = value if isinstance(value, dict) else data
raw_nodes: list[dict[str, Any]] = container.get("nodes") or []
raw_links: list[dict[str, Any]] = container.get("links") or []
if not isinstance(raw_nodes, list):
raise ValueError("Malformed networkmap: 'nodes' is not a list")
if not isinstance(raw_links, list):
raise ValueError("Malformed networkmap: 'links' is not a list")
nodes_list: list[dict[str, Any]] = []
seen_ids: set[str] = set()
coordinator_id: str | None = None
for entry in raw_nodes:
if not isinstance(entry, dict):
continue
node = _node_from_z2m(entry)
if node is None or node["id"] in seen_ids:
continue
seen_ids.add(node["id"])
nodes_list.append(node)
if node["device_type"] == "Coordinator":
coordinator_id = node["id"]
# Z2M `links` is bidirectional/mesh: every pair appears twice and routers
# carry sibling-mesh paths. Walk it only to extract LQI per device and to
# resolve which router an end device hangs off; do NOT emit edges directly
# from links. The final edge set is the strict parent→child tree built
# from parent_id below — that avoids duplicate edges and keeps the visual
# flow consistent (parent bottom → child top).
raw_edges: list[dict[str, Any]] = []
lqi_by_id: dict[str, int] = {}
for link in raw_links:
if not isinstance(link, dict):
continue
src_obj = link.get("source") or {}
tgt_obj = link.get("target") or {}
src = src_obj.get("ieeeAddr") if isinstance(src_obj, dict) else None
tgt = tgt_obj.get("ieeeAddr") if isinstance(tgt_obj, dict) else None
if not src or not tgt:
continue
if src not in seen_ids or tgt not in seen_ids:
continue
raw_edges.append({"source": src, "target": tgt})
lqi = link.get("lqi") or link.get("linkquality")
if isinstance(lqi, int) and tgt not in lqi_by_id:
lqi_by_id[tgt] = lqi
for node in nodes_list:
if node["id"] in lqi_by_id:
node["lqi"] = lqi_by_id[node["id"]]
# Build parent_id hierarchy: coordinator → routers → end devices
if coordinator_id:
router_ids = {n["id"] for n in nodes_list if n["device_type"] == "Router"}
for node in nodes_list:
if node["device_type"] == "Router":
node["parent_id"] = coordinator_id
elif node["device_type"] == "EndDevice":
parent = _find_parent_router(node["id"], router_ids, raw_edges)
node["parent_id"] = parent or coordinator_id
# Final edges = strict parent → child tree (one edge per non-coordinator)
edges_list: list[dict[str, Any]] = [
{"source": node["parent_id"], "target": node["id"]}
for node in nodes_list
if node.get("parent_id")
]
return nodes_list, edges_list
def _find_parent_router(
device_id: str,
router_ids: set[str],
edges: list[dict[str, Any]],
) -> str | None:
"""Return the first router that has a direct edge to device_id."""
for edge in edges:
src: str = edge["source"]
tgt: str = edge["target"]
if tgt == device_id and src in router_ids:
return src
if src == device_id and tgt in router_ids:
return tgt
return None
async def fetch_networkmap(
mqtt_host: str,
mqtt_port: int,
base_topic: str,
username: str | None = None,
password: str | None = None,
tls: bool = False,
tls_insecure: bool = False,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Connect to the MQTT broker, request the Z2M networkmap, and return (nodes, edges).
Raises:
TimeoutError: if the broker does not respond in time.
ConnectionError: if the broker cannot be reached.
ValueError: if the response payload is malformed.
"""
if aiomqtt is None: # pragma: no cover
raise ImportError(
"aiomqtt is required for Zigbee import. "
"Install it with: pip install aiomqtt"
)
request_topic = _NETWORKMAP_REQUEST_TOPIC.format(base_topic=base_topic)
response_topic = _NETWORKMAP_RESPONSE_TOPIC.format(base_topic=base_topic)
response_payload: dict[str, Any] = {}
tls_context = _build_tls_context(tls_insecure) if tls else None
try:
async with aiomqtt.Client(
hostname=mqtt_host,
port=mqtt_port,
username=username,
password=password,
timeout=_CONNECTION_TIMEOUT,
tls_context=tls_context,
) as client:
await client.subscribe(response_topic)
# Give the broker a brief window to register the subscription
# before we publish the request. Without this, brokers that
# race SUBACK with our PUBLISH may deliver the response before
# the subscription is active and we'd hang until timeout.
await asyncio.sleep(0.1)
await client.publish(
request_topic,
json.dumps({"type": "raw", "routes": False}),
)
async def _wait_for_response() -> None:
async for message in client.messages:
if str(message.topic) != response_topic:
continue
raw = message.payload
try:
payload_str = (
raw.decode() if isinstance(raw, bytes | bytearray) else str(raw)
)
response_payload.update(json.loads(payload_str))
except (json.JSONDecodeError, TypeError) as exc:
raise ValueError(
f"Malformed networkmap response: {exc}"
) from exc
return
await asyncio.wait_for(_wait_for_response(), timeout=_NETWORKMAP_TIMEOUT)
except aiomqtt.MqttError as exc:
raise ConnectionError(_sanitize_mqtt_error(exc)) from exc
except asyncio.TimeoutError as exc:
raise TimeoutError("Timed out waiting for networkmap response") from exc
if not response_payload:
raise ValueError("Empty networkmap response received")
return parse_networkmap(response_payload)
async def test_mqtt_connection(
mqtt_host: str,
mqtt_port: int,
username: str | None = None,
password: str | None = None,
tls: bool = False,
tls_insecure: bool = False,
) -> bool:
"""Attempt a quick MQTT connection to verify broker reachability.
Returns True on success, raises ConnectionError on failure.
"""
if aiomqtt is None: # pragma: no cover
raise ImportError("aiomqtt is required")
tls_context = _build_tls_context(tls_insecure) if tls else None
try:
async with aiomqtt.Client(
hostname=mqtt_host,
port=mqtt_port,
username=username,
password=password,
timeout=_CONNECTION_TIMEOUT,
tls_context=tls_context,
):
return True
except aiomqtt.MqttError as exc:
raise ConnectionError(_sanitize_mqtt_error(exc)) from exc
except asyncio.TimeoutError as exc:
raise TimeoutError("Connection to broker timed out") from exc
+2 -1
View File
@@ -9,7 +9,7 @@ pydantic-settings==2.5.2
python-jose[cryptography]==3.5.0
passlib[bcrypt]==1.7.4
bcrypt==4.0.1
python-multipart==0.0.26
python-multipart==0.0.27
apscheduler==3.10.4
python-nmap==0.7.1
pyyaml==6.0.2
@@ -17,6 +17,7 @@ types-PyYAML==6.0.12.20240917
websockets==13.1
httpx==0.27.2
zeroconf==0.131.0
aiomqtt==2.3.0
# Dev
ruff==0.6.9
+39
View File
@@ -557,3 +557,42 @@ async def test_save_canvas_edge_update_existing(client: AsyncClient, headers: di
edge = canvas["edges"][0]
assert edge["label"] == "updated"
assert edge["custom_color"] == "#ff0000"
# ── custom_style ──────────────────────────────────────────────────────────────
async def test_save_and_load_custom_style(client: AsyncClient, headers: dict):
custom_style = {
"nodes": {
"server": {"borderColor": "#ff0000", "borderOpacity": 0.8, "bgColor": "#000000", "bgOpacity": 1, "iconColor": "#ff0000", "iconOpacity": 1, "width": 200, "height": 80},
},
"edges": {
"ethernet": {"color": "#00ff00", "opacity": 1, "pathStyle": "bezier", "animated": "none"},
},
}
payload = {"nodes": [], "edges": [], "viewport": {"theme_id": "custom"}, "custom_style": custom_style}
res = await client.post("/api/v1/canvas/save", json=payload, headers=headers)
assert res.status_code == 200
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["custom_style"] is not None
assert canvas["custom_style"]["nodes"]["server"]["borderColor"] == "#ff0000"
assert canvas["custom_style"]["edges"]["ethernet"]["color"] == "#00ff00"
async def test_load_canvas_custom_style_null_by_default(client: AsyncClient, headers: dict):
res = await client.get("/api/v1/canvas", headers=headers)
assert res.status_code == 200
assert res.json()["custom_style"] is None
async def test_save_canvas_custom_style_overwrite(client: AsyncClient, headers: dict):
style_v1 = {"nodes": {"server": {"borderColor": "#aabbcc", "borderOpacity": 1, "bgColor": "#000000", "bgOpacity": 1, "iconColor": "#aabbcc", "iconOpacity": 1, "width": 0, "height": 0}}, "edges": {}}
style_v2 = {"nodes": {"proxmox": {"borderColor": "#ff6e00", "borderOpacity": 1, "bgColor": "#111111", "bgOpacity": 1, "iconColor": "#ff6e00", "iconOpacity": 1, "width": 0, "height": 0}}, "edges": {}}
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}, "custom_style": style_v1}, headers=headers)
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}, "custom_style": style_v2}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert "proxmox" in canvas["custom_style"]["nodes"]
assert "server" not in canvas["custom_style"]["nodes"]
+272
View File
@@ -140,6 +140,49 @@ async def test_hide_device(client: AsyncClient, headers, pending_device):
assert len(hidden_res.json()) == 1
# --- Restore hidden device ---
@pytest.mark.asyncio
async def test_restore_device(client: AsyncClient, headers, pending_device):
# Hide first
await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
# Restore
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/restore", headers=headers)
assert res.status_code == 200
assert res.json()["restored"] is True
# Now back in pending, gone from hidden
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
assert len(pending_res.json()) == 1
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
assert hidden_res.json() == []
@pytest.mark.asyncio
async def test_restore_device_rejects_non_hidden(client: AsyncClient, headers, pending_device):
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/restore", headers=headers)
assert res.status_code == 409
@pytest.mark.asyncio
async def test_bulk_restore_devices(client: AsyncClient, headers, pending_device):
# Hide
await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
res = await client.post(
"/api/v1/scan/pending/bulk-restore",
headers=headers,
json={"device_ids": [pending_device.id]},
)
assert res.status_code == 200
assert res.json()["restored"] == 1
assert res.json()["skipped"] == 0
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
assert len(pending_res.json()) == 1
# --- Ignore device ---
@pytest.mark.asyncio
@@ -485,6 +528,37 @@ async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_p
assert pending_res.json() == []
@pytest.mark.asyncio
async def test_bulk_approve_sets_default_check_method(client: AsyncClient, headers, two_pending_devices, 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]
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()
for n in nodes:
if n.ip:
assert n.check_method == "ping", f"node {n.id} created without check_method"
@pytest.mark.asyncio
async def test_approve_device_sets_default_check_method(client: AsyncClient, headers, pending_device, db_session):
from sqlalchemy import select
from app.db.models import Node as NodeModel
res = await client.post(
f"/api/v1/scan/pending/{pending_device.id}/approve",
json={"label": "h", "type": "generic", "ip": "192.168.1.10", "status": "unknown", "services": []},
headers=headers,
)
assert res.status_code == 200
node = (await db_session.execute(select(NodeModel))).scalars().first()
assert node is not None
assert node.check_method == "ping"
@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]
@@ -542,3 +616,201 @@ async def test_bulk_hide_requires_auth(client: AsyncClient, two_pending_devices)
ids = [d.id for d in two_pending_devices]
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids})
assert res.status_code == 401
# ---------------------------------------------------------------------------
# Approve auto-creates Edges from pending_device_links (Zigbee flow)
# ---------------------------------------------------------------------------
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, PendingDevice, PendingDeviceLink
coord = Node(
label="Coordinator",
type="zigbee_coordinator",
status="unknown",
ieee_address="0xCOORD",
)
db_session.add(coord)
pending = PendingDevice(
ieee_address="0xR1",
friendly_name="router_1",
suggested_type="zigbee_router",
device_subtype="Router",
status="pending",
discovery_source="zigbee",
)
db_session.add(pending)
db_session.add(
PendingDeviceLink(
source_ieee="0xCOORD",
target_ieee="0xR1",
discovery_source="zigbee",
)
)
await db_session.commit()
return coord, pending
@pytest.mark.asyncio
async def test_approve_zigbee_creates_edge_when_other_endpoint_is_node(
client: AsyncClient, headers, db_session
):
from sqlalchemy import select
from app.db.models import Edge
coord, pending = await _seed_zigbee_pending_pair(db_session)
res = await client.post(
f"/api/v1/scan/pending/{pending.id}/approve",
json={
"label": "router_1",
"type": "zigbee_router",
"ip": None,
"status": "unknown",
"services": [],
},
headers=headers,
)
assert res.status_code == 200
data = res.json()
assert data["approved"] is True
assert data["edges_created"] == 1
edges = (await db_session.execute(select(Edge))).scalars().all()
assert len(edges) == 1
assert edges[0].source == coord.id
assert edges[0].target == data["node_id"]
assert edges[0].source_handle == "bottom"
assert edges[0].target_handle == "top-t"
assert edges[0].type == "iot"
@pytest.mark.asyncio
async def test_approve_zigbee_skips_duplicate_edge(
client: AsyncClient, headers, db_session
):
"""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
coord, pending = await _seed_zigbee_pending_pair(db_session)
body = {"label": "router_1", "type": "zigbee_router", "ip": None, "status": "unknown", "services": []}
await client.post(f"/api/v1/scan/pending/{pending.id}/approve", json=body, headers=headers)
# 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(
ieee_address="0xR1B",
friendly_name="r1b",
suggested_type="zigbee_router",
status="pending",
discovery_source="zigbee",
)
db_session.add(new_pending)
db_session.add(
PendingDeviceLink(source_ieee="0xCOORD", target_ieee="0xR1B", discovery_source="zigbee")
)
await db_session.commit()
res = await client.post(
f"/api/v1/scan/pending/{new_pending.id}/approve", json=body, headers=headers
)
assert res.json()["edges_created"] == 1 # only the new pair
edges = (await db_session.execute(select(Edge))).scalars().all()
assert len(edges) == 2 # original + new, no duplicate
@pytest.mark.asyncio
async def test_approve_zigbee_skips_when_other_endpoint_still_pending(
client: AsyncClient, headers, db_session
):
"""Both endpoints pending → no edge yet, link row preserved for later."""
from sqlalchemy import select
from app.db.models import Edge, PendingDevice, PendingDeviceLink
a = PendingDevice(
ieee_address="0xA",
friendly_name="a",
suggested_type="zigbee_router",
status="pending",
discovery_source="zigbee",
)
b = PendingDevice(
ieee_address="0xB",
friendly_name="b",
suggested_type="zigbee_enddevice",
status="pending",
discovery_source="zigbee",
)
db_session.add_all([a, b])
db_session.add(
PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
)
await db_session.commit()
res = await client.post(
f"/api/v1/scan/pending/{a.id}/approve",
json={
"label": "a",
"type": "zigbee_router",
"ip": None,
"status": "unknown",
"services": [],
},
headers=headers,
)
assert res.status_code == 200
assert res.json()["edges_created"] == 0
edges = (await db_session.execute(select(Edge))).scalars().all()
assert edges == []
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
assert len(links) == 1 # preserved for later resolution
@pytest.mark.asyncio
async def test_approve_zigbee_resolves_link_after_second_approval(
client: AsyncClient, headers, db_session
):
"""First approval keeps link; second approval creates the edge."""
from sqlalchemy import select
from app.db.models import Edge, PendingDevice, PendingDeviceLink
a = PendingDevice(
ieee_address="0xA",
friendly_name="a",
suggested_type="zigbee_router",
status="pending",
discovery_source="zigbee",
)
b = PendingDevice(
ieee_address="0xB",
friendly_name="b",
suggested_type="zigbee_enddevice",
status="pending",
discovery_source="zigbee",
)
db_session.add_all([a, b])
db_session.add(
PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
)
await db_session.commit()
body = {"label": "x", "type": "zigbee_router", "ip": None, "status": "unknown", "services": []}
await client.post(f"/api/v1/scan/pending/{a.id}/approve", json=body, headers=headers)
res = await client.post(f"/api/v1/scan/pending/{b.id}/approve", json=body, headers=headers)
assert res.json()["edges_created"] == 1
edges = (await db_session.execute(select(Edge))).scalars().all()
assert len(edges) == 1
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
assert links == [] # consumed
+25
View File
@@ -169,6 +169,31 @@ async def test_ping_uses_unix_args_on_non_windows():
assert "-c" in captured["args"]
assert "-W" in captured["args"]
assert "-n" not in captured["args"]
# Linux: -W is in seconds; 1s is the intended timeout
w_idx = captured["args"].index("-W")
assert captured["args"][w_idx + 1] == "1"
@pytest.mark.asyncio
async def test_ping_uses_macos_millisecond_timeout():
"""macOS ping(8) -W is milliseconds, not seconds. 1ms would fail any RTT >1ms."""
captured = {}
async def fake_exec(*args, **kwargs):
captured["args"] = args
proc = MagicMock()
proc.returncode = 0
proc.wait = AsyncMock()
return proc
with patch("app.services.status_checker.sys.platform", "darwin"), \
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
await _ping("192.168.1.1")
assert "-c" in captured["args"]
assert "-W" in captured["args"]
w_idx = captured["args"].index("-W")
assert captured["args"][w_idx + 1] == "1000"
@pytest.mark.asyncio
+417
View File
@@ -0,0 +1,417 @@
"""API endpoint tests for /api/v1/zigbee/*."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from httpx import AsyncClient
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
async def headers(client: AsyncClient):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
token = res.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
# ---------------------------------------------------------------------------
# /api/v1/zigbee/test-connection
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_test_connection_success(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zigbee.test_mqtt_connection") as mock_conn:
mock_conn.return_value = True
res = await client.post(
"/api/v1/zigbee/test-connection",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 200
data = res.json()
assert data["connected"] is True
assert "success" in data["message"].lower()
@pytest.mark.asyncio
async def test_test_connection_failure(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zigbee.test_mqtt_connection") as mock_conn:
mock_conn.side_effect = ConnectionError("Connection refused")
res = await client.post(
"/api/v1/zigbee/test-connection",
json={"mqtt_host": "bad-host", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 200
data = res.json()
assert data["connected"] is False
assert "refused" in data["message"].lower()
@pytest.mark.asyncio
async def test_test_connection_requires_auth(client: AsyncClient) -> None:
res = await client.post(
"/api/v1/zigbee/test-connection",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
)
assert res.status_code == 401
@pytest.mark.asyncio
async def test_test_connection_invalid_port(client: AsyncClient, headers: dict) -> None:
res = await client.post(
"/api/v1/zigbee/test-connection",
json={"mqtt_host": "localhost", "mqtt_port": 99999},
headers=headers,
)
assert res.status_code == 422 # pydantic validation error
# ---------------------------------------------------------------------------
# /api/v1/zigbee/import
# ---------------------------------------------------------------------------
_SAMPLE_NODES = [
{
"id": "0x00000000",
"label": "Coordinator",
"type": "zigbee_coordinator",
"ieee_address": "0x00000000",
"friendly_name": "Coordinator",
"device_type": "Coordinator",
"model": None,
"vendor": None,
"lqi": None,
"parent_id": None,
},
{
"id": "0x00000001",
"label": "router_1",
"type": "zigbee_router",
"ieee_address": "0x00000001",
"friendly_name": "router_1",
"device_type": "Router",
"model": "CC2530",
"vendor": "Texas Instruments",
"lqi": 230,
"parent_id": "0x00000000",
},
]
_SAMPLE_EDGES = [
{"source": "0x00000000", "target": "0x00000001"},
]
@pytest.mark.asyncio
async def test_import_success(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
mock_fetch.return_value = (_SAMPLE_NODES, _SAMPLE_EDGES)
res = await client.post(
"/api/v1/zigbee/import",
json={
"mqtt_host": "localhost",
"mqtt_port": 1883,
"base_topic": "zigbee2mqtt",
},
headers=headers,
)
assert res.status_code == 200
data = res.json()
assert data["device_count"] == 2
assert len(data["nodes"]) == 2
assert len(data["edges"]) == 1
coordinator = next(n for n in data["nodes"] if n["type"] == "zigbee_coordinator")
assert coordinator["ieee_address"] == "0x00000000"
@pytest.mark.asyncio
async def test_import_with_credentials(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
mock_fetch.return_value = ([], [])
res = await client.post(
"/api/v1/zigbee/import",
json={
"mqtt_host": "localhost",
"mqtt_port": 1883,
"mqtt_username": "admin",
"mqtt_password": "secret",
"base_topic": "z2m",
},
headers=headers,
)
assert res.status_code == 200
mock_fetch.assert_called_once_with(
mqtt_host="localhost",
mqtt_port=1883,
base_topic="z2m",
username="admin",
password="secret",
tls=False,
tls_insecure=False,
)
@pytest.mark.asyncio
async def test_import_connection_error_returns_502(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
mock_fetch.side_effect = ConnectionError("broker unreachable")
res = await client.post(
"/api/v1/zigbee/import",
json={"mqtt_host": "bad-host", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 502
assert "broker unreachable" in res.json()["detail"]
@pytest.mark.asyncio
async def test_import_timeout_returns_504(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
mock_fetch.side_effect = TimeoutError("timed out")
res = await client.post(
"/api/v1/zigbee/import",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 504
@pytest.mark.asyncio
async def test_import_malformed_payload_returns_422(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
mock_fetch.side_effect = ValueError("malformed response")
res = await client.post(
"/api/v1/zigbee/import",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 422
@pytest.mark.asyncio
async def test_import_requires_auth(client: AsyncClient) -> None:
res = await client.post(
"/api/v1/zigbee/import",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
)
assert res.status_code == 401
@pytest.mark.asyncio
async def test_import_empty_network(client: AsyncClient, headers: dict) -> None:
"""An empty Zigbee network (coordinator only) is a valid response."""
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
mock_fetch.return_value = ([], [])
res = await client.post(
"/api/v1/zigbee/import",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 200
data = res.json()
assert data["device_count"] == 0
assert data["nodes"] == []
assert data["edges"] == []
@pytest.mark.asyncio
async def test_import_missing_mqtt_host(client: AsyncClient, headers: dict) -> None:
res = await client.post(
"/api/v1/zigbee/import",
json={"mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 422
@pytest.mark.asyncio
async def test_import_with_tls_passes_flags(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
mock_fetch.return_value = ([], [])
res = await client.post(
"/api/v1/zigbee/import",
json={
"mqtt_host": "broker.example.com",
"mqtt_port": 8883,
"mqtt_tls": True,
},
headers=headers,
)
assert res.status_code == 200
kwargs = mock_fetch.call_args.kwargs
assert kwargs["tls"] is True
assert kwargs["tls_insecure"] is False
@pytest.mark.asyncio
async def test_import_tls_insecure_requires_tls(client: AsyncClient, headers: dict) -> None:
res = await client.post(
"/api/v1/zigbee/import",
json={
"mqtt_host": "broker.example.com",
"mqtt_port": 1883,
"mqtt_tls": False,
"mqtt_tls_insecure": True,
},
headers=headers,
)
assert res.status_code == 422
# ---------------------------------------------------------------------------
# /api/v1/zigbee/import-pending
# ---------------------------------------------------------------------------
_PENDING_NODES = [
{
"id": "0xCOORD",
"label": "Coordinator",
"type": "zigbee_coordinator",
"ieee_address": "0xCOORD",
"friendly_name": "Coordinator",
"device_type": "Coordinator",
"model": None,
"vendor": None,
"lqi": None,
"parent_id": None,
},
{
"id": "0xR1",
"label": "router_1",
"type": "zigbee_router",
"ieee_address": "0xR1",
"friendly_name": "router_1",
"device_type": "Router",
"model": "CC2530",
"vendor": "TI",
"lqi": 220,
"parent_id": "0xCOORD",
},
{
"id": "0xE1",
"label": "bulb_kitchen",
"type": "zigbee_enddevice",
"ieee_address": "0xE1",
"friendly_name": "bulb_kitchen",
"device_type": "EndDevice",
"model": "TRADFRI",
"vendor": "IKEA",
"lqi": 180,
"parent_id": "0xR1",
},
]
_PENDING_EDGES = [
{"source": "0xCOORD", "target": "0xR1"},
{"source": "0xR1", "target": "0xE1"},
]
@pytest.mark.asyncio
async def test_import_pending_endpoint_creates_zigbee_scan_run(
client: AsyncClient, headers: dict
) -> None:
"""Endpoint returns a ScanRun (kind=zigbee, status=running) immediately;
the actual networkmap fetch + pending persist runs in the background."""
from unittest.mock import AsyncMock
with patch(
"app.api.routes.zigbee._background_zigbee_import",
new_callable=AsyncMock,
):
res = await client.post(
"/api/v1/zigbee/import-pending",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 200
run = res.json()
assert run["kind"] == "zigbee"
assert run["status"] == "running"
assert run["ranges"] == ["localhost:1883"]
@pytest.mark.asyncio
async def test_persist_pending_import_creates_coordinator_and_pending(
db_session,
) -> None:
from app.api.routes.zigbee import _persist_pending_import
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
assert result.device_count == 3
assert result.pending_created == 2
assert result.pending_updated == 0
assert result.coordinator is not None
assert result.coordinator.ieee_address == "0xCOORD"
assert result.coordinator_already_existed is False
assert result.links_recorded == 2
@pytest.mark.asyncio
async def test_persist_pending_import_idempotent_updates_existing(
db_session,
) -> None:
from app.api.routes.zigbee import _persist_pending_import
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
bumped = [dict(n) for n in _PENDING_NODES]
bumped[1]["lqi"] = 99
result = await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
assert result.pending_created == 0
assert result.pending_updated == 2
assert result.coordinator_already_existed is True
assert result.links_recorded == 2
@pytest.mark.asyncio
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
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()
assert len(rows) == 1
assert (rows[0].source_ieee, rows[0].target_ieee) == ("0xCOORD", "0xR1")
@pytest.mark.asyncio
async def test_import_pending_requires_auth(client: AsyncClient) -> None:
res = await client.post(
"/api/v1/zigbee/import-pending",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
)
assert res.status_code == 401
@pytest.mark.asyncio
async def test_test_connection_with_tls(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zigbee.test_mqtt_connection") as mock_conn:
mock_conn.return_value = True
res = await client.post(
"/api/v1/zigbee/test-connection",
json={
"mqtt_host": "broker.example.com",
"mqtt_port": 8883,
"mqtt_tls": True,
"mqtt_tls_insecure": True,
},
headers=headers,
)
assert res.status_code == 200
kwargs = mock_conn.call_args.kwargs
assert kwargs["tls"] is True
assert kwargs["tls_insecure"] is True
+573
View File
@@ -0,0 +1,573 @@
"""Unit tests for zigbee_service: parser and hierarchy builder."""
from __future__ import annotations
import json
from typing import Any
from unittest.mock import patch
import aiomqtt # noqa: F401
import pytest
from app.services.zigbee_service import (
_find_parent_router,
_z2m_type_to_homelable,
fetch_networkmap,
parse_networkmap,
)
from app.services.zigbee_service import (
test_mqtt_connection as _test_mqtt_connection,
)
# ---------------------------------------------------------------------------
# Helper builders — real Z2M `bridge/response/networkmap` shape
# (data.value.nodes + data.value.links)
# ---------------------------------------------------------------------------
def _make_node(
ieee: str,
device_type: str = "EndDevice",
friendly_name: str | None = None,
model: str | None = None,
vendor: str | None = None,
) -> dict[str, Any]:
entry: dict[str, Any] = {
"ieeeAddr": ieee,
"type": device_type,
"friendlyName": friendly_name or ieee,
}
if model or vendor:
entry["definition"] = {"model": model, "vendor": vendor}
return entry
def _make_link(source_ieee: str, target_ieee: str, lqi: int = 200) -> dict[str, Any]:
return {
"source": {"ieeeAddr": source_ieee},
"target": {"ieeeAddr": target_ieee},
"lqi": lqi,
}
def _wrap(nodes: list[dict[str, Any]], links: list[dict[str, Any]] | None = None) -> dict[str, Any]:
return {
"data": {
"type": "raw",
"routes": False,
"value": {"nodes": nodes, "links": links or []},
},
"status": "ok",
}
# ---------------------------------------------------------------------------
# _z2m_type_to_homelable
# ---------------------------------------------------------------------------
class TestZ2mTypeToHomelable:
def test_coordinator(self) -> None:
assert _z2m_type_to_homelable("Coordinator") == "zigbee_coordinator"
def test_router(self) -> None:
assert _z2m_type_to_homelable("Router") == "zigbee_router"
def test_enddevice(self) -> None:
assert _z2m_type_to_homelable("EndDevice") == "zigbee_enddevice"
def test_unknown_defaults_to_enddevice(self) -> None:
assert _z2m_type_to_homelable("Unknown") == "zigbee_enddevice"
# ---------------------------------------------------------------------------
# parse_networkmap
# ---------------------------------------------------------------------------
class TestParseNetworkmap:
def test_empty_payload(self) -> None:
nodes, edges = parse_networkmap({})
assert nodes == []
assert edges == []
def test_empty_value(self) -> None:
nodes, edges = parse_networkmap(_wrap([], []))
assert nodes == []
assert edges == []
def test_coordinator_only(self) -> None:
payload = _wrap([_make_node("0x0000000000000000", "Coordinator", "Coordinator")])
nodes, edges = parse_networkmap(payload)
assert len(nodes) == 1
assert nodes[0]["type"] == "zigbee_coordinator"
assert nodes[0]["ieee_address"] == "0x0000000000000000"
assert edges == []
def test_coordinator_router_enddevice(self) -> None:
coord_ieee = "0x0000000000000000"
router_ieee = "0x0000000000000001"
end_ieee = "0x0000000000000002"
payload = _wrap(
nodes=[
_make_node(coord_ieee, "Coordinator", "Coordinator"),
_make_node(router_ieee, "Router", "my_router"),
_make_node(end_ieee, "EndDevice"),
],
links=[
_make_link(coord_ieee, router_ieee),
_make_link(router_ieee, end_ieee),
],
)
nodes, edges = parse_networkmap(payload)
node_by_id = {n["id"]: n for n in nodes}
assert coord_ieee in node_by_id
assert router_ieee in node_by_id
assert end_ieee in node_by_id
assert node_by_id[coord_ieee]["type"] == "zigbee_coordinator"
assert node_by_id[router_ieee]["type"] == "zigbee_router"
assert node_by_id[end_ieee]["type"] == "zigbee_enddevice"
# Parent hierarchy
assert node_by_id[router_ieee]["parent_id"] == coord_ieee
assert node_by_id[end_ieee]["parent_id"] == router_ieee
assert len(edges) == 2
def test_no_duplicate_nodes(self) -> None:
ieee = "0x0000000000000001"
payload = _wrap(
nodes=[_make_node(ieee, "Router"), _make_node(ieee, "Router")],
)
nodes, _ = parse_networkmap(payload)
assert len(nodes) == 1
def test_edges_built_correctly(self) -> None:
coord = "0x0000"
router = "0x0001"
payload = _wrap(
nodes=[_make_node(coord, "Coordinator"), _make_node(router, "Router")],
links=[_make_link(coord, router)],
)
_, edges = parse_networkmap(payload)
assert len(edges) == 1
assert edges[0]["source"] == coord
assert edges[0]["target"] == router
def test_friendly_name_used_as_label(self) -> None:
payload = _wrap([_make_node("0xABCD", "EndDevice", "Living Room Sensor")])
nodes, _ = parse_networkmap(payload)
assert nodes[0]["label"] == "Living Room Sensor"
def test_enddevice_falls_back_to_coordinator_when_no_router(self) -> None:
coord = "0x0000"
end = "0x0003"
payload = _wrap([_make_node(coord, "Coordinator"), _make_node(end, "EndDevice")])
nodes, _ = parse_networkmap(payload)
end_node = next(n for n in nodes if n["id"] == end)
assert end_node["parent_id"] == coord
def test_missing_ieee_skipped(self) -> None:
payload = _wrap([{"type": "EndDevice"}]) # no ieeeAddr
nodes, edges = parse_networkmap(payload)
assert nodes == []
assert edges == []
def test_lqi_propagated_from_link_to_target_node(self) -> None:
coord = "0x0000"
end = "0x0001"
payload = _wrap(
nodes=[_make_node(coord, "Coordinator"), _make_node(end, "EndDevice")],
links=[_make_link(coord, end, lqi=180)],
)
nodes, _ = parse_networkmap(payload)
end_node = next(n for n in nodes if n["id"] == end)
assert end_node["lqi"] == 180
def test_definition_model_and_vendor_extracted(self) -> None:
payload = _wrap([
_make_node("0xAA", "EndDevice", "Sensor", model="WSDCGQ11LM", vendor="Aqara"),
])
nodes, _ = parse_networkmap(payload)
assert nodes[0]["model"] == "WSDCGQ11LM"
assert nodes[0]["vendor"] == "Aqara"
def test_legacy_shape_without_value_wrapper(self) -> None:
"""Some Z2M variants put nodes/links directly under data."""
payload = {"data": {"nodes": [_make_node("0x01", "Coordinator")], "links": []}}
nodes, _ = parse_networkmap(payload)
assert len(nodes) == 1
assert nodes[0]["type"] == "zigbee_coordinator"
def test_routes_bool_is_ignored(self) -> None:
"""`routes: false` echo from the request must not crash the parser."""
payload = {"data": {"routes": False, "type": "raw", "value": {"nodes": [], "links": []}}}
nodes, edges = parse_networkmap(payload)
assert nodes == []
assert edges == []
def test_malformed_nodes_not_list_raises(self) -> None:
with pytest.raises(ValueError, match="not a list"):
parse_networkmap({"data": {"value": {"nodes": "oops", "links": []}}})
def test_link_to_unknown_node_dropped(self) -> None:
payload = _wrap(
nodes=[_make_node("0x01", "Coordinator")],
links=[_make_link("0x01", "0xDEAD")], # 0xDEAD not in nodes
)
_, edges = parse_networkmap(payload)
assert edges == []
def test_bidirectional_links_yield_single_edge(self) -> None:
"""Z2M links are bidirectional — every pair appears twice. The output
must collapse to a single parent→child edge (no back-link, no dup)."""
coord = "0x0000"
router = "0x0001"
payload = _wrap(
nodes=[_make_node(coord, "Coordinator"), _make_node(router, "Router")],
links=[
_make_link(coord, router),
_make_link(router, coord), # reverse direction
],
)
_, edges = parse_networkmap(payload)
assert edges == [{"source": coord, "target": router}]
def test_router_mesh_siblings_dropped(self) -> None:
"""Router↔router mesh paths in `links` must NOT produce sibling edges
in the final tree. Each router gets exactly one edge from coordinator."""
coord = "0x0000"
r1 = "0x0001"
r2 = "0x0002"
payload = _wrap(
nodes=[
_make_node(coord, "Coordinator"),
_make_node(r1, "Router"),
_make_node(r2, "Router"),
],
links=[
_make_link(coord, r1),
_make_link(coord, r2),
_make_link(r1, r2), # mesh sibling — must be dropped
_make_link(r2, r1),
],
)
_, edges = parse_networkmap(payload)
pairs = {(e["source"], e["target"]) for e in edges}
assert pairs == {(coord, r1), (coord, r2)}
def test_coordinator_has_no_incoming_edge(self) -> None:
coord = "0x0000"
end = "0x0001"
payload = _wrap(
nodes=[_make_node(coord, "Coordinator"), _make_node(end, "EndDevice")],
links=[_make_link(end, coord)], # back-edge from end to coord
)
_, edges = parse_networkmap(payload)
# No edge should target the coordinator
assert all(e["target"] != coord for e in edges)
assert edges == [{"source": coord, "target": end}]
# ---------------------------------------------------------------------------
# _find_parent_router
# ---------------------------------------------------------------------------
class TestFindParentRouter:
def test_finds_router_as_source(self) -> None:
router_ids = {"r1"}
edges = [{"source": "r1", "target": "e1"}]
assert _find_parent_router("e1", router_ids, edges) == "r1"
def test_finds_router_as_target(self) -> None:
router_ids = {"r1"}
edges = [{"source": "e1", "target": "r1"}]
assert _find_parent_router("e1", router_ids, edges) == "r1"
def test_returns_none_when_no_router(self) -> None:
router_ids: set[str] = set()
edges = [{"source": "e1", "target": "e2"}]
assert _find_parent_router("e1", router_ids, edges) is None
def test_returns_none_empty_edges(self) -> None:
assert _find_parent_router("e1", {"r1"}, []) is None
# ---------------------------------------------------------------------------
# fetch_networkmap (integration-style with mocked aiomqtt)
# ---------------------------------------------------------------------------
SAMPLE_RESPONSE_PAYLOAD = {
"data": {
"type": "raw",
"routes": False,
"value": {
"nodes": [
{
"ieeeAddr": "0x00000000",
"type": "Coordinator",
"friendlyName": "Coordinator",
},
{
"ieeeAddr": "0x00000001",
"type": "Router",
"friendlyName": "router_1",
},
],
"links": [
{
"source": {"ieeeAddr": "0x00000000"},
"target": {"ieeeAddr": "0x00000001"},
"lqi": 230,
}
],
},
},
"status": "ok",
}
@pytest.mark.asyncio
async def test_fetch_networkmap_success() -> None:
"""fetch_networkmap returns parsed nodes/edges when MQTT responds normally."""
class _FakeMessage:
topic = "zigbee2mqtt/bridge/response/networkmap"
payload = json.dumps(SAMPLE_RESPONSE_PAYLOAD).encode()
_yielded = False
def __aiter__(self):
return self
async def __anext__(self):
if self._yielded:
raise StopAsyncIteration
self._yielded = True
return self
class _FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_):
pass
async def subscribe(self, _topic: str) -> None:
pass
async def publish(self, _topic: str, _payload: str) -> None:
pass
@property
def messages(self):
return _FakeMessage()
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _FakeClient()
mock_aiomqtt.MqttError = Exception
nodes, edges = await fetch_networkmap(
mqtt_host="localhost",
mqtt_port=1883,
base_topic="zigbee2mqtt",
)
assert any(n["type"] == "zigbee_coordinator" for n in nodes)
assert any(n["type"] == "zigbee_router" for n in nodes)
@pytest.mark.asyncio
async def test_fetch_networkmap_connection_error() -> None:
"""fetch_networkmap raises ConnectionError when MQTT broker is unreachable."""
class _FakeClient:
async def __aenter__(self):
raise Exception("Connection refused")
async def __aexit__(self, *_):
pass
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _FakeClient()
mock_aiomqtt.MqttError = Exception
with pytest.raises(ConnectionError):
await fetch_networkmap(
mqtt_host="bad-host",
mqtt_port=1883,
base_topic="zigbee2mqtt",
)
@pytest.mark.asyncio
async def test_test_mqtt_connection_success() -> None:
class _FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_):
pass
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _FakeClient()
mock_aiomqtt.MqttError = Exception
result = await _test_mqtt_connection("localhost", 1883)
assert result is True
@pytest.mark.asyncio
async def test_test_mqtt_connection_failure() -> None:
class _FakeClient:
async def __aenter__(self):
raise Exception("refused")
async def __aexit__(self, *_):
pass
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _FakeClient()
mock_aiomqtt.MqttError = Exception
with pytest.raises(ConnectionError):
await _test_mqtt_connection("bad-host", 1883)
# ---------------------------------------------------------------------------
# TLS context
# ---------------------------------------------------------------------------
import ssl # noqa: E402
from app.services.zigbee_service import _build_tls_context # noqa: E402
def test_build_tls_context_secure_verifies_cert() -> None:
ctx = _build_tls_context(insecure=False)
assert ctx.check_hostname is True
assert ctx.verify_mode == ssl.CERT_REQUIRED
def test_build_tls_context_insecure_disables_verification() -> None:
ctx = _build_tls_context(insecure=True)
assert ctx.check_hostname is False
assert ctx.verify_mode == ssl.CERT_NONE
@pytest.mark.asyncio
async def test_test_mqtt_connection_passes_tls_context() -> None:
class _FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_):
pass
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _FakeClient()
mock_aiomqtt.MqttError = Exception
await _test_mqtt_connection("host", 8883, tls=True)
kwargs = mock_aiomqtt.Client.call_args.kwargs
assert kwargs["tls_context"] is not None
assert kwargs["tls_context"].verify_mode == ssl.CERT_REQUIRED
@pytest.mark.asyncio
async def test_test_mqtt_connection_no_tls_context_when_disabled() -> None:
class _FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_):
pass
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _FakeClient()
mock_aiomqtt.MqttError = Exception
await _test_mqtt_connection("host", 1883, tls=False)
assert mock_aiomqtt.Client.call_args.kwargs["tls_context"] is None
@pytest.mark.asyncio
async def test_test_mqtt_connection_insecure_passes_no_verify_context() -> None:
class _FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_):
pass
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _FakeClient()
mock_aiomqtt.MqttError = Exception
await _test_mqtt_connection("host", 8883, tls=True, tls_insecure=True)
ctx = mock_aiomqtt.Client.call_args.kwargs["tls_context"]
assert ctx.verify_mode == ssl.CERT_NONE
assert ctx.check_hostname is False
# ---------------------------------------------------------------------------
# Sanitize MQTT errors
# ---------------------------------------------------------------------------
from app.services.zigbee_service import _sanitize_mqtt_error # noqa: E402
def test_sanitize_auth_error_does_not_leak_credentials() -> None:
msg = _sanitize_mqtt_error(
Exception("Not authorized: bad username or password for user=admin pwd=secret")
)
assert msg == "Authentication failed"
assert "admin" not in msg
assert "secret" not in msg
def test_sanitize_refused() -> None:
assert _sanitize_mqtt_error(Exception("Connection refused")) == "Connection refused by broker"
def test_sanitize_dns_failure_strips_host() -> None:
msg = _sanitize_mqtt_error(
Exception("[Errno 8] nodename nor servname provided, or not known: broker.internal.lan")
)
assert msg == "Broker hostname could not be resolved"
assert "broker.internal.lan" not in msg
def test_sanitize_tls_error() -> None:
assert _sanitize_mqtt_error(
Exception("[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed")
) == "TLS handshake failed"
def test_sanitize_unknown_falls_back_to_generic() -> None:
msg = _sanitize_mqtt_error(Exception("mqtt://admin:hunter2@broker:1883 weird state"))
assert msg == "MQTT connection failed"
assert "hunter2" not in msg
assert "admin" not in msg
@pytest.mark.asyncio
async def test_fetch_networkmap_does_not_leak_creds_in_connection_error() -> None:
class _FakeClient:
async def __aenter__(self):
raise Exception("Not authorized: rejected mqtt://admin:hunter2@host")
async def __aexit__(self, *_):
pass
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _FakeClient()
mock_aiomqtt.MqttError = Exception
with pytest.raises(ConnectionError) as ei:
await fetch_networkmap(
mqtt_host="host", mqtt_port=1883, base_topic="zigbee2mqtt"
)
msg = str(ei.value)
assert "hunter2" not in msg
assert "admin" not in msg
assert msg == "Authentication failed"
+130
View File
@@ -0,0 +1,130 @@
# Zigbee2MQTT Network Map Importer
This feature lets you connect Homelable to your MQTT broker, fetch the Zigbee2MQTT network topology, and drop all Zigbee devices onto the canvas as typed nodes with proper hierarchy.
---
## Feature Overview
- **Automatic device discovery** — Requests the Z2M networkmap via the MQTT bridge API and parses the full device list
- **Typed nodes** — Devices are mapped to three homelable node types:
- `zigbee_coordinator` — The Zigbee coordinator (hub)
- `zigbee_router` — Mains-powered router devices
- `zigbee_enddevice` — Battery-powered end devices (sensors, bulbs, etc.)
- **Hierarchy** — `parent_id` is set automatically: coordinator → routers → end devices
- **LQI display** — Link Quality Indicator is stored as a node property
- **IoT edges** — Links between devices are added as `IoT / Zigbee` edge type
---
## Prerequisites
1. A running **MQTT broker** (e.g. Mosquitto) accessible from your Homelable host
2. **Zigbee2MQTT** connected to the broker and running
3. Z2M must respond to networkmap requests on:
- **Request topic:** `<base_topic>/bridge/request/networkmap`
- **Response topic:** `<base_topic>/bridge/response/networkmap`
- The default base topic is `zigbee2mqtt`
---
## Step-by-step Usage
### 1. Open the Zigbee Import dialog
Click **Zigbee Import** in the left sidebar (below "Scan Network").
### 2. Configure the MQTT connection
| Field | Default | Description |
|---|---|---|
| Broker Host | — | IP or hostname of your MQTT broker |
| Port | 1883 | MQTT broker port |
| Base Topic | `zigbee2mqtt` | Zigbee2MQTT base topic |
| Username | _(optional)_ | MQTT username if authentication is enabled |
| Password | _(optional)_ | MQTT password |
### 3. Test the connection (optional)
Click **Test Connection** to verify broker reachability before fetching devices.
A green indicator confirms success; red shows the error message from the broker.
### 4. Fetch devices
Click **Fetch Devices**. Homelable will:
1. Connect to the broker
2. Subscribe to the response topic
3. Publish `{"type": "raw", "routes": false}` to the request topic
4. Wait up to 60 seconds for the network map response (large meshes can take 30 s+)
5. Parse and group devices by type
### 5. Select and add to canvas
Devices are grouped by type (Coordinator / Router / End Device).
Use the checkboxes to select which devices to add, then click **Add N to Canvas**.
> **Tip:** All devices are selected by default. Uncheck any you don't want.
### 6. Arrange on the canvas
Devices are placed in a grid at the top-right of the canvas.
Use **Auto Layout** (toolbar) to re-arrange the full canvas, or drag nodes manually.
---
## MQTT Configuration Tips
### Mosquitto without authentication
```
listener 1883
allow_anonymous true
```
### Mosquitto with password file
```
listener 1883
password_file /etc/mosquitto/passwd
```
Create a user:
```bash
mosquitto_passwd -c /etc/mosquitto/passwd <username>
```
### Zigbee2MQTT `configuration.yaml`
```yaml
mqtt:
base_topic: zigbee2mqtt
server: mqtt://localhost:1883
# user: mqtt_user
# password: mqtt_password
```
---
## Supported Z2M Versions
The networkmap bridge API is available in **Zigbee2MQTT 1.x and 2.x**.
Tested against Z2M 1.35+ and 2.x.
The importer uses the `raw` topology format (`routes: false`) which is the most widely supported mode.
---
## Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| "Connection refused" | Broker unreachable | Check host/port, firewall rules |
| "Timed out waiting for networkmap" | Z2M not running or wrong base_topic | Verify Z2M is connected, check base_topic setting |
| 0 devices returned | Z2M has no devices paired | Pair at least one device first |
| "Malformed networkmap response" | Z2M returned unexpected format | Check Z2M version; open an issue |
---
## Screenshots
_(Screenshots will be added in a future release)_
+440 -93
View File
@@ -1,21 +1,22 @@
{
"name": "frontend",
"version": "1.8.2",
"version": "2.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "1.8.2",
"version": "2.0.3",
"dependencies": {
"@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4",
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/js-yaml": "^4.0.9",
"@xyflow/react": "^12.10.1",
"axios": "^1.13.6",
"axios": "^1.15.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dagre": "^0.8.5",
@@ -1645,9 +1646,9 @@
}
},
"node_modules/@hono/node-server": {
"version": "1.19.12",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.12.tgz",
"integrity": "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==",
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
@@ -2011,6 +2012,415 @@
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
"license": "MIT"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-rect": "1.1.1",
"@radix-ui/react-use-size": "1.1.1",
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-visually-hidden": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
"license": "MIT",
"dependencies": {
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-size": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@@ -2110,9 +2520,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2127,9 +2534,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2144,9 +2548,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2161,9 +2562,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2178,9 +2576,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2195,9 +2590,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2212,9 +2604,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2229,9 +2618,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2246,9 +2632,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2263,9 +2646,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2280,9 +2660,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2297,9 +2674,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2314,9 +2688,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2565,9 +2936,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2585,9 +2953,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2605,9 +2970,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2625,9 +2987,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3024,7 +3383,7 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -3776,12 +4135,12 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
@@ -5192,12 +5551,12 @@
}
},
"node_modules/express-rate-limit": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
"integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
"version": "8.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz",
"integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==",
"license": "MIT",
"dependencies": {
"ip-address": "10.1.0"
"ip-address": "^10.2.0"
},
"engines": {
"node": ">= 16"
@@ -5283,9 +5642,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"funding": [
{
"type": "github",
@@ -5447,9 +5806,9 @@
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
@@ -5786,9 +6145,9 @@
}
},
"node_modules/hono": {
"version": "4.12.11",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.11.tgz",
"integrity": "sha512-r4xbIa3mGGGoH9nN4A14DOg2wx7y2oQyJEb5O57C/xzETG/qx4c7CVDQ5WMeKHZ7ORk2W0hZ/sQKXTav3cmYBA==",
"version": "4.12.18",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -5944,9 +6303,9 @@
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -6525,9 +6884,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6549,9 +6905,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6573,9 +6926,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6597,9 +6947,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7468,9 +7815,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"funding": [
{
"type": "opencollective",
+3 -2
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.10.1",
"version": "2.0.3",
"type": "module",
"scripts": {
"dev": "vite",
@@ -19,9 +19,10 @@
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/js-yaml": "^4.0.9",
"@xyflow/react": "^12.10.1",
"axios": "^1.13.6",
"axios": "^1.15.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dagre": "^0.8.5",
@@ -0,0 +1,27 @@
#!/usr/bin/env node
// Regenerate frontend/src/data/dashboardIcons.json from the upstream
// homarr-labs/dashboard-icons repo. Run manually to refresh the manifest.
//
// node scripts/fetch-dashboard-icons.mjs
import { writeFileSync, mkdirSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const TREE_URL = 'https://raw.githubusercontent.com/homarr-labs/dashboard-icons/main/tree.json'
const OUT = resolve(dirname(fileURLToPath(import.meta.url)), '../src/data/dashboardIcons.json')
const res = await fetch(TREE_URL)
if (!res.ok) {
console.error(`fetch failed: ${res.status} ${res.statusText}`)
process.exit(1)
}
const tree = await res.json()
const slugs = (tree.svg ?? [])
.filter((f) => f.endsWith('.svg'))
.map((f) => f.slice(0, -4))
.sort()
mkdirSync(dirname(OUT), { recursive: true })
writeFileSync(OUT, JSON.stringify(slugs))
console.log(`wrote ${slugs.length} slugs → ${OUT}`)
+219 -43
View File
@@ -19,9 +19,12 @@ import { LoginPage } from '@/components/LoginPage'
import { NodeModal } from '@/components/modals/NodeModal'
import { EdgeModal } from '@/components/modals/EdgeModal'
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
import { TextModal, type TextFormData } from '@/components/modals/TextModal'
import { ThemeModal } from '@/components/modals/ThemeModal'
import { SearchModal } from '@/components/modals/SearchModal'
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore'
@@ -29,50 +32,62 @@ import { useThemeStore } from '@/stores/themeStore'
import { canvasApi } from '@/api/client'
import { demoNodes, demoEdges } from '@/utils/demoData'
import { useStatusPolling } from '@/hooks/useStatusPolling'
import type { NodeData, EdgeData } from '@/types'
import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
const CONTAINER_MODE_TYPES = new Set<NodeData['type']>(['proxmox', 'vm', 'lxc', 'docker_host'])
export default function App() {
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
const canvasRef = useRef<HTMLDivElement>(null)
const { isAuthenticated } = useAuthStore()
const { activeTheme, setTheme } = useThemeStore()
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
useStatusPolling()
const [themeModalOpen, setThemeModalOpen] = useState(false)
const [searchOpen, setSearchOpen] = useState(false)
const [sidebarForceView, setSidebarForceView] = useState<'pending' | 'history' | undefined>(undefined)
const [highlightPendingId, setHighlightPendingId] = useState<string | undefined>(undefined)
const [sidebarForceView, setSidebarForceView] = useState<'history' | undefined>(undefined)
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 [shortcutsOpen, setShortcutsOpen] = useState(false)
const [addNodeOpen, setAddNodeOpen] = useState(false)
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
const [addTextOpen, setAddTextOpen] = useState(false)
const [editNodeId, setEditNodeId] = useState<string | null>(null)
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
const [scanConfigOpen, setScanConfigOpen] = useState(false)
const [exportModalOpen, setExportModalOpen] = useState(false)
const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false)
// Declare handleSave before the Ctrl+S effect so it is in scope
const handleSave = useCallback(async () => {
try {
if (STANDALONE) {
localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme }))
localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme, custom_style: customStyle }))
markSaved()
toast.success('Canvas saved')
return
}
const nodesToSave = nodes.map(serializeNode)
const edgesToSave = edges.map(serializeEdge)
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme } })
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme }, custom_style: customStyle })
markSaved()
toast.success('Canvas saved')
} catch {
toast.error('Save failed')
}
}, [nodes, edges, markSaved, activeTheme])
}, [nodes, edges, markSaved, activeTheme, customStyle])
// Keep a ref so the keydown handler always calls the latest version
const handleSaveRef = useRef(handleSave)
@@ -84,8 +99,9 @@ export default function App() {
try {
const saved = localStorage.getItem(STANDALONE_STORAGE_KEY)
if (saved) {
const { nodes: savedNodes, edges: savedEdges, theme_id } = JSON.parse(saved)
const { nodes: savedNodes, edges: savedEdges, theme_id, custom_style } = JSON.parse(saved)
if (theme_id) setTheme(theme_id)
if (custom_style) setCustomStyle(custom_style)
loadCanvas(savedNodes, savedEdges)
} else {
loadCanvas(demoNodes, demoEdges)
@@ -100,23 +116,24 @@ export default function App() {
.then((res) => {
const { nodes: apiNodes, edges: apiEdges } = res.data
if (apiNodes.length > 0) {
// Build a map of proxmox container mode to know if children should be nested
// Build a map of container mode nodes to know if children should be nested
const proxmoxContainerMap = new Map<string, boolean>(
(apiNodes as ApiNode[])
.filter((n) => n.type === 'proxmox' || n.type === 'group')
.map((n) => [n.id, n.type === 'group' ? true : n.container_mode !== false])
.filter((n) => n.type === 'group' || n.container_mode === true)
.map((n) => [n.id, true])
)
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
const rfEdges = (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)
loadCanvas(rfNodes, rfEdges)
} else {
loadCanvas(demoNodes, demoEdges)
}
})
.catch(() => loadCanvas(demoNodes, demoEdges))
}, [isAuthenticated, loadCanvas, setTheme])
}, [isAuthenticated, loadCanvas, setTheme, setCustomStyle])
// Keep refs for store actions so keydown handler is always up-to-date without re-registering
const undoRef = useRef(undo)
@@ -151,7 +168,7 @@ export default function App() {
const handleAddNode = useCallback((data: Partial<NodeData>) => {
snapshotHistory()
const id = generateUUID()
const isProxmox = data.type === 'proxmox'
const isContainerNode = data.container_mode === true
const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null
// Children position is relative to parent; place near top-left with padding
const position = parentNode
@@ -164,7 +181,7 @@ export default function App() {
position,
data: { status: 'unknown', services: [], ...data } as NodeData,
...(data.parent_id ? { parentId: data.parent_id, extent: 'parent' as const } : {}),
...(isProxmox ? { width: 300, height: 200 } : {}),
...(isContainerNode ? { width: 300, height: 200 } : {}),
}
addNode(newNode)
toast.success(`Added "${data.label}"`)
@@ -226,6 +243,67 @@ export default function App() {
setEditingGroupRectId(null)
}, [editingGroupRectId, nodes, updateNode, setNodeZIndex, setEditingGroupRectId, snapshotHistory])
const handleAddText = useCallback((data: TextFormData) => {
snapshotHistory()
const id = generateUUID()
const newNode: Node<NodeData> = {
id,
// Text lives in `label` because the API serializer only persists top-level
// node fields; text_content is not in the schema and was lost on reload.
// TextNode and the edit modal both already fall back to label.
type: 'text',
position: { x: 250, y: 250 },
data: {
label: data.text,
type: 'text',
status: 'unknown',
services: [],
custom_colors: {
border: data.border_color,
border_style: data.border_style,
border_width: data.border_width,
background: data.background_color,
text_color: data.text_color,
text_size: data.text_size,
font: data.font,
},
},
width: 200,
height: 60,
}
addNode(newNode)
}, [addNode, snapshotHistory])
const handleUpdateText = useCallback((data: TextFormData) => {
if (!editingTextId) return
snapshotHistory()
const existing = nodes.find((n) => n.id === editingTextId)
updateNode(editingTextId, {
label: data.text,
// Clear stale text_content if present from older builds — label is the
// source of truth now.
text_content: undefined,
custom_colors: {
...existing?.data.custom_colors,
border: data.border_color,
border_style: data.border_style,
border_width: data.border_width,
background: data.background_color,
text_color: data.text_color,
text_size: data.text_size,
font: data.font,
},
})
setEditingTextId(null)
}, [editingTextId, nodes, updateNode, setEditingTextId, snapshotHistory])
const handleDeleteText = useCallback(() => {
if (!editingTextId) return
snapshotHistory()
deleteNode(editingTextId)
setEditingTextId(null)
}, [editingTextId, deleteNode, setEditingTextId, snapshotHistory])
const handleDeleteGroupRect = useCallback(() => {
if (!editingGroupRectId) return
snapshotHistory()
@@ -242,13 +320,13 @@ export default function App() {
snapshotHistory()
const existingNode = nodes.find((n) => n.id === editNodeId)
updateNode(editNodeId, data)
// If proxmox container_mode changed, apply structural changes (children parentId, node dimensions)
if (data.type === 'proxmox' && typeof data.container_mode === 'boolean') {
// If container_mode changed, apply structural changes (children parentId, node dimensions)
if (typeof data.container_mode === 'boolean') {
setProxmoxContainerMode(editNodeId, data.container_mode)
}
// Sync virtual edge when parent_id changes on an LXC/VM node
const nodeType = data.type ?? existingNode?.data.type
if ((nodeType === 'lxc' || nodeType === 'vm') && 'parent_id' in data) {
if ((nodeType === 'lxc' || nodeType === 'vm' || nodeType === 'docker_container') && 'parent_id' in data) {
const oldParentId = existingNode?.data.parent_id ?? null
const newParentId = data.parent_id ?? null
if (oldParentId !== newParentId) {
@@ -312,6 +390,54 @@ export default function App() {
setExportModalOpen(true)
}, [])
const handleZigbeeAddToCanvas = useCallback((zigbeeNodes: ZigbeeNode[], zigbeeEdges: ZigbeeEdge[]) => {
snapshotHistory()
// Place nodes in a grid starting at x=500, y=100
const COLS = 4
const SPACING_X = 170
const SPACING_Y = 100
zigbeeNodes.forEach((zn, i) => {
const id = zn.id
const col = i % COLS
const row = Math.floor(i / COLS)
const position = { x: 500 + col * SPACING_X, y: 100 + row * SPACING_Y }
const newNode: import('@xyflow/react').Node<NodeData> = {
id,
type: zn.type,
position,
data: {
label: zn.friendly_name,
type: zn.type as NodeData['type'],
status: 'unknown' as const,
services: [],
...(zn.lqi != null ? { properties: [{ key: 'LQI', value: String(zn.lqi), icon: 'signal', visible: true }] } : {}),
...(zn.model ? { os: zn.model } : {}),
...(zn.parent_id ? { parent_id: zn.parent_id } : {}),
},
}
addNode(newNode)
})
// Add IoT edges between Zigbee devices: parent bottom -> child top
zigbeeEdges.forEach((ze) => {
onConnect({
source: ze.source,
sourceHandle: 'bottom',
target: ze.target,
targetHandle: 'top-t',
type: 'iot',
} as unknown as import('@xyflow/react').Connection)
})
// Auto-select only the freshly imported nodes so the user can drag the
// whole subtree as a group.
const importedIds = new Set(zigbeeNodes.map((zn) => zn.id))
useCanvasStore.setState((state) => ({
nodes: state.nodes.map((n) => ({ ...n, selected: importedIds.has(n.id) })),
selectedNodeIds: Array.from(importedIds),
selectedNodeId: importedIds.size === 1 ? Array.from(importedIds)[0] : null,
}))
markUnsaved()
}, [addNode, onConnect, snapshotHistory, markUnsaved])
const handleEdgeConnect = useCallback((connection: Connection) => {
setPendingConnection(connection)
}, [])
@@ -320,15 +446,19 @@ export default function App() {
if (!pendingConnection) return
snapshotHistory()
onConnect({ ...pendingConnection, ...edgeData } as unknown as Connection)
// When a virtual edge is drawn between LXC/VM (top) and Proxmox (bottom), sync parent_id
// When a virtual edge is drawn between a child node and a container node, sync parent_id
if (edgeData.type === 'virtual') {
const src = nodes.find((n) => n.id === pendingConnection.source)
const tgt = nodes.find((n) => n.id === pendingConnection.target)
const srcType = src?.data.type
const tgtType = tgt?.data.type
if ((srcType === 'lxc' || srcType === 'vm') && tgtType === 'proxmox') {
const srcType = src?.data.type as NodeData['type']
const tgtType = tgt?.data.type as NodeData['type']
if ((srcType === 'lxc' || srcType === 'vm') && CONTAINER_MODE_TYPES.has(tgtType)) {
updateNode(pendingConnection.source, { parent_id: pendingConnection.target })
} else if (srcType === 'proxmox' && (tgtType === 'lxc' || tgtType === 'vm')) {
} else if (CONTAINER_MODE_TYPES.has(srcType) && (tgtType === 'lxc' || tgtType === 'vm')) {
updateNode(pendingConnection.target, { parent_id: pendingConnection.source })
} else if (srcType === 'docker_container' && tgtType === 'docker_host') {
updateNode(pendingConnection.source, { parent_id: pendingConnection.target })
} else if (tgtType === 'docker_container' && srcType === 'docker_host') {
updateNode(pendingConnection.target, { parent_id: pendingConnection.source })
}
}
@@ -340,6 +470,11 @@ export default function App() {
}, [])
const handleNodeDoubleClick = useCallback((node: Node<NodeData>) => {
// 'group' uses inline rename (pencil button in header). Opening the
// generic NodeModal would clobber the group's height (via the
// properties-clears-height rule in updateNode) and lose its children.
// 'groupRect' has its own onDoubleClick that already routes to GroupRectModal.
if (node.data.type === 'group' || node.data.type === 'groupRect') return
handleEditNode(node.id)
}, [handleEditNode])
@@ -376,11 +511,12 @@ export default function App() {
<Sidebar
onAddNode={() => setAddNodeOpen(true)}
onAddGroupRect={() => setAddGroupRectOpen(true)}
onAddText={() => setAddTextOpen(true)}
onScan={() => setScanConfigOpen(true)}
onZigbeeImport={() => setZigbeeImportOpen(true)}
onSave={handleSave}
onNodeApproved={setEditNodeId}
forceView={sidebarForceView}
highlightPendingId={highlightPendingId}
onOpenPending={openPendingModal}
/>
<div className="flex flex-col flex-1 min-w-0">
<Toolbar
@@ -402,14 +538,7 @@ export default function App() {
onEdgeDoubleClick={handleEdgeDoubleClick}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeDragStart={snapshotHistory}
onOpenPending={(deviceId) => {
setHighlightPendingId(undefined)
setSidebarForceView(undefined)
setTimeout(() => {
setHighlightPendingId(deviceId)
setSidebarForceView('pending')
}, 0)
}}
onOpenPending={(deviceId) => openPendingModal(deviceId)}
/>
</div>
{(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
@@ -423,7 +552,9 @@ export default function App() {
onClose={() => setAddNodeOpen(false)}
onSubmit={handleAddNode}
title="Add Node"
proxmoxNodes={nodes.filter((n) => n.type === 'proxmox').map((n) => ({ id: n.id, label: n.data.label }))}
parentContainerNodes={nodes
.filter((n) => CONTAINER_MODE_TYPES.has(n.data.type) && n.data.container_mode)
.map((n) => ({ id: n.id, label: n.data.label, nodeType: n.data.type }))}
/>
{/* key forces re-mount when editing a different node, resetting form state */}
@@ -434,7 +565,9 @@ export default function App() {
onSubmit={handleUpdateNode}
initial={editNode?.data}
title="Edit Node"
proxmoxNodes={nodes.filter((n) => n.type === 'proxmox').map((n) => ({ id: n.id, label: n.data.label }))}
parentContainerNodes={nodes
.filter((n) => n.id !== editNodeId && CONTAINER_MODE_TYPES.has(n.data.type) && n.data.container_mode)
.map((n) => ({ id: n.id, label: n.data.label, nodeType: n.data.type }))}
/>
<EdgeModal
@@ -472,6 +605,18 @@ export default function App() {
/>
)}
{!STANDALONE && (
<ZigbeeImportModal
open={zigbeeImportOpen}
onClose={() => setZigbeeImportOpen(false)}
onAddToCanvas={handleZigbeeAddToCanvas}
onPendingImported={() => {
setSidebarForceView(undefined)
setTimeout(() => setSidebarForceView('history'), 0)
}}
/>
)}
<GroupRectModal
open={addGroupRectOpen}
onClose={() => setAddGroupRectOpen(false)}
@@ -507,6 +652,37 @@ export default function App() {
title="Edit Zone"
/>
<TextModal
open={addTextOpen}
onClose={() => setAddTextOpen(false)}
onSubmit={handleAddText}
title="Add Text"
/>
<TextModal
key={editingTextId ?? 'text-edit'}
open={!!editingTextId}
onClose={() => setEditingTextId(null)}
onSubmit={handleUpdateText}
onDelete={handleDeleteText}
initial={(() => {
const n = editingTextId ? nodes.find((nd) => nd.id === editingTextId) : null
if (!n) return undefined
const rc = n.data.custom_colors ?? {}
return {
text: n.data.text_content ?? n.data.label ?? '',
font: rc.font ?? 'inter',
text_color: rc.text_color ?? '#e6edf3',
text_size: rc.text_size ?? 14,
border_color: rc.border ?? '#30363d',
border_style: (rc.border_style ?? 'none') as TextFormData['border_style'],
border_width: rc.border_width ?? 1,
background_color: rc.background ?? '#00000000',
}
})()}
title="Edit Text"
/>
{/* key forces re-mount on open so useState captures current theme as original */}
<ThemeModal
key={themeModalOpen ? 'theme-open' : 'theme-closed'}
@@ -517,17 +693,17 @@ export default function App() {
<SearchModal
open={searchOpen}
onClose={() => setSearchOpen(false)}
onOpenPending={(deviceId) => {
setHighlightPendingId(undefined)
setSidebarForceView(undefined)
setTimeout(() => {
setHighlightPendingId(deviceId)
setSidebarForceView('pending')
}, 0)
}}
onOpenPending={(deviceId) => openPendingModal(deviceId)}
/>
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
<PendingDevicesModal
open={pendingModalOpen}
onClose={() => setPendingModalOpen(false)}
highlightId={pendingHighlightId}
initialStatus={pendingModalStatus}
/>
<ExportModal
open={exportModalOpen}
onClose={() => setExportModalOpen(false)}
+66 -2
View File
@@ -33,6 +33,7 @@ export const canvasApi = {
nodes: object[]
edges: object[]
viewport: object
custom_style?: object | null
}) => api.post('/canvas/save', payload),
}
@@ -57,11 +58,27 @@ export const scanApi = {
hidden: () => api.get('/scan/hidden'),
runs: () => api.get('/scan/runs'),
clearPending: () => api.delete('/scan/pending'),
approve: (id: string, nodeData: object) => api.post(`/scan/pending/${id}/approve`, nodeData),
approve: (id: string, nodeData: object) =>
api.post<{
approved: boolean
node_id: string
edges_created: number
edges: { id: string; source: string; target: string }[]
}>(`/scan/pending/${id}/approve`, nodeData),
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
bulkApprove: (ids: string[]) => api.post<{ approved: number; node_ids: string[]; device_ids: string[]; skipped: number }>('/scan/pending/bulk-approve', { device_ids: ids }),
bulkApprove: (ids: string[]) =>
api.post<{
approved: number
node_ids: string[]
device_ids: string[]
edges_created: number
edges: { id: string; source: string; target: string }[]
skipped: number
}>('/scan/pending/bulk-approve', { device_ids: ids }),
bulkHide: (ids: string[]) => api.post<{ hidden: number; skipped: number }>('/scan/pending/bulk-hide', { device_ids: ids }),
restore: (id: string) => api.post<{ restored: boolean; device_id: string }>(`/scan/pending/${id}/restore`),
bulkRestore: (ids: string[]) => api.post<{ restored: number; skipped: number }>('/scan/pending/bulk-restore', { device_ids: ids }),
stop: (runId: string) => api.post(`/scan/${runId}/stop`),
getConfig: () => api.get<{ ranges: string[] }>('/scan/config'),
saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
@@ -71,3 +88,50 @@ export const settingsApi = {
get: () => api.get<{ interval_seconds: number }>('/settings'),
save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data),
}
export const zigbeeApi = {
testConnection: (data: {
mqtt_host: string
mqtt_port: number
mqtt_username?: string
mqtt_password?: string
mqtt_tls?: boolean
mqtt_tls_insecure?: boolean
}) =>
api.post<{ connected: boolean; message: string }>('/zigbee/test-connection', data),
importNetwork: (data: {
mqtt_host: string
mqtt_port: number
mqtt_username?: string
mqtt_password?: string
base_topic?: string
mqtt_tls?: boolean
mqtt_tls_insecure?: boolean
}) =>
api.post<{
nodes: import('@/components/zigbee/types').ZigbeeNode[]
edges: import('@/components/zigbee/types').ZigbeeEdge[]
device_count: number
}>('/zigbee/import', data),
importToPending: (data: {
mqtt_host: string
mqtt_port: number
mqtt_username?: string
mqtt_password?: string
base_topic?: string
mqtt_tls?: boolean
mqtt_tls_insecure?: boolean
}) =>
api.post<{
id: string
status: string
kind: string
ranges: string[]
devices_found: number
started_at: string
finished_at: string | null
error: string | null
}>('/zigbee/import-pending', data),
}
@@ -0,0 +1,70 @@
import { useViewport } from '@xyflow/react'
import type { Guide } from '@/utils/alignment'
interface AlignmentGuidesProps {
guides: Guide[]
color?: string
}
/**
* SVG overlay that draws alignment guide lines on top of the React Flow canvas.
* Coordinates are in canvas (flow) space; we read the viewport transform to
* project them into screen space so lines stay locked to nodes when the user
* pans or zooms.
*/
export function AlignmentGuides({ guides, color = '#00d4ff' }: AlignmentGuidesProps) {
const { x: vx, y: vy, zoom } = useViewport()
if (guides.length === 0) return null
return (
<svg
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 5,
overflow: 'visible',
}}
>
{guides.map((g, i) => {
if (g.axis === 'x') {
const x = g.position * zoom + vx
const y1 = g.start * zoom + vy
const y2 = g.end * zoom + vy
return (
<line
key={`x-${i}-${g.position}`}
x1={x}
y1={y1}
x2={x}
y2={y2}
stroke={color}
strokeWidth={1}
strokeDasharray="4 3"
shapeRendering="crispEdges"
/>
)
}
const y = g.position * zoom + vy
const x1 = g.start * zoom + vx
const x2 = g.end * zoom + vx
return (
<line
key={`y-${i}-${g.position}`}
x1={x1}
y1={y}
x2={x2}
y2={y}
stroke={color}
strokeWidth={1}
strokeDasharray="4 3"
shapeRendering="crispEdges"
/>
)
})}
</svg>
)
}
@@ -20,6 +20,8 @@ import { THEMES } from '@/utils/themes'
import { nodeTypes } from './nodes/nodeTypes'
import { edgeTypes } from './edges/edgeTypes'
import { SearchBar } from './SearchBar'
import { AlignmentGuides } from './AlignmentGuides'
import { useAlignmentGuides } from '@/hooks/useAlignmentGuides'
import type { NodeData, EdgeData } from '@/types'
interface CanvasContainerProps {
@@ -83,6 +85,8 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
[]
)
const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides()
return (
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}>
<ReactFlow
@@ -96,6 +100,8 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
onEdgeDoubleClick={handleEdgeDoubleClick}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeDragStart={onNodeDragStart}
onNodeDrag={onNodeDrag}
onNodeDragStop={onNodeDragStop}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
deleteKeyCode={['Backspace', 'Delete']}
@@ -121,6 +127,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
color={theme.colors.canvasDotColor}
/>
<SearchBar onOpenPending={onOpenPending} />
<AlignmentGuides guides={guides} />
<Controls>
<ControlButton
onClick={() => setLassoMode((m) => !m)}
+5 -3
View File
@@ -57,8 +57,10 @@ export function SearchBar({ onOpenPending }: SearchBarProps) {
const pendingResults = q
? pendingDevices.filter((d) =>
d.ip.toLowerCase().includes(q) ||
d.ip?.toLowerCase().includes(q) ||
d.hostname?.toLowerCase().includes(q) ||
d.friendly_name?.toLowerCase().includes(q) ||
d.ieee_address?.toLowerCase().includes(q) ||
d.services.some((s) =>
s.service_name?.toLowerCase().includes(q) ||
s.category?.toLowerCase().includes(q)
@@ -196,10 +198,10 @@ export function SearchBar({ onOpenPending }: SearchBarProps) {
>
<span style={{ fontSize: 10, color: '#e3b341', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>pending</span>
<span style={{ fontSize: 12, fontWeight: 600, color: '#e6edf3', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{d.hostname ?? d.ip}
{d.friendly_name ?? d.hostname ?? d.ip ?? d.ieee_address ?? 'device'}
</span>
<span style={{ fontSize: 11, color: '#8b949e', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>
{serviceName ?? d.ip}
{serviceName ?? d.ip ?? d.ieee_address ?? ''}
</span>
</button>
)
@@ -0,0 +1,47 @@
import { describe, it, expect, vi } from 'vitest'
import { render } from '@testing-library/react'
import { AlignmentGuides } from '../AlignmentGuides'
import type { Guide } from '@/utils/alignment'
vi.mock('@xyflow/react', () => ({
useViewport: () => ({ x: 50, y: 100, zoom: 2 }),
}))
describe('AlignmentGuides', () => {
it('renders nothing when no guides', () => {
const { container } = render(<AlignmentGuides guides={[]} />)
expect(container.querySelector('svg')).toBeNull()
})
it('projects an x-axis guide through the viewport transform', () => {
const guides: Guide[] = [{ axis: 'x', position: 100, start: 0, end: 200 }]
const { container } = render(<AlignmentGuides guides={guides} />)
const line = container.querySelector('line')!
// x = position * zoom + vx → 100*2 + 50 = 250
expect(line.getAttribute('x1')).toBe('250')
expect(line.getAttribute('x2')).toBe('250')
// y1 = start * zoom + vy → 0*2 + 100 = 100; y2 = 200*2 + 100 = 500
expect(line.getAttribute('y1')).toBe('100')
expect(line.getAttribute('y2')).toBe('500')
})
it('projects a y-axis guide horizontally', () => {
const guides: Guide[] = [{ axis: 'y', position: 50, start: 10, end: 60 }]
const { container } = render(<AlignmentGuides guides={guides} />)
const line = container.querySelector('line')!
// y = 50*2 + 100 = 200; x1 = 10*2 + 50 = 70; x2 = 60*2 + 50 = 170
expect(line.getAttribute('y1')).toBe('200')
expect(line.getAttribute('y2')).toBe('200')
expect(line.getAttribute('x1')).toBe('70')
expect(line.getAttribute('x2')).toBe('170')
})
it('renders one line per guide', () => {
const guides: Guide[] = [
{ axis: 'x', position: 100, start: 0, end: 200 },
{ axis: 'y', position: 50, start: 10, end: 60 },
]
const { container } = render(<AlignmentGuides guides={guides} />)
expect(container.querySelectorAll('line')).toHaveLength(2)
})
})
@@ -44,11 +44,13 @@ vi.mock('@/utils/nodeColors', () => ({
vi.mock('@/utils/nodeIcons', () => ({
resolveNodeIcon: (_typeIcon: unknown) => _typeIcon,
isBrandIconKey: (k: string | undefined) => !!k && k.startsWith('brand:'),
}))
vi.mock('@/utils/maskIp', () => ({
maskIp: (ip: string) => ip,
splitIps: (ip: string) => ip ? ip.split(',').map((s: string) => s.trim()).filter(Boolean) : [],
primaryIp: (ip: string) => ip ? ip.split(',')[0].trim() : '',
}))
vi.mock('@/utils/propertyIcons', () => ({
@@ -56,8 +58,9 @@ vi.mock('@/utils/propertyIcons', () => ({
}))
vi.mock('@/utils/handleUtils', () => ({
BOTTOM_HANDLE_IDS: ['bottom'],
BOTTOM_HANDLE_POSITIONS: { 1: [50] },
bottomHandleId: (idx: number) => idx === 0 ? 'bottom' : `bottom-${idx + 1}`,
bottomHandlePositions: () => [50],
clampBottomHandles: (n: unknown) => typeof n === 'number' ? n : 1,
}))
beforeEach(() => { mockZoom = 1 })
@@ -168,6 +171,49 @@ describe('BaseNode — properties rendering', () => {
})
})
describe('BaseNode — services visibility toggle', () => {
it('does not render service toggle button on the node', () => {
renderBaseNode({ services: [{ service_name: 'nginx', port: 80, protocol: 'tcp' }] })
expect(screen.queryByTitle('Show services')).toBeNull()
})
it('renders service rows when services are toggled on', () => {
renderBaseNode({
ip: '192.168.1.10',
custom_colors: { show_services: true },
services: [
{ service_name: 'nginx', port: 80, protocol: 'tcp' },
{ service_name: 'ssh', port: 22, protocol: 'tcp' },
],
})
expect(screen.getByText('nginx')).toBeDefined()
expect(screen.getByText('80')).toBeDefined()
expect(screen.getByText('ssh')).toBeDefined()
})
it('renders clickable service links for web services', () => {
renderBaseNode({
ip: '192.168.1.10',
custom_colors: { show_services: true },
services: [{ service_name: 'nginx', port: 80, protocol: 'tcp' }],
})
const link = screen.getByRole('link', { name: /nginx/i }) as HTMLAnchorElement
expect(link.getAttribute('href')).toBe('http://192.168.1.10:80')
})
it('keeps non-web services as non-clickable rows', () => {
renderBaseNode({
ip: '192.168.1.10',
custom_colors: { show_services: true },
services: [{ service_name: 'ssh', port: 22, protocol: 'tcp' }],
})
expect(screen.queryByRole('link', { name: /ssh/i })).toBeNull()
})
})
describe('BaseNode — legacy hardware fallback', () => {
it('renders legacy hardware when properties is undefined and show_hardware is true', () => {
renderBaseNode({
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { buildWaypointPath, distToSegment, findInsertIndex, snap45, snap45both } from '../waypointUtils'
import { buildWaypointPath, distToSegment, findInsertIndex, getAddWaypointHandlePosition, getWaypointLabelPosition, snap45, snap45both } from '../waypointUtils'
describe('buildWaypointPath — bezier (default)', () => {
it('builds a catmull-rom curve with no waypoints (start = end clamp)', () => {
@@ -173,3 +173,35 @@ describe('findInsertIndex', () => {
expect(idx).toBe(2)
})
})
describe('getWaypointLabelPosition', () => {
it('uses the routed midpoint for a symmetric bezier waypoint path', () => {
const point = getWaypointLabelPosition(0, 0, [{ x: 50, y: 100 }], 100, 0)
expect(point.x).toBeCloseTo(50, 0)
expect(point.y).toBeCloseTo(100, 0)
})
it('uses the routed midpoint for a smooth waypoint path', () => {
const point = getWaypointLabelPosition(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100, 'smooth')
expect(point.x).toBeCloseTo(50, 0)
expect(point.y).toBeCloseTo(50, 0)
})
it('falls back to the source point when the path is degenerate', () => {
const point = getWaypointLabelPosition(10, 20, [], 10, 20, 'smooth')
expect(point).toEqual({ x: 10, y: 20 })
})
})
describe('getAddWaypointHandlePosition', () => {
it('places bezier add handle on the rendered curved segment', () => {
const point = getAddWaypointHandlePosition(0, 0, [{ x: 50, y: 100 }], 100, 0, 0, 'bezier')
expect(point.x).toBeCloseTo(21.875, 3)
expect(point.y).toBeCloseTo(56.25, 3)
})
it('keeps smooth add handle at straight segment midpoint', () => {
const point = getAddWaypointHandlePosition(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100, 1, 'smooth')
expect(point).toEqual({ x: 50, y: 50 })
})
})
@@ -13,7 +13,7 @@ import type { EdgeData, EdgeType, Waypoint } from '@/types'
import { useThemeStore } from '@/stores/themeStore'
import { useCanvasStore } from '@/stores/canvasStore'
import { THEMES } from '@/utils/themes'
import { buildWaypointPath, snap45, snap45both } from './waypointUtils'
import { buildWaypointPath, getAddWaypointHandlePosition, getWaypointLabelPosition, snap45, snap45both } from './waypointUtils'
const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149']
@@ -161,9 +161,9 @@ function segmentMidpoints(
const isSmooth = pathStyle === 'smooth'
return pts.slice(0, -1).map((a, i) => {
const b = pts[i + 1]
let mx = (a.x + b.x) / 2
const my = (a.y + b.y) / 2
const base = getAddWaypointHandlePosition(sourceX, sourceY, waypoints, targetX, targetY, i, pathStyle)
let mx = base.x
const my = base.y
// For smooth style with no existing waypoints, bias the single + handle onto
// the source handle axis so clicking it creates a perpendicular exit.
@@ -205,8 +205,9 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
? buildWaypointPath(sourceX, sourceY, waypoints, targetX, targetY, pathStyle)
: autoPath
const midX = hasWaypoints ? (sourceX + targetX) / 2 : labelX
const midY = (sourceY + targetY) / 2
const labelPosition = hasWaypoints
? getWaypointLabelPosition(sourceX, sourceY, waypoints, targetX, targetY, pathStyle)
: { x: labelX, y: (sourceY + targetY) / 2 }
const edgeType: EdgeType = data?.type ?? 'ethernet'
const edgeColors = theme.colors.edgeColors
@@ -300,7 +301,7 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
<div
className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded"
style={{
transform: `translate(-50%, -50%) translate(${midX}px, ${midY}px)`,
transform: `translate(-50%, -50%) translate(${labelPosition.x}px, ${labelPosition.y}px)`,
background: theme.colors.edgeLabelBackground,
color: theme.colors.edgeLabelColor,
border: `1px solid ${theme.colors.edgeLabelBorder}`,
@@ -72,6 +72,216 @@ export function buildWaypointPath(
return pathStyle === 'smooth' ? buildRoundedPolylinePath(pts) : buildCatmullRomPath(pts)
}
function interpolateLine(a: Waypoint, b: Waypoint, t: number): Waypoint {
return {
x: a.x + (b.x - a.x) * t,
y: a.y + (b.y - a.y) * t,
}
}
function interpolateQuadratic(a: Waypoint, b: Waypoint, c: Waypoint, t: number): Waypoint {
const mt = 1 - t
return {
x: mt * mt * a.x + 2 * mt * t * b.x + t * t * c.x,
y: mt * mt * a.y + 2 * mt * t * b.y + t * t * c.y,
}
}
function interpolateCubic(a: Waypoint, b: Waypoint, c: Waypoint, d: Waypoint, t: number): Waypoint {
const mt = 1 - t
return {
x: mt * mt * mt * a.x + 3 * mt * mt * t * b.x + 3 * mt * t * t * c.x + t * t * t * d.x,
y: mt * mt * mt * a.y + 3 * mt * mt * t * b.y + 3 * mt * t * t * c.y + t * t * t * d.y,
}
}
function approximateLength(pointAt: (t: number) => Waypoint, steps = 24): number {
let length = 0
let prev = pointAt(0)
for (let step = 1; step <= steps; step++) {
const next = pointAt(step / steps)
length += Math.hypot(next.x - prev.x, next.y - prev.y)
prev = next
}
return length
}
type PathSegment = {
length: number
pointAt: (t: number) => Waypoint
}
function buildBezierSegments(pts: Waypoint[]): PathSegment[] {
if (pts.length < 2) return []
return pts.slice(0, -1).map((_, i) => {
const p0 = pts[Math.max(i - 1, 0)]
const p1 = pts[i]
const p2 = pts[i + 1]
const p3 = pts[Math.min(i + 2, pts.length - 1)]
const cp1 = {
x: p1.x + (p2.x - p0.x) / 6,
y: p1.y + (p2.y - p0.y) / 6,
}
const cp2 = {
x: p2.x - (p3.x - p1.x) / 6,
y: p2.y - (p3.y - p1.y) / 6,
}
const pointAt = (t: number) => interpolateCubic(p1, cp1, cp2, p2, t)
return {
length: approximateLength(pointAt),
pointAt,
}
})
}
function buildSmoothSegments(pts: Waypoint[], radius = 8): PathSegment[] {
if (pts.length < 2) return []
if (pts.length === 2) {
const pointAt = (t: number) => interpolateLine(pts[0], pts[1], t)
return [{ length: Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y), pointAt }]
}
const segments: PathSegment[] = []
let cursor = pts[0]
for (let i = 1; i < pts.length - 1; i++) {
const prev = pts[i - 1]
const curr = pts[i]
const next = pts[i + 1]
const dx1 = curr.x - prev.x
const dy1 = curr.y - prev.y
const len1 = Math.hypot(dx1, dy1)
const dx2 = next.x - curr.x
const dy2 = next.y - curr.y
const len2 = Math.hypot(dx2, dy2)
if (len1 < 1 || len2 < 1) {
const start = { x: cursor.x, y: cursor.y }
const end = { x: curr.x, y: curr.y }
const lineToCurr = (t: number) => interpolateLine(start, end, t)
segments.push({
length: Math.hypot(curr.x - cursor.x, curr.y - cursor.y),
pointAt: lineToCurr,
})
cursor = curr
continue
}
const r = Math.min(radius, len1 / 2, len2 / 2)
const before = {
x: curr.x - (dx1 / len1) * r,
y: curr.y - (dy1 / len1) * r,
}
const after = {
x: curr.x + (dx2 / len2) * r,
y: curr.y + (dy2 / len2) * r,
}
const lineStart = { x: cursor.x, y: cursor.y }
const lineEnd = { x: before.x, y: before.y }
const lineToBefore = (t: number) => interpolateLine(lineStart, lineEnd, t)
segments.push({
length: Math.hypot(before.x - cursor.x, before.y - cursor.y),
pointAt: lineToBefore,
})
const curveAroundCorner = (t: number) => interpolateQuadratic(before, curr, after, t)
segments.push({
length: approximateLength(curveAroundCorner),
pointAt: curveAroundCorner,
})
cursor = after
}
const targetStart = { x: cursor.x, y: cursor.y }
const targetEnd = { x: pts[pts.length - 1].x, y: pts[pts.length - 1].y }
const lineToTarget = (t: number) => interpolateLine(targetStart, targetEnd, t)
segments.push({
length: Math.hypot(pts[pts.length - 1].x - cursor.x, pts[pts.length - 1].y - cursor.y),
pointAt: lineToTarget,
})
return segments
}
export function getWaypointLabelPosition(
sourceX: number, sourceY: number,
waypoints: Waypoint[],
targetX: number, targetY: number,
pathStyle: string = 'bezier',
): Waypoint {
const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }]
const segments = pathStyle === 'smooth' ? buildSmoothSegments(pts) : buildBezierSegments(pts)
if (segments.length === 0) return pts[0]
const totalLength = segments.reduce((sum, segment) => sum + segment.length, 0)
if (totalLength <= 0) return pts[Math.floor(pts.length / 2)]
let remaining = totalLength / 2
for (const segment of segments) {
if (remaining <= segment.length) {
const t = segment.length === 0 ? 0 : remaining / segment.length
return segment.pointAt(t)
}
remaining -= segment.length
}
const lastSegment = segments[segments.length - 1]
return lastSegment.pointAt(1)
}
function getBezierSegmentPoint(
pts: Waypoint[],
insertIndex: number,
t: number,
): Waypoint {
const i = Math.max(0, Math.min(insertIndex, pts.length - 2))
const p0 = pts[Math.max(i - 1, 0)]
const p1 = pts[i]
const p2 = pts[i + 1]
const p3 = pts[Math.min(i + 2, pts.length - 1)]
const cp1 = {
x: p1.x + (p2.x - p0.x) / 6,
y: p1.y + (p2.y - p0.y) / 6,
}
const cp2 = {
x: p2.x - (p3.x - p1.x) / 6,
y: p2.y - (p3.y - p1.y) / 6,
}
return interpolateCubic(p1, cp1, cp2, p2, t)
}
export function getAddWaypointHandlePosition(
sourceX: number, sourceY: number,
waypoints: Waypoint[],
targetX: number, targetY: number,
insertIndex: number,
pathStyle: string = 'bezier',
): Waypoint {
const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }]
if (pts.length < 2) return { x: sourceX, y: sourceY }
if (pathStyle !== 'smooth') {
return getBezierSegmentPoint(pts, insertIndex, 0.5)
}
const i = Math.max(0, Math.min(insertIndex, pts.length - 2))
return {
x: (pts[i].x + pts[i + 1].x) / 2,
y: (pts[i].y + pts[i + 1].y) / 2,
}
}
// ── 45° snapping ──────────────────────────────────────────────────────────────
/**
@@ -1,15 +1,17 @@
import { createElement, useEffect, useMemo } from 'react'
import { Handle, Position, NodeResizer, useUpdateNodeInternals, useViewport, type NodeProps, type Node } from '@xyflow/react'
import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react'
import { Cpu, MemoryStick, HardDrive, ExternalLink, type LucideIcon } from 'lucide-react'
import type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors'
import { resolveNodeIcon } from '@/utils/nodeIcons'
import { resolveNodeIcon, isBrandIconKey } from '@/utils/nodeIcons'
import { NodeIcon } from '@/components/ui/NodeIcon'
import { resolvePropertyIcon } from '@/utils/propertyIcons'
import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
import { useCanvasStore } from '@/stores/canvasStore'
import { maskIp, splitIps } from '@/utils/maskIp'
import { BOTTOM_HANDLE_IDS, BOTTOM_HANDLE_POSITIONS } from '@/utils/handleUtils'
import { maskIp, primaryIp, splitIps } from '@/utils/maskIp'
import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils'
import { getServiceUrl } from '@/utils/serviceUrl'
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
icon: LucideIcon
@@ -35,6 +37,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
const colors = resolveNodeColors(data, activeTheme)
const statusColor = theme.colors.statusColors[data.status]
const isOnline = data.status === 'online'
const services = data.services ?? []
const showServices = data.custom_colors?.show_services === true
const serviceHost = data.ip ? primaryIp(data.ip) : data.hostname
// Properties: prefer new system; fall back to legacy hardware fields for unmigrated nodes
const visibleProperties = data.properties?.filter((p) => p.visible) ?? null
@@ -56,7 +61,8 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 8px ${colors.border}44`
: 'none',
opacity: data.status === 'offline' ? 0.55 : 1,
minWidth: 140,
// Grow node width when many bottom handles so each stays clickable (~14px slot).
minWidth: Math.max(140, clampBottomHandles(data.bottom_handles ?? 1) * 14),
width: width ? '100%' : undefined,
height: height ? '100%' : undefined,
}}
@@ -66,7 +72,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
minWidth={140}
minHeight={50}
lineStyle={{ borderColor: 'transparent' }}
handleStyle={{ borderColor: colors.border, background: colors.border, width: 8, height: 8 }}
handleStyle={{ borderColor: colors.border, background: colors.border, width: 16, height: 16 }}
/>
<Handle
type="source"
@@ -76,6 +82,13 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
/>
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
{/* Status dot — absolute to avoid affecting node auto-width */}
<div
className="absolute top-2 right-2 w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: statusColor }}
title={data.status}
/>
{/* Main row */}
<div className="flex flex-row items-center gap-2.5 px-2.5 py-2 min-w-0 overflow-hidden">
{/* Icon */}
@@ -86,7 +99,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
background: theme.colors.nodeIconBackground,
}}
>
{createElement(resolvedIcon, { size: 15 })}
{isBrandIconKey(data.custom_icon)
? <NodeIcon typeIcon={typeIcon} customIconKey={data.custom_icon} size={15} />
: createElement(resolvedIcon, { size: 15 })}
</div>
{/* Label + IP */}
@@ -121,7 +136,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
return (
<div key={prop.key} className="flex items-center gap-1 font-mono text-[10px] min-w-0 overflow-hidden" style={{ color: theme.colors.nodeSubtextColor }}>
{Icon && <Icon size={9} className="shrink-0" />}
<span className="truncate max-w-[60px] shrink-0" title={prop.key}>{prop.key}</span>
<span className="truncate max-w-15 shrink-0" title={prop.key}>{prop.key}</span>
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
</div>
)
@@ -130,6 +145,74 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
</>
)}
{showServices && services.length > 0 && (
<>
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
<div className="flex flex-col gap-1 px-2.5 py-1.5 overflow-hidden">
{services.map((svc, idx) => {
const url = getServiceUrl(svc, serviceHost)
const row = (
<div
className="nodrag flex items-center justify-between gap-2 px-1.5 py-1 rounded text-[10px] min-w-0 overflow-hidden"
style={{
background: theme.colors.nodeIconBackground,
color: theme.colors.nodeSubtextColor,
}}
>
<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 }}
title={svc.service_name}
>
{svc.service_name}
</span>
{/* RIGHT: path + port */}
<div className="flex items-center gap-2 shrink-0 min-w-0">
{svc.path && (
<span
className="truncate text-[#8b949e] text-right max-w-[80px]"
title={svc.path}
>
{svc.path}
</span>
)}
<span className="font-mono opacity-80 flex items-center gap-1">
<span>{svc.port}</span>
<ExternalLink
size={9}
className={`shrink-0 ${url ? '' : 'opacity-0'}`}
/>
</span>
</div>
</div>
</div>
)
if (!url) return <div key={`${svc.port}-${svc.protocol}-${svc.service_name}-${idx}`}>{row}</div>
return (
<a
key={`${svc.port}-${svc.protocol}-${svc.service_name}-${idx}`}
href={url}
target="_blank"
rel="noopener noreferrer"
className="block hover:opacity-85 transition-opacity"
title={url}
onClick={(e) => e.stopPropagation()}
>
{row}
</a>
)
})}
</div>
</>
)}
{/* Legacy hardware section — fallback for nodes not yet migrated */}
{showLegacyHardware && (
<>
@@ -139,7 +222,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
<div className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
<Cpu size={9} className="shrink-0" />
{data.cpu_model && (
<span className="truncate max-w-[80px]" title={data.cpu_model}>{data.cpu_model}</span>
<span className="truncate max-w-20" title={data.cpu_model}>{data.cpu_model}</span>
)}
{data.cpu_count != null && (
<span className="shrink-0">{data.cpu_model ? `· ${data.cpu_count}c` : `${data.cpu_count} cores`}</span>
@@ -166,16 +249,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
</>
)}
{/* Status dot */}
<div
className="absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full shrink-0"
style={{ backgroundColor: statusColor }}
title={data.status}
/>
{(BOTTOM_HANDLE_POSITIONS[data.bottom_handles ?? 1] ?? BOTTOM_HANDLE_POSITIONS[1]).map((leftPct, idx) => {
const sourceId = BOTTOM_HANDLE_IDS[idx]
const targetId = idx === 0 ? 'bottom-t' : `bottom-${idx + 1}-t`
{bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => {
const sourceId = bottomHandleId(idx)
const targetId = `${sourceId}-t`
return (
<span key={sourceId}>
<Handle
@@ -3,8 +3,11 @@ import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflo
import { Layers } from 'lucide-react'
import type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors'
import { resolveNodeIcon } from '@/utils/nodeIcons'
import { resolveNodeIcon, isBrandIconKey } from '@/utils/nodeIcons'
import { NodeIcon } from '@/components/ui/NodeIcon'
import { resolvePropertyIcon } from '@/utils/propertyIcons'
import { useCanvasStore } from '@/stores/canvasStore'
import { maskIp, splitIps } from '@/utils/maskIp'
import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
import { BaseNode } from './BaseNode'
@@ -13,6 +16,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
const { data, selected } = props
const activeTheme = useThemeStore((s) => s.activeTheme)
const hideIp = useCanvasStore((s) => s.hideIp)
const theme = THEMES[activeTheme]
const colors = resolveNodeColors(data, activeTheme)
@@ -53,7 +57,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
minHeight={160}
isVisible={selected}
lineStyle={{ borderColor: glow, opacity: 0.6 }}
handleStyle={{ borderColor: glow, backgroundColor: theme.colors.nodeCardBackground }}
handleStyle={{ borderColor: glow, backgroundColor: theme.colors.nodeCardBackground, width: 6, height: 6 }}
/>
{/* Group border */}
@@ -71,7 +75,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
>
{/* Header bar */}
<div
className="flex items-center gap-2 px-2.5 py-1.5 shrink-0"
className="flex flex-row items-start gap-2 px-2.5 py-1.5 shrink-0"
style={{
background: isOnline ? `${glow}18` : `${theme.colors.nodeIconBackground}88`,
borderBottom: `1px solid ${isOnline ? `${glow}33` : theme.colors.handleBackground}`,
@@ -84,7 +88,9 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
background: theme.colors.nodeIconBackground,
}}
>
{createElement(resolvedIcon, { size: 12 })}
{isBrandIconKey(data.custom_icon)
? <NodeIcon typeIcon={Layers} customIconKey={data.custom_icon} size={12} />
: createElement(resolvedIcon, { size: 12 })}
</div>
<div className="flex flex-col min-w-0 flex-1">
<span
@@ -93,18 +99,19 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
>
{data.label}
</span>
{data.ip && (
{data.ip && splitIps(data.ip).map((ip) => (
<span
key={ip}
className="font-mono text-[9px] truncate"
style={{ color: theme.colors.nodeSubtextColor }}
>
{data.ip}
{hideIp ? maskIp(ip) : ip}
</span>
)}
))}
</div>
{/* Status dot */}
<div
className="w-1.5 h-1.5 rounded-full shrink-0"
className="ml-auto w-1.5 h-1.5 rounded-full shrink-0"
style={{ backgroundColor: statusColor }}
title={data.status}
/>
@@ -125,7 +132,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
}}
>
{Icon && <Icon size={9} className="shrink-0" />}
<span className="truncate max-w-[60px] shrink-0" title={prop.key}>{prop.key}</span>
<span className="truncate max-w-15 shrink-0" title={prop.key}>{prop.key}</span>
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
</div>
)
@@ -0,0 +1,73 @@
import { NodeResizer, type NodeProps, type Node } from '@xyflow/react'
import { useCanvasStore } from '@/stores/canvasStore'
import type { NodeData } from '@/types'
const FONT_FAMILIES: Record<string, string> = {
inter: 'Inter, sans-serif',
mono: '"JetBrains Mono", monospace',
serif: 'Georgia, serif',
sans: 'system-ui, sans-serif',
}
export function TextNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
const setEditingTextId = useCanvasStore((s) => s.setEditingTextId)
const rc = data.custom_colors ?? {}
const borderColor = rc.border ?? '#30363d'
const borderStyle = rc.border_style ?? 'none'
const borderWidth = rc.border_width ?? 1
const backgroundColor = rc.background ?? 'transparent'
const textColor = rc.text_color ?? '#e6edf3'
const textSize: number = rc.text_size ?? 14
const fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter
const content = data.text_content ?? data.label ?? ''
return (
<>
<NodeResizer
isVisible={selected}
minWidth={40}
minHeight={20}
handleStyle={{
width: 8,
height: 8,
borderRadius: 2,
background: '#00d4ff',
border: '1px solid #0d1117',
}}
lineStyle={{ borderColor: 'transparent' }}
/>
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 8,
background: backgroundColor,
border: borderStyle === 'none' ? 'none' : `${borderWidth}px ${borderStyle} ${borderColor}`,
boxShadow: selected ? '0 0 0 1px #00d4ff, 0 0 8px #00d4ff44' : 'none',
borderRadius: 6,
boxSizing: 'border-box',
cursor: 'default',
color: textColor,
fontFamily,
fontSize: textSize,
fontWeight: 500,
userSelect: 'none',
whiteSpace: 'pre-wrap',
textAlign: 'center',
}}
onDoubleClick={(e) => {
e.stopPropagation()
setEditingTextId(id)
}}
>
{content}
</div>
</>
)
}
@@ -0,0 +1,67 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { render, fireEvent } from '@testing-library/react'
import { ReactFlowProvider } from '@xyflow/react'
import { TextNode } from '../TextNode'
import { useCanvasStore } from '@/stores/canvasStore'
import type { NodeData } from '@/types'
import type { NodeProps, Node } from '@xyflow/react'
function renderNode(data: Partial<NodeData> = {}) {
const fullData: NodeData = {
label: '',
type: 'text',
status: 'unknown',
services: [],
text_content: 'Hello',
...data,
}
const props = {
id: 't1',
data: fullData,
selected: false,
type: 'text',
zIndex: 0,
isConnectable: true,
xPos: 0,
yPos: 0,
dragging: false,
deletable: true,
draggable: true,
selectable: true,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
width: 200,
height: 60,
dragHandle: undefined,
parentId: undefined,
sourcePosition: undefined,
targetPosition: undefined,
} as unknown as NodeProps<Node<NodeData>>
return render(
<ReactFlowProvider>
<TextNode {...props} />
</ReactFlowProvider>
)
}
describe('TextNode', () => {
beforeEach(() => {
useCanvasStore.setState({ editingTextId: null })
})
it('renders text_content', () => {
const { getByText } = renderNode({ text_content: 'My label' })
expect(getByText('My label')).toBeDefined()
})
it('falls back to label when text_content is missing', () => {
const { getByText } = renderNode({ text_content: undefined, label: 'Fallback' })
expect(getByText('Fallback')).toBeDefined()
})
it('double-click sets editingTextId in store', () => {
const { getByText } = renderNode({ text_content: 'Edit me' })
fireEvent.doubleClick(getByText('Edit me'))
expect(useCanvasStore.getState().editingTextId).toBe('t1')
})
})
@@ -1,7 +1,7 @@
import { type NodeProps, type Node } from '@xyflow/react'
import {
Globe, Router, Network, Server, Layers, Box, Container,
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor,
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor, Package, Flame, Radio, Antenna,
} from 'lucide-react'
import { BaseNode } from './BaseNode'
import type { NodeData } from '@/types'
@@ -10,6 +10,7 @@ type N = NodeProps<Node<NodeData>>
export const IspNode = (props: N) => <BaseNode {...props} icon={Globe} />
export const RouterNode = (props: N) => <BaseNode {...props} icon={Router} />
export const FirewallNode = (props: N) => <BaseNode {...props} icon={Flame} />
export const SwitchNode = (props: N) => <BaseNode {...props} icon={Network} />
export const ServerNode = (props: N) => <BaseNode {...props} icon={Server} />
export const ProxmoxNode = (props: N) => <BaseNode {...props} icon={Layers} />
@@ -22,5 +23,10 @@ export const CameraNode = (props: N) => <BaseNode {...props} icon={Cctv} />
export const PrinterNode = (props: N) => <BaseNode {...props} icon={Printer} />
export const ComputerNode = (props: N) => <BaseNode {...props} icon={Monitor} />
export const CplNode = (props: N) => <BaseNode {...props} icon={PlugZap} />
export const DockerNode = (props: N) => <BaseNode {...props} icon={Anchor} />
export const DockerHostNode = (props: N) => <BaseNode {...props} icon={Anchor} />
export const DockerContainerNode = (props: N) => <BaseNode {...props} icon={Package} />
export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
// Zigbee node types
export const ZigbeeCoordinatorNode = (props: N) => <BaseNode {...props} icon={Network} />
export const ZigbeeRouterNode = (props: N) => <BaseNode {...props} icon={Radio} />
export const ZigbeeEndDeviceNode = (props: N) => <BaseNode {...props} icon={Antenna} />
@@ -1,11 +1,13 @@
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerNode, GenericNode } from './index'
import { IspNode, RouterNode, FirewallNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode, ZigbeeCoordinatorNode, ZigbeeRouterNode, ZigbeeEndDeviceNode } from './index'
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
import { GroupRectNode } from './GroupRectNode'
import { GroupNode } from './GroupNode'
import { TextNode } from './TextNode'
export const nodeTypes = {
isp: IspNode,
router: RouterNode,
firewall: FirewallNode,
switch: SwitchNode,
server: ServerNode,
proxmox: ProxmoxGroupNode,
@@ -18,8 +20,13 @@ export const nodeTypes = {
printer: PrinterNode,
computer: ComputerNode,
cpl: CplNode,
docker: DockerNode,
docker_host: DockerHostNode,
docker_container: DockerContainerNode,
generic: GenericNode,
groupRect: GroupRectNode,
group: GroupNode,
text: TextNode,
zigbee_coordinator: ZigbeeCoordinatorNode,
zigbee_router: ZigbeeRouterNode,
zigbee_enddevice: ZigbeeEndDeviceNode,
}
@@ -0,0 +1,85 @@
import { useMemo, useState } from 'react'
import { Input } from '@/components/ui/input'
import { brandIconUrl, BRAND_ICON_PREFIX } from '@/utils/nodeIcons'
import dashboardIcons from '@/data/dashboardIcons.json'
const SLUGS: string[] = dashboardIcons as string[]
const PAGE = 120
interface BrandIconPickerProps {
value?: string
onSelect: (key: string) => void
}
export function BrandIconPicker({ value, onSelect }: BrandIconPickerProps) {
const [query, setQuery] = useState('')
const [limit, setLimit] = useState(PAGE)
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return SLUGS
return SLUGS.filter((s) => s.includes(q))
}, [query])
const visible = filtered.slice(0, limit)
const selectedSlug = value?.startsWith(BRAND_ICON_PREFIX) ? value.slice(BRAND_ICON_PREFIX.length) : null
return (
<div className="flex flex-col gap-2">
<Input
type="text"
value={query}
onChange={(e) => { setQuery(e.target.value); setLimit(PAGE) }}
placeholder={`Search ${SLUGS.length} brand icons...`}
className="bg-[#0d1117] border-[#30363d] text-xs h-7"
aria-label="Brand icon search"
/>
<div className="text-[10px] text-muted-foreground/60">
{filtered.length} match{filtered.length === 1 ? '' : 'es'} · icons served via jsDelivr CDN
</div>
<div className="max-h-52 overflow-y-auto pr-1">
<div className="grid grid-cols-7 gap-1">
{visible.map((slug) => {
const selected = slug === selectedSlug
return (
<button
key={slug}
type="button"
onClick={() => onSelect(`${BRAND_ICON_PREFIX}${slug}`)}
title={slug}
aria-label={slug}
aria-pressed={selected}
className={`flex items-center justify-center aspect-square rounded-md border transition-colors cursor-pointer ${
selected
? 'border-[#00d4ff] bg-[#00d4ff]/10'
: 'border-[#30363d] hover:border-[#484f58] bg-[#0d1117]'
}`}
>
<img
src={brandIconUrl(slug)}
alt={slug}
loading="lazy"
width={20}
height={20}
style={{ width: 20, height: 20, objectFit: 'contain' }}
/>
</button>
)
})}
</div>
{filtered.length > limit && (
<button
type="button"
onClick={() => setLimit((l) => l + PAGE)}
className="mt-2 w-full text-[11px] text-muted-foreground hover:text-foreground py-1"
>
Load more ({filtered.length - limit} remaining)
</button>
)}
{filtered.length === 0 && (
<div className="text-center text-[11px] text-muted-foreground py-4">No icons match.</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,488 @@
import { useState, useCallback } from 'react'
import { toast } from 'sonner'
import {
Globe, Router, Network, Server, Layers, Box, Container, HardDrive,
Cpu, Wifi, Camera, Printer, Monitor, PlugZap, Anchor, Package, Circle, Flame,
Radio, Zap, Lightbulb,
type LucideIcon,
} from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { useThemeStore } from '@/stores/themeStore'
import { useCanvasStore } from '@/stores/canvasStore'
import { THEMES } from '@/utils/themes'
import { applyOpacity } from '@/utils/colorUtils'
import type {
NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, EdgePathStyle,
} from '@/types'
import { NODE_TYPE_LABELS, EDGE_TYPE_LABELS } from '@/types'
// ── Node types exposed for custom style (skip groupRect/group) ───────────────
const EDITABLE_NODE_TYPES: NodeType[] = [
'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas',
'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker_host',
'docker_container', 'zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice',
'generic',
]
const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
const NODE_ICONS: Record<string, LucideIcon> = {
isp: Globe, router: Router, firewall: Flame, switch: Network, server: Server, proxmox: Layers,
vm: Box, lxc: Container, nas: HardDrive, iot: Cpu, ap: Wifi,
camera: Camera, printer: Printer, computer: Monitor, cpl: PlugZap,
docker_host: Anchor, docker_container: Package,
zigbee_coordinator: Radio, zigbee_router: Zap, zigbee_enddevice: Lightbulb,
generic: Circle,
}
// ── Default style for a node type (from default theme) ─────────────────────
function defaultNodeStyle(nodeType: NodeType): NodeTypeStyle {
const accent = THEMES.default.colors.nodeAccents[nodeType] ?? THEMES.default.colors.nodeAccents.generic
return {
borderColor: accent.border,
borderOpacity: 1,
bgColor: THEMES.default.colors.nodeCardBackground,
bgOpacity: 1,
iconColor: accent.icon,
iconOpacity: 1,
width: 0,
height: 0,
}
}
function defaultEdgeStyle(edgeType: EdgeType): EdgeTypeStyle {
return {
color: THEMES.default.colors.edgeColors[edgeType],
opacity: 1,
pathStyle: 'bezier',
animated: 'none',
}
}
// ── Color + opacity row ──────────────────────────────────────────────────────
interface ColorRowProps {
label: string
color: string
opacity: number
onColorChange: (v: string) => void
onOpacityChange: (v: number) => void
}
function ColorRow({ label, color, opacity, onColorChange, onOpacityChange }: ColorRowProps) {
return (
<div className="flex items-center gap-3">
<span className="text-xs text-[#8b949e] w-20 shrink-0">{label}</span>
<input
type="color"
value={color}
onChange={(e) => onColorChange(e.target.value)}
className="w-7 h-7 rounded cursor-pointer border border-[#30363d] bg-transparent p-0.5"
/>
<div className="flex items-center gap-2 flex-1">
<input
type="range"
min={0}
max={1}
step={0.01}
value={opacity}
onChange={(e) => onOpacityChange(parseFloat(e.target.value))}
className="flex-1 h-1 accent-[#00d4ff]"
/>
<span className="text-xs text-[#8b949e] w-8 text-right">
{Math.round(opacity * 100)}%
</span>
</div>
<div
className="w-5 h-5 rounded border border-[#30363d] shrink-0"
style={{ background: applyOpacity(color, opacity) }}
/>
</div>
)
}
// ── Node type editor ─────────────────────────────────────────────────────────
interface NodeEditorProps {
nodeType: NodeType
style: NodeTypeStyle
onChange: (s: NodeTypeStyle) => void
onApplyToExisting: () => void
}
function NodeEditor({ nodeType, style, onChange, onApplyToExisting }: NodeEditorProps) {
const set = useCallback(<K extends keyof NodeTypeStyle>(k: K, v: NodeTypeStyle[K]) => {
onChange({ ...style, [k]: v })
}, [style, onChange])
return (
<div className="flex flex-col gap-4">
<div className="text-sm font-semibold text-[#e6edf3]">{NODE_TYPE_LABELS[nodeType]}</div>
<div className="flex flex-col gap-3">
<ColorRow
label="Border"
color={style.borderColor}
opacity={style.borderOpacity}
onColorChange={(v) => set('borderColor', v)}
onOpacityChange={(v) => set('borderOpacity', v)}
/>
<ColorRow
label="Background"
color={style.bgColor}
opacity={style.bgOpacity}
onColorChange={(v) => set('bgColor', v)}
onOpacityChange={(v) => set('bgOpacity', v)}
/>
<ColorRow
label="Icon"
color={style.iconColor}
opacity={style.iconOpacity}
onColorChange={(v) => set('iconColor', v)}
onOpacityChange={(v) => set('iconOpacity', v)}
/>
</div>
<div className="border-t border-[#30363d] pt-3">
<div className="text-xs text-[#8b949e] mb-1">Default size</div>
<div className="text-xs text-[#8b949e]/60 mb-2">0 = auto (min 140 × 50 px, grows with content)</div>
<div className="flex gap-3">
<div className="flex items-center gap-2">
<span className="text-xs text-[#8b949e]">W</span>
<input
type="number"
min={0}
step={10}
value={style.width}
onChange={(e) => set('width', parseInt(e.target.value) || 0)}
className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-[#8b949e]">H</span>
<input
type="number"
min={0}
step={10}
value={style.height}
onChange={(e) => set('height', parseInt(e.target.value) || 0)}
className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
/>
</div>
</div>
</div>
<Button
size="sm"
className="self-start bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
onClick={onApplyToExisting}
>
Apply to existing {NODE_TYPE_LABELS[nodeType]} nodes
</Button>
</div>
)
}
// ── Edge type editor ─────────────────────────────────────────────────────────
interface EdgeEditorProps {
edgeType: EdgeType
style: EdgeTypeStyle
onChange: (s: EdgeTypeStyle) => void
onApplyToExisting: () => void
}
function EdgeEditor({ edgeType, style, onChange, onApplyToExisting }: EdgeEditorProps) {
const set = useCallback(<K extends keyof EdgeTypeStyle>(k: K, v: EdgeTypeStyle[K]) => {
onChange({ ...style, [k]: v })
}, [style, onChange])
return (
<div className="flex flex-col gap-4">
<div className="text-sm font-semibold text-[#e6edf3]">{EDGE_TYPE_LABELS[edgeType]}</div>
<div className="flex flex-col gap-3">
<ColorRow
label="Color"
color={style.color}
opacity={style.opacity}
onColorChange={(v) => set('color', v)}
onOpacityChange={(v) => set('opacity', v)}
/>
</div>
<div className="border-t border-[#30363d] pt-3 flex flex-col gap-3">
<div>
<div className="text-xs text-[#8b949e] mb-2">Path style</div>
<div className="flex gap-2">
{(['bezier', 'smooth'] as EdgePathStyle[]).map((ps) => (
<button
key={ps}
type="button"
onClick={() => set('pathStyle', ps)}
className="px-3 py-1 text-xs rounded border transition-colors"
style={{
borderColor: style.pathStyle === ps ? '#00d4ff' : '#30363d',
background: style.pathStyle === ps ? '#00d4ff22' : 'transparent',
color: style.pathStyle === ps ? '#00d4ff' : '#8b949e',
}}
>
{ps.charAt(0).toUpperCase() + ps.slice(1)}
</button>
))}
</div>
</div>
<div>
<div className="text-xs text-[#8b949e] mb-2">Animation</div>
<select
value={style.animated}
onChange={(e) => set('animated', e.target.value as EdgeTypeStyle['animated'])}
className="w-full h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
>
<option value="none">None</option>
<option value="basic">Basic</option>
<option value="flow">Flow</option>
<option value="snake">Snake</option>
</select>
</div>
</div>
<Button
size="sm"
className="self-start bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
onClick={onApplyToExisting}
>
Apply to existing {EDGE_TYPE_LABELS[edgeType]} edges
</Button>
</div>
)
}
// ── Main modal ───────────────────────────────────────────────────────────────
type Tab = 'nodes' | 'edges'
type Selection = { kind: 'node'; type: NodeType } | { kind: 'edge'; type: EdgeType } | null
interface CustomStyleModalProps {
open: boolean
onClose: () => void
}
export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) {
const { customStyle, setCustomStyle } = useThemeStore()
const { markUnsaved, applyTypeNodeStyle, applyTypeEdgeStyle, applyAllCustomStyles } = useCanvasStore()
const [tab, setTab] = useState<Tab>('nodes')
const [selection, setSelection] = useState<Selection>(null)
const [draft, setDraft] = useState<CustomStyleDef>(() => ({
nodes: { ...customStyle.nodes },
edges: { ...customStyle.edges },
}))
const handleOpen = (isOpen: boolean) => {
if (isOpen) {
// Reset draft to current saved customStyle on open
setDraft({ nodes: { ...customStyle.nodes }, edges: { ...customStyle.edges } })
setSelection(null)
} else {
onClose()
}
}
const getNodeStyle = (t: NodeType): NodeTypeStyle =>
draft.nodes[t] ?? defaultNodeStyle(t)
const getEdgeStyle = (t: EdgeType): EdgeTypeStyle =>
draft.edges[t] ?? defaultEdgeStyle(t)
const handleNodeChange = (t: NodeType, s: NodeTypeStyle) =>
setDraft((d) => ({ ...d, nodes: { ...d.nodes, [t]: s } }))
const handleEdgeChange = (t: EdgeType, s: EdgeTypeStyle) =>
setDraft((d) => ({ ...d, edges: { ...d.edges, [t]: s } }))
const handleApplyNodeType = (t: NodeType) => {
const style = getNodeStyle(t)
applyTypeNodeStyle(t, style)
toast.success(`Applied style to all ${NODE_TYPE_LABELS[t]} nodes`)
}
const handleApplyEdgeType = (t: EdgeType) => {
const style = getEdgeStyle(t)
applyTypeEdgeStyle(t, style)
toast.success(`Applied style to all ${EDGE_TYPE_LABELS[t]} edges`)
}
const handleSave = () => {
setCustomStyle(draft)
markUnsaved()
toast.success('Custom style saved — save your canvas to persist')
onClose()
}
const handleApplyAll = () => {
setCustomStyle(draft)
applyAllCustomStyles(draft)
markUnsaved()
toast.success('Custom style applied to all nodes and edges')
onClose()
}
const selectedNodeStyle = selection?.kind === 'node' ? getNodeStyle(selection.type) : null
const selectedEdgeStyle = selection?.kind === 'edge' ? getEdgeStyle(selection.type) : null
return (
<Dialog open={open} onOpenChange={handleOpen}>
<DialogContent className="bg-[#161b22] border-[#30363d] max-w-[calc(100%-2rem)] sm:max-w-3xl max-h-[90vh] flex flex-col p-0 gap-0">
<DialogHeader className="px-5 pt-5 pb-3 border-b border-[#30363d]">
<DialogTitle className="text-sm font-semibold">Custom Style Editor</DialogTitle>
</DialogHeader>
<div className="flex flex-1 overflow-hidden min-h-0">
{/* Left panel — type list */}
<div className="w-52 shrink-0 border-r border-[#30363d] flex flex-col overflow-hidden">
{/* Tabs */}
<div className="flex border-b border-[#30363d]">
{(['nodes', 'edges'] as Tab[]).map((t) => (
<button
key={t}
type="button"
onClick={() => { setTab(t); setSelection(null) }}
className="flex-1 py-2 text-xs font-medium transition-colors"
style={{
borderBottom: tab === t ? '2px solid #00d4ff' : '2px solid transparent',
color: tab === t ? '#00d4ff' : '#8b949e',
}}
>
{t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
{/* Type list */}
<div className="flex-1 overflow-y-auto py-1">
{tab === 'nodes' && EDITABLE_NODE_TYPES.map((t) => {
const Icon = NODE_ICONS[t] ?? Circle
const style = draft.nodes[t]
const isSelected = selection?.kind === 'node' && selection.type === t
const swatchColor = style
? applyOpacity(style.borderColor, style.borderOpacity)
: THEMES.default.colors.nodeAccents[t]?.border ?? '#8b949e'
return (
<button
key={t}
type="button"
onClick={() => setSelection({ kind: 'node', type: t })}
className="w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left"
style={{
background: isSelected ? '#21262d' : 'transparent',
color: isSelected ? '#e6edf3' : '#8b949e',
}}
>
<Icon size={13} />
<span className="flex-1 truncate">{NODE_TYPE_LABELS[t]}</span>
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ background: swatchColor }}
/>
</button>
)
})}
{tab === 'edges' && EDITABLE_EDGE_TYPES.map((t) => {
const style = draft.edges[t]
const isSelected = selection?.kind === 'edge' && selection.type === t
const swatchColor = style
? applyOpacity(style.color, style.opacity)
: THEMES.default.colors.edgeColors[t]
return (
<button
key={t}
type="button"
onClick={() => setSelection({ kind: 'edge', type: t })}
className="w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left"
style={{
background: isSelected ? '#21262d' : 'transparent',
color: isSelected ? '#e6edf3' : '#8b949e',
}}
>
<span className="flex-1 truncate">{EDGE_TYPE_LABELS[t]}</span>
<span
className="w-8 h-1.5 rounded-full shrink-0"
style={{ background: swatchColor }}
/>
</button>
)
})}
</div>
</div>
{/* Right panel — editor */}
<div className="flex-1 overflow-y-auto p-5">
{!selection && (
<div className="flex items-center justify-center h-full text-xs text-[#8b949e]">
Select a {tab === 'nodes' ? 'node type' : 'edge type'} from the list to edit its style
</div>
)}
{selection?.kind === 'node' && selectedNodeStyle && (
<NodeEditor
key={selection.type}
nodeType={selection.type}
style={selectedNodeStyle}
onChange={(s) => handleNodeChange(selection.type, s)}
onApplyToExisting={() => handleApplyNodeType(selection.type)}
/>
)}
{selection?.kind === 'edge' && selectedEdgeStyle && (
<EdgeEditor
key={selection.type}
edgeType={selection.type}
style={selectedEdgeStyle}
onChange={(s) => handleEdgeChange(selection.type, s)}
onApplyToExisting={() => handleApplyEdgeType(selection.type)}
/>
)}
</div>
</div>
{/* Footer */}
<div className="flex justify-between gap-2 px-5 py-3 border-t border-[#30363d]">
<Button
type="button"
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground"
onClick={onClose}
>
Cancel
</Button>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="border-[#30363d] text-[#e6edf3] hover:bg-[#21262d]"
onClick={handleSave}
>
Save Custom Style
</Button>
<Button
type="button"
size="sm"
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
onClick={handleApplyAll}
>
Apply All to Canvas
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
+19 -12
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import modalStyles from './modal-interactive.module.css'
import { RotateCcw } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
@@ -68,8 +69,8 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Link Type</Label>
<Select value={type} onValueChange={(v) => setType(v as EdgeType)}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
<SelectValue />
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Edge type selector">
<SelectValue>{EDGE_TYPE_LABELS[type]}</SelectValue>
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
{EDGE_TYPES.map(([value, label]) => (
@@ -89,7 +90,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
value={vlanId}
onChange={(e) => setVlanId(e.target.value)}
placeholder="e.g. 20"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`}
/>
</div>
)}
@@ -100,19 +101,21 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="e.g. 1G, trunk..."
className="bg-[#21262d] border-[#30363d] text-sm h-8"
className={`bg-[#21262d] border-[#30363d] text-sm h-8 ${modalStyles['modal-radius']}`}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Path Style</Label>
<div className="flex rounded-md overflow-hidden border border-[#30363d]">
<div className={`flex rounded-md overflow-hidden border border-[#30363d] ${modalStyles['modal-interactive']}`}>
{(['bezier', 'smooth'] as EdgePathStyle[]).map((style) => (
<button
key={style}
type="button"
onClick={() => setPathStyle(style)}
className="flex-1 py-1 text-xs capitalize transition-colors"
className="flex-1 py-1 text-xs capitalize transition-colors cursor-pointer"
tabIndex={0}
aria-label={`Path style ${style}`}
style={{
background: pathStyle === style ? '#00d4ff22' : '#21262d',
color: pathStyle === style ? '#00d4ff' : '#8b949e',
@@ -127,13 +130,15 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Animation</Label>
<div className="flex rounded-md overflow-hidden border border-[#30363d]">
<div className={`flex rounded-md overflow-hidden border border-[#30363d] ${modalStyles['modal-interactive']}`}>
{(['none', 'basic', 'snake', 'flow'] as AnimMode[]).map((mode, i) => (
<button
key={mode}
type="button"
onClick={() => setAnimation(mode)}
className="flex-1 py-1 text-xs capitalize transition-colors"
className="flex-1 py-1 text-xs capitalize transition-colors cursor-pointer"
tabIndex={0}
aria-label={`Animation mode ${mode}`}
style={{
background: animation === mode ? '#00d4ff22' : '#21262d',
color: animation === mode ? '#00d4ff' : '#8b949e',
@@ -160,8 +165,10 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
)}
</div>
<label
className="relative flex items-center gap-2.5 px-2.5 h-8 rounded-md border cursor-pointer"
className={`relative flex items-center gap-2.5 px-2.5 h-8 rounded-md border cursor-pointer ${modalStyles['modal-interactive']}`}
style={{ borderColor: customColor ? effectiveColor : '#30363d', background: '#21262d' }}
tabIndex={0}
aria-label="Edge color picker"
>
<input
type="color"
@@ -189,13 +196,13 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
<div className="flex justify-between gap-2 pt-1">
{onDelete ? (
<Button type="button" variant="ghost" size="sm" className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10" onClick={handleDelete}>
<Button type="button" variant="ghost" size="sm" className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer" onClick={handleDelete}>
Delete
</Button>
) : <span />}
<div className="flex gap-2">
<Button type="button" variant="ghost" size="sm" onClick={onClose}>Cancel</Button>
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90">
<Button type="button" variant="ghost" size="sm" className="cursor-pointer" onClick={onClose}>Cancel</Button>
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer">
{onDelete ? 'Save' : 'Connect'}
</Button>
</div>
@@ -1,4 +1,5 @@
import { useState } from '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'
@@ -129,7 +130,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
value={form.label}
onChange={(e) => set('label', e.target.value)}
placeholder="Zone name…"
className="bg-[#21262d] border-[#30363d] text-sm h-8"
className={`bg-[#21262d] border-[#30363d] text-sm h-8 ${modalStyles['modal-radius']}`}
/>
</div>
@@ -137,7 +138,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Font</Label>
<Select value={form.font} onValueChange={(v: string | null) => set('font', v ?? 'inter')}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`}>
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
@@ -162,7 +163,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
type="button"
title={value}
onClick={() => set('text_position', value)}
className="h-8 rounded text-base transition-colors"
className={`h-8 rounded text-base transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
style={{
background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
@@ -187,7 +188,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
key={value}
type="button"
onClick={() => set('label_position', value)}
className="flex items-center justify-center h-8 rounded text-xs transition-colors"
className={`flex items-center justify-center h-8 rounded text-xs transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
style={{
background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
@@ -248,7 +249,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
key={value}
type="button"
onClick={() => set('text_size', value)}
className="flex items-center justify-center h-8 rounded transition-colors"
className={`flex items-center justify-center h-8 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
style={{
background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
@@ -275,7 +276,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
type="button"
title={label}
onClick={() => set('border_style', value)}
className="flex flex-col items-center justify-center h-10 rounded text-xs gap-0.5 transition-colors"
className={`flex flex-col items-center justify-center h-10 rounded text-xs gap-0.5 transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
style={{
background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
@@ -301,7 +302,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
key={value}
type="button"
onClick={() => set('border_width', value)}
className="flex items-center justify-center h-8 rounded text-xs transition-colors"
className={`flex items-center justify-center h-8 rounded text-xs transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
style={{
background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
@@ -319,7 +320,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Z-Order (1 = furthest back)</Label>
<Select value={String(form.z_order)} onValueChange={(v: string | null) => set('z_order', v !== null ? Number(v) : 1)}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']}`}>
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
@@ -338,17 +339,17 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
type="button"
variant="ghost"
size="sm"
className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10"
className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer"
onClick={() => { onDelete(); onClose() }}
>
Delete
</Button>
)}
<div className="flex gap-2 ml-auto">
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
<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">
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer">
{title === 'Add Zone' ? 'Add' : 'Save'}
</Button>
</div>
+257 -100
View File
@@ -1,4 +1,5 @@
import { createElement, useState } from 'react'
import { Fragment, createElement, useState } from 'react'
import modalStyles from './modal-interactive.module.css'
import { RotateCcw, ChevronDown } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
@@ -7,16 +8,32 @@ import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors'
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS } from '@/utils/nodeIcons'
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
import { BrandIconPicker } from './BrandIconPicker'
import { MIN_BOTTOM_HANDLES, MAX_BOTTOM_HANDLES, clampBottomHandles } from '@/utils/handleUtils'
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
{ label: 'Hardware', types: ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer'] },
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker'] },
{ label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] },
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
{ label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] },
{ label: 'Generic', types: ['computer', 'generic', 'groupRect'] },
]
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
const CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host']
const ZIGBEE_TYPES: NodeType[] = ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice']
const CHECK_METHOD_LABELS: Record<CheckMethod, string> = {
none: 'None',
ping: 'Ping',
http: 'HTTP',
https: 'HTTPS',
tcp: 'TCP',
ssh: 'SSH',
prometheus: 'Prometheus',
health: 'Health',
}
const DEFAULT_DATA: Partial<NodeData> = {
type: 'server',
@@ -26,7 +43,7 @@ const DEFAULT_DATA: Partial<NodeData> = {
status: 'unknown',
check_method: 'ping',
services: [],
container_mode: true,
container_mode: false,
custom_colors: undefined,
custom_icon: undefined,
}
@@ -37,16 +54,26 @@ interface NodeModalProps {
onSubmit: (data: Partial<NodeData>) => void
initial?: Partial<NodeData>
title?: string
proxmoxNodes?: { id: string; label: string }[]
parentContainerNodes?: { id: string; label: string; nodeType?: NodeType }[]
}
const CHILD_TYPES: NodeType[] = ['vm', 'lxc']
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', proxmoxNodes = [] }: NodeModalProps) {
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
// NodeModal is always mounted with a key that changes on open/edit, so useState
// initial value is enough - no need for a reset effect.
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentContainerNodes = [] }: NodeModalProps) {
const merged = { ...DEFAULT_DATA, ...initial }
if (ZIGBEE_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
const hasAppearanceOverrides = Boolean(
form.custom_colors?.border
|| form.custom_colors?.background
|| form.custom_colors?.icon
)
const set = (key: keyof NodeData, value: unknown) =>
setForm((f) => ({ ...f, [key]: value }))
@@ -58,13 +85,22 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
return
}
setLabelError(false)
onSubmit(form)
const selectedType = (form.type ?? 'generic') as NodeType
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
onSubmit({
...form,
container_mode: canUseContainerMode ? !!form.container_mode : false,
})
onClose()
}
const filteredParentNodes = form.type === 'docker_container'
? parentContainerNodes.filter((n) => n.nodeType === 'docker_host')
: parentContainerNodes
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md">
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
</DialogHeader>
@@ -74,15 +110,18 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
{/* Type + Icon on the same row */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Type</Label>
<Select value={form.type} onValueChange={(v) => set('type', v as NodeType)}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8 w-full">
<SelectValue />
<Select value={form.type} onValueChange={(v) => {
const t = v as NodeType
setForm((f) => ({ ...f, type: t, ...(ZIGBEE_TYPES.includes(t) ? { check_method: 'none' as CheckMethod } : {}) }))
}}>
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 w-full cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Node type selector">
<SelectValue>{NODE_TYPE_LABELS[(form.type ?? 'server') as NodeType]}</SelectValue>
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
{NODE_TYPE_GROUPS.map((group, i) => (
<>
{i > 0 && <SelectSeparator key={`sep-${group.label}`} className="bg-[#30363d]" />}
<SelectGroup key={group.label}>
<Fragment key={group.label}>
{i > 0 && <SelectSeparator className="bg-[#30363d]" />}
<SelectGroup>
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50 px-2 py-1">
{group.label}
</SelectLabel>
@@ -92,7 +131,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
</SelectItem>
))}
</SelectGroup>
</>
</Fragment>
))}
</SelectContent>
</Select>
@@ -116,10 +155,15 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
<button
type="button"
onClick={() => setIconPickerOpen((o) => !o)}
className="flex items-center justify-between gap-2 h-8 px-3 rounded-md bg-[#21262d] border border-[#30363d] text-sm hover:border-[#8b949e] transition-colors w-full"
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">
{(() => {
if (isBrandIconKey(form.custom_icon)) {
const slug = brandIconSlug(form.custom_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.custom_icon)
if (entry) {
return <>{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff] shrink-0' })}<span className="text-foreground truncate">{entry.label}</span></>
@@ -132,14 +176,45 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
</button>
</div>
{/* Inline icon picker full width, shown below the type+icon row */}
{/* 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"
className={`bg-[#21262d] border-[#30363d] text-xs h-7 ${modalStyles['modal-radius']}`}
autoFocus
/>
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
@@ -161,7 +236,8 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
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"
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',
@@ -179,6 +255,8 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
)
})}
</div>
</>
)}
</div>
)}
@@ -189,7 +267,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
value={form.label ?? ''}
onChange={(e) => { set('label', e.target.value); if (labelError) setLabelError(false) }}
placeholder="My Server"
className={`bg-[#21262d] text-sm h-8 ${labelError ? 'border-[#f85149] focus-visible:ring-[#f85149]' : 'border-[#30363d]'}`}
className={`bg-[#21262d] text-sm h-8 ${labelError ? 'border-[#f85149] focus-visible:ring-[#f85149]' : 'border-[#30363d]'} ${modalStyles['modal-radius']}`}
/>
{labelError && <p className="text-[11px] text-[#f85149]">Label is required</p>}
</div>
@@ -201,61 +279,70 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
value={form.hostname ?? ''}
onChange={(e) => set('hostname', e.target.value)}
placeholder="server.lan"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`}
/>
</div>
{/* IP */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">IP Address <span className="text-muted-foreground/50">(comma-separated)</span></Label>
<Label className="text-xs text-muted-foreground">IP Address</Label>
<Input
value={form.ip ?? ''}
onChange={(e) => set('ip', e.target.value)}
placeholder="192.168.1.x, 2001:db8::1"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`}
/>
<span className="text-[10px] text-muted-foreground/50">comma-separated</span>
</div>
{/* Check method */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Check Method</Label>
<Select value={form.check_method ?? 'ping'} onValueChange={(v) => set('check_method', v as CheckMethod)}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
{CHECK_METHODS.map((m) => (
<SelectItem key={m} value={m} className="text-sm font-mono">{m}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Check method — hidden for zigbee nodes (always none/online) */}
{!ZIGBEE_TYPES.includes((form.type ?? '') as NodeType) && (
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Check Method</Label>
<Select value={form.check_method ?? 'ping'} onValueChange={(v) => set('check_method', v as CheckMethod)}>
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Check method selector">
<SelectValue>{CHECK_METHOD_LABELS[(form.check_method ?? 'ping') as CheckMethod]}</SelectValue>
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
{CHECK_METHODS.map((m) => (
<SelectItem key={m} value={m} className="text-sm">{CHECK_METHOD_LABELS[m]}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Check target */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Check Target</Label>
<Input
value={form.check_target ?? ''}
onChange={(e) => set('check_target', e.target.value)}
placeholder="http://..."
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
</div>
{/* Check target — hidden for zigbee nodes */}
{!ZIGBEE_TYPES.includes((form.type ?? '') as NodeType) && (
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Check Target</Label>
<Input
value={form.check_target ?? ''}
onChange={(e) => set('check_target', e.target.value)}
placeholder="http://..."
className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`}
/>
</div>
)}
{/* Parent Proxmox (VM / LXC only) */}
{CHILD_TYPES.includes(form.type as NodeType) && proxmoxNodes.length > 0 && (
{/* Parent container */}
{form.type !== 'groupRect' && form.type !== 'group' && filteredParentNodes.length > 0 && (
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Parent Proxmox</Label>
<Label className="text-xs text-muted-foreground">Parent Container</Label>
<Select
value={form.parent_id ?? 'none'}
onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)}
>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
<SelectValue placeholder="None (standalone)" />
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Parent container selector">
<SelectValue placeholder="None (standalone)">
{form.parent_id
? (filteredParentNodes.find((n) => n.id === form.parent_id)?.label ?? 'None (standalone)')
: 'None (standalone)'}
</SelectValue>
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
<SelectItem value="none" className="text-sm">None (standalone)</SelectItem>
{proxmoxNodes.map((n) => (
{filteredParentNodes.map((n) => (
<SelectItem key={n.id} value={n.id} className="text-sm">{n.label}</SelectItem>
))}
</SelectContent>
@@ -263,24 +350,57 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
</div>
)}
{/* Container mode (proxmox only) */}
{form.type === 'proxmox' && (
{/* Container mode */}
{CONTAINER_MODE_TYPES.includes((form.type ?? 'generic') as NodeType) && (
<div className="flex items-center justify-between col-span-2 py-1">
<div className="flex flex-col gap-0.5">
<Label className="text-xs text-muted-foreground">Container Mode</Label>
<span className="text-[10px] text-muted-foreground/60">Show VM/LXC nodes nested inside</span>
<span className="text-[10px] text-muted-foreground/60">
Allow other nodes to nest inside this node
</span>
</div>
<button
type="button"
role="switch"
aria-label="Container Mode"
aria-checked={!!form.container_mode}
onClick={() => set('container_mode', !form.container_mode)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none ${modalStyles['modal-interactive']}`}
style={{ background: form.container_mode ? '#ff6e00' : '#30363d' }}
>
<span
className="pointer-events-none absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ease-in-out"
style={{
transform: form.container_mode ? 'translateX(16px)' : 'translateX(0)'
}}
/>
</button>
</div>
)}
{/* Service visibility */}
{form.type !== 'groupRect' && form.type !== 'group' && (
<div className="flex items-start justify-between col-span-2 py-1">
<div className="flex flex-col gap-0.5">
<Label className="text-xs text-muted-foreground">Show Services</Label>
<span className="text-[10px] text-muted-foreground/60">Display discovered services on the node card</span>
</div>
<button
type="button"
role="switch"
aria-checked={!!form.container_mode}
onClick={() => set('container_mode', !form.container_mode)}
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus:outline-none"
style={{ background: form.container_mode ? '#ff6e00' : '#30363d' }}
aria-label="Show Services"
aria-checked={showServicesEnabled}
onClick={() => set('custom_colors', {
...form.custom_colors,
show_services: !showServicesEnabled,
})}
className="relative inline-flex h-5 w-9 mt-1 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none"
style={{ background: showServicesEnabled ? resolvedNodeColors.icon : '#30363d' }}
>
<span
className="pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform"
style={{ transform: form.container_mode ? 'translateX(16px)' : 'translateX(0)' }}
className="pointer-events-none absolute top-px left-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ease-in-out"
style={{ transform: showServicesEnabled ? 'translateX(16px)' : 'translateX(0)' }}
/>
</button>
</div>
@@ -290,10 +410,20 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
<div className="flex flex-col gap-2 col-span-2">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Appearance</Label>
{form.custom_colors && (
{hasAppearanceOverrides && (
<button
type="button"
onClick={() => set('custom_colors', undefined)}
onClick={() => setForm((f) => {
if (!f.custom_colors) return f
const { border, background, icon, ...rest } = f.custom_colors
void border
void background
void icon
return {
...f,
custom_colors: Object.keys(rest).length > 0 ? rest : undefined,
}
})}
className="flex items-center gap-1 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
>
<RotateCcw size={10} /> Reset to defaults
@@ -308,9 +438,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
return (
<div key={key} className="flex flex-col gap-1 items-center">
<label
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden transition-all"
className={`relative w-full h-7 rounded-md border cursor-pointer overflow-hidden transition-all ${modalStyles['modal-interactive']}`}
style={{ borderColor: isCustom ? currentValue : '#30363d' }}
title={`${key.charAt(0).toUpperCase() + key.slice(1)}: ${currentValue}`}
tabIndex={0}
aria-label={`Color picker for ${key}`}
>
<input
type="color"
@@ -325,29 +457,34 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
)
})}
</div>
{!form.custom_colors && (
<p className="text-[10px] text-muted-foreground/50">Using default colors for {NODE_TYPE_LABELS[form.type ?? 'generic']}. Click a swatch to customize.</p>
)}
<div className="min-h-3.5">
{!hasAppearanceOverrides && (
<p className="text-[10px] text-muted-foreground/50">Using default colors for {NODE_TYPE_LABELS[form.type ?? 'generic']}. Click a swatch to customize.</p>
)}
</div>
</div>
{/* Bottom connection points (not for group containers) */}
{form.type !== 'groupRect' && form.type !== 'group' && (
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Bottom Connection Points</Label>
<Select
value={String(form.bottom_handles ?? 1)}
onValueChange={(v) => set('bottom_handles', parseInt(v ?? '1', 10))}
>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
<SelectItem value="1" className="text-sm">1 center</SelectItem>
<SelectItem value="2" className="text-sm">2 left / right</SelectItem>
<SelectItem value="3" className="text-sm">3 left / center / right</SelectItem>
<SelectItem value="4" className="text-sm">4 evenly spaced</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Bottom Connection Points</Label>
<span className="text-xs font-mono text-foreground">{clampBottomHandles(form.bottom_handles ?? 1)}</span>
</div>
<input
type="range"
min={MIN_BOTTOM_HANDLES}
max={MAX_BOTTOM_HANDLES}
step={1}
value={clampBottomHandles(form.bottom_handles ?? 1)}
onChange={(e) => set('bottom_handles', clampBottomHandles(Number(e.target.value)))}
aria-label="Bottom connection points slider"
className="w-full accent-[#00d4ff] cursor-pointer"
/>
<div className="flex justify-between text-[10px] text-muted-foreground/60 font-mono">
<span>{MIN_BOTTOM_HANDLES}</span>
<span>{MAX_BOTTOM_HANDLES}</span>
</div>
</div>
)}
@@ -358,25 +495,45 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
value={form.notes ?? ''}
onChange={(e) => set('notes', e.target.value)}
placeholder="Optional notes"
className="bg-[#21262d] border-[#30363d] text-sm h-8"
className={`bg-[#21262d] border-[#30363d] text-sm h-8 ${modalStyles['modal-radius']}`}
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-1">
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
Cancel
</Button>
<Button
type="submit"
size="sm"
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
>
{title === 'Add Node' ? 'Add' : 'Save'}
</Button>
<div className="flex justify-between gap-2 pt-1">
{/* Show delete button only for edit mode (not add) */}
{title !== 'Add Node' ? (
<Button
type="button"
variant="ghost"
size="sm"
className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer"
onClick={() => {
if (window.confirm('Delete this node?')) {
onSubmit({ ...form, _delete: true })
onClose()
}
}}
style={{ minWidth: 64 }}
>
Delete
</Button>
) : <span />}
<div className="flex gap-2">
<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"
>
{title === 'Add Node' ? 'Add' : 'Save'}
</Button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
)
}
}
@@ -12,7 +12,7 @@ interface Service {
export interface PendingDevice {
id: string
ip: string
ip: string | null
mac: string | null
hostname: string | null
os: string | null
@@ -20,6 +20,12 @@ export interface PendingDevice {
suggested_type: string | null
status: string
discovery_source: string | null
ieee_address?: string | null
friendly_name?: string | null
device_subtype?: string | null
model?: string | null
vendor?: string | null
lqi?: number | null
discovered_at: string
}
@@ -77,6 +83,8 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
if (!device) return null
const TypeIcon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
const isZigbee = device.discovery_source === 'zigbee'
const titleLabel = device.friendly_name ?? device.hostname ?? device.ip ?? device.ieee_address ?? 'Pending device'
const handleApprove = () => { onApprove(device) }
const handleHide = () => { onHide(device); onClose() }
@@ -88,17 +96,30 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-sm font-semibold">
<TypeIcon size={15} className="text-[#00d4ff] shrink-0" />
{device.hostname ?? device.ip}
{titleLabel}
{isZigbee && (
<span className="ml-1 text-[9px] font-mono uppercase px-1 py-0.5 rounded bg-[#00d4ff]/15 text-[#00d4ff] border border-[#00d4ff]/30">
Zigbee
</span>
)}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
{/* Device info */}
<div className="flex flex-col gap-1.5 p-3 rounded-md bg-[#21262d] border border-[#30363d]">
<InfoRow label="IP" value={device.ip} />
{device.ip && <InfoRow label="IP" value={device.ip} />}
{device.hostname && <InfoRow label="Hostname" value={device.hostname} />}
{device.mac && <InfoRow label="MAC" value={device.mac} />}
{device.os && <InfoRow label="OS" value={device.os} />}
{device.ieee_address && <InfoRow label="IEEE" value={device.ieee_address} />}
{device.friendly_name && device.friendly_name !== device.hostname && (
<InfoRow label="Name" value={device.friendly_name} />
)}
{device.vendor && <InfoRow label="Vendor" value={device.vendor} />}
{device.model && <InfoRow label="Model" value={device.model} />}
{device.device_subtype && <InfoRow label="Role" value={device.device_subtype} />}
{device.lqi != null && <InfoRow label="LQI" value={String(device.lqi)} />}
{device.suggested_type && (
<InfoRow label="Type" value={device.suggested_type} />
)}
@@ -108,8 +129,8 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
<InfoRow label="Discovered" value={new Date(device.discovered_at.endsWith('Z') ? device.discovered_at : device.discovered_at + 'Z').toLocaleString()} />
</div>
{/* Services */}
<div>
{/* Services (skipped for Zigbee devices — they don't have IP services) */}
{!isZigbee && <div>
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">
Services found ({device.services.length})
</p>
@@ -138,7 +159,7 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
))}
</div>
)}
</div>
</div>}
{/* Actions */}
<div className="flex gap-2 pt-1">
@@ -0,0 +1,677 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import {
Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network,
Search, RefreshCw, X, CheckCircle2, EyeOff, Trash2, Loader2,
} from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { scanApi } from '@/api/client'
import { useCanvasStore } from '@/stores/canvasStore'
import { toast } from 'sonner'
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
import type { NodeType, ServiceInfo } from '@/types'
interface PendingDevicesModalProps {
open: boolean
onClose: () => void
highlightId?: string
initialStatus?: 'pending' | 'hidden'
}
const PORT_COLORS: Record<number, string> = {
22: '#a855f7', // SSH purple
80: '#00d4ff', // HTTP cyan
443: '#39d353', // HTTPS green
53: '#e3b341', // DNS amber
3306: '#a855f7', // MySQL
5432: '#a855f7', // Postgres
6379: '#f85149', // Redis
9090: '#e3b341', // Prometheus
3000: '#00d4ff', // Grafana/dev
8080: '#00d4ff',
8443: '#39d353',
}
const CATEGORY_COLORS: Record<string, string> = {
hypervisor: '#ff6e00',
nas: '#39d353',
automation: '#a855f7',
containers: '#00d4ff',
network: '#39d353',
security: '#f85149',
monitoring: '#e3b341',
database: '#a855f7',
web: '#00d4ff',
media: '#ff6e00',
iot: '#e3b341',
}
function serviceColor(port: number | null | undefined, category?: string | null): string {
if (port != null && PORT_COLORS[port]) return PORT_COLORS[port]
if (category && CATEGORY_COLORS[category.toLowerCase()]) return CATEGORY_COLORS[category.toLowerCase()]
return '#8b949e'
}
const TYPE_ICONS: Record<string, React.ElementType> = {
isp: Globe,
router: Router,
server: Server,
proxmox: Layers,
vm: Box,
lxc: Container,
nas: HardDrive,
iot: Cpu,
ap: Wifi,
switch: Network,
generic: Circle,
}
type SourceFilter = 'all' | 'ip' | 'zigbee'
type StatusFilter = 'pending' | 'hidden'
function inferSource(d: PendingDevice): 'zigbee' | 'ip' {
if (d.discovery_source === 'zigbee' || d.ieee_address) return 'zigbee'
return 'ip'
}
const COMMON_PORTS = new Set([22, 80, 443])
function specialServiceName(d: PendingDevice): string | undefined {
const candidates = (d.services ?? []).filter(
(s) => s.category != null && s.port != null && !COMMON_PORTS.has(s.port) && s.service_name,
)
// Deprioritize generic web category so apps like home assistant / jellyfin win
const nonWeb = candidates.find((s) => s.category?.toLowerCase() !== 'web')
return (nonWeb ?? candidates[0])?.service_name ?? undefined
}
function deviceLabel(d: PendingDevice): string {
return d.friendly_name ?? d.hostname ?? specialServiceName(d) ?? d.ip ?? d.ieee_address ?? 'device'
}
function injectAutoEdges(edges: { id: string; source: string; target: string }[] | undefined) {
if (!edges || edges.length === 0) return
useCanvasStore.setState((state) => ({
edges: [
...state.edges,
...edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: 'bottom',
targetHandle: 'top-t',
type: 'iot',
data: { type: 'iot' as const },
})),
],
hasUnsavedChanges: true,
}))
}
export function PendingDevicesModal({ open, onClose, highlightId, initialStatus = 'pending' }: PendingDevicesModalProps) {
const [devices, setDevices] = useState<PendingDevice[]>([])
const [loading, setLoading] = useState(false)
const [selected, setSelected] = useState<PendingDevice | null>(null)
const [selectMode, setSelectMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all')
const [typeFilter, setTypeFilter] = useState<string>('all')
const [statusFilter, setStatusFilter] = useState<StatusFilter>(initialStatus)
const { addNode, scanEventTs } = useCanvasStore()
const highlightRef = useRef<HTMLButtonElement>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const res = statusFilter === 'pending' ? await scanApi.pending() : await scanApi.hidden()
setDevices(res.data)
} catch {
toast.error(`Failed to load ${statusFilter} devices`)
} finally {
setLoading(false)
}
}, [statusFilter])
useEffect(() => { if (open) load() }, [open, load])
useEffect(() => { if (open && scanEventTs > 0) load() }, [scanEventTs, open, load])
// Reset transient state when reopening
useEffect(() => {
if (!open) {
setSelectMode(false)
setSelectedIds(new Set())
setSearch('')
} else {
setStatusFilter(initialStatus)
}
}, [open, initialStatus])
const distinctTypes = useMemo(() => {
const set = new Set<string>()
devices.forEach((d) => { if (d.suggested_type) set.add(d.suggested_type) })
return [...set].sort()
}, [devices])
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
return devices.filter((d) => {
if (sourceFilter !== 'all' && inferSource(d) !== sourceFilter) return false
if (typeFilter !== 'all' && d.suggested_type !== typeFilter) return false
if (q) {
const hay = [
d.friendly_name, d.hostname, d.ip, d.mac, d.ieee_address, d.vendor, d.model,
...d.services.map((s) => s.service_name),
].filter(Boolean).join(' ').toLowerCase()
if (!hay.includes(q)) return false
}
return true
})
}, [devices, search, sourceFilter, typeFilter])
useEffect(() => {
if (!highlightId || loading || !open) return
highlightRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}, [highlightId, loading, open, filtered])
const toggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
const handleCardClick = (d: PendingDevice) => {
if (selectMode) { toggleSelect(d.id); return }
if (statusFilter === 'hidden') { handleRestore(d); return }
setSelected(d)
}
const handleRestore = async (device: PendingDevice) => {
try {
await scanApi.restore(device.id)
setDevices((prev) => prev.filter((d) => d.id !== device.id))
toast.success(`Restored ${deviceLabel(device)}`)
} catch {
toast.error('Failed to restore device')
}
}
const handleBulkRestore = async () => {
const ids = [...selectedIds]
if (ids.length === 0) return
try {
const res = await scanApi.bulkRestore(ids)
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
setSelectedIds(new Set())
toast.success(`Restored ${res.data.restored} device${res.data.restored !== 1 ? 's' : ''}`)
} catch {
toast.error('Failed to bulk restore devices')
}
}
const enterSelectMode = () => {
setSelectMode(true)
}
const exitSelectMode = () => {
setSelectMode(false)
setSelectedIds(new Set())
}
const selectAllVisible = () => {
setSelectedIds(new Set(filtered.map((d) => d.id)))
}
const handleClearAll = async () => {
const targets = filtered
if (targets.length === 0) return
const filtersActive = targets.length !== devices.length
try {
if (filtersActive) {
const results = await Promise.allSettled(targets.map((d) => scanApi.ignore(d.id)))
const failed = results.filter((r) => r.status === 'rejected').length
const removedIds = new Set(
targets.filter((_, i) => results[i].status === 'fulfilled').map((d) => d.id)
)
setDevices((prev) => prev.filter((d) => !removedIds.has(d.id)))
setSelectedIds(new Set())
if (failed > 0) toast.error(`Removed ${removedIds.size}, ${failed} failed`)
else toast.success(`Removed ${removedIds.size} device${removedIds.size !== 1 ? 's' : ''}`)
} else {
await scanApi.clearPending()
setDevices([])
setSelectedIds(new Set())
toast.success('Pending devices cleared')
}
} catch {
toast.error('Failed to clear pending devices')
}
}
const handleApprove = async (device: PendingDevice) => {
try {
const fallbackLabel = deviceLabel(device)
const nodeData = {
label: fallbackLabel,
type: (device.suggested_type ?? 'generic') as NodeType,
ip: device.ip ?? undefined,
hostname: device.hostname ?? undefined,
status: 'unknown',
services: (device.services ?? []) as ServiceInfo[],
}
const res = await scanApi.approve(device.id, nodeData)
const nodeId = res.data.node_id
addNode({
id: nodeId,
type: nodeData.type,
position: { x: 400, y: 300 },
data: { ...nodeData, status: 'unknown' as const },
})
injectAutoEdges(res.data.edges)
const extra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
toast.success(`Approved ${nodeData.label}${extra}`)
setDevices((prev) => prev.filter((d) => d.id !== device.id))
setSelected(null)
} catch {
toast.error('Failed to approve device')
}
}
const handleHide = async (device: PendingDevice) => {
try {
await scanApi.hide(device.id)
setDevices((prev) => prev.filter((d) => d.id !== device.id))
setSelected(null)
toast.success('Device hidden')
} catch {
toast.error('Failed to hide device')
}
}
const handleIgnore = async (device: PendingDevice) => {
try {
await scanApi.ignore(device.id)
setDevices((prev) => prev.filter((d) => d.id !== device.id))
setSelected(null)
} catch {
toast.error('Failed to remove device')
}
}
const handleBulkApprove = async () => {
const ids = [...selectedIds]
if (ids.length === 0) return
try {
const res = await scanApi.bulkApprove(ids)
const deviceToNode: Record<string, string> = {}
res.data.device_ids.forEach((did, i) => { deviceToNode[did] = res.data.node_ids[i] })
const approvedDevices = devices.filter((d) => ids.includes(d.id))
approvedDevices.forEach((d, i) => {
const nodeId = deviceToNode[d.id]
if (!nodeId) return
addNode({
id: nodeId,
type: (d.suggested_type ?? 'generic') as NodeType,
position: { x: 400 + (i % 4) * 160, y: 300 + Math.floor(i / 4) * 100 },
data: {
label: deviceLabel(d),
type: (d.suggested_type ?? 'generic') as NodeType,
ip: d.ip ?? undefined,
hostname: d.hostname ?? undefined,
status: 'unknown' as const,
services: (d.services ?? []) as ServiceInfo[],
},
})
})
injectAutoEdges(res.data.edges)
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
setSelectedIds(new Set())
const linkExtra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
toast.success(`Approved ${res.data.approved} device${res.data.approved !== 1 ? 's' : ''}${linkExtra}`)
} catch {
toast.error('Failed to bulk approve devices')
}
}
const handleBulkHide = async () => {
const ids = [...selectedIds]
if (ids.length === 0) return
try {
const res = await scanApi.bulkHide(ids)
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
setSelectedIds(new Set())
toast.success(`Hidden ${res.data.hidden} device${res.data.hidden !== 1 ? 's' : ''}`)
} catch {
toast.error('Failed to bulk hide devices')
}
}
// Keyboard shortcuts: 's' select-mode, 'a' select-all-visible, Esc clears selection or closes, '/' focuses search
const searchRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!open) return
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null
const inField = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT')
if (e.key === 'Escape') {
if (selectMode && selectedIds.size > 0) { e.preventDefault(); setSelectedIds(new Set()) }
return
}
if (inField) return
if (e.key === '/') { e.preventDefault(); searchRef.current?.focus() }
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) { e.preventDefault(); handleBulkApprove() }
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, selectMode, selectedIds, filtered])
return (
<>
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<DialogContent
showCloseButton={false}
className="!max-w-none w-[95vw] h-[90vh] p-0 flex flex-col gap-0 bg-[#0d1117] border-border"
>
<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' ? 'Pending Devices' : 'Hidden Devices'}
<span className="text-muted-foreground font-normal text-xs">
({filtered.length}{filtered.length !== devices.length && ` of ${devices.length}`})
</span>
</DialogTitle>
<div className="flex items-center gap-1">
<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 && (
<button
onClick={handleClearAll}
className="text-muted-foreground hover:text-[#f85149] p-1.5 rounded transition-colors"
title={filtered.length !== devices.length ? `Remove ${filtered.length} filtered` : 'Clear all pending'}
>
<Trash2 size={14} />
</button>
)}
<button onClick={onClose} className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors" title="Close">
<X size={14} />
</button>
</div>
</div>
</DialogHeader>
{/* Toolbar */}
<div className="px-4 py-2 border-b border-border bg-[#161b22] shrink-0 flex flex-wrap items-center gap-2">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
ref={searchRef}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search name, IP, MAC, IEEE, service…"
className="w-full text-xs bg-[#0d1117] border border-border rounded px-7 py-1.5 outline-none focus:border-[#00d4ff]/50"
/>
</div>
<div className="flex rounded border border-border overflow-hidden text-xs" role="group" aria-label="Source filter">
<button
onClick={() => setSourceFilter('all')}
className={`px-2.5 py-1.5 transition-colors ${sourceFilter === 'all' ? 'bg-[#00d4ff]/20 text-[#00d4ff]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
>
All
</button>
<button
onClick={() => setSourceFilter('ip')}
className={`px-2.5 py-1.5 transition-colors border-l border-border ${sourceFilter === 'ip' ? 'bg-[#a855f7]/20 text-[#a855f7]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
>
IP scan
</button>
<button
onClick={() => setSourceFilter('zigbee')}
className={`px-2.5 py-1.5 transition-colors border-l border-border ${sourceFilter === 'zigbee' ? 'bg-[#00d4ff]/20 text-[#00d4ff]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
>
Zigbee
</button>
</div>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="text-xs bg-[#0d1117] border border-border rounded px-2 py-1.5 outline-none focus:border-[#00d4ff]/50"
aria-label="Type filter"
>
<option value="all">All types</option>
{distinctTypes.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<div className="flex rounded border border-border overflow-hidden text-xs">
<button
onClick={() => setStatusFilter('pending')}
className={`px-2.5 py-1.5 transition-colors ${statusFilter === 'pending' ? 'bg-[#00d4ff]/20 text-[#00d4ff]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
>
Pending
</button>
<button
onClick={() => setStatusFilter('hidden')}
className={`px-2.5 py-1.5 transition-colors ${statusFilter === 'hidden' ? 'bg-[#8b949e]/20 text-foreground' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
>
Hidden
</button>
</div>
<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'}`}
title="Toggle select mode (s)"
>
{selectMode ? 'Exit select' : 'Select mode'}
</button>
</div>
{/* Body */}
<div className="flex-1 min-h-0 overflow-y-auto p-4">
{loading && (
<div className="flex items-center justify-center py-10">
<Loader2 size={20} className="animate-spin text-muted-foreground" />
</div>
)}
{!loading && filtered.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-10">
{devices.length === 0 ? `No ${statusFilter} devices` : 'No devices match filters'}
</p>
)}
{!loading && filtered.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 gap-3">
{filtered.map((d) => (
<DeviceCard
key={d.id}
device={d}
selected={selectedIds.has(d.id)}
selectMode={selectMode}
highlighted={d.id === highlightId}
onClick={() => handleCardClick(d)}
cardRef={d.id === highlightId ? highlightRef : undefined}
/>
))}
</div>
)}
</div>
{/* Selection action bar */}
{selectMode && (
<div className="px-4 py-2.5 border-t border-border bg-[#161b22] shrink-0 flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground mr-1">
{selectedIds.size} selected
</span>
<button
onClick={selectAllVisible}
className="text-xs px-2.5 py-1.5 rounded border border-border text-muted-foreground hover:text-foreground transition-colors"
>
Select all visible ({filtered.length})
</button>
<button
onClick={() => setSelectedIds(new Set())}
disabled={selectedIds.size === 0}
className="text-xs px-2.5 py-1.5 rounded border border-border text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors"
>
Clear
</button>
<div className="flex-1" />
{statusFilter === 'pending' && (
<>
<button
onClick={handleBulkApprove}
disabled={selectedIds.size === 0}
className="text-xs px-3 py-1.5 rounded bg-[#39d353]/20 text-[#39d353] hover:bg-[#39d353]/30 disabled:opacity-40 font-medium transition-colors"
>
Approve ({selectedIds.size})
</button>
<button
onClick={handleBulkHide}
disabled={selectedIds.size === 0}
className="text-xs px-3 py-1.5 rounded bg-[#8b949e]/20 text-[#8b949e] hover:bg-[#8b949e]/30 disabled:opacity-40 font-medium transition-colors"
>
Hide ({selectedIds.size})
</button>
</>
)}
{statusFilter === 'hidden' && (
<button
onClick={handleBulkRestore}
disabled={selectedIds.size === 0}
className="text-xs px-3 py-1.5 rounded bg-[#e3b341]/20 text-[#e3b341] hover:bg-[#e3b341]/30 disabled:opacity-40 font-medium transition-colors"
>
Restore ({selectedIds.size})
</button>
)}
</div>
)}
</DialogContent>
</Dialog>
<PendingDeviceModal
device={selected}
onClose={() => setSelected(null)}
onApprove={handleApprove}
onHide={handleHide}
onIgnore={handleIgnore}
/>
</>
)
}
interface DeviceCardProps {
device: PendingDevice
selected: boolean
selectMode: boolean
highlighted: boolean
onClick: () => void
cardRef?: React.Ref<HTMLButtonElement>
}
function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRef }: DeviceCardProps) {
const source = inferSource(device)
const Icon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
const label = deviceLabel(device)
const sourceColor = source === 'zigbee' ? '#00d4ff' : '#a855f7'
const sourceLabel = source === 'zigbee' ? 'ZIGBEE' : (device.discovery_source ?? 'IP').toUpperCase()
const services = device.services ?? []
const visibleServices = services.slice(0, 4)
const moreServices = services.length - visibleServices.length
const borderClass = highlighted
? 'border-[#e3b341] bg-[#2d3748]'
: selected
? 'border-[#00d4ff] bg-[#00d4ff]/5 shadow-[0_0_0_1px_rgba(0,212,255,0.4)] scale-[1.02]'
: 'border-border bg-[#161b22] hover:border-[#30363d] hover:bg-[#21262d]'
return (
<button
ref={cardRef}
onClick={onClick}
data-testid={`pending-card-${device.id}`}
className={`relative text-left rounded-lg border p-3 transition-all duration-150 ${borderClass}`}
>
{selectMode && selected && (
<CheckCircle2
size={18}
className="absolute top-2 right-2 text-[#00d4ff] fill-[#0d1117]"
/>
)}
{!selectMode && device.status === 'hidden' && (
<EyeOff size={14} className="absolute top-2 right-2 text-muted-foreground" />
)}
{/* Header */}
<div className="flex items-start gap-2 mb-2">
<div className="shrink-0 w-8 h-8 rounded bg-[#21262d] flex items-center justify-center text-foreground">
<Icon size={16} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground break-all leading-snug">{label}</div>
<div className="flex items-center gap-1 mt-0.5 flex-wrap">
<span
className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
style={{ background: `${sourceColor}22`, color: sourceColor }}
>
{sourceLabel}
</span>
{device.suggested_type && (
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider bg-[#21262d] text-muted-foreground">
{device.suggested_type}
</span>
)}
{device.lqi != null && (
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider bg-[#21262d] text-muted-foreground">
LQI {device.lqi}
</span>
)}
</div>
</div>
</div>
{/* Tech grid */}
<div className="grid grid-cols-2 gap-x-2 gap-y-0.5 text-[11px] mb-2">
{device.ip && <InfoLine label="IP" value={device.ip} />}
{device.mac && <InfoLine label="MAC" value={device.mac} />}
{device.ieee_address && <InfoLine label="IEEE" value={device.ieee_address} />}
{device.hostname && <InfoLine label="Host" value={device.hostname} />}
{device.vendor && <InfoLine label="Vendor" value={device.vendor} />}
{device.model && <InfoLine label="Model" value={device.model} />}
</div>
{/* Services */}
{visibleServices.length > 0 && (
<div className="flex items-center gap-1 flex-wrap">
{visibleServices.map((s, i) => {
const color = serviceColor(s.port, s.category)
return (
<span
key={`${s.port}-${s.protocol}-${i}`}
className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
style={{ background: `${color}22`, color }}
title={`${s.service_name} (${s.protocol}/${s.port})`}
>
{s.service_name}
</span>
)
})}
{moreServices > 0 && (
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded bg-[#21262d] text-muted-foreground">
+{moreServices}
</span>
)}
</div>
)}
</button>
)
}
function InfoLine({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-baseline gap-1.5 min-w-0">
<span className="text-muted-foreground shrink-0 w-12">{label}</span>
<span className="font-mono text-foreground truncate">{value}</span>
</div>
)
}
@@ -33,8 +33,10 @@ export function SearchModal({ open, onClose, onOpenPending }: SearchModalProps)
).slice(0, 6)
const pendingResults = q.length === 0 ? [] : pendingDevices.filter((d) =>
d.ip.toLowerCase().includes(q) ||
d.ip?.toLowerCase().includes(q) ||
d.hostname?.toLowerCase().includes(q) ||
d.friendly_name?.toLowerCase().includes(q) ||
d.ieee_address?.toLowerCase().includes(q) ||
d.services.some((s) =>
s.service_name?.toLowerCase().includes(q) ||
s.category?.toLowerCase().includes(q)
@@ -0,0 +1,272 @@
import { useState } from 'react'
import modalStyles from './modal-interactive.module.css'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { hexToRgba, rgbaToHex8 } from '@/utils/colorUtils'
export type TextBorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
export interface TextFormData {
text: string
font: string
text_color: string
text_size: number
border_color: string
border_style: TextBorderStyle
border_width: number
background_color: string
}
const BORDER_STYLES: { value: TextBorderStyle; label: string; preview: string }[] = [
{ value: 'none', label: 'None', preview: ' ' },
{ value: 'solid', label: 'Solid', preview: '───' },
{ value: 'dashed', label: 'Dashed', preview: '╌╌╌' },
{ value: 'dotted', label: 'Dotted', preview: '···' },
{ value: 'double', label: 'Double', preview: '═══' },
]
const TEXT_SIZES: { value: number; label: string }[] = [
{ value: 10, label: '10' },
{ value: 12, label: '12' },
{ value: 14, label: '14' },
{ value: 18, label: '18' },
{ value: 24, label: '24' },
{ value: 32, label: '32' },
]
const BORDER_WIDTHS: { value: number; label: string }[] = [
{ value: 1, label: '1px' },
{ value: 2, label: '2px' },
{ value: 3, label: '3px' },
{ value: 4, label: '4px' },
{ value: 5, label: '5px' },
]
const FONTS = [
{ value: 'inter', label: 'Inter (sans-serif)' },
{ value: 'mono', label: 'JetBrains Mono' },
{ value: 'serif', label: 'Serif' },
{ value: 'sans', label: 'System Sans' },
]
const DEFAULT_FORM: TextFormData = {
text: '',
font: 'inter',
text_color: '#e6edf3',
text_size: 14,
border_color: '#30363d',
border_style: 'none',
border_width: 1,
background_color: '#00000000',
}
interface TextModalProps {
open: boolean
onClose: () => void
onSubmit: (data: TextFormData) => void
onDelete?: () => void
initial?: Partial<TextFormData>
title?: string
}
export function TextModal({ open, onClose, onSubmit, onDelete, initial, title = 'Add Text' }: TextModalProps) {
const [form, setForm] = useState<TextFormData>({ ...DEFAULT_FORM, ...initial })
const set = <K extends keyof TextFormData>(key: K, value: TextFormData[K]) =>
setForm((f) => ({ ...f, [key]: value }))
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSubmit(form)
onClose()
}
const colorFields = [
{ key: 'text_color' as const, label: 'Text' },
{ key: 'border_color' as const, label: 'Border' },
{ key: 'background_color' as const, label: 'Background' },
]
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">
{/* Text content */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Text</Label>
<textarea
value={form.text}
onChange={(e) => set('text', e.target.value)}
placeholder="Type text…"
rows={3}
className={`bg-[#21262d] border border-[#30363d] text-sm p-2 resize-y min-h-[60px] focus:outline-none focus:border-[#00d4ff] ${modalStyles['modal-radius']}`}
/>
</div>
{/* Font (Police) */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Police</Label>
<Select value={form.font} onValueChange={(v: string | null) => set('font', v ?? 'inter')}>
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`}>
<SelectValue>
{FONTS.find((f) => f.value === form.font)?.label ?? form.font}
</SelectValue>
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
{FONTS.map((f) => (
<SelectItem key={f.value} value={f.value} className="text-sm">
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Colors */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Colors</Label>
<div className="grid grid-cols-3 gap-2">
{colorFields.map(({ key, label }) => {
const { hex6, alpha } = hexToRgba(form[key])
return (
<div key={key} className="flex flex-col gap-1 items-center">
<label
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden"
style={{ borderColor: '#30363d' }}
>
<input
type="color"
value={hex6}
onChange={(e) => set(key, rgbaToHex8(e.target.value, alpha))}
className="absolute inset-0 w-full h-full cursor-pointer opacity-0"
/>
<div className="w-full h-full rounded-sm" style={{ background: form[key] }} />
</label>
<input
type="range"
min={0}
max={100}
value={alpha}
onChange={(e) => set(key, rgbaToHex8(hex6, Number(e.target.value)))}
className="w-full h-1 accent-[#00d4ff] cursor-pointer"
title={`Opacity: ${alpha}%`}
/>
<span className="text-[9px] text-muted-foreground/60">{label} {alpha}%</span>
</div>
)
})}
</div>
</div>
{/* Text size */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Size</Label>
<div className="grid grid-cols-6 gap-1">
{TEXT_SIZES.map(({ value, label }) => {
const isSelected = form.text_size === value
return (
<button
key={value}
type="button"
onClick={() => set('text_size', value)}
className={`flex items-center justify-center h-8 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
style={{
background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
color: isSelected ? '#00d4ff' : '#8b949e',
fontSize: Math.min(value, 16),
}}
>
{label}
</button>
)
})}
</div>
</div>
{/* Border style */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Border Style</Label>
<div className="grid grid-cols-5 gap-1">
{BORDER_STYLES.map(({ value, label, preview }) => {
const isSelected = form.border_style === value
return (
<button
key={value}
type="button"
title={label}
onClick={() => set('border_style', value)}
className={`flex flex-col items-center justify-center h-10 rounded text-xs gap-0.5 transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
style={{
background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
color: isSelected ? '#00d4ff' : '#8b949e',
}}
>
<span className="font-mono text-[11px] leading-none">{preview}</span>
<span className="text-[9px]">{label}</span>
</button>
)
})}
</div>
</div>
{/* Border width (only when style != none) */}
{form.border_style !== 'none' && (
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Border Width</Label>
<div className="grid grid-cols-5 gap-1">
{BORDER_WIDTHS.map(({ value, label }) => {
const isSelected = form.border_width === value
return (
<button
key={value}
type="button"
onClick={() => set('border_width', value)}
className={`flex items-center justify-center h-8 rounded text-xs transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
style={{
background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
color: isSelected ? '#00d4ff' : '#8b949e',
}}
>
{label}
</button>
)
})}
</div>
</div>
)}
<div className="flex justify-between gap-2 pt-1">
{onDelete && (
<Button
type="button"
variant="ghost"
size="sm"
className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer"
onClick={() => { onDelete(); onClose() }}
>
Delete
</Button>
)}
<div className="flex gap-2 ml-auto">
<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">
{title === 'Add Text' ? 'Add' : 'Save'}
</Button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
)
}
+172 -107
View File
@@ -1,11 +1,12 @@
import { useState } from 'react'
import { useRef, useState, type KeyboardEvent } from 'react'
import { toast } from 'sonner'
import { Check } from 'lucide-react'
import { Check, Pencil } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { THEMES, THEME_ORDER, type ThemeId } from '@/utils/themes'
import { useThemeStore } from '@/stores/themeStore'
import { useCanvasStore } from '@/stores/canvasStore'
import { CustomStyleModal } from './CustomStyleModal'
// Node-type accent colors to display as preview swatches
const PREVIEW_TYPES = ['isp', 'server', 'proxmox', 'switch', 'iot'] as const
@@ -14,74 +15,108 @@ interface ThemeCardProps {
themeId: ThemeId
selected: boolean
onClick: () => void
onKeyDown?: (event: KeyboardEvent<HTMLButtonElement>) => void
buttonRef?: (element: HTMLButtonElement | null) => void
onEdit?: () => void
}
function ThemeCard({ themeId, selected, onClick }: ThemeCardProps) {
function ThemeCard({ themeId, selected, onClick, onKeyDown, buttonRef, onEdit }: ThemeCardProps) {
const { customStyle } = useThemeStore()
const preset = THEMES[themeId]
const c = preset.colors
const isCustom = themeId === 'custom'
// For custom theme, use defined node colors for preview swatches
const swatchColors = isCustom
? PREVIEW_TYPES.map((t) => customStyle.nodes[t]?.borderColor ?? c.nodeAccents[t].border)
: PREVIEW_TYPES.map((t) => c.nodeAccents[t].border)
const ethernetColor = isCustom
? (customStyle.edges['ethernet']?.color ?? c.edgeColors.ethernet)
: c.edgeColors.ethernet
const wifiColor = isCustom
? (customStyle.edges['wifi']?.color ?? c.edgeColors.wifi)
: c.edgeColors.wifi
return (
<button
type="button"
onClick={onClick}
className="relative rounded-xl border-2 p-3 text-left transition-all duration-150 focus:outline-none w-full"
style={{
borderColor: selected ? c.nodeAccents.isp.border : c.handleBackground,
background: c.canvasBackground,
boxShadow: selected ? `0 0 0 1px ${c.nodeAccents.isp.border}44, 0 0 12px ${c.nodeAccents.isp.border}22` : 'none',
}}
>
{/* Selected checkmark */}
{selected && (
<span
className="absolute top-2 right-2 flex items-center justify-center w-4 h-4 rounded-full"
style={{ background: c.nodeAccents.isp.border }}
>
<Check size={10} style={{ color: c.canvasBackground }} />
</span>
)}
{/* Mini canvas preview */}
<div
className="rounded-md mb-2.5 flex flex-col gap-1.5 p-2"
style={{ background: c.nodeCardBackground, border: `1px solid ${c.handleBackground}` }}
<div className="relative w-full h-full">
<button
ref={buttonRef}
type="button"
onClick={onClick}
onKeyDown={onKeyDown}
className="relative rounded-xl border-2 p-3 text-left transition-all duration-150 focus:outline-none w-full h-full flex flex-col"
style={{
borderColor: selected ? c.nodeAccents.isp.border : c.handleBackground,
background: c.canvasBackground,
boxShadow: selected ? `0 0 0 1px ${c.nodeAccents.isp.border}44, 0 0 12px ${c.nodeAccents.isp.border}22` : 'none',
}}
>
{/* Node accent dots */}
<div className="flex gap-1 items-center flex-wrap">
{PREVIEW_TYPES.map((type) => (
<span
key={type}
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: c.nodeAccents[type].border }}
/>
))}
</div>
{/* Edge line */}
<div style={{ height: 2, background: c.edgeColors.ethernet, width: '80%', borderRadius: 2 }} />
{/* Wifi dashed line */}
{/* Selected checkmark */}
{selected && (
<span
className="absolute top-2 right-2 flex items-center justify-center w-4 h-4 rounded-full"
style={{ background: c.nodeAccents.isp.border }}
>
<Check size={10} style={{ color: c.canvasBackground }} />
</span>
)}
{/* Mini canvas preview */}
<div
style={{
height: 1,
width: '55%',
backgroundImage: `repeating-linear-gradient(90deg, ${c.edgeColors.wifi} 0 5px, transparent 5px 8px)`,
}}
/>
</div>
className="rounded-md mb-2.5 flex flex-col gap-1.5 p-2"
style={{ background: c.nodeCardBackground, border: `1px solid ${c.handleBackground}` }}
>
<div className="flex gap-1 items-center flex-wrap">
{swatchColors.map((color, i) => (
<span
key={i}
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: color }}
/>
))}
</div>
<div style={{ height: 2, background: ethernetColor, width: '80%', borderRadius: 2 }} />
<div
style={{
height: 1,
width: '55%',
backgroundImage: `repeating-linear-gradient(90deg, ${wifiColor} 0 5px, transparent 5px 8px)`,
}}
/>
</div>
{/* Label */}
<div
className="text-xs font-semibold leading-tight"
style={{ color: c.nodeLabelColor }}
>
{preset.label}
</div>
<div
className="text-[10px] leading-snug mt-0.5 line-clamp-2"
style={{ color: c.nodeSubtextColor }}
>
{preset.description}
</div>
</button>
<div
className="text-sm font-semibold leading-tight wrap-break-word"
style={{ color: c.nodeLabelColor }}
>
{preset.label}
</div>
<div
className="text-xs leading-snug mt-1 line-clamp-3 whitespace-normal wrap-break-word overflow-hidden min-h-12"
style={{ color: c.nodeSubtextColor }}
>
{preset.description}
</div>
</button>
{/* Edit button — only for custom theme */}
{isCustom && onEdit && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onEdit() }}
title="Edit custom style"
className="absolute bottom-2 right-2 flex items-center justify-center w-6 h-6 rounded-md transition-colors"
style={{
background: c.nodeCardBackground,
color: c.nodeLabelColor,
border: `1px solid ${c.handleBackground}`,
}}
>
<Pencil size={11} />
</button>
)}
</div>
)
}
@@ -93,6 +128,8 @@ interface ThemeModalProps {
export function ThemeModal({ open, onClose }: ThemeModalProps) {
const { activeTheme, setTheme } = useThemeStore()
const { markUnsaved } = useCanvasStore()
const cardRefs = useRef<Array<HTMLButtonElement | null>>([])
const [customStyleOpen, setCustomStyleOpen] = useState(false)
// Capture the theme that was active when the modal opened
const [originalTheme] = useState<ThemeId>(activeTheme)
@@ -100,68 +137,96 @@ export function ThemeModal({ open, onClose }: ThemeModalProps) {
const handleSelect = (id: ThemeId) => {
setSelected(id)
// Live-preview the selected theme on the canvas
setTheme(id)
}
const handleCardKeyDown = (index: number) => (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'Enter') {
event.preventDefault()
handleApply()
return
}
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
event.preventDefault()
const direction = event.key === 'ArrowRight' ? 1 : -1
const nextIndex = (index + direction + THEME_ORDER.length) % THEME_ORDER.length
const nextTheme = THEME_ORDER[nextIndex]
setSelected(nextTheme)
setTheme(nextTheme)
const nextCard = cardRefs.current[nextIndex]
if (!nextCard) return
nextCard.focus({ preventScroll: true })
nextCard.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
}
const handleApply = () => {
setTheme(selected)
markUnsaved()
onClose()
toast.info('Style applied — save your canvas to make it permanent', {
duration: 5000,
})
toast.info('Style applied — save your canvas to make it permanent', { duration: 5000 })
}
const handleCancel = () => {
// Revert to the original theme
setTheme(originalTheme)
onClose()
}
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) handleCancel() }}>
<DialogContent className="bg-[#161b22] border-[#30363d] w-[90vw] max-w-4xl">
<DialogHeader>
<DialogTitle className="text-sm font-semibold">Choose Canvas Style</DialogTitle>
</DialogHeader>
<>
<Dialog open={open} onOpenChange={(o) => { if (!o) handleCancel() }}>
<DialogContent className="bg-[#161b22] border-[#30363d] w-fit max-w-[calc(100%-2rem)] sm:max-w-[50vw]">
<DialogHeader>
<DialogTitle className="text-sm font-semibold">Choose Canvas Style</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-5 gap-3 py-1">
{THEME_ORDER.map((id) => (
<ThemeCard
key={id}
themeId={id}
selected={selected === id}
onClick={() => handleSelect(id)}
/>
))}
</div>
<div className="flex items-stretch flex-nowrap gap-3 py-1 overflow-x-auto overflow-y-hidden pb-2 pr-1">
{THEME_ORDER.map((id, index) => (
<div key={id} className="shrink-0 w-30 md:w-24 h-full">
<ThemeCard
themeId={id}
selected={selected === id}
onClick={() => handleSelect(id)}
onKeyDown={handleCardKeyDown(index)}
buttonRef={(element) => { cardRefs.current[index] = element }}
onEdit={id === 'custom' ? () => setCustomStyleOpen(true) : undefined}
/>
</div>
))}
</div>
<div className="flex justify-end gap-2 pt-1">
<Button
type="button"
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground"
onClick={handleCancel}
>
Cancel
</Button>
<Button
type="button"
size="sm"
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
style={
selected !== 'default'
? { background: THEMES[selected].colors.nodeAccents.isp.border }
: undefined
}
onClick={handleApply}
>
Apply Style
</Button>
</div>
</DialogContent>
</Dialog>
<div className="flex justify-end gap-2 pt-1">
<Button
type="button"
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground"
onClick={handleCancel}
>
Cancel
</Button>
<Button
type="button"
size="sm"
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
style={
selected !== 'default'
? { background: THEMES[selected].colors.nodeAccents.isp.border }
: undefined
}
onClick={handleApply}
>
Apply Style
</Button>
</div>
</DialogContent>
</Dialog>
<CustomStyleModal open={customStyleOpen} onClose={() => setCustomStyleOpen(false)} />
</>
)
}
@@ -83,6 +83,28 @@ describe('NodeModal', () => {
expect(onClose).toHaveBeenCalledOnce()
})
// ── Delete confirm ────────────────────────────────────────────────────
it('deletes and closes when Delete confirm is accepted', () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
const { onClose, onSubmit } = renderModal({ title: 'Edit Node', initial: BASE })
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ _delete: true }))
expect(onClose).toHaveBeenCalledOnce()
confirmSpy.mockRestore()
})
// Regression: bare-if without braces used to call onClose() unconditionally,
// closing the modal even when the user cancelled the confirm dialog.
it('does not delete or close when Delete confirm is cancelled', () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
const { onClose, onSubmit } = renderModal({ title: 'Edit Node', initial: BASE })
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
expect(onSubmit).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
confirmSpy.mockRestore()
})
// ── Label validation ──────────────────────────────────────────────────
it('blocks submit and shows error when label is empty', () => {
@@ -234,52 +256,103 @@ describe('NodeModal', () => {
expect(screen.queryByTitle('Router')).toBeNull()
})
// ── Container mode (proxmox only) ─────────────────────────────────────
// ── Container mode ─────────────────────────────────────────────────────
it('shows Container Mode toggle for proxmox type', () => {
renderModal({ initial: { ...BASE, type: 'proxmox' } })
const containerModeTypes = ['proxmox', 'vm', 'lxc', 'docker_host'] as const
const nonContainerModeTypes = ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer', 'iot', 'camera', 'cpl', 'computer', 'generic', 'docker_container', 'groupRect', 'group'] as const
it.each(containerModeTypes)('shows Container Mode toggle for %s type', (type) => {
renderModal({ initial: { ...BASE, type } })
expect(screen.getByText('Container Mode')).toBeDefined()
})
it('hides Container Mode for non-proxmox types', () => {
renderModal({ initial: BASE })
it.each(nonContainerModeTypes)('hides Container Mode for %s type', (type) => {
renderModal({ initial: { ...BASE, type } })
expect(screen.queryByText('Container Mode')).toBeNull()
})
it('toggles container_mode on click', () => {
const { onSubmit } = renderModal({ initial: { ...BASE, type: 'proxmox', container_mode: true } })
fireEvent.click(screen.getByRole('switch'))
fireEvent.click(screen.getByRole('switch', { name: 'Container Mode' }))
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).container_mode).toBe(false)
})
// ── Show services toggle (modal-only) ───────────────────────────────
it('shows Show Services toggle for regular nodes', () => {
renderModal({ initial: BASE })
expect(screen.getByText('Show Services')).toBeDefined()
expect(screen.getByRole('switch', { name: 'Show Services' })).toBeDefined()
})
it('hides Show Services toggle for groupRect', () => {
renderModal({ initial: { ...BASE, type: 'groupRect' } })
expect(screen.queryByText('Show Services')).toBeNull()
})
it('submits custom_colors.show_services=true when toggled on', () => {
const { onSubmit } = renderModal({ initial: BASE })
fireEvent.click(screen.getByRole('switch', { name: 'Show Services' }))
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
expect(data.custom_colors?.show_services).toBe(true)
})
it('keeps default colors hint visible when Show Services is toggled on', () => {
renderModal({ initial: BASE })
fireEvent.click(screen.getByRole('switch', { name: 'Show Services' }))
expect(screen.getByText(/Using default colors for/)).toBeDefined()
})
it('does not show Appearance reset when only Show Services is set', () => {
renderModal({ initial: BASE })
fireEvent.click(screen.getByRole('switch', { name: 'Show Services' }))
expect(screen.queryByText('Reset to defaults')).toBeNull()
})
// ── Parent Proxmox (vm / lxc only) ───────────────────────────────────
it('shows Parent Proxmox for vm with proxmoxNodes', () => {
const parentContainerVisibleTypes = ['proxmox', 'vm', 'lxc', 'docker_host', 'isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer', 'iot', 'camera', 'cpl', 'computer', 'generic'] as const
const parentContainerHiddenTypes = ['groupRect', 'group'] as const
it.each(parentContainerVisibleTypes)('shows Parent Container for %s type when options are provided', (type) => {
renderModal({
initial: { ...BASE, type: 'vm' },
proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }],
initial: { ...BASE, type },
parentContainerNodes: [{ id: 'c1', label: 'Container 01' }],
})
expect(screen.getByText('Parent Proxmox')).toBeDefined()
expect(screen.getByText('PVE-01')).toBeDefined()
expect(screen.getByText('Parent Container')).toBeDefined()
expect(screen.getByText('Container 01')).toBeDefined()
})
it('shows Parent Proxmox for lxc with proxmoxNodes', () => {
it.each(parentContainerHiddenTypes)('hides Parent Container for %s type even when options are provided', (type) => {
renderModal({ initial: { ...BASE, type }, parentContainerNodes: [{ id: 'c1', label: 'Container 01' }] })
expect(screen.queryByText('Parent Container')).toBeNull()
})
it.each(parentContainerVisibleTypes)('hides Parent Container for %s type when no container options are available', (type) => {
renderModal({ initial: { ...BASE, type } })
expect(screen.queryByText('Parent Container')).toBeNull()
})
it('docker_container shows only docker_host parents', () => {
renderModal({
initial: { ...BASE, type: 'lxc' },
proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }],
initial: { ...BASE, type: 'docker_container' },
parentContainerNodes: [
{ id: 'h1', label: 'My Docker Host', nodeType: 'docker_host' },
{ id: 'p1', label: 'My Proxmox', nodeType: 'proxmox' },
],
})
expect(screen.getByText('Parent Proxmox')).toBeDefined()
expect(screen.getByText('My Docker Host')).toBeDefined()
expect(screen.queryByText('My Proxmox')).toBeNull()
})
it('hides Parent Proxmox for server type', () => {
renderModal({ initial: BASE, proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }] })
expect(screen.queryByText('Parent Proxmox')).toBeNull()
})
it('hides Parent Proxmox for vm when no proxmoxNodes', () => {
renderModal({ initial: { ...BASE, type: 'vm' } })
expect(screen.queryByText('Parent Proxmox')).toBeNull()
it('docker_container hides Parent Container when no docker_host is available', () => {
renderModal({
initial: { ...BASE, type: 'docker_container' },
parentContainerNodes: [{ id: 'p1', label: 'My Proxmox', nodeType: 'proxmox' }],
})
expect(screen.queryByText('Parent Container')).toBeNull()
})
// ── Appearance ────────────────────────────────────────────────────────
@@ -327,18 +400,57 @@ describe('NodeModal', () => {
it('defaults bottom_handles to 1', () => {
renderModal({ initial: BASE })
expect(selects()[2].value).toBe('1')
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
expect(slider.value).toBe('1')
})
it('pre-fills bottom_handles from initial', () => {
renderModal({ initial: { ...BASE, bottom_handles: 3 } })
expect(selects()[2].value).toBe('3')
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
expect(slider.value).toBe('3')
})
it('submits updated bottom_handles', () => {
const { onSubmit } = renderModal({ initial: BASE })
fireEvent.change(selects()[2], { target: { value: '4' } })
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
fireEvent.change(slider, { target: { value: '12' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(4)
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(12)
})
it('supports the full 1..48 range', () => {
const { onSubmit } = renderModal({ initial: BASE })
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
expect(slider.min).toBe('1')
expect(slider.max).toBe('48')
fireEvent.change(slider, { target: { value: '48' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(48)
})
it('clamps pre-filled out-of-range values into [1,48]', () => {
renderModal({ initial: { ...BASE, bottom_handles: 9999 } })
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
expect(slider.value).toBe('48')
})
// ── Zigbee nodes ──────────────────────────────────────────────────────
const zigbeeTypes = ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] as const
it.each(zigbeeTypes)('hides Check Method for %s type', (type) => {
renderModal({ initial: { ...BASE, type } })
expect(screen.queryByText('Check Method')).toBeNull()
})
it.each(zigbeeTypes)('hides Check Target for %s type', (type) => {
renderModal({ initial: { ...BASE, type } })
expect(screen.queryByText('Check Target')).toBeNull()
})
it.each(zigbeeTypes)('submits check_method=none for %s type', (type) => {
const { onSubmit } = renderModal({ initial: { ...BASE, type, label: 'Zigbee Node' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).check_method).toBe('none')
})
})
@@ -0,0 +1,216 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { PendingDevicesModal } from '../PendingDevicesModal'
import { useCanvasStore } from '@/stores/canvasStore'
vi.mock('@/stores/canvasStore')
const mockBulkApprove = vi.fn()
const mockBulkHide = vi.fn()
const mockRestore = vi.fn()
const mockBulkRestore = vi.fn()
const mockApprove = vi.fn()
const mockHide = vi.fn()
const mockPending = vi.fn()
const mockHidden = vi.fn()
vi.mock('@/api/client', () => ({
scanApi: {
pending: (...a: unknown[]) => mockPending(...a),
hidden: (...a: unknown[]) => mockHidden(...a),
clearPending: vi.fn().mockResolvedValue({}),
approve: (...a: unknown[]) => mockApprove(...a),
hide: (...a: unknown[]) => mockHide(...a),
ignore: vi.fn().mockResolvedValue({}),
bulkApprove: (...a: unknown[]) => mockBulkApprove(...a),
bulkHide: (...a: unknown[]) => mockBulkHide(...a),
restore: (...a: unknown[]) => mockRestore(...a),
bulkRestore: (...a: unknown[]) => mockBulkRestore(...a),
},
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('@/components/modals/PendingDeviceModal', () => ({
PendingDeviceModal: ({ device }: { device: unknown }) =>
device ? <div data-testid="approval-modal" /> : null,
}))
const DEVICE_IP = {
id: 'dev-a',
ip: '192.168.1.10',
hostname: 'host-a',
mac: 'aa:bb:cc:dd:ee:01',
os: null,
services: [{ port: 80, protocol: 'tcp', service_name: 'http' }],
suggested_type: 'server',
status: 'pending',
discovery_source: 'arp',
discovered_at: '2026-01-01T00:00:00Z',
}
const DEVICE_ZIGBEE = {
id: 'dev-b',
ip: null,
hostname: null,
mac: null,
os: null,
services: [],
suggested_type: 'iot',
status: 'pending',
discovery_source: 'zigbee',
ieee_address: '0x00124b001234abcd',
friendly_name: 'living-room-bulb',
vendor: 'Philips',
model: 'Hue White',
discovered_at: '2026-01-02T00:00:00Z',
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useCanvasStore).mockReturnValue({
addNode: vi.fn(),
scanEventTs: 0,
} as unknown as ReturnType<typeof useCanvasStore>)
// setState is used by injectAutoEdges
;(useCanvasStore as unknown as { setState: (fn: unknown) => void }).setState = vi.fn()
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZIGBEE] })
mockHidden.mockResolvedValue({ data: [] })
mockApprove.mockResolvedValue({ data: { node_id: 'n1', edges: [], edges_created: 0 } })
mockHide.mockResolvedValue({ data: {} })
mockBulkApprove.mockResolvedValue({
data: { approved: 2, node_ids: ['n1', 'n2'], device_ids: ['dev-a', 'dev-b'], edges: [], edges_created: 0 },
})
mockBulkHide.mockResolvedValue({ data: { hidden: 2, skipped: 0 } })
mockRestore.mockResolvedValue({ data: { restored: true, device_id: 'dev-a' } })
mockBulkRestore.mockResolvedValue({ data: { restored: 1, skipped: 0 } })
})
const baseProps = {
open: true,
onClose: vi.fn(),
}
describe('PendingDevicesModal', () => {
it('loads and renders pending devices on open', async () => {
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.getByText('living-room-bulb')).toBeInTheDocument()
})
it('shows source chip ZIGBEE for zigbee device', async () => {
render(<PendingDevicesModal {...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} />)
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()
expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument()
})
it('filters by source (zigbee only)', async () => {
render(<PendingDevicesModal {...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()
expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument()
})
it('filters by suggested type', async () => {
render(<PendingDevicesModal {...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()
expect(screen.queryByTestId('pending-card-dev-b')).not.toBeInTheDocument()
})
it('switches to hidden status loads hidden devices', async () => {
mockHidden.mockResolvedValue({
data: [{ ...DEVICE_IP, id: 'h1', hostname: 'hidden-host', status: 'hidden' }],
})
render(<PendingDevicesModal {...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())
expect(mockHidden).toHaveBeenCalled()
})
it('opens approval modal when card is clicked outside select mode', async () => {
render(<PendingDevicesModal {...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()
})
it('toggles selection in select mode instead of opening approval', async () => {
render(<PendingDevicesModal {...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'))
expect(screen.queryByTestId('approval-modal')).not.toBeInTheDocument()
expect(screen.getByText('1 selected')).toBeInTheDocument()
})
it('select all visible selects only filtered devices', async () => {
render(<PendingDevicesModal {...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' } })
fireEvent.click(screen.getByRole('button', { name: /Select all visible/ }))
expect(screen.getByText('1 selected')).toBeInTheDocument()
})
it('bulk approve calls API with selected ids', async () => {
render(<PendingDevicesModal {...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-b'))
fireEvent.click(screen.getByRole('button', { name: /Approve \(2\)/ }))
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b']))
})
it('bulk hide calls API with selected ids', async () => {
render(<PendingDevicesModal {...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.getByRole('button', { name: /Hide \(1\)/ }))
await waitFor(() => expect(mockBulkHide).toHaveBeenCalledWith(['dev-a']))
})
it('does not load when closed', () => {
render(<PendingDevicesModal {...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" />)
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" />)
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'))
expect(screen.queryByTestId('approval-modal')).not.toBeInTheDocument()
})
it('bulk restore in hidden mode calls API with selected ids', async () => {
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] })
render(<PendingDevicesModal {...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'))
fireEvent.click(screen.getByRole('button', { name: /Restore \(1\)/ }))
await waitFor(() => expect(mockBulkRestore).toHaveBeenCalledWith(['dev-a']))
})
})
@@ -0,0 +1,75 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { TextModal, type TextFormData } from '../TextModal'
describe('TextModal', () => {
it('renders nothing when closed', () => {
const { container } = render(
<TextModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />
)
expect(container.querySelector('[role="dialog"]')).toBeNull()
})
it('renders form fields when open', () => {
render(<TextModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByPlaceholderText('Type text…')).toBeDefined()
expect(screen.getByText('Add Text')).toBeDefined()
expect(screen.getByText('Police')).toBeDefined()
expect(screen.getByText('Border Style')).toBeDefined()
expect(screen.getByText('Size')).toBeDefined()
})
it('renders Edit Text title when provided', () => {
render(<TextModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Text" />)
expect(screen.getByText('Edit Text')).toBeDefined()
})
it('calls onSubmit with form data on submit', () => {
const onSubmit = vi.fn()
render(<TextModal open onClose={vi.fn()} onSubmit={onSubmit} />)
const ta = screen.getByPlaceholderText('Type text…')
fireEvent.change(ta, { target: { value: 'Hello' } })
fireEvent.click(screen.getByText('Add'))
expect(onSubmit).toHaveBeenCalledOnce()
const submitted = onSubmit.mock.calls[0][0] as TextFormData
expect(submitted.text).toBe('Hello')
expect(submitted.font).toBe('inter')
expect(submitted.border_style).toBe('none')
})
it('hides Border Width when style is none, shows when not', () => {
render(<TextModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.queryByText('Border Width')).toBeNull()
fireEvent.click(screen.getByTitle('Solid'))
expect(screen.getByText('Border Width')).toBeDefined()
})
it('shows Delete button and calls handlers when provided', () => {
const onDelete = vi.fn()
const onClose = vi.fn()
render(<TextModal open onClose={onClose} onSubmit={vi.fn()} onDelete={onDelete} />)
fireEvent.click(screen.getByText('Delete'))
expect(onDelete).toHaveBeenCalledOnce()
expect(onClose).toHaveBeenCalledOnce()
})
it('pre-fills from initial prop', () => {
render(
<TextModal
open
onClose={vi.fn()}
onSubmit={vi.fn()}
initial={{ text: 'Pre-filled', text_size: 24, font: 'mono' }}
/>
)
const ta = screen.getByPlaceholderText('Type text…') as HTMLTextAreaElement
expect(ta.value).toBe('Pre-filled')
})
it('cancel calls onClose', () => {
const onClose = vi.fn()
render(<TextModal open onClose={onClose} onSubmit={vi.fn()} />)
fireEvent.click(screen.getByText('Cancel'))
expect(onClose).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,27 @@
/* SidebarItem pointer on hover */
.sidebar-pointer:hover {
cursor: pointer !important;
}
/* Consistent border radius for all modal input/select/button elements */
.modal-radius {
border-radius: 6px !important;
}
/* Pointer cursor for close X */
.modal-close-pointer:hover {
cursor: pointer !important;
}
/* Subtle hover background for cancel button */
.modal-cancel-hover:hover {
background: #21262d !important;
}
/* Shared hover/focus border effect for interactive modal elements */
.modal-interactive {
transition: border-color 0.15s;
}
.modal-interactive:hover,
.modal-interactive:focus {
border-color: #8b949e !important;
outline: none;
}
+82 -19
View File
@@ -2,6 +2,7 @@ import { createElement, useState } from 'react'
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useCanvasStore } from '@/stores/canvasStore'
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types'
import { getServiceUrl } from '@/utils/serviceUrl'
@@ -199,7 +200,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<span className="font-semibold text-sm text-foreground truncate">{data.label}</span>
<button aria-label="Close panel" onClick={() => setSelectedNode(null)} className="text-muted-foreground hover:text-foreground transition-colors">
<button aria-label="Close panel" onClick={() => setSelectedNode(null)} className="text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
<X size={16} />
</button>
</div>
@@ -233,7 +234,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
{data.os && <DetailRow label="OS" value={data.os} />}
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
{data.last_seen && <DetailRow label="Last Seen" value={new Date(data.last_seen.endsWith('Z') ? data.last_seen : data.last_seen + 'Z').toLocaleString()} />}
{data.last_seen && <DetailRow label="Last Seen" value={new Date(/[Zz]|[+-]\d{2}:?\d{2}$/.test(data.last_seen) ? data.last_seen : data.last_seen + 'Z').toLocaleString()} />}
</div>
{/* Properties section */}
@@ -242,7 +243,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
<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"
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer"
>
<Plus size={10} /> Add
</button>
@@ -288,7 +289,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
<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">
<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">
<Plus size={10} /> Add
</button>
</div>
@@ -315,10 +316,10 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
)}
<div className="mt-auto flex gap-2 px-4 py-3 border-t border-border">
<Button size="sm" variant="secondary" className="flex-1 gap-1.5" onClick={() => onEdit(node.id)}>
<Button size="sm" variant="secondary" className="flex-1 gap-1.5 cursor-pointer" onClick={() => onEdit(node.id)}>
<Edit size={14} /> Edit
</Button>
<Button size="sm" variant="destructive" className="gap-1.5" aria-label="Delete node" onClick={handleDelete}>
<Button size="sm" variant="destructive" className="gap-1.5 cursor-pointer" aria-label="Delete node" onClick={handleDelete}>
<Trash2 size={14} />
</Button>
</div>
@@ -661,23 +662,85 @@ const CATEGORY_COLORS: Record<string, string> = {
function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) {
const url = getServiceUrl(svc, host)
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
const portLabel = svc.port != null ? String(svc.port) : 'host'
const pathLabel = svc.path?.trim() ? svc.path.trim() : null
const inner = (
<div 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: '#30363d', cursor: url ? 'pointer' : 'default' }}>
<div className="flex items-center gap-1.5 min-w-0">
const pathLabel = svc.path?.trim() ? svc.path.trim() : ''
return (
<div
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' }}
>
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
<span className="font-medium truncate" style={{ color }} title={svc.service_name}>{svc.service_name}</span>
{pathLabel && <span className="truncate text-[#8b949e]" title={pathLabel}>{pathLabel}</span>}
{url ? (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="font-medium truncate min-w-0 flex-1"
style={{ color }}
title={svc.service_name}
onClick={e => e.stopPropagation()}
>
{svc.service_name}
</a>
) : (
<span
className="font-medium truncate min-w-0 flex-1"
style={{ color }}
title={svc.service_name}
>
{svc.service_name}
</span>
)}
{pathLabel && (
<span
className="shrink-0 text-[#8b949e] text-right w-16 truncate"
title={pathLabel}
>
{pathLabel}
</span>
)}
</div>
<div className="flex items-center gap-1.5 shrink-0">
<span className="font-mono text-[#8b949e]">{portLabel}/{svc.protocol}</span>
{url && <ExternalLink size={10} className="text-muted-foreground" />}
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5" title="Edit service"><Pencil size={10} /></button>
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5" title="Remove service"><X size={10} /></button>
{svc.port != null && (
<span className="font-mono text-[#8b949e]">
{svc.port}/{svc.protocol}
</span>
)}
{url ? (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex w-2.5 h-2.5 items-center justify-center"
onClick={e => e.stopPropagation()}
>
<ExternalLink size={10} className="text-muted-foreground" />
</a>
) : (
<span className="w-2.5" />
)}
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }}
className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5 cursor-pointer"
title="Edit service"
>
<Pencil size={10} />
</button>
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }}
className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5 cursor-pointer"
title="Remove service"
>
<X size={10} />
</button>
</div>
</div>
)
if (url) return <a href={url} target="_blank" rel="noopener noreferrer" className="block hover:opacity-80 transition-opacity">{inner}</a>
return inner
}
+125 -399
View File
@@ -1,28 +1,32 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, X } from 'lucide-react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, LogOut, Network, Type } from 'lucide-react'
import { Logo } from '@/components/ui/Logo'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore'
import { scanApi, settingsApi } from '@/api/client'
import { toast } from 'sonner'
import { useLatestRelease } from '@/hooks/useLatestRelease'
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
import {
type AlignmentSettings,
readAlignmentSettings,
writeAlignmentSettings,
subscribeAlignmentSettings,
} from '@/utils/alignmentSettings'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history' | 'settings'
type SidebarView = 'canvas' | 'history' | 'settings'
const ALL_VIEWS = [
{ id: 'canvas' as SidebarView, icon: LayoutDashboard, label: 'Canvas' },
{ id: 'pending' as SidebarView, icon: ScanLine, label: 'Pending Devices' },
{ id: 'hidden' as SidebarView, icon: EyeOff, label: 'Hidden Devices' },
{ id: 'history' as SidebarView, icon: Clock, label: 'Scan History' },
const PENDING_TRIGGERS: { kind: 'pending' | 'hidden'; icon: typeof ScanLine; label: string }[] = [
{ kind: 'pending', icon: ScanLine, label: 'Pending Devices' },
{ kind: 'hidden', icon: EyeOff, label: 'Hidden Devices' },
]
const VIEWS = STANDALONE ? ALL_VIEWS.slice(0, 1) : ALL_VIEWS
interface ScanRun {
id: string
status: string
kind?: string
ranges: string[]
devices_found: number
started_at: string
@@ -33,24 +37,32 @@ interface ScanRun {
interface SidebarProps {
onAddNode: () => void
onAddGroupRect: () => void
onAddText: () => void
onScan: () => void
onZigbeeImport: () => void
onSave: () => void
onNodeApproved: (nodeId: string) => void
forceView?: SidebarView
highlightPendingId?: string
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
}
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) {
const [_collapsed, setCollapsed] = useState(false)
const [_activeView, setActiveView] = useState<SidebarView>('canvas')
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onSave, forceView, onOpenPending }: SidebarProps) {
const [collapsed, setCollapsed] = useState(false)
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
const [prevForceView, setPrevForceView] = useState(forceView)
const logout = useAuthStore((s) => s.logout)
// When forceView is set, override local state without useEffect
const collapsed = forceView ? false : _collapsed
const activeView = forceView ?? _activeView
// forceView acts as a one-shot trigger from parent; user clicks afterwards still control view.
if (forceView !== prevForceView) {
setPrevForceView(forceView)
if (forceView) {
setActiveView(forceView)
setCollapsed(false)
}
}
const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect')
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length
@@ -78,23 +90,36 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
{/* Views */}
<nav className="flex flex-col gap-0.5 p-2">
{VIEWS.map(({ id, icon: Icon, label }) => (
<SidebarItem
icon={LayoutDashboard}
label="Canvas"
collapsed={collapsed}
active={activeView === 'canvas'}
onClick={() => setActiveView('canvas')}
/>
{!STANDALONE && PENDING_TRIGGERS.map((t) => (
<SidebarItem
key={id}
icon={Icon}
label={label}
key={t.kind}
icon={t.icon}
label={t.label}
collapsed={collapsed}
active={activeView === id}
onClick={() => setActiveView(id)}
onClick={() => onOpenPending(undefined, t.kind)}
/>
))}
{!STANDALONE && (
<SidebarItem
icon={Clock}
label="Scan History"
collapsed={collapsed}
active={activeView === 'history'}
onClick={() => setActiveView('history')}
/>
)}
</nav>
{/* View content (only when expanded) */}
{!collapsed && activeView !== 'canvas' && (
<div className="flex-1 min-h-0 overflow-y-auto border-t border-border">
{activeView === 'pending' && <PendingDevicesPanel onNodeApproved={onNodeApproved} highlightId={highlightPendingId} />}
{activeView === 'hidden' && <HiddenDevicesPanel />}
{activeView === 'history' && <ScanHistoryPanel />}
{activeView === 'settings' && <SettingsPanel />}
</div>
@@ -127,7 +152,9 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
<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} />}
<SidebarItem
icon={hideIp ? EyeOff : Eye}
label={hideIp ? 'Show IPs' : 'Hide IPs'}
@@ -152,6 +179,14 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
onClick={() => setActiveView((v) => v === 'settings' ? 'canvas' : 'settings')}
/>
)}
{!STANDALONE && (
<SidebarItem
icon={LogOut}
label="Logout"
collapsed={collapsed}
onClick={logout}
/>
)}
</div>
{!collapsed && <VersionBadge />}
@@ -159,325 +194,6 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
)
}
const COMMON_PORTS = new Set([22, 80, 443])
function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved: (nodeId: string) => void; highlightId?: string }) {
const [devices, setDevices] = useState<PendingDevice[]>([])
const [loading, setLoading] = useState(false)
const [selected, setSelected] = useState<PendingDevice | null>(null)
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set())
const { addNode, scanEventTs } = useCanvasStore()
const highlightRef = useRef<HTMLButtonElement>(null)
const allChecked = devices.length > 0 && checkedIds.size === devices.length
const someChecked = checkedIds.size > 0
const toggleCheck = (id: string, e: React.MouseEvent) => {
e.stopPropagation()
setCheckedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
const toggleAll = () => {
setCheckedIds(allChecked ? new Set() : new Set(devices.map((d) => d.id)))
}
const load = useCallback(async () => {
setLoading(true)
try {
const res = await scanApi.pending()
setDevices(res.data)
} catch {
toast.error('Failed to load pending devices')
} finally {
setLoading(false)
}
}, [])
const handleClearAll = async () => {
try {
await scanApi.clearPending()
setDevices([])
setCheckedIds(new Set())
toast.success('Pending devices cleared')
} catch {
toast.error('Failed to clear pending devices')
}
}
const handleBulkApprove = async () => {
const ids = [...checkedIds]
try {
const res = await scanApi.bulkApprove(ids)
const deviceToNode: Record<string, string> = {}
res.data.device_ids.forEach((did, i) => { deviceToNode[did] = res.data.node_ids[i] })
const approvedDevices = devices.filter((d) => ids.includes(d.id))
approvedDevices.forEach((d, i) => {
const nodeId = deviceToNode[d.id]
if (!nodeId) return
addNode({
id: nodeId,
type: (d.suggested_type ?? 'generic') as import('@/types').NodeType,
position: { x: 400 + (i % 4) * 160, y: 300 + Math.floor(i / 4) * 100 },
data: {
label: d.hostname ?? d.ip,
type: (d.suggested_type ?? 'generic') as import('@/types').NodeType,
ip: d.ip,
hostname: d.hostname ?? undefined,
status: 'unknown' as const,
services: (d.services ?? []) as import('@/types').ServiceInfo[],
},
})
onNodeApproved(nodeId)
})
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
setCheckedIds(new Set())
toast.success(`Approved ${res.data.approved} device${res.data.approved !== 1 ? 's' : ''}`)
} catch {
toast.error('Failed to bulk approve devices')
}
}
const handleBulkHide = async () => {
const ids = [...checkedIds]
try {
const res = await scanApi.bulkHide(ids)
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
setCheckedIds(new Set())
toast.success(`Hidden ${res.data.hidden} device${res.data.hidden !== 1 ? 's' : ''}`)
} catch {
toast.error('Failed to bulk hide devices')
}
}
useEffect(() => { load() }, [load])
useEffect(() => {
if (scanEventTs > 0) load()
}, [scanEventTs, load])
useEffect(() => {
if (!highlightId || loading) return
highlightRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}, [highlightId, loading])
const handleApprove = async (device: PendingDevice) => {
try {
const nodeData = {
label: device.hostname ?? device.ip,
type: (device.suggested_type ?? 'generic') as import('@/types').NodeType,
ip: device.ip,
hostname: device.hostname ?? undefined,
status: 'unknown',
services: (device.services ?? []) as import('@/types').ServiceInfo[],
}
const res = await scanApi.approve(device.id, nodeData)
const nodeId = res.data.node_id
addNode({
id: nodeId,
type: nodeData.type,
position: { x: 400, y: 300 },
data: { ...nodeData, status: 'unknown' as const },
})
toast.success(`Approved ${nodeData.label}`)
setDevices((prev) => prev.filter((d) => d.id !== device.id))
setSelected(null)
onNodeApproved(nodeId)
} catch {
toast.error('Failed to approve device')
}
}
const handleHide = async (device: PendingDevice) => {
try {
await scanApi.hide(device.id)
setDevices((prev) => prev.filter((d) => d.id !== device.id))
toast.success('Device hidden')
} catch {
toast.error('Failed to hide device')
}
}
const handleIgnore = async (device: PendingDevice) => {
try {
await scanApi.ignore(device.id)
setDevices((prev) => prev.filter((d) => d.id !== device.id))
} catch {
toast.error('Failed to ignore device')
}
}
return (
<>
<div className="p-2">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
{devices.length > 0 && (
<input
type="checkbox"
checked={allChecked}
ref={(el) => { if (el) el.indeterminate = someChecked && !allChecked }}
onChange={toggleAll}
className="w-3 h-3 accent-[#00d4ff] cursor-pointer"
title="Select all"
/>
)}
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Pending</span>
</div>
<div className="flex items-center gap-1">
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5" title="Refresh">
<RefreshCw size={12} />
</button>
{devices.length > 0 && (
<button onClick={handleClearAll} className="text-muted-foreground hover:text-[#f85149] p-0.5" title="Clear all pending">
<X size={12} />
</button>
)}
</div>
</div>
{someChecked && (
<div className="flex items-center gap-1 mb-2">
<button
onClick={handleBulkApprove}
className="flex-1 text-[10px] py-1 px-2 rounded bg-[#39d353]/20 text-[#39d353] hover:bg-[#39d353]/30 transition-colors font-medium"
>
Approve ({checkedIds.size})
</button>
<button
onClick={handleBulkHide}
className="flex-1 text-[10px] py-1 px-2 rounded bg-[#8b949e]/20 text-[#8b949e] hover:bg-[#8b949e]/30 transition-colors font-medium"
>
Hide ({checkedIds.size})
</button>
</div>
)}
{loading && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
{!loading && devices.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-4">No pending devices</p>
)}
{devices.map((d) => {
const namedService = d.services.find((s) => s.category != null && s.port != null && !COMMON_PORTS.has(s.port))
const titleService = namedService
?? d.services.find((s) => s.port === 80)
?? d.services.find((s) => s.port === 443)
?? d.services.find((s) => s.port === 22)
const title = titleService?.service_name ?? d.hostname ?? d.ip
const showIpBelow = title !== d.ip
const hasSsh = d.services.some((s) => s.port === 22)
const hasHttp = d.services.some((s) => s.port === 80)
const hasHttps = d.services.some((s) => s.port === 443)
const otherCount = d.services.filter((s) => s.port !== 22 && s.port !== 80 && s.port !== 443).length
const virtualBadge = detectVirtualBadge(d.mac)
const sourceColor = d.discovery_source === 'mdns' ? '#a855f7' : '#8b949e'
const sourceLabel = d.discovery_source === 'mdns' ? 'mDNS' : d.discovery_source === 'arp' ? 'ARP' : null
const isHighlighted = d.id === highlightId
return (
<button
key={d.id}
ref={isHighlighted ? highlightRef : null}
onClick={() => setSelected(d)}
className={`w-full mb-1.5 p-2 rounded-md text-xs text-left transition-colors border ${isHighlighted ? 'bg-[#2d3748] border-[#e3b341]' : checkedIds.has(d.id) ? 'bg-[#21262d] border-[#00d4ff]/40' : 'bg-[#21262d] border-transparent hover:bg-[#30363d] hover:border-[#30363d]'}`}
>
<div className="flex items-center gap-1.5">
<input
type="checkbox"
checked={checkedIds.has(d.id)}
onClick={(e) => toggleCheck(d.id, e)}
onChange={() => {}}
className="w-3 h-3 accent-[#00d4ff] cursor-pointer shrink-0"
/>
<span className="text-foreground truncate font-medium">{title}</span>
</div>
{showIpBelow && (
<div className="font-mono text-muted-foreground truncate pl-3 text-[10px] mt-0.5">{d.ip}</div>
)}
{(hasSsh || hasHttp || hasHttps || otherCount > 0 || virtualBadge || sourceLabel) && (
<div className="flex items-center gap-1 pl-3 mt-1.5 flex-wrap">
{sourceLabel && <ServiceBadge label={sourceLabel} color={sourceColor} />}
{virtualBadge && (
<Tooltip>
<TooltipTrigger>
<span><ServiceBadge label={virtualBadge.label} color="#ff6e00" /></span>
</TooltipTrigger>
<TooltipContent side="right">{virtualBadge.title}</TooltipContent>
</Tooltip>
)}
{hasSsh && <ServiceBadge label="SSH" color="#a855f7" />}
{hasHttp && <ServiceBadge label="HTTP" color="#00d4ff" />}
{hasHttps && <ServiceBadge label="HTTPS" color="#39d353" />}
{otherCount > 0 && <ServiceBadge label={`+${otherCount}`} color="#8b949e" />}
</div>
)}
</button>
)
})}
</div>
<PendingDeviceModal
device={selected}
onClose={() => setSelected(null)}
onApprove={handleApprove}
onHide={handleHide}
onIgnore={handleIgnore}
/>
</>
)
}
function HiddenDevicesPanel() {
const [devices, setDevices] = useState<PendingDevice[]>([])
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const res = await scanApi.hidden()
setDevices(res.data)
} catch {
toast.error('Failed to load hidden devices')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const handleIgnore = async (id: string) => {
try {
await scanApi.ignore(id)
setDevices((prev) => prev.filter((d) => d.id !== id))
} catch {
toast.error('Failed to remove device')
}
}
return (
<div className="p-2">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Hidden</span>
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5">
<RefreshCw size={12} />
</button>
</div>
{loading && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
{!loading && devices.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-4">No hidden devices</p>
)}
{devices.map((d) => (
<div key={d.id} className="mb-2 p-2 rounded-md bg-[#21262d] text-xs">
<div className="font-mono text-foreground">{d.ip}</div>
{d.hostname && <div className="text-muted-foreground truncate">{d.hostname}</div>}
<div className="flex gap-1 mt-1.5">
<ActionButton icon={Trash2} label="Remove" color="red" onClick={() => handleIgnore(d.id)} />
</div>
</div>
))}
</div>
)
}
function ScanHistoryPanel() {
const [runs, setRuns] = useState<ScanRun[]>([])
@@ -490,12 +206,19 @@ function ScanHistoryPanel() {
const res = await scanApi.runs()
const next: ScanRun[] = res.data
// Toast when a run transitions from running → error
// Surface transitions and refresh dependent UI
for (const run of next) {
const prev = prevRunsRef.current.find((r) => r.id === run.id)
if (prev?.status === 'running' && run.status === 'error') {
toast.error(`Scan failed: ${run.error ?? 'unknown error'}`)
}
if (prev?.status === 'running' && run.status === 'done') {
if (run.kind === 'zigbee') {
toast.success(`Zigbee import done — ${run.devices_found} device${run.devices_found !== 1 ? 's' : ''}`)
}
// Notify pending modal/canvas to refresh
useCanvasStore.getState().notifyScanDeviceFound()
}
}
prevRunsRef.current = next
setRuns(next)
@@ -556,6 +279,14 @@ function ScanHistoryPanel() {
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: statusColor(r.status) }} />
<span className="font-mono text-foreground capitalize">{r.status}</span>
{r.status === 'running' && <Loader2 size={10} className="animate-spin text-[#e3b341]" />}
<span
className="text-[9px] font-mono px-1 py-0.5 rounded uppercase tracking-wider"
style={r.kind === 'zigbee'
? { background: '#00d4ff22', color: '#00d4ff' }
: { background: '#a855f722', color: '#a855f7' }}
>
{r.kind === 'zigbee' ? 'ZIG' : 'IP'}
</span>
<span className="ml-auto text-muted-foreground font-mono">{r.devices_found} found</span>
{r.status === 'running' && (
<Tooltip>
@@ -583,7 +314,7 @@ function ScanHistoryPanel() {
<div className="text-[#8b949e] text-[10px] font-mono truncate">{r.ranges.join(', ')}</div>
)}
{r.error && (
<div className="text-[#f85149] text-[10px] mt-1 leading-tight break-words whitespace-pre-wrap">
<div className="text-[#f85149] text-[10px] mt-1 leading-tight wrap-break-word whitespace-pre-wrap">
{r.error}
</div>
)}
@@ -596,6 +327,7 @@ function ScanHistoryPanel() {
function SettingsPanel() {
const [interval, setIntervalValue] = useState(60)
const [saving, setSaving] = useState(false)
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
useEffect(() => {
settingsApi.get()
@@ -603,6 +335,14 @@ function SettingsPanel() {
.catch(() => {/* use default */})
}, [])
useEffect(() => subscribeAlignmentSettings(setAlignment), [])
const updateAlignment = (patch: Partial<AlignmentSettings>) => {
const next = { ...alignment, ...patch }
setAlignment(next)
writeAlignmentSettings(next)
}
const handleSave = async () => {
setSaving(true)
try {
@@ -627,7 +367,7 @@ function SettingsPanel() {
min={10}
max={3600}
value={interval}
onChange={(e) => setIntervalValue(Number(e.target.value))}
onChange={(e) => { const v = Number(e.target.value); if (!isNaN(v)) setIntervalValue(v) }}
className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]"
/>
<span className="text-xs text-muted-foreground">seconds</span>
@@ -644,6 +384,41 @@ function SettingsPanel() {
>
{saving ? 'Saving…' : 'Save'}
</button>
<div className="pt-3 border-t border-border space-y-3">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Canvas</span>
<label className="flex items-center justify-between gap-2 cursor-pointer">
<span className="text-xs text-foreground">Snap to nodes</span>
<input
type="checkbox"
checked={alignment.enabled}
onChange={(e) => updateAlignment({ enabled: e.target.checked })}
className="cursor-pointer accent-[#00d4ff]"
aria-label="Toggle alignment guides"
/>
</label>
<div className={alignment.enabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
<label className="text-xs text-muted-foreground">Snap distance</label>
<div className="flex items-center gap-2">
<input
type="range"
min={2}
max={16}
step={1}
value={alignment.threshold}
onChange={(e) => updateAlignment({ threshold: Number(e.target.value) })}
className="flex-1 cursor-pointer accent-[#00d4ff]"
aria-label="Alignment snap threshold"
/>
<span className="font-mono text-[11px] text-foreground w-8 text-right">{alignment.threshold}px</span>
</div>
<p className="text-[10px] text-muted-foreground leading-tight">
Distance at which dragged nodes snap to neighbours. Hold Alt while dragging to disable.
</p>
</div>
</div>
</div>
)
}
@@ -664,7 +439,7 @@ function VersionBadge() {
</a>
{hasUpdate && latest && (
<a
href={latest.url}
href={latest.url.startsWith('https://') ? latest.url : '#'}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-[#e3b341]/15 text-[#e3b341] border border-[#e3b341]/30 hover:bg-[#e3b341]/25 transition-colors self-start"
@@ -676,55 +451,6 @@ function VersionBadge() {
)
}
const MAC_OUI: Record<string, { label: string; title: string }> = {
'52:54:00': { label: 'QEMU', title: 'QEMU/KVM Virtual Machine' },
'bc:24:11': { label: 'PVE', title: 'Proxmox Virtual Machine or LXC' },
'00:50:56': { label: 'VMware', title: 'VMware Virtual Machine' },
'00:0c:29': { label: 'VMware', title: 'VMware Virtual Machine' },
'08:00:27': { label: 'VBox', title: 'VirtualBox Virtual Machine' },
'00:15:5d': { label: 'Hyper-V', title: 'Hyper-V Virtual Machine' },
}
function detectVirtualBadge(mac: string | null) {
if (!mac) return null
return MAC_OUI[mac.toLowerCase().slice(0, 8)] ?? null
}
function ServiceBadge({ label, color }: { label: string; color: string }) {
return (
<span
className="px-1 py-0.5 rounded text-[9px] font-mono font-medium leading-none border"
style={{ color, borderColor: `${color}40`, backgroundColor: `${color}15` }}
>
{label}
</span>
)
}
interface ActionButtonProps {
icon: React.ElementType
label: string
color?: 'green' | 'red'
onClick: () => void
}
function ActionButton({ icon: Icon, label, color, onClick }: ActionButtonProps) {
const colorClass =
color === 'green' ? 'text-[#39d353] hover:bg-[#39d353]/10' :
color === 'red' ? 'text-[#f85149] hover:bg-[#f85149]/10' :
'text-muted-foreground hover:text-foreground hover:bg-[#30363d]'
return (
<Tooltip>
<TooltipTrigger>
<button onClick={onClick} className={`p-1 rounded ${colorClass} transition-colors`}>
<Icon size={11} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
)
}
interface SidebarItemProps {
icon: React.ElementType
label: string
@@ -739,7 +465,7 @@ function SidebarItem({ icon: Icon, label, collapsed, active, badge, accent, onCl
const btn = (
<button
onClick={onClick}
className={`relative flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm transition-colors ${
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]'
: accent
+10 -10
View File
@@ -39,7 +39,7 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
<div className="flex-1" />
<Button
size="sm" variant="ghost"
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
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)"
@@ -48,7 +48,7 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
</Button>
<Button
size="sm" variant="ghost"
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
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)"
@@ -56,13 +56,13 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
<Redo2 size={14} />
</Button>
<div className="w-px h-4 bg-border mx-1" />
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onAutoLayout}>
<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" onClick={onChangeStyle}>
<Button 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" onClick={() => fileInputRef.current?.click()} title="Import from YAML">
<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">
<Upload size={14} /> Import
</Button>
<input
@@ -72,21 +72,21 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
className="hidden"
onChange={handleFileChange}
/>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExportYaml} title="Export canvas as YAML">
<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>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExport} title="Download canvas as PNG">
<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" onClick={onExportMd} title="Copy inventory as Markdown table">
<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>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onShortcuts} title="Keyboard shortcuts (?)">
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onShortcuts} title="Keyboard shortcuts (?)">
<HelpCircle size={14} />
</Button>
<Button
size="sm"
className="gap-1.5 relative"
className="gap-1.5 relative cursor-pointer border border-transparent hover:border-white"
style={{
background: hasUnsavedChanges ? '#00d4ff' : undefined,
color: hasUnsavedChanges ? '#0d1117' : undefined,
@@ -445,4 +445,69 @@ describe('DetailPanel', () => {
expect(screen.getByText(/192\.168\.1\.10, 192\.168\.1\.11/)).toBeDefined()
})
})
describe('ServiceBadge rendering', () => {
it('renders service name and port/protocol label', () => {
setupStore({ services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('nginx')).toBeDefined()
expect(screen.getByText('8080/tcp')).toBeDefined()
})
it('renders path label when path is set', () => {
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'web', path: '/admin' }] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('/admin')).toBeDefined()
})
it('renders no path text when path is empty', () => {
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'web', path: '' }] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.queryByText('/')).toBeNull()
})
it('renders port/protocol omitted when port is absent', () => {
setupStore({ services: [{ protocol: 'tcp', service_name: 'health', path: '' }] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('health')).toBeDefined()
expect(screen.queryByText(/\/tcp/)).toBeNull()
})
it('renders service name as link when ip and port are set', () => {
setupStore({ ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] })
render(<DetailPanel onEdit={vi.fn()} />)
const link = screen.getByRole('link', { name: 'nginx' })
expect(link.getAttribute('href')).toContain('192.168.1.10')
expect(link.getAttribute('target')).toBe('_blank')
})
it('renders service name as plain text when no url can be built', () => {
setupStore({ ip: undefined, services: [{ protocol: 'tcp', service_name: 'health', path: '' }] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('health').tagName).not.toBe('A')
})
})
describe('Last Seen formatting', () => {
it('renders a valid date when last_seen has +00:00 offset (no Z)', () => {
setupStore({ last_seen: '2026-05-10T17:54:38.221403+00:00' })
render(<DetailPanel onEdit={vi.fn()} />)
const row = screen.getByText('Last Seen').parentElement
expect(row?.textContent).not.toMatch(/Invalid Date/)
})
it('renders a valid date when last_seen ends with Z', () => {
setupStore({ last_seen: '2026-05-10T17:54:38.221403Z' })
render(<DetailPanel onEdit={vi.fn()} />)
const row = screen.getByText('Last Seen').parentElement
expect(row?.textContent).not.toMatch(/Invalid Date/)
})
it('treats naive ISO strings as UTC', () => {
setupStore({ last_seen: '2026-05-10T17:54:38' })
render(<DetailPanel onEdit={vi.fn()} />)
const row = screen.getByText('Last Seen').parentElement
expect(row?.textContent).not.toMatch(/Invalid Date/)
})
})
})
@@ -2,29 +2,20 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { Sidebar } from '../Sidebar'
import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore'
import type { Node } from '@xyflow/react'
import type { NodeData } from '@/types'
// ── Mocks ────────────────────────────────────────────────────────────────────
vi.mock('@/stores/canvasStore')
const mockBulkApprove = vi.fn()
const mockBulkHide = vi.fn()
vi.mock('@/stores/authStore')
vi.mock('@/api/client', () => ({
scanApi: {
trigger: vi.fn().mockResolvedValue({}),
pending: vi.fn().mockResolvedValue({ data: [] }),
hidden: vi.fn().mockResolvedValue({ data: [] }),
runs: vi.fn().mockResolvedValue({ data: [] }),
stop: vi.fn().mockResolvedValue({}),
clearPending: vi.fn().mockResolvedValue({}),
approve: vi.fn().mockResolvedValue({ data: { approved: true, node_id: 'new-node-1' } }),
hide: vi.fn().mockResolvedValue({ data: { hidden: true } }),
ignore: vi.fn().mockResolvedValue({ data: { ignored: true } }),
bulkApprove: (...args: unknown[]) => mockBulkApprove(...args),
bulkHide: (...args: unknown[]) => mockBulkHide(...args),
},
settingsApi: {
get: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }),
@@ -46,10 +37,6 @@ vi.mock('@/components/ui/tooltip', () => ({
TooltipContent: () => null,
}))
vi.mock('@/components/modals/PendingDeviceModal', () => ({
PendingDeviceModal: () => null,
}))
// ── Helpers ───────────────────────────────────────────────────────────────────
const makeNode = (id: string, status: NodeData['status'], type: NodeData['type'] = 'server'): Node<NodeData> => ({
@@ -60,6 +47,7 @@ const makeNode = (id: string, status: NodeData['status'], type: NodeData['type']
})
const mockToggleHideIp = vi.fn()
const mockLogout = vi.fn()
function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
vi.mocked(useCanvasStore).mockReturnValue({
@@ -73,12 +61,19 @@ function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
} as ReturnType<typeof useCanvasStore>)
}
function mockAuth() {
vi.mocked(useAuthStore).mockImplementation((selector: (s: { logout: () => void }) => unknown) =>
selector({ logout: mockLogout }) as ReturnType<typeof useAuthStore>
)
}
const defaultProps = {
onAddNode: vi.fn(),
onAddGroupRect: vi.fn(),
onScan: vi.fn(),
onZigbeeImport: vi.fn(),
onSave: vi.fn(),
onNodeApproved: vi.fn(),
onOpenPending: vi.fn(),
}
// ── Tests ─────────────────────────────────────────────────────────────────────
@@ -86,6 +81,7 @@ const defaultProps = {
describe('Sidebar', () => {
beforeEach(() => {
mockStore()
mockAuth()
vi.clearAllMocks()
})
@@ -119,26 +115,22 @@ describe('Sidebar', () => {
],
})
render(<Sidebar {...defaultProps} />)
// Total (excludes groupRect)
expect(screen.getByText('4')).toBeInTheDocument()
// Online
expect(screen.getByText('2')).toBeInTheDocument()
// Offline
expect(screen.getByText('1')).toBeInTheDocument()
})
it('excludes groupRect nodes from stats', () => {
mockStore({
nodes: [
makeNode('n1', 'unknown'), // 1 real node, not online/offline
makeNode('n1', 'unknown'),
makeNode('zone', 'unknown', 'groupRect'),
],
})
render(<Sidebar {...defaultProps} />)
// Total row shows 1 (groupRect excluded), online/offline both 0
const totalRow = screen.getByText('Total').closest('div')!
expect(totalRow).toHaveTextContent('1')
expect(screen.getAllByText('0')).toHaveLength(2) // online=0, offline=0
expect(screen.getAllByText('0')).toHaveLength(2)
})
// ── Collapse ───────────────────────────────────────────────────────────────
@@ -215,7 +207,6 @@ describe('Sidebar', () => {
it('shows unsaved badge dot on Save Canvas when hasUnsavedChanges', () => {
mockStore({ hasUnsavedChanges: true })
render(<Sidebar {...defaultProps} />)
// The badge is a span sibling of the Save Canvas button icon
const saveBtn = screen.getByText('Save Canvas').closest('button')!
const badge = saveBtn.querySelector('span.rounded-full')
expect(badge).toBeInTheDocument()
@@ -231,24 +222,24 @@ describe('Sidebar', () => {
// ── Scan action ────────────────────────────────────────────────────────────
it('calls onScan prop when Scan Network is clicked (scan trigger moved to ScanConfigModal)', () => {
it('calls onScan prop when Scan Network is clicked', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Scan Network'))
expect(defaultProps.onScan).toHaveBeenCalledOnce()
})
// ── Navigation ─────────────────────────────────────────────────────────────
// ── Pending / Hidden open modal ────────────────────────────────────────────
it('shows Pending panel when Pending Devices nav item is clicked', async () => {
it('calls onOpenPending with pending status when Pending Devices is clicked', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Pending Devices'))
await waitFor(() => expect(screen.getByText('No pending devices')).toBeInTheDocument())
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'pending')
})
it('shows Hidden panel when Hidden Devices nav item is clicked', async () => {
it('calls onOpenPending with hidden status when Hidden Devices is clicked', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Hidden Devices'))
await waitFor(() => expect(screen.getByText('No hidden devices')).toBeInTheDocument())
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'hidden')
})
it('shows History panel when Scan History nav item is clicked', async () => {
@@ -257,111 +248,35 @@ describe('Sidebar', () => {
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
})
// Regression: forceView must not freeze local state across rerenders.
it('allows switching views after forceView is set by parent', async () => {
const { rerender } = render(<Sidebar {...defaultProps} forceView="history" />)
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
rerender(<Sidebar {...defaultProps} forceView="history" />)
fireEvent.click(screen.getByText('Canvas'))
await waitFor(() => expect(screen.queryByText('No scans yet')).not.toBeInTheDocument())
})
it('toggles Settings panel on Settings click', async () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Settings'))
await waitFor(() =>
expect(screen.getByText('Status check interval (s)')).toBeInTheDocument(),
)
// Click the nav button again to close (use role to avoid matching the panel heading)
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument()
})
})
// ── PendingDevicesPanel — bulk select ─────────────────────────────────────────
// ── Logout ─────────────────────────────────────────────────────────────────
const DEVICE_A = {
id: 'dev-a',
ip: '192.168.1.10',
hostname: 'host-a',
mac: null,
os: null,
services: [],
suggested_type: 'generic',
status: 'pending',
discovery_source: 'arp',
}
const DEVICE_B = {
id: 'dev-b',
ip: '192.168.1.11',
hostname: 'host-b',
mac: null,
os: null,
services: [],
suggested_type: 'generic',
status: 'pending',
discovery_source: 'arp',
}
describe('PendingDevicesPanel — bulk select', () => {
beforeEach(() => {
mockStore()
vi.clearAllMocks()
mockBulkApprove.mockResolvedValue({
data: { approved: 2, node_ids: ['n1', 'n2'], device_ids: ['dev-a', 'dev-b'], skipped: 0 },
})
mockBulkHide.mockResolvedValue({ data: { hidden: 2, skipped: 0 } })
it('shows Logout button in normal mode', () => {
render(<Sidebar {...defaultProps} />)
expect(screen.getByText('Logout')).toBeInTheDocument()
})
async function renderWithDevices() {
const { scanApi } = await import('@/api/client')
vi.mocked(scanApi.pending).mockResolvedValue({ data: [DEVICE_A, DEVICE_B] } as never)
render(<Sidebar {...defaultProps} forceView="pending" />)
await waitFor(() => expect(screen.getByText('host-a')).toBeInTheDocument())
}
it('renders checkboxes for each device', async () => {
await renderWithDevices()
const checkboxes = screen.getAllByRole('checkbox')
// select-all + 2 device checkboxes
expect(checkboxes.length).toBe(3)
})
it('shows bulk action bar when a device is checked', async () => {
await renderWithDevices()
const [, firstDeviceCheckbox] = screen.getAllByRole('checkbox')
fireEvent.click(firstDeviceCheckbox)
await waitFor(() => expect(screen.getByText(/Approve \(1\)/)).toBeInTheDocument())
expect(screen.getByText(/Hide \(1\)/)).toBeInTheDocument()
})
it('hides bulk action bar when no device is checked', async () => {
await renderWithDevices()
expect(screen.queryByText(/Approve \(/)).not.toBeInTheDocument()
})
it('select-all checks all devices', async () => {
await renderWithDevices()
const [selectAll] = screen.getAllByRole('checkbox')
fireEvent.click(selectAll)
await waitFor(() => expect(screen.getByText(/Approve \(2\)/)).toBeInTheDocument())
})
it('select-all unchecks all when all are selected', async () => {
await renderWithDevices()
const [selectAll] = screen.getAllByRole('checkbox')
fireEvent.click(selectAll) // select all
fireEvent.click(selectAll) // deselect all
await waitFor(() => expect(screen.queryByText(/Approve \(/)).not.toBeInTheDocument())
})
it('calls bulkApprove with checked ids and removes devices from list', async () => {
await renderWithDevices()
const [selectAll] = screen.getAllByRole('checkbox')
fireEvent.click(selectAll)
fireEvent.click(screen.getByText(/Approve \(2\)/))
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b']))
await waitFor(() => expect(screen.queryByText('host-a')).not.toBeInTheDocument())
})
it('calls bulkHide with checked ids and removes devices from list', async () => {
await renderWithDevices()
const [selectAll] = screen.getAllByRole('checkbox')
fireEvent.click(selectAll)
fireEvent.click(screen.getByText(/Hide \(2\)/))
await waitFor(() => expect(mockBulkHide).toHaveBeenCalledWith(['dev-a', 'dev-b']))
await waitFor(() => expect(screen.queryByText('host-b')).not.toBeInTheDocument())
it('calls logout when Logout is clicked', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Logout'))
expect(mockLogout).toHaveBeenCalledOnce()
})
})
+35
View File
@@ -0,0 +1,35 @@
import { createElement } from 'react'
import type { LucideIcon } from 'lucide-react'
import { resolveCustomIcon, brandIconUrl, isBrandIconKey } from '@/utils/nodeIcons'
interface NodeIconProps {
/** Default icon for the node type (lucide). Used when no customIconKey or unknown key. */
typeIcon: LucideIcon
/** Optional override key. Legacy lucide keys or `brand:<slug>` for dashboard-icons. */
customIconKey?: string
size?: number
className?: string
/** Optional inline color (lucide only — ignored for brand icons). */
color?: string
}
export function NodeIcon({ typeIcon, customIconKey, size = 16, className, color }: NodeIconProps) {
const resolved = resolveCustomIcon(customIconKey)
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' }}
/>
)
}
const Icon = resolved?.kind === 'lucide' ? resolved.icon : typeIcon
return createElement(Icon, { size, className, color })
}
export { brandIconUrl, isBrandIconKey }
+3 -3
View File
@@ -1,3 +1,4 @@
import modalStyles from '../modals/modal-interactive.module.css'
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
@@ -63,13 +64,12 @@ function DialogContent({
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
className={"absolute top-2 right-2 " + modalStyles['modal-close-pointer']}
size="icon-sm"
/>
}
>
<XIcon
/>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
+16 -50
View File
@@ -1,64 +1,30 @@
"use client"
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
}: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
<TooltipPrimitive.Content
sideOffset={sideOffset}
className="isolate z-50"
className={[
'z-50 overflow-hidden rounded-md border border-[#30363d] bg-[#161b22] px-2 py-1 text-xs text-[#e6edf3] shadow-md',
'animate-in fade-in-0 zoom-in-95',
className,
]
.filter(Boolean)
.join(' ')}
{...props}
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
{children}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
@@ -0,0 +1,450 @@
import { useState } from 'react'
import { Network, Router, Cpu, CheckCircle2, XCircle, Loader2, Plus } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { zigbeeApi } from '@/api/client'
import { toast } from 'sonner'
import type { ZigbeeNode, ZigbeeEdge } from './types'
interface ZigbeeImportModalProps {
open: boolean
onClose: () => void
onAddToCanvas: (nodes: ZigbeeNode[], edges: ZigbeeEdge[]) => void
onPendingImported?: (
coordinator?: { id: string; label: string; ieee_address: string } | null,
) => void
}
type ImportMode = 'pending' | 'canvas'
interface ConnectionForm {
mqtt_host: string
mqtt_port: string
mqtt_username: string
mqtt_password: string
base_topic: string
mqtt_tls: boolean
mqtt_tls_insecure: boolean
port_user_edited: boolean
}
const DEFAULT_FORM: ConnectionForm = {
mqtt_host: '',
mqtt_port: '1883',
mqtt_username: '',
mqtt_password: '',
base_topic: 'zigbee2mqtt',
mqtt_tls: false,
mqtt_tls_insecure: false,
port_user_edited: false,
}
const DEVICE_TYPE_ICON = {
zigbee_coordinator: Network,
zigbee_router: Router,
zigbee_enddevice: Cpu,
} as const
const DEVICE_TYPE_LABEL = {
zigbee_coordinator: 'Coordinator',
zigbee_router: 'Router',
zigbee_enddevice: 'End Device',
} as const
const DEVICE_TYPE_COLOR = {
zigbee_coordinator: '#00d4ff',
zigbee_router: '#39d353',
zigbee_enddevice: '#e3b341',
} as const
export function ZigbeeImportModal({ open, onClose, onAddToCanvas, onPendingImported }: ZigbeeImportModalProps) {
const [form, setForm] = useState<ConnectionForm>(DEFAULT_FORM)
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>('idle')
const [connectionMsg, setConnectionMsg] = useState('')
const [loading, setLoading] = useState(false)
const [devices, setDevices] = useState<ZigbeeNode[]>([])
const [edges, setEdges] = useState<ZigbeeEdge[]>([])
const [checked, setChecked] = useState<Set<string>>(new Set())
const [importMode, setImportMode] = useState<ImportMode>('pending')
const updateField = (field: keyof ConnectionForm, value: string) =>
setForm((f) => ({
...f,
[field]: value,
...(field === 'mqtt_port' ? { port_user_edited: true } : {}),
}))
const toggleTls = (next: boolean) =>
setForm((f) => {
const port = f.port_user_edited
? f.mqtt_port
: next
? '8883'
: '1883'
return {
...f,
mqtt_tls: next,
mqtt_tls_insecure: next ? f.mqtt_tls_insecure : false,
mqtt_port: port,
}
})
const buildPayload = () => ({
mqtt_host: form.mqtt_host.trim(),
mqtt_port: Number(form.mqtt_port) || (form.mqtt_tls ? 8883 : 1883),
mqtt_username: form.mqtt_username.trim() || undefined,
mqtt_password: form.mqtt_password || undefined,
base_topic: form.base_topic.trim() || 'zigbee2mqtt',
mqtt_tls: form.mqtt_tls,
mqtt_tls_insecure: form.mqtt_tls_insecure,
})
const handleTestConnection = async () => {
if (!form.mqtt_host.trim()) { toast.error('Enter a broker hostname'); return }
setConnectionStatus('testing')
try {
const res = await zigbeeApi.testConnection({
mqtt_host: form.mqtt_host.trim(),
mqtt_port: Number(form.mqtt_port) || (form.mqtt_tls ? 8883 : 1883),
mqtt_username: form.mqtt_username.trim() || undefined,
mqtt_password: form.mqtt_password || undefined,
mqtt_tls: form.mqtt_tls,
mqtt_tls_insecure: form.mqtt_tls_insecure,
})
if (res.data.connected) {
setConnectionStatus('ok')
setConnectionMsg(res.data.message)
} else {
setConnectionStatus('fail')
setConnectionMsg(res.data.message)
}
} catch {
setConnectionStatus('fail')
setConnectionMsg('Request failed — check broker address')
}
}
const extractError = (err: unknown): string | undefined => {
if (err && typeof err === 'object' && 'response' in err) {
return (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
}
return undefined
}
const handleFetchDevices = async () => {
if (!form.mqtt_host.trim()) { toast.error('Enter a broker hostname'); return }
setLoading(true)
try {
if (importMode === 'pending') {
await zigbeeApi.importToPending(buildPayload())
toast.success('Zigbee import started — track progress in Scan History')
onPendingImported?.(null)
handleClose()
} else {
const res = await zigbeeApi.importNetwork(buildPayload())
setDevices(res.data.nodes)
setEdges(res.data.edges)
setChecked(new Set(res.data.nodes.map((n) => n.id)))
if (res.data.device_count === 0) {
toast.info('No Zigbee devices found in the network map')
} else {
toast.success(`Found ${res.data.device_count} device${res.data.device_count !== 1 ? 's' : ''}`)
}
}
} catch (err: unknown) {
toast.error(extractError(err) ?? 'Failed to fetch Zigbee devices')
} finally {
setLoading(false)
}
}
const toggleCheck = (id: string) =>
setChecked((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
const toggleAll = () => {
setChecked(checked.size === devices.length ? new Set() : new Set(devices.map((d) => d.id)))
}
const handleAddToCanvas = () => {
const selectedDevices = devices.filter((d) => checked.has(d.id))
const selectedIds = new Set(selectedDevices.map((d) => d.id))
const selectedEdges = edges.filter((e) => selectedIds.has(e.source) && selectedIds.has(e.target))
onAddToCanvas(selectedDevices, selectedEdges)
toast.success(`Added ${selectedDevices.length} device${selectedDevices.length !== 1 ? 's' : ''} to canvas`)
onClose()
}
const handleClose = () => {
setDevices([])
setEdges([])
setChecked(new Set())
setConnectionStatus('idle')
setConnectionMsg('')
setImportMode('pending')
onClose()
}
const groupedDevices = {
zigbee_coordinator: devices.filter((d) => d.type === 'zigbee_coordinator'),
zigbee_router: devices.filter((d) => d.type === 'zigbee_router'),
zigbee_enddevice: devices.filter((d) => d.type === 'zigbee_enddevice'),
} as const
return (
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
<DialogContent className="bg-[#161b22] border-border max-w-xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="text-foreground flex items-center gap-2">
<Network size={16} className="text-[#00d4ff]" />
Zigbee2MQTT Import
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-4 py-2 min-h-0">
{/* Connection Form */}
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 space-y-1">
<Label className="text-xs text-muted-foreground">Broker Host</Label>
<Input
value={form.mqtt_host}
onChange={(e) => updateField('mqtt_host', e.target.value)}
placeholder="192.168.1.x or mqtt.local"
className="font-mono text-sm bg-[#0d1117] border-border"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Port</Label>
<Input
value={form.mqtt_port}
onChange={(e) => updateField('mqtt_port', e.target.value)}
placeholder="1883"
type="number"
className="font-mono text-sm bg-[#0d1117] border-border"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Base Topic</Label>
<Input
value={form.base_topic}
onChange={(e) => updateField('base_topic', e.target.value)}
placeholder="zigbee2mqtt"
className="font-mono text-sm bg-[#0d1117] border-border"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Username (optional)</Label>
<Input
value={form.mqtt_username}
onChange={(e) => updateField('mqtt_username', e.target.value)}
placeholder="mqtt_user"
className="text-sm bg-[#0d1117] border-border"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Password (optional)</Label>
<Input
value={form.mqtt_password}
onChange={(e) => updateField('mqtt_password', e.target.value)}
placeholder="••••••••"
type="password"
autoComplete="new-password"
className="text-sm bg-[#0d1117] border-border"
/>
</div>
<div className="col-span-2 flex items-center gap-4 pt-1">
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={form.mqtt_tls}
onChange={(e) => toggleTls(e.target.checked)}
className="w-3 h-3 accent-[#00d4ff] cursor-pointer"
/>
Use TLS (port 8883)
</label>
<label
className={`flex items-center gap-1.5 text-xs cursor-pointer ${
form.mqtt_tls ? 'text-[#f85149]' : 'text-muted-foreground/40 cursor-not-allowed'
}`}
>
<input
type="checkbox"
checked={form.mqtt_tls_insecure}
disabled={!form.mqtt_tls}
onChange={(e) =>
setForm((f) => ({ ...f, mqtt_tls_insecure: e.target.checked }))
}
className="w-3 h-3 accent-[#f85149] cursor-pointer disabled:cursor-not-allowed"
/>
Skip cert verify (self-signed only)
</label>
</div>
</div>
{/* Connection status indicator */}
{connectionStatus !== 'idle' && (
<div className={`flex items-center gap-1.5 text-xs px-2 py-1.5 rounded-md border ${
connectionStatus === 'ok'
? 'bg-[#39d353]/10 border-[#39d353]/30 text-[#39d353]'
: connectionStatus === 'fail'
? 'bg-[#f85149]/10 border-[#f85149]/30 text-[#f85149]'
: 'bg-[#e3b341]/10 border-[#e3b341]/30 text-[#e3b341]'
}`}>
{connectionStatus === 'testing' && <Loader2 size={12} className="animate-spin" />}
{connectionStatus === 'ok' && <CheckCircle2 size={12} />}
{connectionStatus === 'fail' && <XCircle size={12} />}
<span>{connectionStatus === 'testing' ? 'Testing…' : connectionMsg}</span>
</div>
)}
<div className="flex items-center gap-3 text-xs">
<span className="text-muted-foreground">Send devices to:</span>
<label className="flex items-center gap-1.5 cursor-pointer text-foreground">
<input
type="radio"
name="zigbee-import-mode"
checked={importMode === 'pending'}
onChange={() => setImportMode('pending')}
className="accent-[#00d4ff] cursor-pointer"
/>
Pending section
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-foreground">
<input
type="radio"
name="zigbee-import-mode"
checked={importMode === 'canvas'}
onChange={() => setImportMode('canvas')}
className="accent-[#00d4ff] cursor-pointer"
/>
Canvas directly
</label>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="ghost"
className="gap-1.5 text-muted-foreground hover:text-foreground border border-border hover:bg-[#21262d]"
onClick={handleTestConnection}
disabled={connectionStatus === 'testing' || loading}
>
{connectionStatus === 'testing'
? <Loader2 size={13} className="animate-spin" />
: <CheckCircle2 size={13} />}
Test Connection
</Button>
<Button
size="sm"
style={{ background: '#00d4ff', color: '#0d1117' }}
className="gap-1.5"
onClick={handleFetchDevices}
disabled={loading || connectionStatus === 'testing'}
>
{loading ? <Loader2 size={13} className="animate-spin" /> : <Network size={13} />}
{importMode === 'pending' ? 'Import to Pending' : 'Fetch Devices'}
</Button>
</div>
<p className="text-[11px] text-muted-foreground italic">
Fetching the network map can take several minutes on large meshes.
</p>
</div>
{/* Device List */}
{devices.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<input
type="checkbox"
checked={checked.size === devices.length}
ref={(el) => { if (el) el.indeterminate = checked.size > 0 && checked.size < devices.length }}
onChange={toggleAll}
className="w-3 h-3 accent-[#00d4ff] cursor-pointer"
title="Select all"
/>
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Devices ({checked.size}/{devices.length} selected)
</span>
</div>
</div>
{(Object.entries(groupedDevices) as [keyof typeof groupedDevices, ZigbeeNode[]][])
.filter(([, group]) => group.length > 0)
.map(([type, group]) => {
const Icon = DEVICE_TYPE_ICON[type]
const color = DEVICE_TYPE_COLOR[type]
return (
<div key={type}>
<div className="flex items-center gap-1.5 mb-1">
<Icon size={11} style={{ color }} />
<span className="text-[10px] font-medium uppercase tracking-wider" style={{ color }}>
{DEVICE_TYPE_LABEL[type]} ({group.length})
</span>
</div>
{group.map((device) => (
<div
key={device.id}
className={`flex items-start gap-2 p-2 mb-1 rounded-md text-xs cursor-pointer transition-colors border ${
checked.has(device.id)
? 'bg-[#21262d] border-[#00d4ff]/40'
: 'bg-[#21262d] border-transparent hover:bg-[#30363d]'
}`}
onClick={() => toggleCheck(device.id)}
>
<input
type="checkbox"
checked={checked.has(device.id)}
onChange={() => toggleCheck(device.id)}
onClick={(e) => e.stopPropagation()}
className="w-3 h-3 mt-0.5 accent-[#00d4ff] cursor-pointer shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="text-foreground font-medium truncate">{device.friendly_name}</div>
<div className="font-mono text-[10px] text-muted-foreground truncate">{device.ieee_address}</div>
{(device.model || device.vendor) && (
<div className="text-[10px] text-muted-foreground truncate">
{[device.vendor, device.model].filter(Boolean).join(' · ')}
</div>
)}
</div>
{device.lqi != null && (
<span
className="text-[9px] font-mono px-1 py-0.5 rounded border shrink-0"
style={{ color: '#8b949e', borderColor: '#8b949e40' }}
>
LQI {device.lqi}
</span>
)}
</div>
))}
</div>
)
})}
</div>
)}
</div>
<DialogFooter className="gap-2 shrink-0 pt-2 border-t border-border">
<Button variant="ghost" onClick={handleClose}>Cancel</Button>
{devices.length > 0 && (
<Button
onClick={handleAddToCanvas}
disabled={checked.size === 0}
style={{ background: '#00d4ff', color: '#0d1117' }}
className="gap-1.5"
>
<Plus size={13} />
Add {checked.size} to Canvas
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,223 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { ZigbeeImportModal } from '../ZigbeeImportModal'
vi.mock('@/api/client', () => ({
zigbeeApi: {
testConnection: vi.fn(),
importNetwork: vi.fn(),
importToPending: vi.fn(),
},
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
import { zigbeeApi } from '@/api/client'
import { toast } from 'sonner'
const defaultProps = {
open: true,
onClose: vi.fn(),
onAddToCanvas: vi.fn(),
}
const sampleNodes = [
{
id: '0x0000',
label: 'Coordinator',
type: 'zigbee_coordinator' as const,
ieee_address: '0x0000',
friendly_name: 'Coordinator',
device_type: 'Coordinator',
model: null,
vendor: null,
lqi: null,
parent_id: null,
},
{
id: '0x0001',
label: 'router_1',
type: 'zigbee_router' as const,
ieee_address: '0x0001',
friendly_name: 'router_1',
device_type: 'Router',
model: 'CC2530',
vendor: 'TI',
lqi: 200,
parent_id: '0x0000',
},
]
describe('ZigbeeImportModal', () => {
beforeEach(() => {
vi.mocked(zigbeeApi.testConnection).mockReset()
vi.mocked(zigbeeApi.importNetwork).mockReset()
vi.mocked(zigbeeApi.importToPending).mockReset()
vi.mocked(toast.success).mockReset()
vi.mocked(toast.error).mockReset()
vi.mocked(toast.info).mockReset()
defaultProps.onClose.mockReset()
defaultProps.onAddToCanvas.mockReset()
})
it('renders nothing when closed', () => {
const { container } = render(<ZigbeeImportModal {...defaultProps} open={false} />)
expect(container.querySelector('[role="dialog"]')).toBeNull()
})
it('renders the modal with form fields when open', () => {
render(<ZigbeeImportModal {...defaultProps} />)
expect(screen.getByText('Zigbee2MQTT Import')).toBeDefined()
expect(screen.getByPlaceholderText('192.168.1.x or mqtt.local')).toBeDefined()
expect(screen.getByPlaceholderText('1883')).toBeDefined()
expect(screen.getByPlaceholderText('zigbee2mqtt')).toBeDefined()
})
it('shows error toast when testing connection without a host', async () => {
render(<ZigbeeImportModal {...defaultProps} />)
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Enter a broker hostname')
})
expect(zigbeeApi.testConnection).not.toHaveBeenCalled()
})
it('shows success status when connection test passes', async () => {
vi.mocked(zigbeeApi.testConnection).mockResolvedValue({
data: { connected: true, message: 'Connection successful' },
} as never)
render(<ZigbeeImportModal {...defaultProps} />)
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
await waitFor(() => {
expect(screen.getByText('Connection successful')).toBeDefined()
})
})
it('shows failure status when connection test fails', async () => {
vi.mocked(zigbeeApi.testConnection).mockResolvedValue({
data: { connected: false, message: 'Connection refused' },
} as never)
render(<ZigbeeImportModal {...defaultProps} />)
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
fireEvent.change(hostInput, { target: { value: '10.0.0.1' } })
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
await waitFor(() => {
expect(screen.getByText('Connection refused')).toBeDefined()
})
})
const selectCanvasMode = () => {
fireEvent.click(screen.getByRole('radio', { name: /canvas directly/i }))
}
it('fetches devices and renders them grouped by type', async () => {
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
data: { nodes: sampleNodes, edges: [], device_count: 2 },
} as never)
render(<ZigbeeImportModal {...defaultProps} />)
selectCanvasMode()
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
await waitFor(() => {
expect(screen.getByText('Coordinator')).toBeDefined()
expect(screen.getByText('router_1')).toBeDefined()
})
expect(toast.success).toHaveBeenCalledWith('Found 2 devices')
})
it('shows info toast when no devices found', async () => {
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
data: { nodes: [], edges: [], device_count: 0 },
} as never)
render(<ZigbeeImportModal {...defaultProps} />)
selectCanvasMode()
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
await waitFor(() => {
expect(toast.info).toHaveBeenCalledWith('No Zigbee devices found in the network map')
})
})
it('calls onAddToCanvas with selected devices and closes modal', async () => {
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
data: { nodes: sampleNodes, edges: [{ source: '0x0000', target: '0x0001' }], device_count: 2 },
} as never)
render(<ZigbeeImportModal {...defaultProps} />)
selectCanvasMode()
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
await waitFor(() => screen.getByText('Coordinator'))
// Click "Add N to Canvas" button
const addBtn = screen.getByRole('button', { name: /add.*canvas/i })
fireEvent.click(addBtn)
await waitFor(() => {
expect(defaultProps.onAddToCanvas).toHaveBeenCalledOnce()
expect(defaultProps.onClose).toHaveBeenCalledOnce()
})
})
it('calls onClose when Cancel is clicked', () => {
render(<ZigbeeImportModal {...defaultProps} />)
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(defaultProps.onClose).toHaveBeenCalledOnce()
})
it('imports to pending by default and notifies parent', async () => {
vi.mocked(zigbeeApi.importToPending).mockResolvedValue({
data: {
id: 'run-1',
status: 'running',
kind: 'zigbee',
ranges: ['192.168.1.100:1883'],
devices_found: 0,
started_at: '2026-01-01T00:00:00Z',
finished_at: null,
error: null,
},
} as never)
const onPendingImported = vi.fn()
render(<ZigbeeImportModal {...defaultProps} onPendingImported={onPendingImported} />)
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
fireEvent.click(screen.getByRole('button', { name: /import to pending/i }))
await waitFor(() => {
expect(zigbeeApi.importToPending).toHaveBeenCalled()
expect(onPendingImported).toHaveBeenCalled()
expect(defaultProps.onClose).toHaveBeenCalled()
})
expect(zigbeeApi.importNetwork).not.toHaveBeenCalled()
})
it('switching to canvas mode calls importNetwork and not importToPending', async () => {
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
data: { nodes: sampleNodes, edges: [], device_count: 2 },
} as never)
render(<ZigbeeImportModal {...defaultProps} />)
fireEvent.click(screen.getByRole('radio', { name: /canvas directly/i }))
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
await waitFor(() => expect(zigbeeApi.importNetwork).toHaveBeenCalled())
expect(zigbeeApi.importToPending).not.toHaveBeenCalled()
})
})
+37
View File
@@ -0,0 +1,37 @@
/** Shared Zigbee type definitions for the frontend. */
export interface ZigbeeNode {
id: string
label: string
type: 'zigbee_coordinator' | 'zigbee_router' | 'zigbee_enddevice'
ieee_address: string
friendly_name: string
device_type: string
model?: string | null
vendor?: string | null
lqi?: number | null
parent_id?: string | null
}
export interface ZigbeeEdge {
source: string
target: string
}
export interface ZigbeeImportResponse {
nodes: ZigbeeNode[]
edges: ZigbeeEdge[]
device_count: number
}
export interface ZigbeeTestConnectionRequest {
mqtt_host: string
mqtt_port: number
mqtt_username?: string
mqtt_password?: string
}
export interface ZigbeeTestConnectionResponse {
connected: boolean
message: string
}
File diff suppressed because one or more lines are too long
+122
View File
@@ -0,0 +1,122 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useReactFlow, type Node, type OnNodeDrag } from '@xyflow/react'
import { computeSnap, unionBox, type Box, type Guide } from '@/utils/alignment'
import {
type AlignmentSettings,
readAlignmentSettings,
writeAlignmentSettings,
subscribeAlignmentSettings,
} from '@/utils/alignmentSettings'
import type { NodeData } from '@/types'
type NodeDrag = OnNodeDrag<Node<NodeData>>
function nodeBox(n: Node<NodeData>): Box | null {
// Skip parented nodes — alignment between absolute and parent-relative
// coordinates is misleading. Top-level nodes only for v1.
if (n.parentId) return null
const width = n.measured?.width ?? n.width ?? null
const height = n.measured?.height ?? n.height ?? null
if (width == null || height == null) return null
return { id: n.id, x: n.position.x, y: n.position.y, width, height }
}
/**
* Drag-time alignment guides + snap (draw.io / Figma style).
*
* Snap is applied on drag stop (not during drag) to avoid racing React Flow's
* internal drag handler, which computes positions from the cursor offset
* captured at drag start. Guides update live for visual feedback.
*
* Settings (enabled, threshold) live in localStorage and propagate same-tab
* via a CustomEvent so the panel and the hook stay in sync.
*/
export function useAlignmentGuides() {
const [settings, setSettings] = useState<AlignmentSettings>(readAlignmentSettings)
const [guides, setGuides] = useState<Guide[]>([])
// ref mirror of guides so callbacks don't need it in their deps
const guidesRef = useRef<Guide[]>([])
useEffect(() => { guidesRef.current = guides }, [guides])
// latest snap deltas, applied on drag stop
const pendingSnapRef = useRef<{ deltaX: number; deltaY: number; ids: Set<string> } | null>(null)
const altDownRef = useRef(false)
const { setNodes, getNodes } = useReactFlow<Node<NodeData>>()
useEffect(() => subscribeAlignmentSettings(setSettings), [])
useEffect(() => {
const down = (e: KeyboardEvent) => { if (e.altKey) altDownRef.current = true }
const up = (e: KeyboardEvent) => { if (!e.altKey) altDownRef.current = false }
window.addEventListener('keydown', down)
window.addEventListener('keyup', up)
return () => {
window.removeEventListener('keydown', down)
window.removeEventListener('keyup', up)
}
}, [])
const update = useCallback((patch: Partial<AlignmentSettings>) => {
setSettings((s) => {
const next = { ...s, ...patch }
writeAlignmentSettings(next)
return next
})
}, [])
const clearState = useCallback(() => {
if (guidesRef.current.length > 0) setGuides([])
pendingSnapRef.current = null
}, [])
const onNodeDrag: NodeDrag = useCallback((_event, dragNode, dragNodes) => {
if (!settings.enabled || altDownRef.current) {
clearState()
return
}
const all = getNodes()
const draggedIds = new Set((dragNodes.length > 0 ? dragNodes : [dragNode]).map((n) => n.id))
// Restrict the snap to top-level dragged nodes. nodeBox returns null for
// parented nodes; if we kept their ids in pendingSnap, onNodeDragStop
// would shift their parent-relative position by the same delta the parent
// already moves by, double-snapping the child. Children follow the parent
// automatically; no extra shift needed.
const draggedBoxEntries = all
.filter((n) => draggedIds.has(n.id))
.map((n) => ({ id: n.id, box: nodeBox(n) }))
.filter((e): e is { id: string; box: Box } => e.box !== null)
if (draggedBoxEntries.length === 0) {
clearState()
return
}
const snapIds = new Set(draggedBoxEntries.map((e) => e.id))
const boxes = draggedBoxEntries.map((e) => e.box)
const dragged = boxes.length === 1 ? boxes[0] : unionBox(boxes)
if (!dragged) return
const candidates = all
.filter((n) => !draggedIds.has(n.id))
.map(nodeBox)
.filter((b): b is Box => b !== null)
const result = computeSnap(dragged, candidates, settings.threshold)
setGuides(result.guides)
pendingSnapRef.current =
result.deltaX !== 0 || result.deltaY !== 0
? { deltaX: result.deltaX, deltaY: result.deltaY, ids: snapIds }
: null
}, [settings.enabled, settings.threshold, getNodes, clearState])
const onNodeDragStop: NodeDrag = useCallback(() => {
const pending = pendingSnapRef.current
if (pending) {
setNodes((ns) =>
ns.map((n) =>
pending.ids.has(n.id)
? { ...n, position: { x: n.position.x + pending.deltaX, y: n.position.y + pending.deltaY } }
: n,
),
)
}
clearState()
}, [setNodes, clearState])
return { guides, settings, update, onNodeDrag, onNodeDragStop }
}
@@ -27,12 +27,71 @@ describe('canvasStore', () => {
selectedNodeId: null,
selectedNodeIds: [],
editingGroupRectId: null,
editingTextId: null,
past: [],
future: [],
clipboard: [],
})
})
it('setEditingTextId sets and clears editing text id', () => {
const { setEditingTextId } = useCanvasStore.getState()
setEditingTextId('t1')
expect(useCanvasStore.getState().editingTextId).toBe('t1')
setEditingTextId(null)
expect(useCanvasStore.getState().editingTextId).toBeNull()
})
it('addNode supports text type with text_content and style', () => {
const { addNode } = useCanvasStore.getState()
const textNode: Node<NodeData> = {
id: 't1',
type: 'text',
position: { x: 50, y: 50 },
data: {
label: '',
type: 'text',
status: 'unknown',
services: [],
text_content: 'Hello world',
custom_colors: {
text_color: '#ffffff',
text_size: 24,
font: 'mono',
border_style: 'dashed',
border_width: 2,
border: '#00d4ff',
background: '#00000000',
},
},
width: 200,
height: 60,
}
addNode(textNode)
const stored = useCanvasStore.getState().nodes.find((n) => n.id === 't1')
expect(stored?.type).toBe('text')
expect(stored?.data.text_content).toBe('Hello world')
expect(stored?.data.custom_colors?.text_size).toBe(24)
expect(stored?.data.custom_colors?.border_style).toBe('dashed')
})
it('updateNode updates text_content and custom_colors on a text node', () => {
const { addNode, updateNode } = useCanvasStore.getState()
addNode({ ...makeNode('t1', { type: 'text', text_content: 'old' }), type: 'text' })
updateNode('t1', { text_content: 'new', custom_colors: { text_color: '#ff0000', text_size: 32 } })
const stored = useCanvasStore.getState().nodes.find((n) => n.id === 't1')
expect(stored?.data.text_content).toBe('new')
expect(stored?.data.custom_colors?.text_color).toBe('#ff0000')
expect(stored?.data.custom_colors?.text_size).toBe(32)
})
it('deleteNode removes a text node', () => {
const { addNode, deleteNode } = useCanvasStore.getState()
addNode({ ...makeNode('t1', { type: 'text' }), type: 'text' })
deleteNode('t1')
expect(useCanvasStore.getState().nodes).toHaveLength(0)
})
it('starts empty', () => {
const { nodes, edges, hasUnsavedChanges } = useCanvasStore.getState()
expect(nodes).toHaveLength(0)
@@ -49,6 +108,32 @@ describe('canvasStore', () => {
expect(hasUnsavedChanges).toBe(true)
})
it('addNode nests under parent only when parent is in container mode', () => {
const parent = { ...makeNode('p1', { container_mode: false }), position: { x: 100, y: 100 } }
const child = { ...makeNode('c1', { parent_id: 'p1' }), position: { x: 150, y: 180 } }
useCanvasStore.getState().addNode(parent)
useCanvasStore.getState().addNode(child)
const childNode = useCanvasStore.getState().nodes.find((n) => n.id === 'c1')
expect(childNode?.parentId).toBeUndefined()
useCanvasStore.getState().updateNode('p1', { container_mode: true })
useCanvasStore.getState().setProxmoxContainerMode('p1', true)
const nested = useCanvasStore.getState().nodes.find((n) => n.id === 'c1')
expect(nested?.parentId).toBe('p1')
expect(nested?.extent).toBe('parent')
})
it('docker_container nests under docker_host with container_mode on', () => {
const host = { ...makeNode('dh1', { type: 'docker_host', container_mode: true }), position: { x: 100, y: 100 } }
const container = { ...makeNode('dc1', { type: 'docker_container' }), position: { x: 160, y: 180 } }
useCanvasStore.getState().addNode(host)
useCanvasStore.getState().addNode(container)
useCanvasStore.getState().updateNode('dc1', { parent_id: 'dh1' })
const node = useCanvasStore.getState().nodes.find((n) => n.id === 'dc1')
expect(node?.parentId).toBe('dh1')
expect(node?.extent).toBe('parent')
})
it('updateNode updates data fields', () => {
useCanvasStore.getState().addNode(makeNode('n1', { label: 'old' }))
useCanvasStore.getState().updateNode('n1', { label: 'new', ip: '10.0.0.1' })
@@ -84,7 +169,7 @@ describe('canvasStore', () => {
it('updateNode clearing parent_id converts position to absolute and clears parentId', () => {
const proxmox = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 100, y: 100 } }
const lxc = { ...makeNode('lxc1', { type: 'lxc', parent_id: 'px1' }), position: { x: 30, y: 40 }, parentId: 'px1', extent: 'parent' as const }
const lxc = { ...makeNode('lxc1', { type: 'lxc', parent_id: 'px1' }), position: { x: 130, y: 140 }, parentId: 'px1', extent: 'parent' as const }
useCanvasStore.getState().addNode(proxmox)
useCanvasStore.getState().addNode(lxc)
useCanvasStore.getState().updateNode('lxc1', { parent_id: undefined })
@@ -214,7 +299,7 @@ describe('canvasStore', () => {
})
it('deleteNode also removes children with matching parentId', () => {
useCanvasStore.getState().addNode(makeNode('parent'))
useCanvasStore.getState().addNode(makeNode('parent', { container_mode: true }))
useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' }))
useCanvasStore.getState().deleteNode('parent')
const { nodes } = useCanvasStore.getState()
@@ -222,8 +307,8 @@ describe('canvasStore', () => {
expect(nodes.find((n) => n.id === 'child')).toBeUndefined()
})
it('addNode with parent_id sets parentId and extent', () => {
useCanvasStore.getState().addNode(makeNode('parent'))
it('addNode with parent_id sets parentId and extent when parent is in container mode', () => {
useCanvasStore.getState().addNode(makeNode('parent', { container_mode: true }))
useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' }))
const child = useCanvasStore.getState().nodes.find((n) => n.id === 'child')
expect(child?.parentId).toBe('parent')
@@ -422,6 +507,26 @@ describe('canvasStore', () => {
expect(hasUnsavedChanges).toBe(true)
})
it('setProxmoxContainerMode ON sets width/height for docker_host (not just proxmox)', () => {
const host: Node<NodeData> = { id: 'dh', type: 'docker_host', position: { x: 0, y: 0 }, data: { label: 'dh', type: 'docker_host', status: 'unknown', services: [], container_mode: false } }
useCanvasStore.setState({ nodes: [host] })
useCanvasStore.getState().setProxmoxContainerMode('dh', true)
const updated = useCanvasStore.getState().nodes.find((n) => n.id === 'dh')
expect(updated?.data.container_mode).toBe(true)
expect(updated?.width).toBe(300)
expect(updated?.height).toBe(200)
})
it('setProxmoxContainerMode OFF clears width/height for docker_host', () => {
const host: Node<NodeData> = { id: 'dh', type: 'docker_host', position: { x: 0, y: 0 }, width: 300, height: 200, data: { label: 'dh', type: 'docker_host', status: 'unknown', services: [], container_mode: true } }
useCanvasStore.setState({ nodes: [host] })
useCanvasStore.getState().setProxmoxContainerMode('dh', false)
const updated = useCanvasStore.getState().nodes.find((n) => n.id === 'dh')
expect(updated?.data.container_mode).toBe(false)
expect(updated?.width).toBeUndefined()
expect(updated?.height).toBeUndefined()
})
it('setProxmoxContainerMode ON nests children inside proxmox', () => {
const proxmox: Node<NodeData> = { id: 'px', type: 'proxmox', position: { x: 0, y: 0 }, data: { label: 'px', type: 'proxmox', status: 'unknown', services: [], container_mode: false } }
const child = makeNode('vm1', { parent_id: 'px', type: 'vm' })
@@ -619,6 +724,36 @@ describe('canvasStore', () => {
expect(stored?.height).toBeUndefined()
})
it('updateNode preserves height for group type when properties change (children must not vanish)', () => {
const group: Node<NodeData> = {
id: 'g1',
type: 'group',
position: { x: 0, y: 0 },
width: 360,
height: 240,
data: { label: 'Zone', type: 'group', status: 'unknown', services: [] },
}
useCanvasStore.setState({ nodes: [group], edges: [] })
useCanvasStore.getState().updateNode('g1', { properties: [] })
const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'g1')
expect(stored?.height).toBe(240)
expect(stored?.width).toBe(360)
})
it('updateNode preserves height for groupRect when properties change', () => {
const rect: Node<NodeData> = {
id: 'r1',
type: 'groupRect',
position: { x: 0, y: 0 },
width: 360,
height: 240,
data: { label: 'Rect', type: 'groupRect', status: 'unknown', services: [] },
}
useCanvasStore.setState({ nodes: [rect], edges: [] })
useCanvasStore.getState().updateNode('r1', { properties: [] })
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'r1')?.height).toBe(240)
})
// ── bottom_handles edge remapping ──────────────────────────────────────────
it('remaps source edges to "bottom" when bottom_handles is reduced', () => {
@@ -675,4 +810,118 @@ describe('canvasStore', () => {
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
expect(updated?.sourceHandle).toBe('bottom')
})
// Regression: handle cap raised from 4 to 48 — remap must scale.
it('remaps high-count handles when shrinking from 12 to 2', () => {
const node = makeNode('n1', { bottom_handles: 12 })
const edges = [
{ ...makeEdge('e2', 'n1', 'n2'), sourceHandle: 'bottom-2' },
{ ...makeEdge('e3', 'n1', 'n2'), sourceHandle: 'bottom-5' },
{ ...makeEdge('e12', 'n1', 'n2'), sourceHandle: 'bottom-12' },
]
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges })
useCanvasStore.getState().updateNode('n1', { bottom_handles: 2 })
const after = useCanvasStore.getState().edges
expect(after.find((e) => e.id === 'e2')?.sourceHandle).toBe('bottom-2')
expect(after.find((e) => e.id === 'e3')?.sourceHandle).toBe('bottom')
expect(after.find((e) => e.id === 'e12')?.sourceHandle).toBe('bottom')
})
})
describe('canvasStore — custom style apply', () => {
beforeEach(() => {
useCanvasStore.setState({
nodes: [],
edges: [],
hasUnsavedChanges: false,
selectedNodeId: null,
selectedNodeIds: [],
editingGroupRectId: null,
editingTextId: null,
past: [],
future: [],
clipboard: [],
})
})
const serverStyle = {
borderColor: '#ff0000',
borderOpacity: 1,
bgColor: '#111111',
bgOpacity: 1,
iconColor: '#ff0000',
iconOpacity: 1,
width: 220,
height: 90,
}
it('applyTypeNodeStyle updates matching nodes custom_colors', () => {
useCanvasStore.setState({
nodes: [makeNode('n1', { type: 'server' }), makeNode('n2', { type: 'proxmox' })],
edges: [],
})
useCanvasStore.getState().applyTypeNodeStyle('server', serverStyle)
const n1 = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')!
const n2 = useCanvasStore.getState().nodes.find((n) => n.id === 'n2')!
expect(n1.data.custom_colors?.border).toBe('#ff0000')
expect(n1.width).toBe(220)
expect(n1.height).toBe(90)
expect(n2.data.custom_colors?.border).toBeUndefined()
})
it('applyTypeNodeStyle with opacity < 1 produces rgba', () => {
useCanvasStore.setState({ nodes: [makeNode('n1', { type: 'server' })], edges: [] })
useCanvasStore.getState().applyTypeNodeStyle('server', { ...serverStyle, borderOpacity: 0.5 })
const n1 = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')!
expect(n1.data.custom_colors?.border).toMatch(/^rgba\(/)
})
it('applyTypeNodeStyle marks canvas unsaved', () => {
useCanvasStore.setState({ nodes: [makeNode('n1')], edges: [] })
useCanvasStore.getState().applyTypeNodeStyle('server', serverStyle)
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
it('applyTypeEdgeStyle updates matching edges', () => {
const e1: Edge<EdgeData> = { id: 'e1', source: 'n1', target: 'n2', type: 'ethernet', data: { type: 'ethernet' } }
const e2: Edge<EdgeData> = { id: 'e2', source: 'n1', target: 'n2', type: 'wifi', data: { type: 'wifi' } }
useCanvasStore.setState({ nodes: [], edges: [e1, e2] })
useCanvasStore.getState().applyTypeEdgeStyle('ethernet', { color: '#00ff00', opacity: 1, pathStyle: 'smooth', animated: 'flow' })
const updated1 = useCanvasStore.getState().edges.find((e) => e.id === 'e1')!
const updated2 = useCanvasStore.getState().edges.find((e) => e.id === 'e2')!
expect(updated1.data?.custom_color).toBe('#00ff00')
expect(updated1.data?.path_style).toBe('smooth')
expect(updated1.data?.animated).toBe('flow')
expect(updated2.data?.custom_color).toBeUndefined()
})
it('applyAllCustomStyles applies all defined types', () => {
const proxmoxNode = makeNode('np', { type: 'proxmox' })
const serverNode = makeNode('ns', { type: 'server' })
const e1: Edge<EdgeData> = { id: 'e1', source: 'np', target: 'ns', type: 'ethernet', data: { type: 'ethernet' } }
useCanvasStore.setState({ nodes: [proxmoxNode, serverNode], edges: [e1] })
useCanvasStore.getState().applyAllCustomStyles({
nodes: {
proxmox: { borderColor: '#ff6e00', borderOpacity: 1, bgColor: '#111', bgOpacity: 1, iconColor: '#ff6e00', iconOpacity: 1, width: 0, height: 0 },
},
edges: {
ethernet: { color: '#aabbcc', opacity: 1, pathStyle: 'bezier', animated: 'none' },
},
})
const np = useCanvasStore.getState().nodes.find((n) => n.id === 'np')!
const ns = useCanvasStore.getState().nodes.find((n) => n.id === 'ns')!
const e = useCanvasStore.getState().edges.find((e) => e.id === 'e1')!
expect(np.data.custom_colors?.border).toBe('#ff6e00')
expect(ns.data.custom_colors?.border).toBeUndefined()
expect(e.data?.custom_color).toBe('#aabbcc')
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
})
@@ -1,9 +1,10 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useThemeStore } from '@/stores/themeStore'
import type { CustomStyleDef } from '@/types'
describe('themeStore', () => {
beforeEach(() => {
useThemeStore.setState({ activeTheme: 'default' })
useThemeStore.setState({ activeTheme: 'default', customStyle: { nodes: {}, edges: {} } })
})
it('starts with default theme', () => {
@@ -15,8 +16,8 @@ describe('themeStore', () => {
expect(useThemeStore.getState().activeTheme).toBe('matrix')
})
it('setTheme can switch between all presets', () => {
const themes = ['default', 'dark', 'light', 'neon', 'matrix'] as const
it('setTheme can switch between all presets including custom', () => {
const themes = ['default', 'dark', 'light', 'neon', 'matrix', 'custom'] as const
for (const id of themes) {
useThemeStore.getState().setTheme(id)
expect(useThemeStore.getState().activeTheme).toBe(id)
@@ -28,4 +29,26 @@ describe('themeStore', () => {
useThemeStore.getState().setTheme('default')
expect(useThemeStore.getState().activeTheme).toBe('default')
})
it('starts with empty customStyle', () => {
const { customStyle } = useThemeStore.getState()
expect(customStyle.nodes).toEqual({})
expect(customStyle.edges).toEqual({})
})
it('setCustomStyle replaces the entire definition', () => {
const def: CustomStyleDef = {
nodes: { server: { borderColor: '#ff0000', borderOpacity: 1, bgColor: '#000000', bgOpacity: 1, iconColor: '#ff0000', iconOpacity: 1, width: 200, height: 80 } },
edges: { ethernet: { color: '#00ff00', opacity: 0.8, pathStyle: 'bezier', animated: 'none' } },
}
useThemeStore.getState().setCustomStyle(def)
expect(useThemeStore.getState().customStyle.nodes.server?.borderColor).toBe('#ff0000')
expect(useThemeStore.getState().customStyle.edges.ethernet?.color).toBe('#00ff00')
})
it('setCustomStyle with empty def clears styles', () => {
useThemeStore.getState().setCustomStyle({ nodes: { server: { borderColor: '#aaa', borderOpacity: 1, bgColor: '#000', bgOpacity: 1, iconColor: '#aaa', iconOpacity: 1, width: 0, height: 0 } }, edges: {} })
useThemeStore.getState().setCustomStyle({ nodes: {}, edges: {} })
expect(useThemeStore.getState().customStyle.nodes).toEqual({})
})
})
+125 -5
View File
@@ -9,9 +9,10 @@ import {
applyEdgeChanges,
addEdge,
} from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types'
import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef } from '@/types'
import { generateUUID } from '@/utils/uuid'
import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
import { applyOpacity } from '@/utils/colorUtils'
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
@@ -48,6 +49,8 @@ interface CanvasState {
setNodeZIndex: (id: string, zIndex: number) => void
editingGroupRectId: string | null
setEditingGroupRectId: (id: string | null) => void
editingTextId: string | null
setEditingTextId: (id: string | null) => void
createGroup: (nodeIds: string[], name: string) => void
ungroup: (groupId: string) => void
markSaved: () => void
@@ -58,6 +61,9 @@ interface CanvasState {
notifyScanDeviceFound: () => void
hideIp: boolean
toggleHideIp: () => void
applyTypeNodeStyle: (nodeType: NodeType, style: NodeTypeStyle) => void
applyTypeEdgeStyle: (edgeType: EdgeType, style: EdgeTypeStyle) => void
applyAllCustomStyles: (def: CustomStyleDef) => void
}
export const useCanvasStore = create<CanvasState>((set) => ({
@@ -67,6 +73,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
selectedNodeId: null,
selectedNodeIds: [],
editingGroupRectId: null,
editingTextId: null,
hideIp: false,
scanEventTs: 0,
fitViewPending: false,
@@ -172,8 +179,18 @@ export const useCanvasStore = create<CanvasState>((set) => ({
addNode: (node) =>
set((state) => {
const enriched = node.data.parent_id
? { ...node, parentId: node.data.parent_id, extent: 'parent' as const }
const parent = node.data.parent_id ? state.nodes.find((n) => n.id === node.data.parent_id) : null
const shouldNestInParent = !!(parent?.data.container_mode)
const enriched = node.data.parent_id && shouldNestInParent
? {
...node,
parentId: node.data.parent_id,
extent: 'parent' as const,
position: {
x: Math.max(10, node.position.x - parent.position.x),
y: Math.max(10, node.position.y - parent.position.y),
},
}
: node
// Parents must come before children in the array (React Flow requirement)
const withoutNew = state.nodes.filter((n) => n.id !== node.id)
@@ -192,7 +209,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
if (n.id !== id) return n
const updated: Node<NodeData> = { ...n, data: { ...n.data, ...data } }
// When properties change, clear stored height so the node auto-sizes to fit new content
if ('properties' in data && n.data.type !== 'proxmox' && n.data.type !== 'groupRect') {
if ('properties' in data && n.data.type !== 'proxmox' && n.data.type !== 'groupRect' && n.data.type !== 'group') {
updated.height = undefined
}
if ('parent_id' in data) {
@@ -283,14 +300,37 @@ export const useCanvasStore = create<CanvasState>((set) => ({
setProxmoxContainerMode: (proxmoxId, enabled) =>
set((state) => {
const parentNode = state.nodes.find((n) => n.id === proxmoxId)
let nodes = state.nodes.map((n) => {
if (n.id === proxmoxId) {
const withMode = { ...n, data: { ...n.data, container_mode: enabled } }
return enabled
? { ...withMode, width: 300, height: 200 }
? { ...withMode, width: n.width ?? 300, height: n.height ?? 200 }
: { ...withMode, width: undefined, height: undefined }
}
if (n.data.parent_id === proxmoxId) {
if (enabled && parentNode) {
return {
...n,
parentId: proxmoxId,
extent: 'parent' as const,
position: {
x: Math.max(10, n.position.x - parentNode.position.x),
y: Math.max(10, n.position.y - parentNode.position.y),
},
}
}
if (!enabled && parentNode) {
return {
...n,
parentId: undefined,
extent: undefined,
position: {
x: parentNode.position.x + n.position.x,
y: parentNode.position.y + n.position.y,
},
}
}
return enabled
? { ...n, parentId: proxmoxId, extent: 'parent' as const }
: { ...n, parentId: undefined, extent: undefined }
@@ -313,6 +353,8 @@ export const useCanvasStore = create<CanvasState>((set) => ({
setEditingGroupRectId: (id) => set({ editingGroupRectId: id }),
setEditingTextId: (id) => set({ editingTextId: id }),
createGroup: (nodeIds, name) =>
set((state) => {
const PADDING_H = 24
@@ -435,4 +477,82 @@ export const useCanvasStore = create<CanvasState>((set) => ({
},
clearFitViewPending: () => set({ fitViewPending: false }),
applyTypeNodeStyle: (nodeType, style) =>
set((state) => ({
nodes: state.nodes.map((n) => {
if (n.data.type !== nodeType) return n
return {
...n,
width: style.width > 0 ? style.width : n.width,
height: style.height > 0 ? style.height : n.height,
data: {
...n.data,
custom_colors: {
...n.data.custom_colors,
border: applyOpacity(style.borderColor, style.borderOpacity),
background: applyOpacity(style.bgColor, style.bgOpacity),
icon: applyOpacity(style.iconColor, style.iconOpacity),
},
},
}
}),
hasUnsavedChanges: true,
})),
applyTypeEdgeStyle: (edgeType, style) =>
set((state) => ({
edges: state.edges.map((e) => {
if ((e.data?.type ?? 'ethernet') !== edgeType) return e
return {
...e,
data: {
...e.data,
type: edgeType,
custom_color: applyOpacity(style.color, style.opacity),
path_style: style.pathStyle,
animated: style.animated,
} as EdgeData,
}
}),
hasUnsavedChanges: true,
})),
applyAllCustomStyles: (def) =>
set((state) => {
const nodes = state.nodes.map((n) => {
const style = def.nodes[n.data.type]
if (!style) return n
return {
...n,
width: style.width > 0 ? style.width : n.width,
height: style.height > 0 ? style.height : n.height,
data: {
...n.data,
custom_colors: {
...n.data.custom_colors,
border: applyOpacity(style.borderColor, style.borderOpacity),
background: applyOpacity(style.bgColor, style.bgOpacity),
icon: applyOpacity(style.iconColor, style.iconOpacity),
},
},
}
})
const edges = state.edges.map((e) => {
const edgeType = (e.data?.type ?? 'ethernet') as EdgeType
const style = def.edges[edgeType]
if (!style) return e
return {
...e,
data: {
...e.data,
type: edgeType,
custom_color: applyOpacity(style.color, style.opacity),
path_style: style.pathStyle,
animated: style.animated,
} as EdgeData,
}
})
return { nodes, edges, hasUnsavedChanges: true }
}),
}))
+5
View File
@@ -1,12 +1,17 @@
import { create } from 'zustand'
import type { ThemeId } from '@/utils/themes'
import type { CustomStyleDef } from '@/types'
interface ThemeState {
activeTheme: ThemeId
setTheme: (id: ThemeId) => void
customStyle: CustomStyleDef
setCustomStyle: (def: CustomStyleDef) => void
}
export const useThemeStore = create<ThemeState>((set) => ({
activeTheme: 'default',
setTheme: (id) => set({ activeTheme: id }),
customStyle: { nodes: {}, edges: {} },
setCustomStyle: (def) => set({ customStyle: def }),
}))
+1 -1
View File
@@ -4,7 +4,7 @@ import type { CheckMethod } from '@/types'
describe('NODE_TYPE_LABELS', () => {
it('has an entry for every node type', () => {
const expectedTypes = ['isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas', 'iot', 'ap', 'camera', 'generic']
const expectedTypes = ['isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas', 'iot', 'ap', 'camera', 'generic']
expectedTypes.forEach((t) => {
expect(NODE_TYPE_LABELS).toHaveProperty(t)
expect(typeof NODE_TYPE_LABELS[t as keyof typeof NODE_TYPE_LABELS]).toBe('string')
+41 -2
View File
@@ -1,6 +1,7 @@
export type NodeType =
| 'isp'
| 'router'
| 'firewall'
| 'switch'
| 'server'
| 'proxmox'
@@ -13,10 +14,15 @@ export type NodeType =
| 'printer'
| 'computer'
| 'cpl'
| 'docker'
| 'docker_host'
| 'docker_container'
| 'generic'
| 'groupRect'
| 'group'
| 'text'
| 'zigbee_coordinator'
| 'zigbee_router'
| 'zigbee_enddevice'
export type TextPosition =
| 'top-left'
@@ -77,6 +83,7 @@ export interface NodeData extends Record<string, unknown> {
border?: string
background?: string
icon?: string
show_services?: boolean
// Group rectangle extras (type === 'groupRect')
text_color?: string
text_position?: TextPosition
@@ -91,7 +98,10 @@ export interface NodeData extends Record<string, unknown> {
height?: number
}
custom_icon?: string
/** Number of bottom connection points, 1..48. Default 1 (centered). */
bottom_handles?: number
/** Text node content (type === 'text') */
text_content?: string
}
export type EdgePathStyle = 'bezier' | 'smooth'
@@ -115,6 +125,7 @@ export interface EdgeData extends Record<string, unknown> {
export const NODE_TYPE_LABELS: Record<NodeType, string> = {
isp: 'ISP / Modem',
router: 'Router',
firewall: 'Firewall',
switch: 'Switch',
server: 'Server',
proxmox: 'Proxmox VE',
@@ -127,10 +138,15 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
printer: 'Printer',
computer: 'Computer',
cpl: 'CPL / Powerline',
docker: 'Docker Host',
docker_host: 'Docker Host',
docker_container: 'Docker Container',
generic: 'Generic Device',
groupRect: 'Group Rectangle',
group: 'Node Group',
text: 'Text',
zigbee_coordinator: 'Zigbee Coordinator',
zigbee_router: 'Zigbee Router',
zigbee_enddevice: 'Zigbee End Device',
}
export const STATUS_COLORS: Record<NodeStatus, string> = {
@@ -148,3 +164,26 @@ export const EDGE_TYPE_LABELS: Record<EdgeType, string> = {
virtual: 'Virtual',
cluster: 'Cluster',
}
export interface NodeTypeStyle {
borderColor: string
borderOpacity: number
bgColor: string
bgOpacity: number
iconColor: string
iconOpacity: number
width: number
height: number
}
export interface EdgeTypeStyle {
color: string
opacity: number
pathStyle: EdgePathStyle
animated: 'none' | 'snake' | 'flow' | 'basic'
}
export interface CustomStyleDef {
nodes: Partial<Record<NodeType, NodeTypeStyle>>
edges: Partial<Record<EdgeType, EdgeTypeStyle>>
}
@@ -0,0 +1,110 @@
import { describe, it, expect } from 'vitest'
import { computeSnap, unionBox, type Box } from '../alignment'
const box = (id: string, x: number, y: number, width = 100, height = 50): Box => ({ id, x, y, width, height })
describe('computeSnap', () => {
it('returns zero deltas with no candidates', () => {
const r = computeSnap(box('a', 0, 0), [], 6)
expect(r).toEqual({ deltaX: 0, deltaY: 0, guides: [] })
})
it('snaps left edges when within threshold (same-size boxes show all aligned guides)', () => {
const dragged = box('a', 102, 200) // left=102
const other = box('b', 100, 0) // left=100 → delta -2 → all three edges tie
const r = computeSnap(dragged, [other], 6)
expect(r.deltaX).toBe(-2)
expect(r.deltaY).toBe(0)
// Same width: left/center/right all tie at delta -2. Draw all three guides.
expect(r.guides.map((g) => g.position).sort((a, b) => a - b)).toEqual([100, 150, 200])
expect(r.guides.every((g) => g.axis === 'x')).toBe(true)
})
it('shows a single guide when only one edge aligns', () => {
const dragged = box('a', 102, 200, 80, 50) // left=102, center=142, right=182
const other = box('b', 100, 0, 100, 50) // left=100, center=150, right=200
const r = computeSnap(dragged, [other], 6)
expect(r.deltaX).toBe(-2) // left↔left wins
expect(r.guides).toHaveLength(1)
expect(r.guides[0].position).toBe(100)
})
it('does not snap when outside threshold', () => {
const dragged = box('a', 110, 200)
const other = box('b', 100, 0)
const r = computeSnap(dragged, [other], 6)
expect(r.deltaX).toBe(0)
expect(r.guides).toHaveLength(0)
})
it('snaps centerX to centerX', () => {
const dragged = box('a', 51, 300, 100, 50) // center=101
const other = box('b', 75, 0, 50, 50) // center=100 → delta -1
const r = computeSnap(dragged, [other], 6)
expect(r.deltaX).toBe(-1)
expect(r.guides).toHaveLength(1)
expect(r.guides[0].position).toBe(100)
})
it('snaps right edge of dragged to left edge of candidate', () => {
const dragged = box('a', 0, 200) // right=100
const other = box('b', 102, 0) // left=102 → delta +2
const r = computeSnap(dragged, [other], 6)
expect(r.deltaX).toBe(2)
})
it('snaps both axes simultaneously', () => {
const dragged = box('a', 102, 53, 80, 30)
const other = box('b', 100, 50, 100, 50)
const r = computeSnap(dragged, [other], 6)
expect(r.deltaX).toBe(-2)
expect(r.deltaY).toBe(-3)
// Distinct sizes → exactly one X guide and one Y guide.
expect(r.guides.filter((g) => g.axis === 'x')).toHaveLength(1)
expect(r.guides.filter((g) => g.axis === 'y')).toHaveLength(1)
})
it('picks the closest match when multiple candidates compete', () => {
const dragged = box('a', 103, 0)
const closer = box('b', 100, 0) // delta -3
const farther = box('c', 109, 0) // delta +6 — also within threshold but worse
const r = computeSnap(dragged, [closer, farther], 6)
expect(r.deltaX).toBe(-3)
})
it('ignores the dragged box itself when present in candidates', () => {
const dragged = box('a', 102, 0)
const r = computeSnap(dragged, [dragged, box('b', 100, 0)], 6)
expect(r.deltaX).toBe(-2)
})
it('returns no snap with non-positive threshold', () => {
const r = computeSnap(box('a', 100, 0), [box('b', 100, 0)], 0)
expect(r.guides).toEqual([])
})
it('guide span covers both involved boxes', () => {
const dragged = box('a', 102, 200, 100, 50) // y range 200..250
const other = box('b', 100, 0, 100, 50) // y range 0..50
const r = computeSnap(dragged, [other], 6)
const g = r.guides[0]
expect(g.start).toBeLessThanOrEqual(0)
expect(g.end).toBeGreaterThanOrEqual(250)
})
})
describe('unionBox', () => {
it('returns null for empty input', () => {
expect(unionBox([])).toBeNull()
})
it('returns the box itself for one input', () => {
const b = box('a', 10, 20, 30, 40)
expect(unionBox([b])).toMatchObject({ x: 10, y: 20, width: 30, height: 40 })
})
it('computes union of multiple boxes', () => {
const u = unionBox([box('a', 0, 0, 50, 50), box('b', 100, 100, 50, 50)])
expect(u).toMatchObject({ x: 0, y: 0, width: 150, height: 150 })
})
})
@@ -0,0 +1,42 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
DEFAULT_ALIGNMENT_SETTINGS,
readAlignmentSettings,
writeAlignmentSettings,
subscribeAlignmentSettings,
} from '../alignmentSettings'
describe('alignmentSettings', () => {
beforeEach(() => {
localStorage.clear()
})
it('returns defaults when nothing stored', () => {
expect(readAlignmentSettings()).toEqual(DEFAULT_ALIGNMENT_SETTINGS)
})
it('roundtrips through localStorage', () => {
writeAlignmentSettings({ enabled: false, threshold: 10 })
expect(readAlignmentSettings()).toEqual({ enabled: false, threshold: 10 })
})
it('falls back to defaults when stored value is corrupted', () => {
localStorage.setItem('homelable.alignmentGuides', '{not json')
expect(readAlignmentSettings()).toEqual(DEFAULT_ALIGNMENT_SETTINGS)
})
it('fills missing fields from defaults', () => {
localStorage.setItem('homelable.alignmentGuides', JSON.stringify({ enabled: false }))
expect(readAlignmentSettings()).toEqual({ enabled: false, threshold: DEFAULT_ALIGNMENT_SETTINGS.threshold })
})
it('notifies subscribers on write', () => {
const listener = vi.fn()
const unsubscribe = subscribeAlignmentSettings(listener)
writeAlignmentSettings({ enabled: false, threshold: 8 })
expect(listener).toHaveBeenCalledWith({ enabled: false, threshold: 8 })
unsubscribe()
writeAlignmentSettings({ enabled: true, threshold: 4 })
expect(listener).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest'
import {
BRAND_ICON_PREFIX,
isBrandIconKey,
brandIconSlug,
brandIconUrl,
resolveCustomIcon,
ICON_MAP,
} from '../nodeIcons'
describe('brand icon helpers', () => {
it('isBrandIconKey returns true only for prefixed keys', () => {
expect(isBrandIconKey('brand:plex')).toBe(true)
expect(isBrandIconKey('plex')).toBe(false)
expect(isBrandIconKey('plug')).toBe(false)
expect(isBrandIconKey(undefined)).toBe(false)
expect(isBrandIconKey(null)).toBe(false)
expect(isBrandIconKey('')).toBe(false)
})
it('brandIconSlug strips the prefix', () => {
expect(brandIconSlug(`${BRAND_ICON_PREFIX}home-assistant`)).toBe('home-assistant')
})
it('brandIconUrl points at jsDelivr CDN', () => {
expect(brandIconUrl('plex')).toBe(
'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg',
)
})
})
describe('resolveCustomIcon', () => {
it('returns null when no key', () => {
expect(resolveCustomIcon(undefined)).toBeNull()
expect(resolveCustomIcon('')).toBeNull()
})
it('resolves legacy lucide keys', () => {
const r = resolveCustomIcon('plug')
expect(r?.kind).toBe('lucide')
if (r?.kind === 'lucide') expect(r.icon).toBe(ICON_MAP['plug'])
})
it('resolves brand-prefixed keys to a CDN url', () => {
const r = resolveCustomIcon('brand:plex')
expect(r?.kind).toBe('brand')
if (r?.kind === 'brand') {
expect(r.slug).toBe('plex')
expect(r.url).toContain('cdn.jsdelivr.net')
expect(r.url).toContain('/plex.svg')
}
})
it('returns null for unknown legacy key', () => {
expect(resolveCustomIcon('definitely-not-a-known-icon-key')).toBeNull()
})
})
@@ -412,3 +412,24 @@ describe('round-trip: serialize → deserialize', () => {
expect(restored.height).toBe(300)
})
})
// ── text nodes survive save+reload (regression for #lost text node) ──────────
describe('serializeNode — text node roundtrip', () => {
const emptyMap = new Map<string, boolean>()
it('persists text content through label so it survives reload', () => {
const node = makeRfNode({
type: 'text',
data: {
label: 'Hello world',
type: 'text',
status: 'unknown',
services: [],
},
})
const serialized = serializeNode(node) as ApiNode
expect(serialized.label).toBe('Hello world')
const restored = deserializeApiNode(serialized, emptyMap)
expect(restored.data.label).toBe('Hello world')
})
})
@@ -1,51 +1,105 @@
import { describe, it, expect } from 'vitest'
import {
BOTTOM_HANDLE_IDS,
BOTTOM_HANDLE_POSITIONS,
MIN_BOTTOM_HANDLES,
MAX_BOTTOM_HANDLES,
bottomHandleId,
bottomHandlePositions,
clampBottomHandles,
normalizeHandle,
removedBottomHandleIds,
} from '../handleUtils'
describe('BOTTOM_HANDLE_IDS', () => {
describe('bottomHandleId', () => {
it('first id is always "bottom" for backward compatibility', () => {
expect(BOTTOM_HANDLE_IDS[0]).toBe('bottom')
expect(bottomHandleId(0)).toBe('bottom')
})
it('has ids for 14 handles', () => {
expect(BOTTOM_HANDLE_IDS).toHaveLength(4)
expect(BOTTOM_HANDLE_IDS).toEqual(['bottom', 'bottom-2', 'bottom-3', 'bottom-4'])
it('subsequent ids follow bottom-N pattern (1-indexed shift)', () => {
expect(bottomHandleId(1)).toBe('bottom-2')
expect(bottomHandleId(2)).toBe('bottom-3')
expect(bottomHandleId(3)).toBe('bottom-4')
expect(bottomHandleId(11)).toBe('bottom-12')
expect(bottomHandleId(47)).toBe('bottom-48')
})
})
describe('BOTTOM_HANDLE_POSITIONS', () => {
describe('clampBottomHandles', () => {
it('clamps below MIN to MIN', () => {
expect(clampBottomHandles(0)).toBe(MIN_BOTTOM_HANDLES)
expect(clampBottomHandles(-5)).toBe(MIN_BOTTOM_HANDLES)
})
it('clamps above MAX to MAX', () => {
expect(clampBottomHandles(49)).toBe(MAX_BOTTOM_HANDLES)
expect(clampBottomHandles(9999)).toBe(MAX_BOTTOM_HANDLES)
})
it('returns MIN for non-finite or non-number', () => {
expect(clampBottomHandles(NaN)).toBe(MIN_BOTTOM_HANDLES)
expect(clampBottomHandles(Infinity)).toBe(MIN_BOTTOM_HANDLES)
expect(clampBottomHandles('4' as unknown)).toBe(MIN_BOTTOM_HANDLES)
expect(clampBottomHandles(undefined)).toBe(MIN_BOTTOM_HANDLES)
})
it('floors fractional values', () => {
expect(clampBottomHandles(3.9)).toBe(3)
})
it('passes valid integers through', () => {
expect(clampBottomHandles(1)).toBe(1)
expect(clampBottomHandles(24)).toBe(24)
expect(clampBottomHandles(48)).toBe(48)
})
})
describe('bottomHandlePositions — backward-compat lock for 1..4', () => {
// These exact arrays are the pre-multi-handle hand-tuned positions.
// Existing user canvases depend on them — do NOT change.
it('1 handle is centered at 50%', () => {
expect(BOTTOM_HANDLE_POSITIONS[1]).toEqual([50])
expect(bottomHandlePositions(1)).toEqual([50])
})
it('2 handles are symmetric', () => {
const [a, b] = BOTTOM_HANDLE_POSITIONS[2]
expect(a).toBeLessThan(50)
expect(b).toBeGreaterThan(50)
expect(a + b).toBe(100)
it('2 handles use exact prior positions', () => {
expect(bottomHandlePositions(2)).toEqual([25, 75])
})
it('3 handles include a center at 50%', () => {
expect(BOTTOM_HANDLE_POSITIONS[3]).toContain(50)
expect(BOTTOM_HANDLE_POSITIONS[3]).toHaveLength(3)
it('3 handles use exact prior positions', () => {
expect(bottomHandlePositions(3)).toEqual([20, 50, 80])
})
it('4 handles are evenly spaced', () => {
const pos = BOTTOM_HANDLE_POSITIONS[4]
expect(pos).toHaveLength(4)
// All values should be between 0 and 100 exclusive
it('4 handles use exact prior positions', () => {
expect(bottomHandlePositions(4)).toEqual([15, 38, 62, 85])
})
})
describe('bottomHandlePositions — uniform spacing for ≥5', () => {
it('returns count entries', () => {
expect(bottomHandlePositions(5)).toHaveLength(5)
expect(bottomHandlePositions(12)).toHaveLength(12)
expect(bottomHandlePositions(48)).toHaveLength(48)
})
it('all positions strictly between 0 and 100', () => {
const pos = bottomHandlePositions(48)
pos.forEach((p) => {
expect(p).toBeGreaterThan(0)
expect(p).toBeLessThan(100)
})
// Positions should be strictly increasing
})
it('positions are strictly increasing and uniform', () => {
const pos = bottomHandlePositions(12)
for (let i = 1; i < pos.length; i++) {
expect(pos[i]).toBeGreaterThan(pos[i - 1])
}
const step = 100 / 13
expect(pos[0]).toBeCloseTo(step, 5)
expect(pos[11]).toBeCloseTo(step * 12, 5)
})
it('clamps out-of-range counts before computing', () => {
expect(bottomHandlePositions(0)).toEqual([50])
expect(bottomHandlePositions(99)).toHaveLength(MAX_BOTTOM_HANDLES)
})
})
@@ -63,16 +117,10 @@ describe('normalizeHandle', () => {
expect(normalizeHandle('bottom-t')).toBe('bottom')
})
it('maps bottom-2-t → bottom-2', () => {
it('maps bottom-N-t → bottom-N for any N', () => {
expect(normalizeHandle('bottom-2-t')).toBe('bottom-2')
})
it('maps bottom-3-t → bottom-3', () => {
expect(normalizeHandle('bottom-3-t')).toBe('bottom-3')
})
it('maps bottom-4-t → bottom-4', () => {
expect(normalizeHandle('bottom-4-t')).toBe('bottom-4')
expect(normalizeHandle('bottom-12-t')).toBe('bottom-12')
expect(normalizeHandle('bottom-48-t')).toBe('bottom-48')
})
it('passes through non-stub handles unchanged', () => {
@@ -90,22 +138,35 @@ describe('removedBottomHandleIds', () => {
})
it('4 → 1 removes bottom-2, bottom-3, bottom-4', () => {
const removed = removedBottomHandleIds(4, 1)
expect(removed).toEqual(new Set(['bottom-2', 'bottom-3', 'bottom-4']))
expect(removedBottomHandleIds(4, 1)).toEqual(new Set(['bottom-2', 'bottom-3', 'bottom-4']))
})
it('4 → 2 removes bottom-3, bottom-4', () => {
const removed = removedBottomHandleIds(4, 2)
expect(removed).toEqual(new Set(['bottom-3', 'bottom-4']))
expect(removedBottomHandleIds(4, 2)).toEqual(new Set(['bottom-3', 'bottom-4']))
})
it('3 → 2 removes only bottom-3', () => {
const removed = removedBottomHandleIds(3, 2)
expect(removed).toEqual(new Set(['bottom-3']))
expect(removedBottomHandleIds(3, 2)).toEqual(new Set(['bottom-3']))
})
it('never removes "bottom" (index 0)', () => {
const removed = removedBottomHandleIds(4, 1)
expect(removedBottomHandleIds(4, 1).has('bottom')).toBe(false)
})
// Regression: scaling the cap from 4 → 48 must not break the remap loop.
it('scales to high counts (48 → 1 removes 47 ids)', () => {
const removed = removedBottomHandleIds(48, 1)
expect(removed.size).toBe(47)
expect(removed.has('bottom-2')).toBe(true)
expect(removed.has('bottom-48')).toBe(true)
expect(removed.has('bottom')).toBe(false)
})
it('10 → 2 leaves bottom + bottom-2, removes bottom-3..bottom-10', () => {
const removed = removedBottomHandleIds(10, 2)
expect(removed.size).toBe(8)
expect(removed.has('bottom-2')).toBe(false)
expect(removed.has('bottom-3')).toBe(true)
expect(removed.has('bottom-10')).toBe(true)
})
})
@@ -33,6 +33,22 @@ describe('ICON_REGISTRY', () => {
expect(keys).toContain('database') // DB services
expect(keys).toContain('cctv') // IP Camera / CCTV
})
it('contains Smart Home / Sensors icons', () => {
const keys = ICON_REGISTRY.map((e) => e.key)
expect(keys).toContain('plug')
expect(keys).toContain('smoke')
expect(keys).toContain('door')
expect(keys).toContain('motion')
expect(keys).toContain('leak')
expect(keys).toContain('lock-smart')
expect(keys).toContain('battery-charging')
})
it('exposes the Smart Home / Sensors category', () => {
const categories = ICON_REGISTRY.map((e) => e.category)
expect(categories).toContain('Smart Home / Sensors')
})
})
describe('ICON_CATEGORIES', () => {
+3 -3
View File
@@ -3,8 +3,8 @@ import { THEMES, THEME_ORDER, type ThemeId } from '../themes'
import type { NodeType, EdgeType, NodeStatus } from '@/types'
const NODE_TYPES: NodeType[] = [
'isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc',
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker', 'generic', 'groupRect',
'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc',
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker_host', 'docker_container', 'generic', 'groupRect',
]
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown']
@@ -84,7 +84,7 @@ describe('THEMES', () => {
expect(d.nodeAccents.server.border).toBe('#a855f7')
expect(d.nodeAccents.isp.border).toBe('#00d4ff')
expect(d.nodeAccents.proxmox.border).toBe('#ff6e00')
expect(d.nodeAccents.docker.border).toBe('#2496ED')
expect(d.nodeAccents.docker_host.border).toBe('#2496ED')
expect(d.nodeCardBackground).toBe('#21262d')
expect(d.nodeIconBackground).toBe('#161b22')
expect(d.canvasBackground).toBe('#0d1117')
+173
View File
@@ -0,0 +1,173 @@
// Alignment-snap math for drag-time guides (draw.io / Figma style).
//
// Given a dragged box and a set of candidate boxes, return the snap delta
// (clamped by threshold, expressed in canvas px) plus the guide segments that
// should be drawn while the snap is engaged.
//
// All coordinates are absolute canvas coordinates — callers must resolve
// child-of-parent positions before calling.
export interface Box {
id: string
x: number
y: number
width: number
height: number
}
export type GuideAxis = 'x' | 'y'
export interface Guide {
axis: GuideAxis
// Position along the perpendicular axis (x for vertical guide, y for horizontal).
position: number
// Span along the parallel axis: min/max of involved boxes' opposite-axis extents.
start: number
end: number
}
export interface SnapResult {
deltaX: number
deltaY: number
guides: Guide[]
}
type EdgeKind = 'min' | 'center' | 'max'
interface AxisLine {
kind: EdgeKind
pos: number
// Span of the source box along the perpendicular axis (used to extend guide segments).
spanStart: number
spanEnd: number
}
function xLines(box: Box): AxisLine[] {
const top = box.y
const bottom = box.y + box.height
return [
{ kind: 'min', pos: box.x, spanStart: top, spanEnd: bottom },
{ kind: 'center', pos: box.x + box.width / 2, spanStart: top, spanEnd: bottom },
{ kind: 'max', pos: box.x + box.width, spanStart: top, spanEnd: bottom },
]
}
function yLines(box: Box): AxisLine[] {
const left = box.x
const right = box.x + box.width
return [
{ kind: 'min', pos: box.y, spanStart: left, spanEnd: right },
{ kind: 'center', pos: box.y + box.height / 2, spanStart: left, spanEnd: right },
{ kind: 'max', pos: box.y + box.height, spanStart: left, spanEnd: right },
]
}
interface Match {
delta: number
guidePos: number
candidate: AxisLine
dragLine: AxisLine
}
// Find all (dragLine, candidateLine) pairs at the smallest |delta| within threshold.
// When boxes share dimensions, all three edges align simultaneously — return all.
function bestMatches(dragLines: AxisLine[], candidateLines: AxisLine[], threshold: number): Match[] {
let bestAbs = Infinity
const matches: Match[] = []
for (const d of dragLines) {
for (const c of candidateLines) {
const delta = c.pos - d.pos
const abs = Math.abs(delta)
if (abs > threshold) continue
if (abs < bestAbs - 1e-9) {
bestAbs = abs
matches.length = 0
}
if (abs <= bestAbs + 1e-9) {
matches.push({ delta, guidePos: c.pos, candidate: c, dragLine: d })
}
}
}
return matches
}
/**
* Compute snap delta + visible guides for a drag operation.
*
* @param dragged Bounding box of the dragged node (or selection bbox).
* @param candidates Other nodes' boxes to consider.
* @param threshold Max distance (canvas px) at which a snap engages.
*/
export function computeSnap(dragged: Box, candidates: Box[], threshold: number): SnapResult {
if (candidates.length === 0 || threshold <= 0) {
return { deltaX: 0, deltaY: 0, guides: [] }
}
const dragX = xLines(dragged)
const dragY = yLines(dragged)
let bestXMatches: Match[] = []
let bestYMatches: Match[] = []
let xBestAbs = Infinity
let yBestAbs = Infinity
for (const cand of candidates) {
if (cand.id === dragged.id) continue
const mx = bestMatches(dragX, xLines(cand), threshold)
if (mx.length > 0) {
const abs = Math.abs(mx[0].delta)
if (abs < xBestAbs - 1e-9) {
xBestAbs = abs
bestXMatches = mx
} else if (abs <= xBestAbs + 1e-9) {
bestXMatches = bestXMatches.concat(mx)
}
}
const my = bestMatches(dragY, yLines(cand), threshold)
if (my.length > 0) {
const abs = Math.abs(my[0].delta)
if (abs < yBestAbs - 1e-9) {
yBestAbs = abs
bestYMatches = my
} else if (abs <= yBestAbs + 1e-9) {
bestYMatches = bestYMatches.concat(my)
}
}
}
const deltaX = bestXMatches[0]?.delta ?? 0
const deltaY = bestYMatches[0]?.delta ?? 0
const guides: Guide[] = []
const seenX = new Set<number>()
for (const m of bestXMatches) {
if (seenX.has(m.guidePos)) continue
seenX.add(m.guidePos)
const start = Math.min(m.dragLine.spanStart + deltaY, m.candidate.spanStart)
const end = Math.max(m.dragLine.spanEnd + deltaY, m.candidate.spanEnd)
guides.push({ axis: 'x', position: m.guidePos, start, end })
}
const seenY = new Set<number>()
for (const m of bestYMatches) {
if (seenY.has(m.guidePos)) continue
seenY.add(m.guidePos)
const start = Math.min(m.dragLine.spanStart + deltaX, m.candidate.spanStart)
const end = Math.max(m.dragLine.spanEnd + deltaX, m.candidate.spanEnd)
guides.push({ axis: 'y', position: m.guidePos, start, end })
}
return { deltaX, deltaY, guides }
}
/** Bounding box of multiple boxes — used for multi-selection drag. */
export function unionBox(boxes: Box[]): Box | null {
if (boxes.length === 0) return null
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
for (const b of boxes) {
if (b.x < minX) minX = b.x
if (b.y < minY) minY = b.y
if (b.x + b.width > maxX) maxX = b.x + b.width
if (b.y + b.height > maxY) maxY = b.y + b.height
}
return { id: '__union__', x: minX, y: minY, width: maxX - minX, height: maxY - minY }
}
+43
View File
@@ -0,0 +1,43 @@
// Persisted client-side settings for alignment guides.
// Kept in localStorage (per-user UI preference, not canvas data).
// Same-tab updates propagate via a CustomEvent so the drag hook and the
// settings panel can stay in sync without a global store.
export interface AlignmentSettings {
enabled: boolean
threshold: number
}
export const DEFAULT_ALIGNMENT_SETTINGS: AlignmentSettings = { enabled: true, threshold: 6 }
const KEY = 'homelable.alignmentGuides'
const EVENT = 'homelable:alignment-settings-changed'
export function readAlignmentSettings(): AlignmentSettings {
try {
const raw = localStorage.getItem(KEY)
if (!raw) return DEFAULT_ALIGNMENT_SETTINGS
const parsed = JSON.parse(raw) as Partial<AlignmentSettings>
return {
enabled: parsed.enabled ?? DEFAULT_ALIGNMENT_SETTINGS.enabled,
threshold: typeof parsed.threshold === 'number' ? parsed.threshold : DEFAULT_ALIGNMENT_SETTINGS.threshold,
}
} catch {
return DEFAULT_ALIGNMENT_SETTINGS
}
}
export function writeAlignmentSettings(s: AlignmentSettings): void {
try {
localStorage.setItem(KEY, JSON.stringify(s))
window.dispatchEvent(new CustomEvent<AlignmentSettings>(EVENT, { detail: s }))
} catch {
/* quota / SSR */
}
}
export function subscribeAlignmentSettings(listener: (s: AlignmentSettings) => void): () => void {
const handler = (e: Event) => listener((e as CustomEvent<AlignmentSettings>).detail)
window.addEventListener(EVENT, handler)
return () => window.removeEventListener(EVENT, handler)
}
+10 -6
View File
@@ -1,6 +1,6 @@
import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData, Waypoint } from '@/types'
import { normalizeHandle } from '@/utils/handleUtils'
import { normalizeHandle, clampBottomHandles } from '@/utils/handleUtils'
// ── Types ────────────────────────────────────────────────────────────────────
@@ -104,7 +104,7 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
properties: n.data.properties ?? [],
width: n.measured?.width ?? n.width ?? null,
height: n.measured?.height ?? n.height ?? null,
bottom_handles: n.data.bottom_handles ?? 1,
bottom_handles: clampBottomHandles(n.data.bottom_handles ?? 1),
pos_x: n.position.x,
pos_y: n.position.y,
}
@@ -134,6 +134,7 @@ export function deserializeApiNode(
n: ApiNode,
proxmoxContainerMap: Map<string, boolean>,
): Node<NodeData> {
const normalizedType = n.type === 'docker' ? 'docker_host' : n.type
if (n.type === 'groupRect') {
const w = (n.custom_colors?.width as number | undefined) ?? 360
const h = (n.custom_colors?.height as number | undefined) ?? 240
@@ -152,12 +153,15 @@ export function deserializeApiNode(
const parentIsContainer = n.parent_id ? (proxmoxContainerMap.get(n.parent_id) ?? false) : false
return {
id: n.id,
type: n.type,
type: normalizedType,
position: { x: n.pos_x, y: n.pos_y },
data: n as unknown as NodeData,
data: { ...n, type: normalizedType, bottom_handles: clampBottomHandles(n.bottom_handles ?? 1) } as unknown as NodeData,
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
...(n.width ? { width: n.width } : n.type === 'proxmox' && n.container_mode !== false ? { width: 300 } : {}),
...(n.height ? { height: n.height } : n.type === 'proxmox' && n.container_mode !== false ? { height: 200 } : {}),
...(['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) && n.container_mode !== false
? { width: n.width ?? 300, height: n.height ?? 200 }
: {}),
...(n.width && !['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) ? { width: n.width } : {}),
...(n.height && !['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) ? { height: n.height } : {}),
}
}
+14
View File
@@ -27,3 +27,17 @@ export function rgbaToHex8(hex6: string, alpha: number): string {
const alphaHex = alphaByte.toString(16).padStart(2, '0')
return `${hex6}${alphaHex}`
}
/**
* Combine a hex color and opacity (01) into a CSS rgba() string.
* Returns the plain hex when opacity is 1.
*/
export function applyOpacity(hex: string, opacity: number): string {
if (opacity >= 1) return hex
let h = hex.replace('#', '')
if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2]
const r = parseInt(h.slice(0, 2), 16)
const g = parseInt(h.slice(2, 4), 16)
const b = parseInt(h.slice(4, 6), 16)
return `rgba(${r}, ${g}, ${b}, ${Math.round(opacity * 100) / 100})`
}
+42
View File
@@ -62,6 +62,48 @@ export const demoNodes: Node<NodeData>[] = [
position: { x: 460, y: 500 },
data: { label: 'Zigbee Hub', type: 'iot', ip: '192.168.2.1', status: 'pending', check_method: 'tcp', services: [] },
},
{
id: 'demo-text-1',
type: 'text',
position: { x: 660, y: 20 },
data: {
label: 'Demo banner',
type: 'text',
status: 'unknown',
services: [],
text_content: 'This is demo canvas, start with fresh scan',
custom_colors: {
text_color: '#e6edf3',
text_size: 14,
font: 'inter',
border: '#00d4ff',
border_style: 'dashed',
border_width: 1,
background: '#00d4ff14',
},
},
},
{
id: 'demo-text-2',
type: 'text',
position: { x: -240, y: 540 },
data: {
label: 'Demo hint',
type: 'text',
status: 'unknown',
services: [],
text_content: 'You can remove all nodes',
custom_colors: {
text_color: '#8b949e',
text_size: 12,
font: 'inter',
border: '#30363d',
border_style: 'dotted',
border_width: 1,
background: '#00000000',
},
},
},
]
export const demoEdges: Edge<EdgeData>[] = [
+34 -10
View File
@@ -2,20 +2,44 @@
* Bottom handle configuration for multi-handle nodes.
*
* Handle IDs: index 0 = 'bottom' (always the default, backward-compatible)
* index 1 = 'bottom-2', index 2 = 'bottom-3', index 3 = 'bottom-4'
* index N1 = 'bottom-${N+1}' (so idx 1 = 'bottom-2', idx 47 = 'bottom-48')
*
* Invisible target handles follow the same pattern with a '-t' suffix:
* 'bottom-t', 'bottom-2-t', 'bottom-3-t', 'bottom-4-t'
* 'bottom-t', 'bottom-2-t', ..., 'bottom-48-t'
*/
export const BOTTOM_HANDLE_IDS = ['bottom', 'bottom-2', 'bottom-3', 'bottom-4'] as const
export const MIN_BOTTOM_HANDLES = 1
export const MAX_BOTTOM_HANDLES = 48
/** Left % position for each handle slot, per count. */
export const BOTTOM_HANDLE_POSITIONS: Record<number, number[]> = {
1: [50],
2: [25, 75],
3: [20, 50, 80],
4: [15, 38, 62, 85],
/** Returns the source handle ID at a given slot index. */
export function bottomHandleId(idx: number): string {
return idx === 0 ? 'bottom' : `bottom-${idx + 1}`
}
/** Clamp a raw count into the supported range. Non-finite or non-int → MIN. */
export function clampBottomHandles(n: unknown): number {
if (typeof n !== 'number' || !Number.isFinite(n)) return MIN_BOTTOM_HANDLES
const i = Math.floor(n)
if (i < MIN_BOTTOM_HANDLES) return MIN_BOTTOM_HANDLES
if (i > MAX_BOTTOM_HANDLES) return MAX_BOTTOM_HANDLES
return i
}
/**
* Left % positions for each handle slot.
* Counts 1..4 keep their original hand-tuned values to preserve exact pixel
* positions on canvases saved before the multi-handle expansion.
* Counts 5 use uniform spacing.
*/
export function bottomHandlePositions(count: number): number[] {
const c = clampBottomHandles(count)
switch (c) {
case 1: return [50]
case 2: return [25, 75]
case 3: return [20, 50, 80]
case 4: return [15, 38, 62, 85]
default: return Array.from({ length: c }, (_, i) => ((i + 1) * 100) / (c + 1))
}
}
/**
@@ -39,7 +63,7 @@ export function normalizeHandle(h: string | null | undefined): string | null {
export function removedBottomHandleIds(oldCount: number, newCount: number): Set<string> {
const removed = new Set<string>()
for (let i = newCount; i < oldCount; i++) {
removed.add(BOTTOM_HANDLE_IDS[i])
removed.add(bottomHandleId(i))
}
return removed
}
+138 -63
View File
@@ -9,9 +9,14 @@ import {
// Storage & Databases
Database, Archive, Cloud, FolderOpen,
// Security & Auth
Shield, ShieldCheck, Lock, Key, Users, UserCheck,
Shield, ShieldCheck, Lock, Key, Users, UserCheck, Flame,
// Automation & IoT
Zap, Workflow, Bot, Home, Thermometer, Lightbulb, Radio,
Zap, Workflow, Bot, Home, Thermometer, Lightbulb, Radio, BotMessageSquare, Webhook,
// Smart Home / Sensors
Plug, Power, BatteryCharging, Sun, DoorOpen, KeyRound, AlarmSmoke, Siren,
Radar, PersonStanding, Vibrate, Droplet, Droplets, Wind, AirVent, Fan,
Snowflake, LampCeiling, Blinds, BellRing, Speaker, Joystick, Warehouse,
CircleDot, CloudSun,
// Transfers & sync
Download, Upload, RefreshCw,
// Containers & Dev
@@ -19,7 +24,7 @@ import {
// Communications
Mail, MessageSquare, Phone,
// Misc devices
Printer, Smartphone, Search, Filter, BookOpen, PlugZap,
Printer, Smartphone, Search, Filter, BookOpen, PlugZap, Type,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
@@ -32,21 +37,22 @@ export interface IconEntry {
export const ICON_REGISTRY: IconEntry[] = [
// --- Infrastructure ---
{ key: 'globe', label: 'Globe / ISP', category: 'Infrastructure', icon: Globe },
{ key: 'router', label: 'Router', category: 'Infrastructure', icon: Router },
{ key: 'network', label: 'Switch / Network', category: 'Infrastructure', icon: Network },
{ key: 'server', label: 'Server', category: 'Infrastructure', icon: Server },
{ key: 'globe', label: 'Globe / ISP', category: 'Infrastructure', icon: Globe },
{ key: 'router', label: 'Router', category: 'Infrastructure', icon: Router },
{ key: 'flame', label: 'Firewall', category: 'Infrastructure', icon: Flame },
{ key: 'network', label: 'Switch / Network', category: 'Infrastructure', icon: Network },
{ key: 'server', label: 'Server', category: 'Infrastructure', icon: Server },
{ key: 'layers', label: 'Proxmox / Hypervisor', category: 'Infrastructure', icon: Layers },
{ key: 'box', label: 'VM', category: 'Infrastructure', icon: Box },
{ key: 'container', label: 'Container / LXC', category: 'Infrastructure', icon: Container },
{ key: 'harddrive', label: 'NAS / Storage', category: 'Infrastructure', icon: HardDrive },
{ key: 'cpu', label: 'IoT / Embedded', category: 'Infrastructure', icon: Cpu },
{ key: 'wifi', label: 'Access Point', category: 'Infrastructure', icon: Wifi },
{ key: 'circle', label: 'Generic', category: 'Infrastructure', icon: Circle },
{ key: 'monitor', label: 'Workstation', category: 'Infrastructure', icon: Monitor },
{ key: 'smartphone', label: 'Phone / Mobile', category: 'Infrastructure', icon: Smartphone },
{ key: 'printer', label: 'Printer', category: 'Infrastructure', icon: Printer },
{ key: 'plugzap', label: 'CPL / Powerline', category: 'Infrastructure', icon: PlugZap },
{ key: 'box', label: 'VM', category: 'Infrastructure', icon: Box },
{ key: 'container', label: 'Container / LXC', category: 'Infrastructure', icon: Container },
{ key: 'harddrive', label: 'NAS / Storage', category: 'Infrastructure', icon: HardDrive },
{ key: 'cpu', label: 'IoT / Embedded', category: 'Infrastructure', icon: Cpu },
{ key: 'wifi', label: 'Access Point', category: 'Infrastructure', icon: Wifi },
{ key: 'circle', label: 'Generic', category: 'Infrastructure', icon: Circle },
{ key: 'monitor', label: 'Workstation', category: 'Infrastructure', icon: Monitor },
{ key: 'smartphone', label: 'Phone / Mobile', category: 'Infrastructure', icon: Smartphone },
{ key: 'printer', label: 'Printer', category: 'Infrastructure', icon: Printer },
{ key: 'plugzap', label: 'CPL / Powerline', category: 'Infrastructure', icon: PlugZap },
// --- Media ---
{ key: 'play', label: 'Jellyfin / Emby', category: 'Media', icon: Play },
@@ -62,44 +68,73 @@ export const ICON_REGISTRY: IconEntry[] = [
{ key: 'cctv', label: 'CCTV / IP Camera', category: 'Media', icon: Cctv },
// --- Monitoring ---
{ key: 'activity', label: 'Prometheus / Uptime', category: 'Monitoring', icon: Activity },
{ key: 'barchart', label: 'Grafana / Kibana', category: 'Monitoring', icon: BarChart2 },
{ key: 'linechart', label: 'InfluxDB / Metrics', category: 'Monitoring', icon: LineChart },
{ key: 'activity', label: 'Prometheus / Uptime', category: 'Monitoring', icon: Activity },
{ key: 'barchart', label: 'Grafana / Kibana', category: 'Monitoring', icon: BarChart2 },
{ key: 'linechart', label: 'InfluxDB / Metrics', category: 'Monitoring', icon: LineChart },
{ key: 'eye', label: 'Overseerr / Watchlist', category: 'Monitoring', icon: Eye },
{ key: 'bell', label: 'Alerts / Notifiarr', category: 'Monitoring', icon: Bell },
{ key: 'gauge', label: 'Dashboard / Status', category: 'Monitoring', icon: Gauge },
{ key: 'bell', label: 'Alerts / Notifiarr', category: 'Monitoring', icon: Bell },
{ key: 'gauge', label: 'Dashboard / Status', category: 'Monitoring', icon: Gauge },
// --- Storage & Databases ---
{ key: 'database', label: 'Database (SQL/NoSQL)', category: 'Storage', icon: Database },
{ key: 'archive', label: 'Backup / Archive', category: 'Storage', icon: Archive },
{ key: 'cloud', label: 'Nextcloud / S3', category: 'Storage', icon: Cloud },
{ key: 'folder', label: 'Files / Filebrowser', category: 'Storage', icon: FolderOpen },
{ key: 'database', label: 'Database (SQL/NoSQL)', category: 'Storage', icon: Database },
{ key: 'archive', label: 'Backup / Archive', category: 'Storage', icon: Archive },
{ key: 'cloud', label: 'Nextcloud / S3', category: 'Storage', icon: Cloud },
{ key: 'folder', label: 'Files / Filebrowser', category: 'Storage', icon: FolderOpen },
{ key: 'download', label: 'Downloader (Torrent/NZB)', category: 'Storage', icon: Download },
{ key: 'upload', label: 'Upload / Sync', category: 'Storage', icon: Upload },
{ key: 'refresh', label: 'Sync / Resilio', category: 'Storage', icon: RefreshCw },
{ key: 'upload', label: 'Upload / Sync', category: 'Storage', icon: Upload },
{ key: 'refresh', label: 'Sync / Resilio', category: 'Storage', icon: RefreshCw },
// --- Security & Auth ---
{ key: 'shield', label: 'Pi-hole / DNS Block', category: 'Security', icon: Shield },
{ key: 'shieldcheck', label: 'AdGuard Home', category: 'Security', icon: ShieldCheck },
{ key: 'shield', label: 'Pi-hole / DNS Block', category: 'Security', icon: Shield },
{ key: 'shieldcheck', label: 'AdGuard Home', category: 'Security', icon: ShieldCheck },
{ key: 'lock', label: 'Authelia / Authentik', category: 'Security', icon: Lock },
{ key: 'key', label: 'Vaultwarden / Vault', category: 'Security', icon: Key },
{ key: 'users', label: 'LDAP / SSO', category: 'Security', icon: Users },
{ key: 'usercheck', label: 'Keycloak', category: 'Security', icon: UserCheck },
{ key: 'filter', label: 'Prowlarr / Jackett', category: 'Security', icon: Filter },
{ key: 'search', label: 'Search / Indexer', category: 'Security', icon: Search },
{ key: 'key', label: 'Vaultwarden / Vault', category: 'Security', icon: Key },
{ key: 'users', label: 'LDAP / SSO', category: 'Security', icon: Users },
{ key: 'usercheck', label: 'Keycloak', category: 'Security', icon: UserCheck },
{ key: 'filter', label: 'Prowlarr / Jackett', category: 'Security', icon: Filter },
{ key: 'search', label: 'Search / Indexer', category: 'Security', icon: Search },
// --- Automation & Smart Home ---
{ key: 'home', label: 'Home Assistant', category: 'Automation', icon: Home },
{ key: 'zap', label: 'ESPHome / Node-RED', category: 'Automation', icon: Zap },
{ key: 'workflow', label: 'n8n / Node-RED', category: 'Automation', icon: Workflow },
{ key: 'bot', label: 'Bot / Automation', category: 'Automation', icon: Bot },
{ key: 'home', label: 'Home Assistant', category: 'Automation', icon: Home },
{ key: 'zap', label: 'ESPHome / Node-RED', category: 'Automation', icon: Zap },
{ key: 'workflow', label: 'n8n / Node-RED', category: 'Automation', icon: Workflow },
{ key: 'bot', label: 'Bot / Automation', category: 'Automation', icon: Bot },
{ key: 'thermometer', label: 'Sensor / Temperature', category: 'Automation', icon: Thermometer },
{ key: 'lightbulb', label: 'Smart Light / Zigbee', category: 'Automation', icon: Lightbulb },
{ key: 'radio', label: 'MQTT / RTL-SDR', category: 'Automation', icon: Radio },
{ key: 'lightbulb', label: 'Smart Light / Zigbee', category: 'Automation', icon: Lightbulb },
{ key: 'radio', label: 'MQTT / RTL-SDR', category: 'Automation', icon: Radio },
{ key: 'voice', label: 'Voice Assistant', category: 'Automation', icon: BotMessageSquare },
{ key: 'webhook', label: 'Webhook / IFTTT', category: 'Automation', icon: Webhook },
// --- Smart Home / Sensors ---
{ key: 'plug', label: 'Smart Plug / Outlet', category: 'Smart Home / Sensors', icon: Plug },
{ key: 'power', label: 'Switch / Relay', category: 'Smart Home / Sensors', icon: Power },
{ key: 'battery-charging', label: 'Energy Meter / EV', category: 'Smart Home / Sensors', icon: BatteryCharging },
{ key: 'solar', label: 'Solar Panel', category: 'Smart Home / Sensors', icon: Sun },
{ key: 'door', label: 'Door / Window Sensor', category: 'Smart Home / Sensors', icon: DoorOpen },
{ key: 'lock-smart', label: 'Smart Lock', category: 'Smart Home / Sensors', icon: KeyRound },
{ key: 'smoke', label: 'Smoke Detector', category: 'Smart Home / Sensors', icon: AlarmSmoke },
{ key: 'siren', label: 'Siren / Alarm', category: 'Smart Home / Sensors', icon: Siren },
{ key: 'motion', label: 'Motion / Radar', category: 'Smart Home / Sensors', icon: Radar },
{ key: 'presence', label: 'Presence Sensor', category: 'Smart Home / Sensors', icon: PersonStanding },
{ key: 'vibration', label: 'Vibration Sensor', category: 'Smart Home / Sensors', icon: Vibrate },
{ key: 'leak', label: 'Water Leak', category: 'Smart Home / Sensors', icon: Droplet },
{ key: 'humidity', label: 'Humidity', category: 'Smart Home / Sensors', icon: Droplets },
{ key: 'air-quality', label: 'Air Quality / VOC', category: 'Smart Home / Sensors', icon: Wind },
{ key: 'air-vent', label: 'HVAC Vent', category: 'Smart Home / Sensors', icon: AirVent },
{ key: 'fan', label: 'Fan', category: 'Smart Home / Sensors', icon: Fan },
{ key: 'snowflake', label: 'AC / Cooling', category: 'Smart Home / Sensors', icon: Snowflake },
{ key: 'lamp', label: 'Smart Light', category: 'Smart Home / Sensors', icon: LampCeiling },
{ key: 'blinds', label: 'Blinds / Cover', category: 'Smart Home / Sensors', icon: Blinds },
{ key: 'doorbell', label: 'Doorbell', category: 'Smart Home / Sensors', icon: BellRing },
{ key: 'speaker', label: 'Smart Speaker', category: 'Smart Home / Sensors', icon: Speaker },
{ key: 'remote', label: 'Remote / Button', category: 'Smart Home / Sensors', icon: Joystick },
{ key: 'garage', label: 'Garage Door', category: 'Smart Home / Sensors', icon: Warehouse },
{ key: 'valve', label: 'Smart Valve', category: 'Smart Home / Sensors', icon: CircleDot },
{ key: 'weather', label: 'Weather Station', category: 'Smart Home / Sensors', icon: CloudSun },
// --- Containers & Dev ---
{ key: 'anchor', label: 'Portainer / Docker', category: 'Dev & Containers', icon: Anchor },
{ key: 'package', label: 'Docker Host', category: 'Dev & Containers', icon: Package },
{ key: 'anchor', label: 'Portainer / Docker', category: 'Dev & Containers', icon: Anchor },
{ key: 'package', label: 'Docker Host / Container', category: 'Dev & Containers', icon: Package },
{ key: 'gitbranch', label: 'Gitea / Gitlab', category: 'Dev & Containers', icon: GitBranch },
{ key: 'terminal', label: 'SSH / Shell', category: 'Dev & Containers', icon: Terminal },
{ key: 'code', label: 'VS Code Server', category: 'Dev & Containers', icon: Code2 },
@@ -118,31 +153,71 @@ export const ICON_MAP: Record<string, LucideIcon> = Object.fromEntries(
)
export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = {
isp: Globe,
router: Router,
switch: Network,
server: Server,
proxmox: Layers,
vm: Box,
lxc: Container,
nas: HardDrive,
iot: Cpu,
ap: Wifi,
camera: Cctv,
printer: Printer,
computer: Monitor,
cpl: PlugZap,
docker: Anchor,
generic: Circle,
group: Circle,
groupRect: Circle,
isp: Globe,
router: Router,
firewall: Flame,
switch: Network,
server: Server,
proxmox: Layers,
vm: Box,
lxc: Container,
nas: HardDrive,
iot: Cpu,
ap: Wifi,
camera: Cctv,
printer: Printer,
computer: Monitor,
cpl: PlugZap,
docker_host: Anchor,
docker_container: Package,
zigbee_coordinator: Radio,
zigbee_router: Zap,
zigbee_enddevice: Lightbulb,
generic: Circle,
group: Circle,
groupRect: Circle,
text: Type,
}
/** Resolve the display icon for a node — custom_icon takes priority over type default. */
/** Resolve the display icon for a node custom_icon takes priority over type default.
* Legacy: returns a LucideIcon component. Brand icons must use `resolveCustomIcon`. */
export function resolveNodeIcon(
typeIcon: LucideIcon,
customIconKey?: string,
): LucideIcon {
if (customIconKey && ICON_MAP[customIconKey]) return ICON_MAP[customIconKey]
if (customIconKey && !customIconKey.startsWith('brand:') && ICON_MAP[customIconKey]) {
return ICON_MAP[customIconKey]
}
return typeIcon
}
export const BRAND_ICON_PREFIX = 'brand:'
export function isBrandIconKey(key: string | undefined | null): boolean {
return !!key && key.startsWith(BRAND_ICON_PREFIX)
}
export function brandIconSlug(key: string): string {
return key.slice(BRAND_ICON_PREFIX.length)
}
export function brandIconUrl(slug: string): string {
return `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/${slug}.svg`
}
export type ResolvedIcon =
| { kind: 'lucide'; icon: LucideIcon }
| { kind: 'brand'; slug: string; url: string }
/** Resolve a node's icon to either a lucide component or a brand CDN URL.
* Used by renderers that support brand icons. Backwards-compatible with legacy
* string keys (no prefix lucide lookup). Returns null when key unknown. */
export function resolveCustomIcon(customIconKey?: string): ResolvedIcon | null {
if (!customIconKey) return null
if (isBrandIconKey(customIconKey)) {
const slug = brandIconSlug(customIconKey)
return { kind: 'brand', slug, url: brandIconUrl(slug) }
}
const icon = ICON_MAP[customIconKey] as LucideIcon | undefined
return icon ? { kind: 'lucide', icon } : null
}
+183 -92
View File
@@ -1,6 +1,6 @@
import type { NodeType, EdgeType, NodeStatus } from '@/types'
export type ThemeId = 'default' | 'dark' | 'light' | 'neon' | 'matrix'
export type ThemeId = 'default' | 'dark' | 'light' | 'neon' | 'matrix' | 'custom'
export interface ThemeColors {
// Per node-type accent (border + icon)
@@ -42,24 +42,30 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
description: 'Dark futuristic — the original Homelable look',
colors: {
nodeAccents: {
isp: { border: '#00d4ff', icon: '#00d4ff' },
router: { border: '#00d4ff', icon: '#00d4ff' },
switch: { border: '#39d353', icon: '#39d353' },
server: { border: '#a855f7', icon: '#a855f7' },
proxmox: { border: '#ff6e00', icon: '#ff6e00' },
vm: { border: '#a855f7', icon: '#a855f7' },
lxc: { border: '#00d4ff', icon: '#00d4ff' },
nas: { border: '#39d353', icon: '#39d353' },
iot: { border: '#e3b341', icon: '#e3b341' },
ap: { border: '#00d4ff', icon: '#00d4ff' },
camera: { border: '#8b949e', icon: '#8b949e' },
printer: { border: '#8b949e', icon: '#8b949e' },
computer: { border: '#a855f7', icon: '#a855f7' },
cpl: { border: '#e3b341', icon: '#e3b341' },
docker: { border: '#2496ED', icon: '#2496ED' },
generic: { border: '#8b949e', icon: '#8b949e' },
groupRect:{ border: '#00d4ff', icon: '#00d4ff' },
group: { border: '#00d4ff', icon: '#00d4ff' },
isp: { border: '#00d4ff', icon: '#00d4ff' },
router: { border: '#00d4ff', icon: '#00d4ff' },
firewall: { border: '#f85149', icon: '#f85149' },
switch: { border: '#39d353', icon: '#39d353' },
server: { border: '#a855f7', icon: '#a855f7' },
proxmox: { border: '#ff6e00', icon: '#ff6e00' },
vm: { border: '#a855f7', icon: '#a855f7' },
lxc: { border: '#00d4ff', icon: '#00d4ff' },
nas: { border: '#39d353', icon: '#39d353' },
iot: { border: '#e3b341', icon: '#e3b341' },
ap: { border: '#00d4ff', icon: '#00d4ff' },
camera: { border: '#8b949e', icon: '#8b949e' },
printer: { border: '#8b949e', icon: '#8b949e' },
computer: { border: '#a855f7', icon: '#a855f7' },
cpl: { border: '#e3b341', icon: '#e3b341' },
docker_host: { border: '#2496ED', icon: '#2496ED' },
docker_container: { border: '#0ea5e9', icon: '#0ea5e9' },
zigbee_coordinator:{ border: '#ff6e00', icon: '#ff6e00' },
zigbee_router: { border: '#e3b341', icon: '#e3b341' },
zigbee_enddevice: { border: '#a855f7', icon: '#a855f7' },
generic: { border: '#8b949e', icon: '#8b949e' },
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
group: { border: '#00d4ff', icon: '#00d4ff' },
text: { border: '#30363d', icon: '#e6edf3' },
},
nodeCardBackground: '#21262d',
nodeIconBackground: '#161b22',
@@ -97,24 +103,30 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
description: 'Pure black with maximum contrast',
colors: {
nodeAccents: {
isp: { border: '#22d3ee', icon: '#22d3ee' },
router: { border: '#22d3ee', icon: '#22d3ee' },
switch: { border: '#4ade80', icon: '#4ade80' },
server: { border: '#c084fc', icon: '#c084fc' },
proxmox: { border: '#fb923c', icon: '#fb923c' },
vm: { border: '#c084fc', icon: '#c084fc' },
lxc: { border: '#22d3ee', icon: '#22d3ee' },
nas: { border: '#4ade80', icon: '#4ade80' },
iot: { border: '#fbbf24', icon: '#fbbf24' },
ap: { border: '#22d3ee', icon: '#22d3ee' },
camera: { border: '#94a3b8', icon: '#94a3b8' },
printer: { border: '#94a3b8', icon: '#94a3b8' },
computer: { border: '#c084fc', icon: '#c084fc' },
cpl: { border: '#fbbf24', icon: '#fbbf24' },
docker: { border: '#2496ED', icon: '#2496ED' },
generic: { border: '#94a3b8', icon: '#94a3b8' },
groupRect:{ border: '#22d3ee', icon: '#22d3ee' },
group: { border: '#22d3ee', icon: '#22d3ee' },
isp: { border: '#22d3ee', icon: '#22d3ee' },
router: { border: '#22d3ee', icon: '#22d3ee' },
firewall: { border: '#ef4444', icon: '#ef4444' },
switch: { border: '#4ade80', icon: '#4ade80' },
server: { border: '#c084fc', icon: '#c084fc' },
proxmox: { border: '#fb923c', icon: '#fb923c' },
vm: { border: '#c084fc', icon: '#c084fc' },
lxc: { border: '#22d3ee', icon: '#22d3ee' },
nas: { border: '#4ade80', icon: '#4ade80' },
iot: { border: '#fbbf24', icon: '#fbbf24' },
ap: { border: '#22d3ee', icon: '#22d3ee' },
camera: { border: '#94a3b8', icon: '#94a3b8' },
printer: { border: '#94a3b8', icon: '#94a3b8' },
computer: { border: '#c084fc', icon: '#c084fc' },
cpl: { border: '#fbbf24', icon: '#fbbf24' },
docker_host: { border: '#2496ED', icon: '#2496ED' },
docker_container: { border: '#38bdf8', icon: '#38bdf8' },
zigbee_coordinator:{ border: '#fb923c', icon: '#fb923c' },
zigbee_router: { border: '#fbbf24', icon: '#fbbf24' },
zigbee_enddevice: { border: '#c084fc', icon: '#c084fc' },
generic: { border: '#94a3b8', icon: '#94a3b8' },
groupRect: { border: '#22d3ee', icon: '#22d3ee' },
group: { border: '#22d3ee', icon: '#22d3ee' },
text: { border: '#404040', icon: '#ffffff' },
},
nodeCardBackground: '#0a0a0a',
nodeIconBackground: '#111111',
@@ -152,24 +164,30 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
description: 'Clean light theme with dark text',
colors: {
nodeAccents: {
isp: { border: '#0284c7', icon: '#0284c7' },
router: { border: '#0284c7', icon: '#0284c7' },
switch: { border: '#16a34a', icon: '#16a34a' },
server: { border: '#7c3aed', icon: '#7c3aed' },
proxmox: { border: '#ea580c', icon: '#ea580c' },
vm: { border: '#7c3aed', icon: '#7c3aed' },
lxc: { border: '#0284c7', icon: '#0284c7' },
nas: { border: '#16a34a', icon: '#16a34a' },
iot: { border: '#b45309', icon: '#b45309' },
ap: { border: '#0284c7', icon: '#0284c7' },
camera: { border: '#6b7280', icon: '#6b7280' },
printer: { border: '#6b7280', icon: '#6b7280' },
computer: { border: '#7c3aed', icon: '#7c3aed' },
cpl: { border: '#b45309', icon: '#b45309' },
docker: { border: '#2496ED', icon: '#2496ED' },
generic: { border: '#6b7280', icon: '#6b7280' },
groupRect:{ border: '#0284c7', icon: '#0284c7' },
group: { border: '#0284c7', icon: '#0284c7' },
isp: { border: '#0284c7', icon: '#0284c7' },
router: { border: '#0284c7', icon: '#0284c7' },
firewall: { border: '#dc2626', icon: '#dc2626' },
switch: { border: '#16a34a', icon: '#16a34a' },
server: { border: '#7c3aed', icon: '#7c3aed' },
proxmox: { border: '#ea580c', icon: '#ea580c' },
vm: { border: '#7c3aed', icon: '#7c3aed' },
lxc: { border: '#0284c7', icon: '#0284c7' },
nas: { border: '#16a34a', icon: '#16a34a' },
iot: { border: '#b45309', icon: '#b45309' },
ap: { border: '#0284c7', icon: '#0284c7' },
camera: { border: '#6b7280', icon: '#6b7280' },
printer: { border: '#6b7280', icon: '#6b7280' },
computer: { border: '#7c3aed', icon: '#7c3aed' },
cpl: { border: '#b45309', icon: '#b45309' },
docker_host: { border: '#2496ED', icon: '#2496ED' },
docker_container: { border: '#0369a1', icon: '#0369a1' },
zigbee_coordinator:{ border: '#ea580c', icon: '#ea580c' },
zigbee_router: { border: '#b45309', icon: '#b45309' },
zigbee_enddevice: { border: '#7c3aed', icon: '#7c3aed' },
generic: { border: '#6b7280', icon: '#6b7280' },
groupRect: { border: '#0284c7', icon: '#0284c7' },
group: { border: '#0284c7', icon: '#0284c7' },
text: { border: '#cbd5e1', icon: '#1f2328' },
},
nodeCardBackground: '#ffffff',
nodeIconBackground: '#f0f6ff',
@@ -207,24 +225,30 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
description: 'Cyberpunk vibes with vivid glowing accents',
colors: {
nodeAccents: {
isp: { border: '#00ffff', icon: '#00ffff' },
router: { border: '#00ffff', icon: '#00ffff' },
switch: { border: '#00ff80', icon: '#00ff80' },
server: { border: '#ff00ff', icon: '#ff00ff' },
proxmox: { border: '#ff8800', icon: '#ff8800' },
vm: { border: '#ff00ff', icon: '#ff00ff' },
lxc: { border: '#00ffff', icon: '#00ffff' },
nas: { border: '#00ff80', icon: '#00ff80' },
iot: { border: '#ffff00', icon: '#ffff00' },
ap: { border: '#00ffff', icon: '#00ffff' },
camera: { border: '#8888ff', icon: '#8888ff' },
printer: { border: '#8888ff', icon: '#8888ff' },
computer: { border: '#ff00ff', icon: '#ff00ff' },
cpl: { border: '#ffff00', icon: '#ffff00' },
docker: { border: '#00aaff', icon: '#00aaff' },
generic: { border: '#8888ff', icon: '#8888ff' },
groupRect:{ border: '#00ffff', icon: '#00ffff' },
group: { border: '#00ffff', icon: '#00ffff' },
isp: { border: '#00ffff', icon: '#00ffff' },
router: { border: '#00ffff', icon: '#00ffff' },
firewall: { border: '#ff0040', icon: '#ff0040' },
switch: { border: '#00ff80', icon: '#00ff80' },
server: { border: '#ff00ff', icon: '#ff00ff' },
proxmox: { border: '#ff8800', icon: '#ff8800' },
vm: { border: '#ff00ff', icon: '#ff00ff' },
lxc: { border: '#00ffff', icon: '#00ffff' },
nas: { border: '#00ff80', icon: '#00ff80' },
iot: { border: '#ffff00', icon: '#ffff00' },
ap: { border: '#00ffff', icon: '#00ffff' },
camera: { border: '#8888ff', icon: '#8888ff' },
printer: { border: '#8888ff', icon: '#8888ff' },
computer: { border: '#ff00ff', icon: '#ff00ff' },
cpl: { border: '#ffff00', icon: '#ffff00' },
docker_host: { border: '#00aaff', icon: '#00aaff' },
docker_container: { border: '#00ddff', icon: '#00ddff' },
zigbee_coordinator:{ border: '#ff8800', icon: '#ff8800' },
zigbee_router: { border: '#ffff00', icon: '#ffff00' },
zigbee_enddevice: { border: '#ff00ff', icon: '#ff00ff' },
generic: { border: '#8888ff', icon: '#8888ff' },
groupRect: { border: '#00ffff', icon: '#00ffff' },
group: { border: '#00ffff', icon: '#00ffff' },
text: { border: '#3a3a6a', icon: '#ffffff' },
},
nodeCardBackground: '#0f0f2a',
nodeIconBackground: '#0a0a1a',
@@ -262,24 +286,30 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
description: 'Everything in terminal green',
colors: {
nodeAccents: {
isp: { border: '#00ff41', icon: '#00ff41' },
router: { border: '#00ff41', icon: '#00ff41' },
switch: { border: '#00cc33', icon: '#00cc33' },
server: { border: '#008822', icon: '#008822' },
proxmox: { border: '#33ff66', icon: '#33ff66' },
vm: { border: '#008822', icon: '#008822' },
lxc: { border: '#00ff41', icon: '#00ff41' },
nas: { border: '#00cc33', icon: '#00cc33' },
iot: { border: '#66ff33', icon: '#66ff33' },
ap: { border: '#00ff41', icon: '#00ff41' },
camera: { border: '#005500', icon: '#005500' },
printer: { border: '#005500', icon: '#005500' },
computer: { border: '#008822', icon: '#008822' },
cpl: { border: '#66ff33', icon: '#66ff33' },
docker: { border: '#00cc88', icon: '#00cc88' },
generic: { border: '#006600', icon: '#006600' },
groupRect:{ border: '#00ff41', icon: '#00ff41' },
group: { border: '#00ff41', icon: '#00ff41' },
isp: { border: '#00ff41', icon: '#00ff41' },
router: { border: '#00ff41', icon: '#00ff41' },
firewall: { border: '#88ff00', icon: '#88ff00' },
switch: { border: '#00cc33', icon: '#00cc33' },
server: { border: '#008822', icon: '#008822' },
proxmox: { border: '#33ff66', icon: '#33ff66' },
vm: { border: '#008822', icon: '#008822' },
lxc: { border: '#00ff41', icon: '#00ff41' },
nas: { border: '#00cc33', icon: '#00cc33' },
iot: { border: '#66ff33', icon: '#66ff33' },
ap: { border: '#00ff41', icon: '#00ff41' },
camera: { border: '#005500', icon: '#005500' },
printer: { border: '#005500', icon: '#005500' },
computer: { border: '#008822', icon: '#008822' },
cpl: { border: '#66ff33', icon: '#66ff33' },
docker_host: { border: '#00cc88', icon: '#00cc88' },
docker_container: { border: '#00aacc', icon: '#00aacc' },
zigbee_coordinator:{ border: '#33ff66', icon: '#33ff66' },
zigbee_router: { border: '#66ff33', icon: '#66ff33' },
zigbee_enddevice: { border: '#008822', icon: '#008822' },
generic: { border: '#006600', icon: '#006600' },
groupRect: { border: '#00ff41', icon: '#00ff41' },
group: { border: '#00ff41', icon: '#00ff41' },
text: { border: '#003311', icon: '#00ff41' },
},
nodeCardBackground: '#001100',
nodeIconBackground: '#002200',
@@ -310,7 +340,68 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
reactFlowColorMode: 'dark',
},
},
custom: {
id: 'custom',
label: 'Custom',
description: 'Your own colors per node and edge type',
colors: {
nodeAccents: {
isp: { border: '#00d4ff', icon: '#00d4ff' },
router: { border: '#00d4ff', icon: '#00d4ff' },
firewall: { border: '#f85149', icon: '#f85149' },
switch: { border: '#39d353', icon: '#39d353' },
server: { border: '#a855f7', icon: '#a855f7' },
proxmox: { border: '#ff6e00', icon: '#ff6e00' },
vm: { border: '#a855f7', icon: '#a855f7' },
lxc: { border: '#00d4ff', icon: '#00d4ff' },
nas: { border: '#39d353', icon: '#39d353' },
iot: { border: '#e3b341', icon: '#e3b341' },
ap: { border: '#00d4ff', icon: '#00d4ff' },
camera: { border: '#8b949e', icon: '#8b949e' },
printer: { border: '#8b949e', icon: '#8b949e' },
computer: { border: '#a855f7', icon: '#a855f7' },
cpl: { border: '#e3b341', icon: '#e3b341' },
docker_host: { border: '#2496ED', icon: '#2496ED' },
docker_container: { border: '#0ea5e9', icon: '#0ea5e9' },
zigbee_coordinator:{ border: '#ff6e00', icon: '#ff6e00' },
zigbee_router: { border: '#e3b341', icon: '#e3b341' },
zigbee_enddevice: { border: '#a855f7', icon: '#a855f7' },
generic: { border: '#8b949e', icon: '#8b949e' },
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
group: { border: '#00d4ff', icon: '#00d4ff' },
text: { border: '#30363d', icon: '#e6edf3' },
},
nodeCardBackground: '#21262d',
nodeIconBackground: '#161b22',
nodeLabelColor: '#e6edf3',
nodeSubtextColor: '#8b949e',
statusColors: {
online: '#39d353',
offline: '#f85149',
pending: '#e3b341',
unknown: '#8b949e',
},
edgeColors: {
ethernet: '#30363d',
wifi: '#00d4ff',
iot: '#e3b341',
vlan: '#00d4ff',
virtual: '#8b949e',
cluster: '#ff6e00',
},
edgeSelectedColor: '#00d4ff',
edgeLabelBackground:'#161b22',
edgeLabelColor: '#8b949e',
edgeLabelBorder: '#30363d',
canvasBackground: '#0d1117',
canvasDotColor: '#30363d',
handleBackground: '#30363d',
handleBorder: '#8b949e',
reactFlowColorMode: 'dark',
},
},
}
// Ordered list for display in the modal
export const THEME_ORDER: ThemeId[] = ['default', 'dark', 'light', 'neon', 'matrix']
export const THEME_ORDER: ThemeId[] = ['default', 'dark', 'light', 'neon', 'matrix', 'custom']
+5 -1
View File
@@ -28,7 +28,11 @@ export default defineConfig({
},
server: {
proxy: {
'/api': 'http://localhost:8000',
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
ws: true,
},
'/ws': {
target: 'ws://localhost:8000',
ws: true,
+13 -7
View File
@@ -1,7 +1,8 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi import FastAPI
from mcp.server import Server
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from starlette.routing import Mount
from .auth import ApiKeyMiddleware
from .backend_client import backend
@@ -28,15 +29,20 @@ async def lifespan(app: FastAPI):
await backend.stop()
app = FastAPI(title="Homelable MCP", lifespan=lifespan)
# Mount the session manager as an ASGI sub-app instead of wrapping it in a
# FastAPI @app.api_route handler. Wrapping it in a route handler causes
# FastAPI to send http.response.start after the session manager has already
# started the response, raising `RuntimeError: Unexpected ASGI message
# 'http.response.start' sent, after response already completed` on every
# POST /mcp — which makes the server unreachable from any MCP client.
app = FastAPI(
title="Homelable MCP",
lifespan=lifespan,
routes=[Mount("/mcp", app=session_manager.handle_request)],
)
app.add_middleware(ApiKeyMiddleware)
@app.api_route("/mcp", methods=["GET", "POST", "DELETE"])
async def mcp_endpoint(request: Request):
await session_manager.handle_request(request.scope, request.receive, request._send)
@app.get("/health")
async def health():
return {"status": "ok"}