Compare commits

...

195 Commits

Author SHA1 Message Date
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
Pouzor 4260a6582c Bimp version 1.10.1 2026-04-20 15:05:12 +02:00
Pouzor a0f18dd237 fix: use custom icon in proxmox container mode header 2026-04-20 14:57:45 +02:00
Pouzor 00edc32aeb fix: show visible properties on proxmox container mode node header 2026-04-20 14:45:37 +02:00
findthelorax cd6a788f77 fix: test was missing leading / 2026-04-20 14:37:08 +02:00
findthelorax 9cf6a48b04 fix: port input to text numeric and removed up/down arrows 2026-04-20 14:37:08 +02:00
findthelorax 2c94616afa cleanup path examples 2026-04-20 14:37:08 +02:00
findthelorax c7be851c34 feature: add support for services to use a path 2026-04-20 14:37:08 +02:00
Remy fddfd0a769 Merge pull request #90 from Pouzor/feat/zone-color-opacity
feat: add opacity slider to zone color pickers (fixes #72)
2026-04-20 14:13:35 +02:00
Pouzor 074b49358b feat: add opacity slider to zone color pickers (fixes #72)
The native <input type="color"> only supports 6-digit hex, stripping alpha
and forcing background/border/text colors to be fully opaque on edit.

Each color field now shows an opacity slider (0–100%) below the swatch.
Values are stored as 8-digit hex (#rrggbbaa). Existing zones with 6-digit
colors are handled transparently (alpha defaults to 100%).

- colorUtils.ts: hexToRgba / rgbaToHex8 helpers
- GroupRectModal: opacity sliders for all three color fields
- 26 new tests across colorUtils and GroupRectModal
2026-04-20 14:05:06 +02:00
Remy a47b7649f0 Merge pull request #89 from Pouzor/feat/export-quality
feat: PNG export quality selector (standard / high / ultra)
2026-04-20 13:50:42 +02:00
Pouzor 7e08a85f73 feat: add quality selector to PNG export (standard / high / ultra)
Clicking Export PNG now opens a modal with three quality presets:
- Standard (1× pixel ratio) — small file
- High (2×, default) — recommended for sharing
- Ultra (4×) — print quality

Adds ExportModal component, updates exportToPng() to accept a quality
param, and wires the modal into App.tsx replacing the direct export call.
2026-04-20 11:40:16 +02:00
Remy c7c5183356 Merge pull request #87 from findthelorax/fix/reset-form-data
fix: resets form data after submission
2026-04-20 10:48:02 +02:00
Pouzor 7608d07255 fix: flush before reading node IDs in bulk/single approve; 404/409 guards; catch scan errors
- db.flush() ensures node.id is populated before reading — fixes bulk approve
  where node_ids were null, causing frontend to skip addNode for every device
- approve_device raises 404 on missing device, 409 on already-processed device
- _background_scan rollbacks dirty session then marks run as "failed"
- Explicit Node() field mapping instead of **model_dump() to prevent injection
- update_scan_config rolls back in-memory change if save_overrides() fails
- clear_pending uses bulk DELETE instead of N individual row deletes
2026-04-20 10:41:02 +02:00
Remy 1bc6798d76 Merge pull request #84 from findthelorax/feature/property-icons
feature: added new icons for properties
2026-04-20 10:07:49 +02:00
findthelorax f6de7d1770 fix: removed setState within an effect, responsibility moved to parent key 2026-04-20 00:13:07 -04:00
findthelorax 9dddd00858 fix: resets form data after submission 2026-04-20 00:06:29 -04:00
findthelorax a5bf9c9db6 feature: added new icons for properties 2026-04-19 21:00:20 -04:00
Remy a9c5c538b4 Merge pull request #82 from Pouzor/1.10.0
1.10.0
2026-04-19 23:49:38 +02:00
Pouzor a816faa0b9 fix: prevent node width expansion when content overflows after resize
Proxmox nodes with container_mode=false fell through both width conditions
in deserializeApiNode and got no explicit width on reload, causing RF to
auto-size to content width and ignoring the user's manual resize.

- canvasSerializer: unified width restore logic — saved width applies to all
  node types; proxmox container_mode defaults (300x200) only kick in when
  no saved width exists
- BaseNode: add overflow-hidden + min-w-0 to properties row so truncate
  actually clips long values instead of expanding the node
2026-04-19 23:43:15 +02:00
Pouzor fbfacec6dc fix: prevent node from expanding beyond resized width on reload 2026-04-19 22:58:10 +02:00
Pouzor b5eb8d1b74 fix: remove duplicate primaryIp export in maskIp.ts after rebase 2026-04-19 22:30:50 +02:00
Pouzor 0193f933ce feat: bulk approve/hide pending devices (#70)
- Backend: POST /scan/pending/bulk-approve and /scan/pending/bulk-hide endpoints (registered before dynamic routes to avoid conflict); bulk-approve response includes device_ids for frontend mapping
- Frontend: PendingDevicesPanel gains per-row checkboxes, select-all, and a bulk action bar (Approve N / Hide N) that appears when ≥1 device is selected
- Tests: 6 new backend API tests + 7 new frontend UI tests for bulk selection flows
2026-04-19 22:13:40 +02:00
Pouzor 5ad5eba58c feat: add connection handles to zone nodes (closes #58)
- GroupRectNode now renders source+target handles on all four sides
  (top, right, bottom, left) using IDs zone-{side} / zone-{side}-t
- Handles are hover-only: opacity 0 by default, fade in on mouse enter
- Handle color matches the zone border color (respects custom_colors)
- Zone↔zone and zone↔node connections both allowed; edge type picker
  (EdgeModal) opens on connect so user chooses ethernet/wifi/vlan/etc.
- Add GroupRectNode.test.tsx: verifies 8 handles rendered (4 source + 4 target)
- Fix @xyflow/react mocks in LiveView and CanvasContainer tests to include Position
2026-04-19 22:13:40 +02:00
Pouzor ef96cafcc8 feat: IPv6 support and multi-IP per node (closes #60)
- maskIp handles IPv6 addresses (masks second and last group)
- maskIp handles comma-separated IP strings (masks each address)
- Add splitIps() helper to parse comma-separated IP field
- Add primaryIp() helper used by status checker (first IP wins)
- BaseNode renders each IP on its own line when comma-separated
- NodeModal placeholder shows comma-separated example
- Backend status_checker uses only first IP for connectivity checks
- Expand maskIp test suite: IPv6, comma-separated, splitIps, primaryIp
2026-04-19 22:13:40 +02:00
Pouzor 6c9974b357 bump version 1.10 2026-04-19 22:13:40 +02:00
Pouzor ce5fc785e1 chore: bump version to 1.10.0 2026-04-19 22:13:40 +02:00
Pouzor 0019c086cf feat: automatic DB backup before migrations using VERSION file
- Add VERSION file at repo root as single source of truth for app version
- frontend/vite.config.ts reads VERSION file instead of package.json
- backend config.py exposes APP_VERSION read from VERSION (dev) or /app/VERSION (Docker)
- database.py backs up DB to homelab.db.back-{version} before running migrations
  (skipped if DB doesn't exist or backup already exists — fully idempotent)
- Dockerfile.backend and Dockerfile.frontend copy VERSION into the image
- Add test_db_backup.py with 4 tests covering create/skip/idempotent/version cases
2026-04-19 22:09:43 +02:00
Remy 0eff7da46e Merge pull request #81 from Pouzor/fix/clickable-ip-multiip
fix: handle multi-IP for clickable IP link
2026-04-19 22:08:19 +02:00
Pouzor 2e6ee9dad2 fix: handle multi-IP and add tests for clickable IP link
Follow-up to #78:
- Use primaryIp() so href targets the first IP when data.ip is comma-separated (e.g. "192.168.1.1, 2001:db8::1")
- Add primaryIp() helper to maskIp.ts
- Add 4 tests covering single IP link, absent IP, multi-IP href, multi-IP display text
2026-04-19 22:04:37 +02:00
findthelorax 81b109f981 feat: make IP Address clickable in detail panel
- Display IP as a clickable link that opens http://<ip> in a new tab
- Match the existing Hostname link styling and behavior
- Add external link icon to indicate it's clickable
2026-04-19 10:08:09 -04:00
Brett Ferrante 73b16a7620 Merge pull request #15 from Pouzor/main
Merge updates from Pouzor main
2026-04-19 09:21:21 -04:00
Remy a37bf101d2 Merge pull request #76 from findthelorax/bug/drag-from-title
Fix/drag from title
2026-04-19 11:41:41 +02:00
Remy 5def6b7fbf Merge pull request #75 from dopp1e/fix-curl-healthcheck
fix: add curl to backend image to support the default healthcheck
2026-04-19 11:35:07 +02:00
Brett Ferrante 892710faac Merge branch 'Pouzor:main' into bug/bezier-label 2026-04-18 22:27:02 -04:00
Brett Ferrante eb235cb101 Merge branch 'Pouzor:main' into bug/drag-from-title 2026-04-18 22:14:00 -04:00
Brett Ferrante 04a1c63558 Merge pull request #14 from Pouzor/main
Merge with Pouzor main branch
2026-04-18 21:36:53 -04:00
doppie 88f0c03c57 fix: add curl to backend image to support the default healthcheck 2026-04-19 01:08:51 +02:00
Remy 718aff5918 Merge pull request #73 from Pouzor/feature/extended-zoom
feat: extended zoom range + zoom-aware node borders
2026-04-18 23:10:24 +02:00
Pouzor 70311e6331 fix: propertyIcons mock, key stability, isValidConnection memoized
- Add resolvePropertyIcon mock to BaseNode tests
- Reset mockZoom in top-level beforeEach to prevent test bleed
- Use prop.key instead of index as list key in BaseNode properties
- Extract isValidConnection into useCallback in CanvasContainer
2026-04-18 23:03:21 +02:00
Pouzor 6a3da5aded fix: memoize onBeforeDelete and fix test mock selectors
Extract onBeforeDelete into useCallback to avoid new fn ref each render.
Fix useThemeStore/useCanvasStore mocks to call the selector fn rather
than ignoring it, and reset mockZoom in beforeEach.
2026-04-18 23:03:21 +02:00
Pouzor 35c3d00f17 fix: memoize borderWidth and sync boxShadow glow ring with zoom
- Wrap borderWidth in useMemo to avoid re-computing on unrelated renders
- Replace hardcoded 1px in boxShadow with borderWidth so glow ring
  matches border thickness at all zoom levels
- Add useViewport mock + 4 zoom-scaling tests to BaseNode.test.tsx
2026-04-18 23:01:27 +02:00
findthelorax 3a5cb0de21 feature: extend zoom out and border width math 2026-04-18 23:01:27 +02:00
Remy f72d44d5e5 Merge pull request #71 from Pouzor/feature/double-click-to-edit
feat: double-click node to open edit modal
2026-04-18 21:55:01 +02:00
Pouzor a7b244502e fix: bump pytest-asyncio to 1.3.0 (first version supporting pytest 9) 2026-04-18 21:52:32 +02:00
Pouzor 72d5a51b44 fix: bump pytest-asyncio to 1.0.0 for pytest 9 compatibility
pytest-asyncio 0.26.0 requires pytest<9; 1.0.0 supports pytest 9.x
2026-04-18 21:45:10 +02:00
Pouzor 12f46715c1 fix: bump python-multipart, pytest, pytest-asyncio to fix CVEs
- python-multipart 0.0.22 → 0.0.26 (CVE-2026-40347)
- pytest 8.3.3 → 9.0.3 (CVE-2025-71176)
- pytest-asyncio 0.24.0 → 0.26.0 (pytest 9 compat)
2026-04-18 21:36:09 +02:00
Pouzor 62f674b15d fix: extract handleNodeDoubleClick into useCallback and add tests
Replace inline arrow with named useCallback handler to avoid creating
a new fn ref on every render. Add two CanvasContainer tests covering
the double-click callback and the no-op path when prop is omitted.
2026-04-18 21:09:49 +02:00
findthelorax b0a67744f5 bug: fixed to allow draging from the titlebar 2026-04-17 22:34:17 -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
findthelorax 04069e080a feature: double-click on a node to open an edit modal 2026-04-16 10:10:30 -04:00
Pouzor dd1f690892 feat: add logo assets in docs/logo
Icon (house + network nodes, dark bg) and horizontal logo (icon +
"Home"/"lable" color-split text) in multiple sizes:
- icon.svg + icon-16/32/64/128/256/512.svg
- logo.svg + logo-sm/md/lg.svg
2026-04-10 12:04:02 +02:00
Remy 8b04deb608 Merge pull request #56 from Pouzor/1.9
v1.9.0 — Node properties, edge waypoints, version display
2026-04-09 19:17:14 +02:00
Pouzor 531fb12eab fix: replace SVG animate with CSS animation for basic edge to prevent bounce
SVG <animate> restarts on every React re-render (especially visible under
StrictMode double-invoke in dev), causing a visible bounce. CSS animation
runs on the compositor thread independently of React renders.
2026-04-09 16:52:43 +02:00
Pouzor e666abefad chore: remove db backup from tracking and add *.db.back to gitignore 2026-04-09 16:34:25 +02:00
Pouzor f1e9fd7cf8 test: verify standalone mode round-trips for properties, waypoints and animations
- Fix ApiEdge type: add 'basic' to animated union
- Add 5 standalone storage tests: properties, empty properties, waypoints,
  basic animation, and all three animation types round-trip through localStorage
2026-04-09 16:28:20 +02:00
Pouzor 9134812e32 feat: add Basic edge animation with consistent downward direction
Adds a "basic" animation type that uses React Flow's native moving-dash
style — the entire stroke is animated dashes with no solid line underneath.
Direction is always physically downward (top node → bottom node) regardless
of which end was the source when the edge was drawn.
2026-04-09 16:04:43 +02:00
Pouzor 4844576c3b feat: add Basic animation type for edges
Adds a new "basic" animation mode that uses React Flow's native animated
dashes — the entire stroke moves as dashes, with no solid line underneath.
Distinct from "snake" (moving dot) and "flow" (overlay on solid line).

- Frontend: new Basic button in EdgeModal, animMode mapping, passes
  animated={true} to BaseEdge when mode is "basic"
- Backend: normalize_animated accepts "basic" as a valid value
- Tests: EdgeModal + canvas round-trip tests for basic mode
2026-04-09 15:46:52 +02:00
Pouzor 4976f2e694 test: improve canvas route coverage to 100% and fix async tracing
- Add 22 new tests for canvas save/load: properties round-trip,
  edge waypoints, edge handles, animated, parent/child nodes,
  group nodes, all node fields, bottom_handles, services/notes,
  check_method/target, viewport update branches, edge update branch
- Fix coverage.py async tracing by setting core = "sysmon" in
  pyproject.toml (Python 3.12+ sys.monitoring API), resolving the
  sys.settrace drop-after-await issue
2026-04-09 15:17:58 +02:00
Pouzor d6a7b062f4 fix: clear node height when properties change to prevent overflow outside border 2026-04-09 14:34:01 +02:00
Pouzor 94c6ac7fa7 fix: add type parameters to bare list annotations for mypy compliance 2026-04-09 14:23:13 +02:00
Pouzor e0f96001e2 chore: bump version to 1.9.0 2026-04-09 14:06:24 +02:00
135 changed files with 11440 additions and 1204 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']
+1
View File
@@ -45,6 +45,7 @@ htmlcov/
*.db
*.db-shm
*.db-wal
*.db.back
# Docker
.docker/
+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.
+3 -2
View File
@@ -2,13 +2,14 @@ FROM python:3.13-slim
WORKDIR /app
# Install nmap for network scanning + iputils-ping for ping-based status checks
RUN apt-get update && apt-get install -y --no-install-recommends nmap iputils-ping && rm -rf /var/lib/apt/lists/*
# Install nmap for network scanning + iputils-ping for ping-based status checks + curl for the health check
RUN apt-get update && apt-get install -y --no-install-recommends nmap iputils-ping curl && rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/ .
COPY VERSION /app/VERSION
# Create data directory (volume mount point)
RUN mkdir -p /app/data
+1
View File
@@ -12,6 +12,7 @@ COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ .
COPY VERSION ../VERSION
RUN npm run build
# Stage 2: serve
+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
View File
@@ -0,0 +1 @@
2.0.2
+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}
+231 -11
View File
@@ -11,12 +11,16 @@ 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
class BulkActionRequest(BaseModel):
device_ids: list[str]
class ScanConfig(BaseModel):
ranges: list[str]
@@ -37,7 +41,15 @@ router = APIRouter()
async def _background_scan(run_id: str, ranges: list[str]) -> None:
async with AsyncSessionLocal() as db:
try:
await run_scan(ranges, db, run_id)
except Exception:
logger.exception("Scan run %s failed unexpectedly", run_id)
await db.rollback()
run = await db.get(ScanRun, run_id)
if run and run.status == "running":
run.status = "failed"
await db.commit()
@router.post("/trigger", response_model=ScanRunResponse)
@@ -85,12 +97,10 @@ async def clear_pending(
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, int]:
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending"))
devices = result.scalars().all()
for device in devices:
await db.delete(device)
from sqlalchemy import delete as sa_delete
result = await db.execute(sa_delete(PendingDevice).where(PendingDevice.status == "pending"))
await db.commit()
return {"deleted": len(devices)}
return {"deleted": result.rowcount}
@router.get("/hidden", response_model=list[PendingDeviceResponse])
@@ -99,6 +109,109 @@ async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_c
return list(result.scalars().all())
@router.post("/pending/bulk-approve", response_model=dict)
async def bulk_approve_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 == "pending",
)
)
devices = result.scalars().all()
created_nodes: list[Node] = []
for device in devices:
device.status = "approved"
node = Node(
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),
}
@router.post("/pending/bulk-hide", response_model=dict)
async def bulk_hide_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 == "pending",
)
)
devices = result.scalars().all()
for device in devices:
device.status = "hidden"
await db.commit()
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,
@@ -107,13 +220,118 @@ async def approve_device(
_: str = Depends(get_current_user),
) -> dict[str, Any]:
device = await db.get(PendingDevice, device_id)
if device:
if not device:
raise HTTPException(status_code=404, detail="Device not found")
if device.status != "pending":
raise HTTPException(status_code=409, detail="Device already processed")
device.status = "approved"
node = Node(**node_data.model_dump())
node = Node(
label=node_data.label,
type=node_data.type,
ip=node_data.ip,
hostname=node_data.hostname,
status=node_data.status,
services=node_data.services or [],
ieee_address=device.ieee_address,
# Honour caller-supplied check_method, else default to ping when an IP exists
# so the scheduler doesn't silently skip the new node.
check_method=node_data.check_method or ("ping" if node_data.ip else None),
check_target=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": False}
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")
@@ -153,10 +371,12 @@ async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
@router.post("/config", response_model=ScanConfig)
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig:
try:
previous = settings.scanner_ranges
settings.scanner_ranges = payload.ranges
try:
settings.save_overrides()
return payload
except Exception as exc:
settings.scanner_ranges = previous
logger.error("Failed to save scan config: %s", exc)
raise HTTPException(status_code=500, detail="Failed to save scan config") from exc
+260
View File
@@ -0,0 +1,260 @@
"""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="unknown",
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")
+11
View File
@@ -7,6 +7,17 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
logger = logging.getLogger(__name__)
def _read_version() -> str:
for candidate in [
Path(__file__).parent.parent.parent.parent / "VERSION", # repo root (dev)
Path("/app/VERSION"), # Docker image
]:
if candidate.exists():
return candidate.read_text().strip()
return "unknown"
APP_VERSION = _read_version()
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
+111 -2
View File
@@ -1,12 +1,33 @@
import logging
import shutil
from collections.abc import AsyncGenerator
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 settings
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)
@@ -23,7 +44,22 @@ class Base(DeclarativeBase):
pass
def _backup_db() -> None:
db_path = Path(settings.sqlite_path)
if not db_path.exists():
return
backup_path = db_path.with_suffix(f".db.back-{APP_VERSION}")
if backup_path.exists():
return
try:
shutil.copy2(db_path, backup_path)
logger.info("DB backup created: %s", backup_path.name)
except OSError:
logger.warning("Could not create DB backup at %s", backup_path)
async def init_db() -> None:
_backup_db()
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Add columns introduced after initial schema (idempotent)
@@ -61,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(
+30 -2
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)
@@ -69,7 +70,7 @@ class Edge(Base):
animated: Mapped[str] = mapped_column(String, nullable=False, default='none')
source_handle: Mapped[str | None] = mapped_column(String)
target_handle: Mapped[str | None] = mapped_column(String)
waypoints: Mapped[list | None] = mapped_column(JSON, nullable=True)
waypoints: Mapped[list[dict[str, float]] | None] = mapped_column(JSON, nullable=True)
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)
+3 -2
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
@@ -35,7 +35,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
app = FastAPI(
title="Homelable API",
version="1.8.3",
version="1.9.0",
lifespan=lifespan,
)
@@ -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")
+3 -1
View File
@@ -50,7 +50,7 @@ class EdgeSave(BaseModel):
animated: str = 'none'
source_handle: str | None = None
target_handle: str | None = None
waypoints: list | None = None
waypoints: list[dict[str, float]] | None = None
@field_validator('animated', mode='before')
@classmethod
@@ -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
+2 -2
View File
@@ -17,7 +17,7 @@ class EdgeBase(BaseModel):
animated: str = 'none'
source_handle: str | None = None
target_handle: str | None = None
waypoints: list | None = None
waypoints: list[dict[str, float]] | None = None
@field_validator('animated', mode='before')
@classmethod
@@ -39,7 +39,7 @@ class EdgeUpdate(BaseModel):
animated: str | None = None
source_handle: str | None = None
target_handle: str | None = None
waypoints: list | None = None
waypoints: list[dict[str, float]] | None = None
@field_validator('animated', mode='before')
@classmethod
+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
+1 -1
View File
@@ -4,6 +4,6 @@ def normalize_animated(v: object) -> str:
return 'snake'
if v is False or v == 0 or v == '0' or v is None or v == 'none':
return 'none'
if v in ('snake', 'flow'):
if v in ('snake', 'flow', 'basic'):
return str(v)
return 'none'
+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
+9 -1
View File
@@ -19,7 +19,9 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
if check_method == "none":
return {"status": "online", "response_time_ms": None}
host = target or ip
# Use only the first IP when the field contains comma-separated addresses
raw_ip = ip.split(",")[0].strip() if ip else None
host = target or raw_ip
if not host:
return {"status": "unknown", "response_time_ms": None}
@@ -58,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
+1
View File
@@ -2,3 +2,4 @@
*.db-shm
*.db-wal
scan_config.json
homelab.db.*
Binary file not shown.
+2
View File
@@ -25,6 +25,8 @@ addopts = "--tb=short -q"
[tool.coverage.run]
source = ["app"]
omit = ["*/migrations/*", "*/tests/*"]
concurrency = ["thread"]
core = "sysmon"
[tool.coverage.report]
skip_empty = true
+4 -3
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.22
python-multipart==0.0.27
apscheduler==3.10.4
python-nmap==0.7.1
pyyaml==6.0.2
@@ -17,10 +17,11 @@ 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
mypy==1.11.2
pytest==8.3.3
pytest-asyncio==0.24.0
pytest==9.0.3
pytest-asyncio==1.3.0
pytest-cov==5.0.0
+340
View File
@@ -256,3 +256,343 @@ async def test_save_canvas_dimensions_cleared_when_null(client: AsyncClient, hea
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["width"] is None
assert canvas["nodes"][0]["height"] is None
# ── properties ────────────────────────────────────────────────────────────────
async def test_save_canvas_properties_default_empty(client: AsyncClient, headers: dict):
n1 = node_payload()
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["properties"] == []
async def test_save_canvas_persists_properties(client: AsyncClient, headers: dict):
props = [
{"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": True},
{"key": "CPU", "value": "Intel i9", "icon": "Cpu", "visible": False},
]
n1 = node_payload(properties=props)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
returned = canvas["nodes"][0]["properties"]
assert len(returned) == 2
assert returned[0] == {"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": True}
assert returned[1] == {"key": "CPU", "value": "Intel i9", "icon": "Cpu", "visible": False}
async def test_save_canvas_properties_updated_on_second_save(client: AsyncClient, headers: dict):
n1 = node_payload(properties=[{"key": "RAM", "value": "16 GB", "icon": None, "visible": True}])
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
n1_updated = {**n1, "properties": [
{"key": "RAM", "value": "64 GB", "icon": "MemoryStick", "visible": True},
{"key": "Disk", "value": "2 TB", "icon": "HardDrive", "visible": True},
]}
await client.post("/api/v1/canvas/save", json={"nodes": [n1_updated], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
props = canvas["nodes"][0]["properties"]
assert len(props) == 2
assert props[0]["value"] == "64 GB"
assert props[1]["key"] == "Disk"
async def test_save_canvas_properties_with_null_icon(client: AsyncClient, headers: dict):
props = [{"key": "Note", "value": "custom rack", "icon": None, "visible": True}]
n1 = node_payload(properties=props)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["properties"][0]["icon"] is None
async def test_save_canvas_properties_cleared_to_empty(client: AsyncClient, headers: dict):
n1 = node_payload(properties=[{"key": "RAM", "value": "32 GB", "icon": None, "visible": True}])
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
n1_cleared = {**n1, "properties": []}
await client.post("/api/v1/canvas/save", json={"nodes": [n1_cleared], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["properties"] == []
# ── edge waypoints & handles ──────────────────────────────────────────────────
async def test_save_canvas_edge_waypoints_default_null(client: AsyncClient, headers: dict):
n1 = node_payload()
n2 = node_payload()
e1 = edge_payload(n1["id"], n2["id"])
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["edges"][0]["waypoints"] is None
async def test_save_canvas_persists_waypoints_on_edge(client: AsyncClient, headers: dict):
n1 = node_payload()
n2 = node_payload()
waypoints = [{"x": 100.0, "y": 200.0}, {"x": 300.0, "y": 150.0}]
e1 = edge_payload(n1["id"], n2["id"], waypoints=waypoints)
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
returned = canvas["edges"][0]["waypoints"]
assert returned == [{"x": 100.0, "y": 200.0}, {"x": 300.0, "y": 150.0}]
async def test_save_canvas_waypoints_updated_on_second_save(client: AsyncClient, headers: dict):
n1 = node_payload()
n2 = node_payload()
e1 = edge_payload(n1["id"], n2["id"], waypoints=[{"x": 10.0, "y": 20.0}])
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
e1_updated = {**e1, "waypoints": [{"x": 50.0, "y": 60.0}, {"x": 70.0, "y": 80.0}]}
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1_updated], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["edges"][0]["waypoints"] == [{"x": 50.0, "y": 60.0}, {"x": 70.0, "y": 80.0}]
async def test_save_canvas_persists_edge_handles(client: AsyncClient, headers: dict):
n1 = node_payload(bottom_handles=3)
n2 = node_payload()
e1 = edge_payload(n1["id"], n2["id"], source_handle="bottom-1", target_handle="top")
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
edge = canvas["edges"][0]
assert edge["source_handle"] == "bottom-1"
assert edge["target_handle"] == "top"
async def test_save_canvas_persists_animated_edge(client: AsyncClient, headers: dict):
n1 = node_payload()
n2 = node_payload()
e1 = edge_payload(n1["id"], n2["id"], animated="snake")
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["edges"][0]["animated"] == "snake"
async def test_save_canvas_persists_animated_basic(client: AsyncClient, headers: dict):
n1 = node_payload()
n2 = node_payload()
e1 = edge_payload(n1["id"], n2["id"], animated="basic")
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["edges"][0]["animated"] == "basic"
# ── node fields ───────────────────────────────────────────────────────────────
async def test_save_canvas_persists_all_node_fields(client: AsyncClient, headers: dict):
n1 = node_payload(
type="server",
label="Main Server",
hostname="server.local",
ip="192.168.1.10",
mac="aa:bb:cc:dd:ee:ff",
os="Ubuntu 22.04",
status="online",
check_method="http",
check_target="http://192.168.1.10",
services=[{"name": "nginx", "port": 80}],
notes="Primary web server",
pos_x=150.0,
pos_y=250.0,
bottom_handles=2,
)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
node = canvas["nodes"][0]
assert node["hostname"] == "server.local"
assert node["ip"] == "192.168.1.10"
assert node["mac"] == "aa:bb:cc:dd:ee:ff"
assert node["os"] == "Ubuntu 22.04"
assert node["status"] == "online"
assert node["check_method"] == "http"
assert node["check_target"] == "http://192.168.1.10"
assert node["services"] == [{"name": "nginx", "port": 80}]
assert node["notes"] == "Primary web server"
assert node["pos_x"] == 150.0
assert node["pos_y"] == 250.0
assert node["bottom_handles"] == 2
async def test_save_canvas_persists_bottom_handles(client: AsyncClient, headers: dict):
n1 = node_payload(bottom_handles=4)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["bottom_handles"] == 4
async def test_save_canvas_bottom_handles_defaults_one(client: AsyncClient, headers: dict):
n1 = node_payload()
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["bottom_handles"] == 1
async def test_save_canvas_persists_services_and_notes(client: AsyncClient, headers: dict):
services = [{"name": "ssh", "port": 22}, {"name": "http", "port": 80}]
n1 = node_payload(services=services, notes="My NAS device")
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
node = canvas["nodes"][0]
assert node["services"] == services
assert node["notes"] == "My NAS device"
async def test_save_canvas_persists_service_paths(client: AsyncClient, headers: dict):
services = [{"service_name": "Grafana", "protocol": "tcp", "port": 3000, "path": "/login"}]
n1 = node_payload(ip="192.168.1.50:8080", services=services)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["services"] == services
async def test_save_canvas_persists_check_fields(client: AsyncClient, headers: dict):
n1 = node_payload(check_method="ping", check_target="192.168.1.1")
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
node = canvas["nodes"][0]
assert node["check_method"] == "ping"
assert node["check_target"] == "192.168.1.1"
# ── parent/child nodes ────────────────────────────────────────────────────────
async def test_save_canvas_persists_parent_child_nodes(client: AsyncClient, headers: dict):
parent = node_payload(type="proxmox", label="PVE Host")
child = node_payload(type="vm", label="VM-100", parent_id=parent["id"])
await client.post("/api/v1/canvas/save", json={"nodes": [parent, child], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
node_map = {n["id"]: n for n in canvas["nodes"]}
assert node_map[child["id"]]["parent_id"] == parent["id"]
assert node_map[parent["id"]]["parent_id"] is None
async def test_save_canvas_child_removed_with_parent(client: AsyncClient, headers: dict):
parent = node_payload(type="proxmox", label="PVE Host")
child = node_payload(type="lxc", label="LXC-101", parent_id=parent["id"])
await client.post("/api/v1/canvas/save", json={"nodes": [parent, child], "edges": [], "viewport": {}}, headers=headers)
# Remove both parent and child
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"] == []
# ── groupRect / group node ────────────────────────────────────────────────────
async def test_save_canvas_persists_group_node(client: AsyncClient, headers: dict):
group = node_payload(type="group", label="Network Zone", width=400.0, height=300.0)
member = node_payload(type="server", label="Member", parent_id=group["id"])
await client.post("/api/v1/canvas/save", json={"nodes": [group, member], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
node_map = {n["id"]: n for n in canvas["nodes"]}
assert node_map[group["id"]]["type"] == "group"
assert node_map[group["id"]]["width"] == 400.0
assert node_map[group["id"]]["height"] == 300.0
assert node_map[member["id"]]["parent_id"] == group["id"]
# ── viewport ──────────────────────────────────────────────────────────────────
async def test_load_canvas_returns_default_viewport_when_no_state(client: AsyncClient, headers: dict):
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["viewport"] == {"x": 0, "y": 0, "zoom": 1}
async def test_save_canvas_updates_existing_canvas_state(client: AsyncClient, headers: dict):
"""Second save updates the existing CanvasState row (exercises the state.viewport branch)."""
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {"x": 1, "y": 2, "zoom": 1}}, headers=headers)
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {"x": 99, "y": 88, "zoom": 0.75}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["viewport"] == {"x": 99, "y": 88, "zoom": 0.75}
# ── edge types ────────────────────────────────────────────────────────────────
async def test_save_canvas_persists_edge_type_vlan(client: AsyncClient, headers: dict):
n1 = node_payload()
n2 = node_payload()
e1 = edge_payload(n1["id"], n2["id"], type="vlan", vlan_id=10, label="VLAN 10")
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
edge = canvas["edges"][0]
assert edge["type"] == "vlan"
assert edge["vlan_id"] == 10
assert edge["label"] == "VLAN 10"
async def test_save_canvas_edge_update_existing(client: AsyncClient, headers: dict):
"""Second save updates an existing edge (exercises the db_edge branch)."""
n1 = node_payload()
n2 = node_payload()
e1 = edge_payload(n1["id"], n2["id"], label="original")
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
e1_updated = {**e1, "label": "updated", "custom_color": "#ff0000"}
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1_updated], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
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"]
+57
View File
@@ -0,0 +1,57 @@
"""
Tests for automatic DB backup before migrations.
"""
import os
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
from pathlib import Path
from unittest.mock import patch
import pytest
from app.db.database import _backup_db
@pytest.fixture()
def tmp_db(tmp_path: Path):
db = tmp_path / "homelab.db"
db.write_bytes(b"SQLite placeholder")
return db
def test_backup_created_when_db_exists(tmp_db: Path):
with patch("app.db.database.settings") as mock_settings, \
patch("app.db.database.APP_VERSION", "1.9"):
mock_settings.sqlite_path = str(tmp_db)
_backup_db()
backup = tmp_db.parent / "homelab.db.back-1.9"
assert backup.exists()
assert backup.read_bytes() == b"SQLite placeholder"
def test_backup_skipped_when_db_missing(tmp_path: Path):
with patch("app.db.database.settings") as mock_settings, \
patch("app.db.database.APP_VERSION", "1.9"):
mock_settings.sqlite_path = str(tmp_path / "nonexistent.db")
_backup_db()
assert not any(tmp_path.glob("*.back-*"))
def test_backup_idempotent_second_call_no_overwrite(tmp_db: Path):
with patch("app.db.database.settings") as mock_settings, \
patch("app.db.database.APP_VERSION", "1.9"):
mock_settings.sqlite_path = str(tmp_db)
_backup_db()
backup = tmp_db.parent / "homelab.db.back-1.9"
backup.write_bytes(b"original backup")
_backup_db()
assert backup.read_bytes() == b"original backup"
def test_backup_version_in_filename(tmp_db: Path):
with patch("app.db.database.settings") as mock_settings, \
patch("app.db.database.APP_VERSION", "2.0"):
mock_settings.sqlite_path = str(tmp_db)
_backup_db()
assert (tmp_db.parent / "homelab.db.back-2.0").exists()
+372 -2
View File
@@ -120,8 +120,7 @@ async def test_approve_nonexistent_device(client: AsyncClient, headers):
json=node_payload,
headers=headers,
)
assert res.status_code == 200
assert res.json()["approved"] is False
assert res.status_code == 404
# --- Hide device ---
@@ -141,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
@@ -444,3 +486,331 @@ async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession
# Services and hostname should be updated
assert device.hostname == "myhost.lan"
assert any(s["port"] == 8096 for s in device.services)
# --- Bulk approve ---
@pytest.fixture
async def two_pending_devices(db_session):
devices = []
for i in range(2):
d = PendingDevice(
id=str(uuid.uuid4()),
ip=f"192.168.1.{10 + i}",
mac=None,
hostname=f"host-{i}",
os=None,
services=[],
suggested_type="generic",
status="pending",
)
db_session.add(d)
devices.append(d)
await db_session.commit()
for d in devices:
await db_session.refresh(d)
return devices
@pytest.mark.asyncio
async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_pending_devices):
ids = [d.id for d in two_pending_devices]
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
assert res.status_code == 200
data = res.json()
assert data["approved"] == 2
assert len(data["node_ids"]) == 2
assert all(nid is not None for nid in data["node_ids"]), "node_ids must be non-null UUIDs"
assert len(data["device_ids"]) == 2
assert data["skipped"] == 0
# Pending list should now be empty
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
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]
# Approve first device individually first
await client.post(
f"/api/v1/scan/pending/{ids[0]}/approve",
json={"label": "h", "type": "generic", "ip": "192.168.1.10", "status": "unknown", "services": []},
headers=headers,
)
# Bulk approve both — first one is already approved (not pending), should be skipped
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
assert res.status_code == 200
data = res.json()
assert data["approved"] == 1
assert data["skipped"] == 1
@pytest.mark.asyncio
async def test_bulk_approve_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-approve", json={"device_ids": ids})
assert res.status_code == 401
# --- Bulk hide ---
@pytest.mark.asyncio
async def test_bulk_hide_hides_devices(client: AsyncClient, headers, two_pending_devices):
ids = [d.id for d in two_pending_devices]
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers)
assert res.status_code == 200
data = res.json()
assert data["hidden"] == 2
assert data["skipped"] == 0
# Should appear in hidden list
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
assert len(hidden_res.json()) == 2
@pytest.mark.asyncio
async def test_bulk_hide_skips_non_pending(client: AsyncClient, headers, two_pending_devices):
ids = [d.id for d in two_pending_devices]
# Hide first device individually first
await client.post(f"/api/v1/scan/pending/{ids[0]}/hide", headers=headers)
# Bulk hide both — first is already hidden (not pending anymore)
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers)
assert res.status_code == 200
data = res.json()
assert data["hidden"] == 1
assert data["skipped"] == 1
@pytest.mark.asyncio
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"
+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="128" height="128" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="16" height="16" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="256" height="256" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="32" height="32" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="512" height="512" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+46
View File
@@ -0,0 +1,46 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<!-- Background -->
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<!-- House body -->
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<!-- Floor line (subtle) -->
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<!-- Door -->
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<!-- Network lines (drawn under nodes) -->
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<!-- Center hub glow -->
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<!-- Center hub -->
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<!-- Left node -->
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<!-- Right node -->
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<!-- Top node -->
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+33
View File
@@ -0,0 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 72" width="640" height="164" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<g transform="translate(4, 4)">
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</g>
<text x="80" y="42" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="600" font-size="30" letter-spacing="-0.5">
<tspan fill="#e6edf3">Home</tspan><tspan fill="#00d4ff">lable</tspan>
</text>
<text x="81" y="58" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="400" font-size="11" fill="#8b949e" letter-spacing="0.5">HomeLab Visualizer</text>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+33
View File
@@ -0,0 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 72" width="360" height="92" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<g transform="translate(4, 4)">
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</g>
<text x="80" y="42" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="600" font-size="30" letter-spacing="-0.5">
<tspan fill="#e6edf3">Home</tspan><tspan fill="#00d4ff">lable</tspan>
</text>
<text x="81" y="58" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="400" font-size="11" fill="#8b949e" letter-spacing="0.5">HomeLab Visualizer</text>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+33
View File
@@ -0,0 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 72" width="200" height="51" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<g transform="translate(4, 4)">
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</g>
<text x="80" y="42" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="600" font-size="30" letter-spacing="-0.5">
<tspan fill="#e6edf3">Home</tspan><tspan fill="#00d4ff">lable</tspan>
</text>
<text x="81" y="58" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="400" font-size="11" fill="#8b949e" letter-spacing="0.5">HomeLab Visualizer</text>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+48
View File
@@ -0,0 +1,48 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 72" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<!-- Icon (72×72, scaled from 64 viewBox) -->
<g transform="translate(4, 4) scale(1)">
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</g>
<!-- Text -->
<text x="80" y="42"
font-family="Inter, system-ui, -apple-system, sans-serif"
font-weight="600"
font-size="30"
letter-spacing="-0.5">
<tspan fill="#e6edf3">Home</tspan><tspan fill="#00d4ff">lable</tspan>
</text>
<!-- Subtitle -->
<text x="81" y="58"
font-family="Inter, system-ui, -apple-system, sans-serif"
font-weight="400"
font-size="11"
fill="#8b949e"
letter-spacing="0.5">HomeLab Visualizer</text>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+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.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "1.8.2",
"version": "1.13.0",
"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.8.3",
"version": "2.0.2",
"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}`)
+235 -51
View File
@@ -5,7 +5,7 @@ import { applyDagreLayout } from '@/utils/layout'
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
import { generateUUID } from '@/utils/uuid'
import { generateMarkdownTable } from '@/utils/exportMarkdown'
import { exportToPng } from '@/utils/export'
import { ExportModal } from '@/components/modals/ExportModal'
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
import { parseYamlToCanvas } from '@/utils/importYaml'
import { TooltipProvider } from '@/components/ui/tooltip'
@@ -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,49 +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)
@@ -83,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)
@@ -99,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)
@@ -150,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
@@ -163,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}"`)
@@ -225,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()
@@ -241,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) {
@@ -305,17 +384,60 @@ export default function App() {
}
}, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved])
const handleExport = useCallback(async () => {
const handleExport = useCallback(() => {
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
if (!el) { toast.error('Canvas not ready'); return }
try {
await exportToPng(el)
toast.success('Exported as PNG')
} catch {
toast.error('Export failed')
}
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)
}, [])
@@ -324,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 })
}
}
@@ -343,6 +469,15 @@ export default function App() {
setEditEdgeId(edge.id)
}, [])
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])
const handleEdgeUpdate = useCallback((data: EdgeData) => {
if (!editEdgeId) return
snapshotHistory()
@@ -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
@@ -400,15 +536,9 @@ export default function App() {
<CanvasContainer
onConnect={handleEdgeConnect}
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} />}
@@ -417,11 +547,14 @@ export default function App() {
</div>
<NodeModal
key={addNodeOpen ? 'add-open' : 'add-closed'}
open={addNodeOpen}
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 */}
@@ -432,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
@@ -470,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)}
@@ -505,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'}
@@ -515,17 +693,23 @@ 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)}
getElement={() => canvasRef.current?.querySelector<HTMLElement>('.react-flow') ?? null}
/>
<Toaster theme="dark" position="bottom-right" />
</ReactFlowProvider>
</TooltipProvider>
+67 -1
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,9 +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[]
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),
@@ -69,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),
}
@@ -11,6 +11,7 @@ vi.mock('@xyflow/react', () => ({
Controls: () => null,
BackgroundVariant: { Dots: 'dots' },
ConnectionMode: { Loose: 'loose' },
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
useReactFlow: () => ({ fitView: vi.fn() }),
}))
vi.mock('@xyflow/react/dist/style.css', () => ({}))
@@ -143,6 +144,7 @@ const XYFLOW_MOCK = {
Controls: () => null,
BackgroundVariant: { Dots: 'dots' },
ConnectionMode: { Loose: 'loose' },
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
useReactFlow: () => ({ fitView: vi.fn() }),
}
@@ -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,16 +20,19 @@ 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 {
onConnect?: (connection: Connection) => void
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
onNodeDoubleClick?: (node: Node<NodeData>) => void
onNodeDragStart?: () => void
onOpenPending?: (deviceId: string) => void
}
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart, onOpenPending }: CanvasContainerProps) {
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onOpenPending }: CanvasContainerProps) {
const [lassoMode, setLassoMode] = useState(true)
const {
nodes, edges,
@@ -68,6 +71,22 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
onEdgeDoubleClick?.(edge)
}, [onEdgeDoubleClick])
const handleNodeDoubleClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => {
onNodeDoubleClick?.(node)
}, [onNodeDoubleClick])
const handleBeforeDelete = useCallback(async () => {
snapshotHistory()
return true
}, [snapshotHistory])
const isValidConnection = useCallback(
(connection: { source: string | null; target: string | null }) => connection.source !== connection.target,
[]
)
const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides()
return (
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}>
<ReactFlow
@@ -79,22 +98,27 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
onEdgeDoubleClick={handleEdgeDoubleClick}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeDragStart={onNodeDragStart}
onNodeDrag={onNodeDrag}
onNodeDragStop={onNodeDragStop}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
deleteKeyCode={['Backspace', 'Delete']}
onBeforeDelete={async () => { snapshotHistory(); return true }}
onBeforeDelete={handleBeforeDelete}
selectionOnDrag={lassoMode}
panOnDrag={lassoMode ? [1, 2] : true}
panActivationKeyCode="Space"
selectionMode={SelectionMode.Partial}
multiSelectionKeyCode={['Meta', 'Control']}
minZoom={0.25}
maxZoom={2.5}
snapToGrid
snapGrid={[8, 8]}
colorMode={theme.colors.reactFlowColorMode}
elevateNodesOnSelect={false}
connectionMode={ConnectionMode.Loose}
isValidConnection={(connection) => connection.source !== connection.target}
isValidConnection={isValidConnection}
>
<Background
variant={BackgroundVariant.Dots}
@@ -103,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)
})
})
@@ -1,23 +1,26 @@
import { describe, it, expect, vi } from 'vitest'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Server } from 'lucide-react'
import { BaseNode } from '../nodes/BaseNode'
import type { NodeData } from '@/types'
import type { Node } from '@xyflow/react'
let mockZoom = 1
vi.mock('@xyflow/react', () => ({
Handle: () => null,
Position: { Top: 'top', Bottom: 'bottom' },
NodeResizer: () => null,
useUpdateNodeInternals: () => vi.fn(),
useViewport: () => ({ zoom: mockZoom }),
}))
vi.mock('@/stores/themeStore', () => ({
useThemeStore: () => 'dark',
useThemeStore: (sel: (s: { activeTheme: string }) => unknown) => sel({ activeTheme: 'dark' }),
}))
vi.mock('@/stores/canvasStore', () => ({
useCanvasStore: () => ({ hideIp: false }),
useCanvasStore: (sel: (s: { hideIp: boolean }) => unknown) => sel({ hideIp: false }),
}))
vi.mock('@/utils/themes', () => ({
@@ -41,17 +44,27 @@ 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', () => ({
resolvePropertyIcon: (icon: string | null) => icon ? Server : null,
}))
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 })
function makeNode(data: Partial<NodeData>): Node<NodeData> {
return {
id: 'n1',
@@ -85,6 +98,39 @@ function renderBaseNode(data: Partial<NodeData>) {
)
}
describe('BaseNode — borderWidth zoom scaling', () => {
beforeEach(() => { mockZoom = 1 })
it('borderWidth is 1px at zoom=1', () => {
mockZoom = 1
const { container } = renderBaseNode({})
expect((container.firstChild as HTMLElement).style.borderWidth).toBe('1px')
})
it('borderWidth scales to 2px at zoom=0.5', () => {
mockZoom = 0.5
const { container } = renderBaseNode({})
expect((container.firstChild as HTMLElement).style.borderWidth).toBe('2px')
})
it('borderWidth is clamped to 1px at zoom=2', () => {
mockZoom = 2
const { container } = renderBaseNode({})
expect((container.firstChild as HTMLElement).style.borderWidth).toBe('1px')
})
it('boxShadow glow ring uses borderWidth when selected + online at zoom=0.5', () => {
mockZoom = 0.5
const node = makeNode({ status: 'online' })
const { container } = render(
<BaseNode id={node.id} data={node.data} selected={true} icon={Server}
type="server" dragging={false} zIndex={0} isConnectable={true}
positionAbsoluteX={0} positionAbsoluteY={0} />
)
expect((container.firstChild as HTMLElement).style.boxShadow).toContain('0 0 0 2px')
})
})
describe('BaseNode — properties rendering', () => {
it('renders visible properties on the node', () => {
renderBaseNode({
@@ -125,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({
@@ -20,6 +20,7 @@ vi.mock('@xyflow/react', () => ({
BackgroundVariant: { Dots: 'dots' },
ConnectionMode: { Loose: 'loose' },
SelectionMode: { Partial: 'partial' },
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
useReactFlow: () => ({ fitView: vi.fn() }),
}))
@@ -104,6 +105,24 @@ describe('CanvasContainer', () => {
}).not.toThrow()
})
// ── Node double-click ─────────────────────────────────────────────────────
it('calls onNodeDoubleClick prop when a node is double-clicked', () => {
const onNodeDoubleClick = vi.fn()
const node = makeNode('n1')
render(<CanvasContainer onNodeDoubleClick={onNodeDoubleClick} />)
;(rfProps.onNodeDoubleClick as (...args: unknown[]) => unknown)({} as MouseEvent, node)
expect(onNodeDoubleClick).toHaveBeenCalledWith(node)
})
it('does not throw when onNodeDoubleClick is not provided', () => {
const node = makeNode('n1')
render(<CanvasContainer />)
expect(() => {
;(rfProps.onNodeDoubleClick as (...args: unknown[]) => unknown)({} as MouseEvent, node)
}).not.toThrow()
})
// ── Connection validation ─────────────────────────────────────────────────
it('isValidConnection returns false for self-connections', () => {
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { GroupNode } from '../nodes/GroupNode'
import * as canvasStore from '@/stores/canvasStore'
import type { Node } from '@xyflow/react'
@@ -102,6 +102,19 @@ describe('GroupNode', () => {
expect(screen.getByTestId('node-resizer').getAttribute('data-visible')).toBe('true')
})
it('allows dragging from the header while keeping rename controls nodrag', () => {
renderGroupNode({ selected: true })
expect(screen.getByText('My Group').closest('div')).not.toHaveClass('nodrag')
const renameButton = screen.getByTitle('Rename group')
expect(renameButton).toHaveClass('nodrag')
fireEvent.click(renameButton)
expect(screen.getByDisplayValue('My Group')).toHaveClass('nodrag')
})
it('shows online/offline status summary from children', () => {
const storeNodes = [
{ id: 'c1', parentId: 'g1', data: { status: 'online' } },
@@ -0,0 +1,77 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { GroupRectNode } from '../nodes/GroupRectNode'
import type { NodeData } from '@/types'
import type { Node } from '@xyflow/react'
vi.mock('@xyflow/react', () => ({
Handle: ({ id, type }: { id: string; type: string }) => <div data-testid={`handle-${id}`} data-type={type} />,
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
NodeResizer: () => null,
}))
vi.mock('@/stores/canvasStore', () => ({
useCanvasStore: (sel: (s: { setEditingGroupRectId: () => void }) => unknown) =>
sel({ setEditingGroupRectId: vi.fn() }),
}))
function makeNode(overrides: Partial<NodeData> = {}): Node<NodeData> {
return {
id: 'zone1',
type: 'groupRect',
position: { x: 0, y: 0 },
data: { label: 'My Zone', type: 'groupRect', status: 'unknown', services: [], ...overrides },
}
}
function renderZone(overrides: Partial<NodeData> = {}) {
const node = makeNode(overrides)
return render(
<GroupRectNode
id={node.id}
data={node.data}
selected={false}
type="groupRect"
dragging={false}
zIndex={0}
isConnectable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
)
}
describe('GroupRectNode — handles', () => {
it('renders source handles on all four sides', () => {
renderZone()
expect(screen.getByTestId('handle-zone-top')).toBeDefined()
expect(screen.getByTestId('handle-zone-right')).toBeDefined()
expect(screen.getByTestId('handle-zone-bottom')).toBeDefined()
expect(screen.getByTestId('handle-zone-left')).toBeDefined()
})
it('renders target handles on all four sides', () => {
renderZone()
expect(screen.getByTestId('handle-zone-top-t')).toBeDefined()
expect(screen.getByTestId('handle-zone-right-t')).toBeDefined()
expect(screen.getByTestId('handle-zone-bottom-t')).toBeDefined()
expect(screen.getByTestId('handle-zone-left-t')).toBeDefined()
})
it('renders 8 handles total (4 source + 4 target)', () => {
renderZone()
expect(screen.getAllByTestId(/^handle-zone-/).length).toBe(8)
})
})
describe('GroupRectNode — label', () => {
it('renders inside label by default', () => {
renderZone({ label: 'DMZ' })
expect(screen.getByText('DMZ')).toBeDefined()
})
it('renders no label when label is empty', () => {
renderZone({ label: '' })
expect(screen.queryByText('DMZ')).toBeNull()
})
})
@@ -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 })
})
})
+27 -10
View File
@@ -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
@@ -233,9 +234,10 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}),
}
const animMode: 'none' | 'snake' | 'flow' =
const animMode: 'none' | 'snake' | 'flow' | 'basic' =
data?.animated === true || data?.animated === 'snake' ? 'snake' :
data?.animated === 'flow' ? 'flow' : 'none'
data?.animated === 'flow' ? 'flow' :
data?.animated === 'basic' ? 'basic' : 'none'
const animColor = customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : edgeColors[edgeType as keyof typeof edgeColors] as string)
@@ -245,7 +247,22 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
return (
<>
<BaseEdge id={id} path={edgePath} style={style} interactionWidth={16} />
<BaseEdge id={id} path={edgePath} style={animMode === 'basic' ? { ...style, stroke: 'transparent' } : style} interactionWidth={16} />
{animMode === 'basic' && (
<path
d={edgePath}
fill="none"
stroke={strokeColor}
strokeWidth={style.strokeWidth as number ?? 2}
strokeDasharray="5"
style={{
pointerEvents: 'none',
animation: 'homelable-basic-dash 0.5s linear infinite',
animationDirection: sourceY <= targetY ? 'normal' : 'reverse',
}}
/>
)}
{animMode === 'snake' && (
<path
@@ -284,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 ──────────────────────────────────────────────────────────────
/**
+114 -34
View File
@@ -1,15 +1,17 @@
import { createElement, useEffect } from 'react'
import { Handle, Position, NodeResizer, useUpdateNodeInternals, type NodeProps, type Node } from '@xyflow/react'
import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react'
import { createElement, useEffect, useMemo } from 'react'
import { Handle, Position, NodeResizer, useUpdateNodeInternals, useViewport, type NodeProps, type Node } from '@xyflow/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 } 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
@@ -24,6 +26,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
const updateNodeInternals = useUpdateNodeInternals()
useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals])
const { zoom } = useViewport()
const borderWidth = useMemo(() => Math.max(1, 1 / zoom), [zoom])
const activeTheme = useThemeStore((s) => s.activeTheme)
const hideIp = useCanvasStore((s) => s.hideIp)
const theme = THEMES[activeTheme]
@@ -32,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
@@ -40,20 +48,21 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
return (
<div
className="relative flex flex-col rounded-lg border transition-all duration-200"
className="relative flex flex-col rounded-lg border transition-all duration-200 overflow-hidden"
style={{
background: colors.background,
borderColor: colors.border,
borderWidth: 1,
borderWidth,
boxShadow: isOnline && selected
? `0 0 0 1px ${colors.border}, 0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
: isOnline
? `0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
: selected
? `0 0 0 1px ${colors.border}, 0 0 8px ${colors.border}44`
? `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,
}}
@@ -63,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"
@@ -73,8 +82,15 @@ 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">
<div className="flex flex-row items-center gap-2.5 px-2.5 py-2 min-w-0 overflow-hidden">
{/* Icon */}
<div
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
@@ -83,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 */}
@@ -95,15 +113,16 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
>
{data.label}
</div>
{data.ip && (
{data.ip && splitIps(data.ip).map((ip) => (
<div
key={ip}
className="font-mono text-[10px] truncate"
style={{ color: theme.colors.nodeSubtextColor }}
title={data.ip}
title={ip}
>
{hideIp ? maskIp(data.ip) : data.ip}
{hideIp ? maskIp(ip) : ip}
</div>
)}
))}
</div>
</div>
@@ -111,14 +130,14 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
{visibleProperties && visibleProperties.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">
{visibleProperties.map((prop, i) => {
<div className="flex flex-col gap-1 px-2.5 py-1.5 overflow-hidden">
{visibleProperties.map((prop) => {
const Icon = resolvePropertyIcon(prop.icon)
return (
<div key={i} className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
<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" title={prop.value}>· {prop.value}</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>
)
})}
@@ -126,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 && (
<>
@@ -135,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>
@@ -162,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
@@ -66,13 +66,13 @@ export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
borderBottom: isVisible ? `1px solid ${borderColor}40` : 'none',
pointerEvents: 'auto',
}}
className="nodrag"
>
<Layers size={12} style={{ color: '#00d4ff', flexShrink: 0 }} />
{editing ? (
<input
autoFocus
className="nodrag"
value={labelDraft}
onChange={(e) => setLabelDraft(e.target.value)}
onKeyDown={(e) => {
@@ -97,11 +97,12 @@ export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
{editing ? (
<>
<button onClick={handleRename} style={{ color: '#39d353', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><Check size={11} /></button>
<button onClick={() => { setLabelDraft(data.label); setEditing(false) }} style={{ color: '#f85149', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><X size={11} /></button>
<button className="nodrag" onClick={handleRename} style={{ color: '#39d353', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><Check size={11} /></button>
<button className="nodrag" onClick={() => { setLabelDraft(data.label); setEditing(false) }} style={{ color: '#f85149', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><X size={11} /></button>
</>
) : (
<button
className="nodrag"
onClick={() => { setLabelDraft(data.label); setEditing(true) }}
style={{ color: '#8b949e', background: 'none', border: 'none', cursor: 'pointer', padding: 1, opacity: selected ? 1 : 0 }}
title="Rename group"
@@ -1,4 +1,5 @@
import { NodeResizer, type NodeProps, type Node } from '@xyflow/react'
import { useState } from 'react'
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
import { useCanvasStore } from '@/stores/canvasStore'
import type { NodeData, TextPosition } from '@/types'
@@ -26,8 +27,16 @@ const POSITION_STYLES: Record<TextPosition, AlignStyle> = {
'bottom-right': { alignItems: 'flex-end', justifyContent: 'flex-end', textAlign: 'right' },
}
const HANDLE_SIDES = [
{ id: 'zone-top', position: Position.Top },
{ id: 'zone-right', position: Position.Right },
{ id: 'zone-bottom', position: Position.Bottom },
{ id: 'zone-left', position: Position.Left },
] as const
export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
const setEditingGroupRectId = useCanvasStore((s) => s.setEditingGroupRectId)
const [hovered, setHovered] = useState(false)
const rc = data.custom_colors ?? {}
const borderColor = rc.border ?? '#00d4ff'
@@ -60,6 +69,16 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
whiteSpace: 'pre-wrap',
}
const handleStyle: React.CSSProperties = {
width: 10,
height: 10,
background: borderColor,
border: '2px solid #0d1117',
borderRadius: '50%',
opacity: hovered ? 1 : 0,
transition: 'opacity 0.15s',
}
return (
<>
<NodeResizer
@@ -75,6 +94,14 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
}}
lineStyle={{ borderColor: 'transparent' }}
/>
{HANDLE_SIDES.map(({ id: hid, position }) => (
<span key={hid}>
<Handle type="source" id={hid} position={position} style={handleStyle} />
<Handle type="target" id={`${hid}-t`} position={position} style={{ ...handleStyle, opacity: 0, width: 14, height: 14 }} />
</span>
))}
<div
style={{
position: 'relative',
@@ -92,6 +119,8 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
boxSizing: 'border-box',
cursor: 'default',
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onDoubleClick={(e) => {
e.stopPropagation()
setEditingGroupRectId(id)
@@ -1,7 +1,13 @@
import { createElement } from 'react'
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
import { Layers } from 'lucide-react'
import type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors'
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'
@@ -10,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)
@@ -41,6 +48,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
const isOnline = data.status === 'online'
const glow = colors.border
const proxmoxAccent = theme.colors.nodeAccents.proxmox.border
const resolvedIcon = resolveNodeIcon(Layers, data.custom_icon)
return (
<>
@@ -49,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 */}
@@ -67,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}`,
@@ -80,7 +88,9 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
background: theme.colors.nodeIconBackground,
}}
>
<Layers 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
@@ -89,23 +99,45 @@ 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}
/>
</div>
{/* Properties */}
{data.properties?.filter((p) => p.visible).map((prop, i, arr) => {
const Icon = resolvePropertyIcon(prop.icon)
return (
<div
key={prop.key}
className="flex items-center gap-1 font-mono text-[10px] min-w-0 overflow-hidden px-2.5 shrink-0"
style={{
color: theme.colors.nodeSubtextColor,
paddingTop: i === 0 ? 4 : 2,
paddingBottom: i === arr.length - 1 ? 4 : 2,
borderTop: i === 0 ? `1px solid ${glow}22` : undefined,
}}
>
{Icon && <Icon size={9} className="shrink-0" />}
<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>
)
})}
{/* Inner area — React Flow places children here */}
<div className="flex-1 relative" />
</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>
)
}
+24 -16
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'
@@ -10,11 +11,12 @@ import { EDGE_DEFAULT_COLORS } from '@/utils/edgeColors'
const EDGE_TYPES = Object.entries(EDGE_TYPE_LABELS) as [EdgeType, string][]
type AnimMode = 'none' | 'snake' | 'flow'
type AnimMode = 'none' | 'basic' | 'snake' | 'flow'
function toAnimMode(v: EdgeData['animated']): AnimMode {
if (v === true || v === 'snake') return 'snake'
if (v === 'flow') return 'flow'
if (v === 'basic') return 'basic'
return 'none'
}
@@ -67,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]) => (
@@ -88,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>
)}
@@ -99,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',
@@ -126,20 +130,22 @@ 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]">
{(['none', 'snake', 'flow'] as AnimMode[]).map((mode, i) => (
<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',
borderRight: i < 2 ? '1px solid #30363d' : undefined,
borderRight: i < 3 ? '1px solid #30363d' : undefined,
}}
>
{mode === 'none' ? 'None' : mode === 'snake' ? 'Snake' : 'Flow'}
{mode === 'none' ? 'None' : mode === 'basic' ? 'Basic' : mode === 'snake' ? 'Snake' : 'Flow'}
</button>
))}
</div>
@@ -159,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"
@@ -188,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>
@@ -0,0 +1,71 @@
import { useState } from 'react'
import { Download, Loader2 } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { exportToPng, EXPORT_QUALITY_OPTIONS, type ExportQuality } from '@/utils/export'
interface ExportModalProps {
open: boolean
onClose: () => void
getElement: () => HTMLElement | null
}
export function ExportModal({ open, onClose, getElement }: ExportModalProps) {
const [quality, setQuality] = useState<ExportQuality>('high')
const [exporting, setExporting] = useState(false)
const handleExport = async () => {
const el = getElement()
if (!el) return
setExporting(true)
try {
await exportToPng(el, quality)
onClose()
} finally {
setExporting(false)
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="bg-[#161b22] border-border max-w-sm">
<DialogHeader>
<DialogTitle className="text-foreground">Export as PNG</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
{EXPORT_QUALITY_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setQuality(opt.value)}
className={[
'w-full flex items-center justify-between px-3 py-2.5 rounded-md border text-sm transition-colors',
quality === opt.value
? 'border-[#00d4ff] bg-[#00d4ff10] text-foreground'
: 'border-border bg-[#0d1117] text-muted-foreground hover:border-muted-foreground',
].join(' ')}
>
<span className="font-medium">{opt.label}</span>
<span className="text-xs opacity-70">{opt.hint}</span>
</button>
))}
</div>
<DialogFooter className="gap-2">
<Button variant="ghost" onClick={onClose} disabled={exporting}>Cancel</Button>
<Button
onClick={handleExport}
disabled={exporting}
style={{ background: '#00d4ff', color: '#0d1117' }}
>
{exporting
? <><Loader2 size={14} className="animate-spin mr-1.5" />Exporting</>
: <><Download size={14} className="mr-1.5" />Download</>
}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,10 +1,12 @@
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'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import type { TextPosition } from '@/types'
import { hexToRgba, rgbaToHex8 } from '@/utils/colorUtils'
export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
@@ -128,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>
@@ -136,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]">
@@ -161,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'}`,
@@ -186,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'}`,
@@ -204,7 +206,9 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
<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 }) => (
{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"
@@ -212,15 +216,25 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
>
<input
type="color"
value={form[key]}
onChange={(e) => set(key, e.target.value)}
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>
<span className="text-[9px] text-muted-foreground/60">{label}</span>
<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>
@@ -235,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'}`,
@@ -262,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'}`,
@@ -288,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'}`,
@@ -306,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]">
@@ -325,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>
+212 -67
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,31 @@ 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 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 +42,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,18 +53,24 @@ 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']
// 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', proxmoxNodes = [] }: NodeModalProps) {
// initial value is enough - no need for a reset effect.
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentContainerNodes = [] }: NodeModalProps) {
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
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 }))
@@ -60,13 +82,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>
@@ -77,14 +108,14 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
<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 />
<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>
@@ -94,7 +125,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
</SelectItem>
))}
</SelectGroup>
</>
</Fragment>
))}
</SelectContent>
</Select>
@@ -118,10 +149,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></>
@@ -134,14 +170,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">
@@ -163,7 +230,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',
@@ -181,6 +249,8 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
)
})}
</div>
</>
)}
</div>
)}
@@ -191,7 +261,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>
@@ -203,7 +273,7 @@ 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>
@@ -213,21 +283,22 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
<Input
value={form.ip ?? ''}
onChange={(e) => set('ip', e.target.value)}
placeholder="192.168.1.x"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
placeholder="192.168.1.x, 2001:db8::1"
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 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 font-mono">{m}</SelectItem>
<SelectItem key={m} value={m} className="text-sm">{CHECK_METHOD_LABELS[m]}</SelectItem>
))}
</SelectContent>
</Select>
@@ -240,24 +311,28 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
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"
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>
@@ -265,24 +340,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>
@@ -292,10 +400,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
@@ -310,9 +428,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"
@@ -327,29 +447,34 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
)
})}
</div>
{!form.custom_colors && (
<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">
<div className="flex items-center justify-between">
<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>
<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>
)}
@@ -360,23 +485,43 @@ 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}>
<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"
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>
)
}
+89 -24
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,17 +15,37 @@ 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 (
<div className="relative w-full h-full">
<button
ref={buttonRef}
type="button"
onClick={onClick}
className="relative rounded-xl border-2 p-3 text-left transition-all duration-150 focus:outline-none w-full"
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,
@@ -46,42 +67,56 @@ function ThemeCard({ themeId, selected, onClick }: ThemeCardProps) {
className="rounded-md mb-2.5 flex flex-col gap-1.5 p-2"
style={{ background: c.nodeCardBackground, border: `1px solid ${c.handleBackground}` }}
>
{/* Node accent dots */}
<div className="flex gap-1 items-center flex-wrap">
{PREVIEW_TYPES.map((type) => (
{swatchColors.map((color, i) => (
<span
key={type}
key={i}
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: c.nodeAccents[type].border }}
style={{ backgroundColor: color }}
/>
))}
</div>
{/* Edge line */}
<div style={{ height: 2, background: c.edgeColors.ethernet, width: '80%', borderRadius: 2 }} />
{/* Wifi dashed line */}
<div style={{ height: 2, background: ethernetColor, width: '80%', borderRadius: 2 }} />
<div
style={{
height: 1,
width: '55%',
backgroundImage: `repeating-linear-gradient(90deg, ${c.edgeColors.wifi} 0 5px, transparent 5px 8px)`,
backgroundImage: `repeating-linear-gradient(90deg, ${wifiColor} 0 5px, transparent 5px 8px)`,
}}
/>
</div>
{/* Label */}
<div
className="text-xs font-semibold leading-tight"
className="text-sm font-semibold leading-tight wrap-break-word"
style={{ color: c.nodeLabelColor }}
>
{preset.label}
</div>
<div
className="text-[10px] leading-snug mt-0.5 line-clamp-2"
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,40 +137,65 @@ 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">
<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) => (
<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
key={id}
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>
@@ -163,5 +225,8 @@ export function ThemeModal({ open, onClose }: ThemeModalProps) {
</div>
</DialogContent>
</Dialog>
<CustomStyleModal open={customStyleOpen} onClose={() => setCustomStyleOpen(false)} />
</>
)
}
@@ -122,6 +122,21 @@ describe('EdgeModal', () => {
expect(onSubmit.mock.calls[0][0].animated).toBe('flow')
})
it('selecting Basic sends animated: "basic"', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Basic'))
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].animated).toBe('basic')
})
it('pre-fills animation from initial "basic" string', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ animated: 'basic' }} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].animated).toBe('basic')
})
it('selecting None after Snake omits animated from payload', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { ExportModal } from '../ExportModal'
const mockExportToPng = vi.fn()
vi.mock('@/utils/export', () => ({
exportToPng: (...args: unknown[]) => mockExportToPng(...args),
EXPORT_QUALITY_OPTIONS: [
{ value: 'standard', label: 'Standard', pixelRatio: 1, hint: '1× — small file' },
{ value: 'high', label: 'High', pixelRatio: 2, hint: '2× — recommended' },
{ value: 'ultra', label: 'Ultra', pixelRatio: 4, hint: '4× — print quality, large file' },
],
}))
const el = document.createElement('div')
const getElement = () => el
const onClose = vi.fn()
describe('ExportModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockExportToPng.mockResolvedValue(undefined)
})
it('renders all three quality options', () => {
render(<ExportModal open onClose={onClose} getElement={getElement} />)
expect(screen.getByText('Standard')).toBeInTheDocument()
expect(screen.getByText('High')).toBeInTheDocument()
expect(screen.getByText('Ultra')).toBeInTheDocument()
})
it('selects High by default', () => {
render(<ExportModal open onClose={onClose} getElement={getElement} />)
const highBtn = screen.getByText('High').closest('button')!
expect(highBtn.className).toContain('border-[#00d4ff]')
})
it('changes selection when another option is clicked', () => {
render(<ExportModal open onClose={onClose} getElement={getElement} />)
fireEvent.click(screen.getByText('Ultra').closest('button')!)
expect(screen.getByText('Ultra').closest('button')!.className).toContain('border-[#00d4ff]')
expect(screen.getByText('High').closest('button')!.className).not.toContain('border-[#00d4ff]')
})
it('calls exportToPng with selected quality on Download click', async () => {
render(<ExportModal open onClose={onClose} getElement={getElement} />)
fireEvent.click(screen.getByText('Standard').closest('button')!)
fireEvent.click(screen.getByRole('button', { name: /download/i }))
await waitFor(() => expect(mockExportToPng).toHaveBeenCalledWith(el, 'standard'))
})
it('closes after successful export', async () => {
render(<ExportModal open onClose={onClose} getElement={getElement} />)
fireEvent.click(screen.getByRole('button', { name: /download/i }))
await waitFor(() => expect(onClose).toHaveBeenCalled())
})
it('calls onClose when Cancel is clicked', () => {
render(<ExportModal open onClose={onClose} getElement={getElement} />)
fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
expect(onClose).toHaveBeenCalled()
})
it('does not call exportToPng when getElement returns null', async () => {
render(<ExportModal open onClose={onClose} getElement={() => null} />)
fireEvent.click(screen.getByRole('button', { name: /download/i }))
await waitFor(() => expect(mockExportToPng).not.toHaveBeenCalled())
})
it('does not render when closed', () => {
render(<ExportModal open={false} onClose={onClose} getElement={getElement} />)
expect(screen.queryByText('Export as PNG')).not.toBeInTheDocument()
})
})
@@ -251,4 +251,60 @@ describe('GroupRectModal', () => {
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.border_style).toBe('solid')
})
it('shows opacity sliders for all three color fields', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
const sliders = screen.getAllByRole('slider')
expect(sliders).toHaveLength(3)
})
it('default background_color is 8-digit hex with low alpha', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.background_color).toBe('#00d4ff0d')
expect(submitted.background_color.length).toBe(9)
})
it('moving background opacity slider updates background_color alpha', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
// background slider is the third one (Text, Border, Background)
const sliders = screen.getAllByRole('slider')
fireEvent.change(sliders[2], { target: { value: '50' } })
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
// alpha 50% → 0x80 = 128
expect(submitted.background_color).toBe('#00d4ff80')
})
it('moving border opacity slider to 0 makes border fully transparent', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
const sliders = screen.getAllByRole('slider')
fireEvent.change(sliders[1], { target: { value: '0' } })
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.border_color).toBe('#00d4ff00')
})
it('pre-fills opacity from 8-digit initial background_color', () => {
render(
<GroupRectModal
open
onClose={vi.fn()}
onSubmit={vi.fn()}
initial={{ background_color: '#ff6e0080' }}
/>
)
const sliders = screen.getAllByRole('slider')
expect((sliders[2] as HTMLInputElement).value).toBe('50')
})
it('shows opacity percentage in label', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
// Background default is 5% opacity
expect(screen.getByText(/Background 5%/)).toBeInTheDocument()
})
})
@@ -72,7 +72,7 @@ describe('NodeModal', () => {
renderModal({ initial: BASE })
expect((screen.getByPlaceholderText('My Server') as HTMLInputElement).value).toBe('My Server')
expect((screen.getByPlaceholderText('server.lan') as HTMLInputElement).value).toBe('server.lan')
expect((screen.getByPlaceholderText('192.168.1.x') as HTMLInputElement).value).toBe('192.168.1.10')
expect((screen.getByPlaceholderText('192.168.1.x, 2001:db8::1') as HTMLInputElement).value).toBe('192.168.1.10')
})
// ── Cancel ────────────────────────────────────────────────────────────
@@ -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', () => {
@@ -121,7 +143,7 @@ describe('NodeModal', () => {
it('submits updated hostname, IP and notes', () => {
const { onSubmit } = renderModal({ initial: BASE })
fireEvent.change(screen.getByPlaceholderText('server.lan'), { target: { value: 'nas.local' } })
fireEvent.change(screen.getByPlaceholderText('192.168.1.x'), { target: { value: '10.0.0.1' } })
fireEvent.change(screen.getByPlaceholderText('192.168.1.x, 2001:db8::1'), { target: { value: '10.0.0.1' } })
fireEvent.change(screen.getByPlaceholderText('Optional notes'), { target: { value: 'rack A' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
@@ -130,6 +152,21 @@ describe('NodeModal', () => {
expect(data.notes).toBe('rack A')
})
it('resets form values when reopened in Add mode', () => {
const onClose = vi.fn()
const onSubmit = vi.fn()
const { rerender } = render(<NodeModal key="open-1" open onClose={onClose} onSubmit={onSubmit} />)
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Temp Node' } })
fireEvent.change(screen.getByPlaceholderText('server.lan'), { target: { value: 'temp.local' } })
rerender(<NodeModal key="closed" open={false} onClose={onClose} onSubmit={onSubmit} />)
rerender(<NodeModal key="open-2" open onClose={onClose} onSubmit={onSubmit} />)
expect((screen.getByPlaceholderText('My Server') as HTMLInputElement).value).toBe('')
expect((screen.getByPlaceholderText('server.lan') as HTMLInputElement).value).toBe('')
})
it('submits check_target', () => {
const { onSubmit } = renderModal({ initial: BASE })
fireEvent.change(screen.getByPlaceholderText('http://...'), { target: { value: 'http://192.168.1.10:8080' } })
@@ -219,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('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' }],
})
it('hides Parent Proxmox for vm when no proxmoxNodes', () => {
renderModal({ initial: { ...BASE, type: 'vm' } })
expect(screen.queryByText('Parent Proxmox')).toBeNull()
expect(screen.queryByText('Parent Container')).toBeNull()
})
// ── Appearance ────────────────────────────────────────────────────────
@@ -312,18 +400,37 @@ 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')
})
})
@@ -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;
}
+146 -30
View File
@@ -2,9 +2,11 @@ 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'
import { primaryIp } from '@/utils/maskIp'
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
import type { Node } from '@xyflow/react'
@@ -12,8 +14,8 @@ interface DetailPanelProps {
onEdit: (id: string) => void
}
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string }
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '' }
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '', path: '' }
type PropForm = { key: string; value: string; icon: string | null; visible: boolean }
const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true }
@@ -93,10 +95,18 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
}
const handleAddService = () => {
const port = parseInt(newSvc.port, 10)
if (!newSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
const trimmedPort = newSvc.port.trim()
const port = trimmedPort === '' ? undefined : parseInt(trimmedPort, 10)
if (!newSvc.service_name.trim()) return
if (trimmedPort !== '' && (port == null || Number.isNaN(port) || port < 1 || port > 65535)) return
snapshotHistory()
const svc: ServiceInfo = { port, protocol: newSvc.protocol, service_name: newSvc.service_name.trim() }
const path = newSvc.path.trim()
const svc: ServiceInfo = {
...(port != null ? { port } : {}),
protocol: newSvc.protocol,
service_name: newSvc.service_name.trim(),
...(path ? { path } : {}),
}
updateNode(node.id, { services: [...services, svc] })
setNewSvc(EMPTY_FORM)
setAddingForNode(null)
@@ -112,18 +122,29 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
const handleStartEdit = (index: number) => {
const svc = services[index]
if (!svc) return
setEditSvc({ port: String(svc.port), protocol: svc.protocol, service_name: svc.service_name })
setEditSvc({ port: svc.port != null ? String(svc.port) : '', protocol: svc.protocol, service_name: svc.service_name, path: svc.path ?? '' })
setEditingFor({ nodeId: node.id, index })
setAddingForNode(null)
}
const handleSaveEdit = () => {
if (editingIndex === null) return
const port = parseInt(editSvc.port, 10)
if (!editSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
const trimmedPort = editSvc.port.trim()
const port = trimmedPort === '' ? undefined : parseInt(trimmedPort, 10)
if (!editSvc.service_name.trim()) return
if (trimmedPort !== '' && (port == null || Number.isNaN(port) || port < 1 || port > 65535)) return
snapshotHistory()
const path = editSvc.path.trim()
const updated = services.map((svc, i) =>
i === editingIndex ? { ...svc, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() } : svc
i === editingIndex
? {
...svc,
protocol: editSvc.protocol,
service_name: editSvc.service_name.trim(),
...(port != null ? { port } : { port: undefined }),
...(path ? { path } : { path: undefined }),
}
: svc
)
updateNode(node.id, { services: updated })
setEditingFor(null)
@@ -179,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>
@@ -202,11 +223,18 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
</a>
</div>
)}
{data.ip && <DetailRow label="IP Address" value={data.ip} mono />}
{data.ip && (
<div className="flex justify-between gap-2 items-baseline">
<span className="text-muted-foreground text-xs shrink-0">IP Address</span>
<a href={`http://${primaryIp(data.ip)}`} target="_blank" rel="noopener noreferrer" className="text-xs font-mono text-[#00d4ff] hover:underline truncate flex items-center gap-1" title={data.ip}>
{data.ip}<ExternalLink size={10} className="shrink-0" />
</a>
</div>
)}
{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 */}
@@ -215,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>
@@ -261,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>
@@ -272,7 +300,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
editingIndex === i ? (
<ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus />
) : (
<ServiceBadge key={`${svc.port}-${svc.protocol}-${i}`} svc={svc} host={host} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
<ServiceBadge key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`} svc={svc} host={host} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
)
)}
</div>
@@ -288,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>
@@ -472,23 +500,46 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono?
}
function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFocus }: {
form: { port: string; protocol: 'tcp' | 'udp'; service_name: string }
onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string }) => void
form: { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }
onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }) => void
onConfirm: () => void
onCancel: () => void
confirmLabel: string
autoFocus?: boolean
}) {
const setPort = (value: string) => {
const digitsOnly = value.replace(/\D/g, '').slice(0, 5)
onChange({ ...form, port: digitsOnly })
}
const clampPort = (value: string) => {
if (!value) return ''
const parsed = Number.parseInt(value, 10)
if (!Number.isFinite(parsed)) return ''
return String(Math.max(1, Math.min(65535, parsed)))
}
return (
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
<Input value={form.service_name} onChange={(e) => onChange({ ...form, service_name: e.target.value })} placeholder="Service name" className="bg-[#21262d] border-[#30363d] text-xs h-7" autoFocus={autoFocus} onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
<div className="flex gap-1.5">
<Input type="number" value={form.port} onChange={(e) => onChange({ ...form, port: e.target.value })} placeholder="Port" min={1} max={65535} className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-20 shrink-0" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
<Input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.port}
onChange={(e) => setPort(e.target.value)}
onBlur={() => onChange({ ...form, port: clampPort(form.port) })}
placeholder="Port"
className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-28 shrink-0"
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
/>
<select value={form.protocol} onChange={(e) => onChange({ ...form, protocol: e.target.value as 'tcp' | 'udp' })} className="flex-1 bg-[#21262d] border border-[#30363d] rounded-md text-xs h-7 px-1.5 text-foreground">
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
</div>
<Input value={form.path} onChange={(e) => onChange({ ...form, path: e.target.value })} placeholder="Path (/admin)" className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
<div className="flex gap-1.5">
<Button size="sm" className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={onConfirm}>{confirmLabel}</Button>
<Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}>Cancel</Button>
@@ -611,20 +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 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 }}>{svc.service_name}</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]">{svc.port}/{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 -302
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
key={id}
icon={Icon}
label={label}
icon={LayoutDashboard}
label="Canvas"
collapsed={collapsed}
active={activeView === id}
onClick={() => setActiveView(id)}
active={activeView === 'canvas'}
onClick={() => setActiveView('canvas')}
/>
{!STANDALONE && PENDING_TRIGGERS.map((t) => (
<SidebarItem
key={t.kind}
icon={t.icon}
label={t.label}
collapsed={collapsed}
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,228 +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 { addNode, scanEventTs } = useCanvasStore()
const highlightRef = useRef<HTMLButtonElement>(null)
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([])
toast.success('Pending devices cleared')
} catch {
toast.error('Failed to clear pending 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">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Pending</span>
<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>
{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 && !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]' : 'bg-[#21262d] border-transparent hover:bg-[#30363d] hover:border-[#30363d]'}`}
>
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-[#e3b341] 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[]>([])
@@ -393,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)
@@ -459,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>
@@ -486,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>
)}
@@ -499,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()
@@ -506,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 {
@@ -530,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>
@@ -547,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>
)
}
@@ -567,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"
@@ -579,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
@@ -642,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,
@@ -2,10 +2,11 @@ import { describe, it, expect } from 'vitest'
import { getServiceUrl } from '@/utils/serviceUrl'
import type { ServiceInfo } from '@/types'
const svc = (port: number, protocol: 'tcp' | 'udp' = 'tcp', service_name = 'test'): ServiceInfo => ({
port,
const svc = (port?: number, protocol: 'tcp' | 'udp' = 'tcp', service_name = 'test', path?: string): ServiceInfo => ({
...(port != null ? { port } : {}),
protocol,
service_name,
...(path ? { path } : {}),
})
describe('getServiceUrl', () => {
@@ -63,4 +64,20 @@ describe('getServiceUrl', () => {
it('uses host string directly (works with both IP and hostname)', () => {
expect(getServiceUrl(svc(80), 'myserver.lan')).toBe('http://myserver.lan:80')
})
it('uses the node port when the host already includes one', () => {
expect(getServiceUrl(svc(undefined, 'tcp', 'app'), '192.168.1.10:8080')).toBe('http://192.168.1.10:8080')
})
it('lets the service port override the node port', () => {
expect(getServiceUrl(svc(3000, 'tcp', 'app'), '192.168.1.10:8080')).toBe('http://192.168.1.10:3000')
})
it('appends a normalized path to the final URL', () => {
expect(getServiceUrl(svc(3000, 'tcp', 'app', 'admin/login'), '192.168.1.10')).toBe('http://192.168.1.10:3000/admin/login')
})
it('supports path-only services inheriting the node port', () => {
expect(getServiceUrl(svc(undefined, 'tcp', 'app', '/metrics'), '192.168.1.10:9090')).toBe('http://192.168.1.10:9090/metrics')
})
})
@@ -293,9 +293,35 @@ describe('DetailPanel', () => {
fireEvent.click(addHeaders[addHeaders.length - 1])
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } })
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } })
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '/admin' } })
fireEvent.keyDown(screen.getByPlaceholderText('Port'), { key: 'Enter' })
expect(updateNode).toHaveBeenCalledOnce()
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp' })
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp', path: '/admin' })
})
it('allows adding a service without a port', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ ip: '192.168.1.10:8080' })],
selectedNodeId: 'n1',
selectedNodeIds: [],
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode,
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
const addHeaders = screen.getAllByText('Add')
fireEvent.click(addHeaders[addHeaders.length - 1])
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'health' } })
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: 'healthz' } })
fireEvent.click(screen.getAllByRole('button', { name: 'Add' }).at(-1) as HTMLButtonElement)
expect(updateNode).toHaveBeenCalledOnce()
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'health', protocol: 'tcp', path: 'healthz' })
expect(updateNode.mock.calls[0][1].services[0].port).toBeUndefined()
})
it('calls updateNode without the removed service when X is clicked', () => {
@@ -332,7 +358,7 @@ describe('DetailPanel', () => {
const svc = { port: 80, protocol: 'tcp' as const, service_name: 'nginx' }
it('shows edit form pre-filled when pencil is clicked', () => {
setupStore({ services: [svc] })
setupStore({ services: [{ ...svc, path: '/admin' }] })
render(<DetailPanel onEdit={vi.fn()} />)
// Hover to reveal edit button (fireEvent.mouseOver isn't needed — opacity is CSS only)
const editBtn = screen.getByTitle('Edit service')
@@ -341,6 +367,8 @@ describe('DetailPanel', () => {
expect(nameInput.value).toBe('nginx')
const portInput = screen.getByPlaceholderText('Port') as HTMLInputElement
expect(portInput.value).toBe('80')
const pathInput = screen.getByPlaceholderText('Path (/admin)') as HTMLInputElement
expect(pathInput.value).toBe('/admin')
})
it('calls updateNode with updated values on Save', () => {
@@ -359,11 +387,13 @@ describe('DetailPanel', () => {
const nameInput = screen.getByPlaceholderText('Service name')
fireEvent.change(nameInput, { target: { value: 'apache' } })
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '/admin' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(updateNode).toHaveBeenCalledOnce()
expect(updateNode.mock.calls[0][1].services[0].service_name).toBe('apache')
expect(updateNode.mock.calls[0][1].services[0].port).toBe(80)
expect(updateNode.mock.calls[0][1].services[0].path).toBe('/admin')
})
it('cancels edit without updating', () => {
@@ -385,4 +415,99 @@ describe('DetailPanel', () => {
expect(screen.getByText('nginx')).toBeDefined()
})
})
describe('IP Address — clickable link', () => {
it('renders a link for a single IP', () => {
setupStore({ ip: '192.168.1.10' })
render(<DetailPanel onEdit={vi.fn()} />)
const link = screen.getByRole('link', { name: /192\.168\.1\.10/ })
expect(link).toBeDefined()
expect(link.getAttribute('href')).toBe('http://192.168.1.10')
expect(link.getAttribute('target')).toBe('_blank')
})
it('renders no IP link when ip is absent', () => {
setupStore({ ip: undefined })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.queryByText('IP Address')).toBeNull()
})
it('uses primary IP as href for comma-separated IPs', () => {
setupStore({ ip: '192.168.1.10, 192.168.1.11' })
render(<DetailPanel onEdit={vi.fn()} />)
const link = screen.getByRole('link', { name: /192\.168\.1\.10/ })
expect(link.getAttribute('href')).toBe('http://192.168.1.10')
})
it('displays full comma-separated IP string as link text', () => {
setupStore({ ip: '192.168.1.10, 192.168.1.11' })
render(<DetailPanel onEdit={vi.fn()} />)
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,18 +2,18 @@ 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')
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({}),
},
@@ -37,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> => ({
@@ -51,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({
@@ -64,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 ─────────────────────────────────────────────────────────────────────
@@ -77,6 +81,7 @@ const defaultProps = {
describe('Sidebar', () => {
beforeEach(() => {
mockStore()
mockAuth()
vi.clearAllMocks()
})
@@ -110,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 ───────────────────────────────────────────────────────────────
@@ -206,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()
@@ -222,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 () => {
@@ -248,14 +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()
})
// ── Logout ─────────────────────────────────────────────────────────────────
it('shows Logout button in normal mode', () => {
render(<Sidebar {...defaultProps} />)
expect(screen.getByText('Logout')).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>
)}
+14 -48
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"
>
<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
)}
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}
>
{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>
</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

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