Compare commits

...

64 Commits

Author SHA1 Message Date
Rémy 1efc1c95ca Merge pull request #217 from Pouzor/chore/bump-2.5.1
chore: bump version to 2.5.1
2026-06-18 02:14:16 +02:00
Pouzor b70ee78d25 chore: bump version to 2.5.1
ha-relevant: no
2026-06-18 01:51:13 +02:00
Rémy 9efaaccb51 Merge pull request #210 from Pouzor/fix/node-resize-persist
fix: persist manual node sizes and add width/height inputs
2026-06-18 01:39:07 +02:00
Pouzor 6e5bda5860 chore: bump python-multipart to 0.0.31 for CVE fixes
pip-audit (dependency-audit CI step) flagged python-multipart 0.0.27
for CVE-2026-53538/53539/53540 (fixed in 0.0.30/0.0.31). Bump to
0.0.31. pip-audit clean and backend test suite green.

ha-relevant: no
2026-06-17 17:59:32 +02:00
Pouzor 0796c96fc1 chore: resolve high-severity npm audit advisories
The dependency-audit CI step (npm audit --audit-level=high) was failing
on pre-existing transitive advisories. Force patched versions via
overrides and bump vite to a patched 7.x:

- esbuild ^0.28.1  (GHSA-g7r4-m6w7-qqqr, high)
- form-data ^4.0.6 (GHSA-hmw2-7cc7-3qxx, high)
- vite ^7.3.5      (GHSA-v6wh-96g9-6wx3 / GHSA-fx2h-pf6j-xcff, high)

Audit now passes at --audit-level=high (only a low @babel/core and
moderate js-yaml remain, both below the gate and build/dev-time only;
js-yaml is a direct dep so it can't be overridden). Build + full test
suite (1130) green on the bumped toolchain.

ha-relevant: no
2026-06-17 16:49:25 +02:00
Pouzor 6fba0cdec4 fix: don't trap new child nodes under a non-container parent
Adding an LXC/VM with a parent_id pointed at a Proxmox node that is
NOT in container_mode set extent:'parent' anyway, confining the node
to the parent's ~140px bounding box with no way to drag it out.

Nesting is now gated on the parent's container_mode in both places:
App.handleAddNode no longer sets parentId/extent itself (it seeds an
absolute position and lets addNode decide), and addNode strips any
stray parentId/extent when it isn't nesting. New children of a real
container still land at the container's top-left.

ha-relevant: yes
2026-06-17 14:23:49 +02:00
Pouzor 2a4d109ee6 fix: persist manual node sizes and add width/height inputs
Nested vm/lxc/docker leaf nodes lost their size on reload: the
deserialize restore branch excluded those types entirely, so a
resized child snapped back to content-fit. Gate the restore on
container_mode instead of node type.

Also prefer explicit width/height over the DOM-measured value when
serializing, so a manual resize persists its exact target rather than
drifting to the fractional content-fit size.

Add a Size section to the detail panel with W/H number inputs
(setNodeSize store action, clamped to the resizer minimums). Inputs
resync live when the node is resized by corner drag, without
clobbering active keystrokes.

ha-relevant: yes
2026-06-17 11:55:55 +02:00
Rémy 96bb048228 Merge pull request #204 from Pouzor/chore/bump-2.5.0
chore: bump version to 2.5.0
2026-06-12 01:16:25 +02:00
Pouzor 88c006764a test: fix flaky per-service toggle assertion in SettingsModal
The toggle label renders before settingsApi.get() resolves, so reading
.checked immediately raced the async state update and failed on slower CI.
Wait for the resolved checked state instead.

ha-relevant: no
2026-06-12 01:05:48 +02:00
Pouzor 9c3dace12f chore: bump version to 2.5.0
ha-relevant: no
2026-06-12 00:54:52 +02:00
Rémy ec53363fdb Merge pull request #203 from Pouzor/feat/scan-history-modal
feat: move scan history into a modal
2026-06-12 00:40:19 +02:00
Pouzor 0e260ede73 feat: move scan history from sidebar panel into a modal
Replace the inline sidebar 'history' view with a dedicated ScanHistoryModal.
Same data and actions (refresh, auto-refresh while running, stop scan,
transition toasts) plus run duration, finished timestamp, and kind/status
filters. Scan/Zigbee start now surface a toast instead of force-switching
the sidebar view.

ha-relevant: yes
2026-06-12 00:11:16 +02:00
Rémy 60902167c0 Merge pull request #202 from Pouzor/feat/node-into-container
feat: drop node onto container node to nest it
2026-06-11 18:36:48 +02:00
Pouzor 592e7865c5 fix(deps): bump zeroconf to 0.149.12 for CVE-2026-48045
pip-audit (Security workflow) flagged zeroconf 0.149.7 as vulnerable to
CVE-2026-48045, fixed in 0.149.12.

ha-relevant: no
2026-06-11 18:24:59 +02:00
Pouzor f082c295fd feat: edit container parent in the node modal instead of detail panel
Replaces the detail-panel container selector with the existing "Parent
Container" dropdown in the edit modal (double-click). The selector now
shows for any node that is nested in — or can nest into — a container_mode
node, not just lxc/vm/docker_container, so a dragged-in node can be
re-targeted or detached ("None") from the modal.

- NodeModal: container_mode candidates count as valid parents; selector
  shown for nested nodes; submit/type-change keep valid container parents
- App: pass container_mode in parentCandidates
- revert detail-panel selector + setNodeParent store action (updateNode
  already handles parent attach/detach)

ha-relevant: yes
2026-06-11 17:25:44 +02:00
Pouzor 10b981ad1d feat: editable container selector to detach/re-parent a nested node
When a node is nested in a container, the detail panel now shows a
Container selector. Pick another container to move it, or "None" to
detach it back to the canvas (clears parentId).

- store: setNodeParent(childId, parentId|null) — attach/detach/re-parent
  via absolute coords, container_mode-only targets, history snapshot
- DetailPanel: Container <select>, shown only for nested nodes

ha-relevant: yes
2026-06-11 15:39:49 +02:00
Pouzor 2058e453ff feat: drop node onto container node to nest it
Dropping a top-level node over any container_mode node (proxmox,
docker_host, ...) now pops a confirm modal that nests it as a child
(sets parentId), mirroring the existing drop-onto-group flow.

- store: addToContainer(containerId, childId)
- CanvasContainer: detect container_mode intersection on drag stop
  (group still wins if both intersect)
- generalize ConfirmAddToGroupModal with a container variant

ha-relevant: yes
2026-06-11 14:59:25 +02:00
Rémy b776b6b8f1 Merge pull request #200 from Pouzor/feat/editable-node-groups
feat: editable node groups (add/remove members, description)
2026-06-11 02:05:34 +02:00
Pouzor fe3787f0cd fix: persist group description with Ctrl+S, not just on blur
The description field committed to the store only on textarea blur, so
saving via Ctrl+S (the primary shortcut, which never blurs the field)
dropped the edit. Make it controlled and commit on each change; snapshot
history once per edit session (on focus) so undo stays a single step.

ha-relevant: yes
2026-06-11 01:48:22 +02:00
Pouzor 7ea1fc2d69 feat: editable node groups (add/remove members, description)
Make Node Groups mutable instead of fixed-at-creation:
- removeFromGroup / addToGroup store actions (inverse pair, history-aware)
- right panel: per-member remove button + editable group description
  (reuses data.notes, no backend/serializer change)
- drag a node over a group → confirm modal to add it (getIntersectingNodes)

Tests: +21 (store, panel, canvas detection, modal).

ha-relevant: yes
2026-06-10 16:50:36 +02:00
Rémy 71cc183efb Merge pull request #199 from Pouzor/feat/multiline-edge-labels
feat: multi-line edge labels
2026-06-10 15:13:12 +02:00
Pouzor 7873f8b94f feat: multi-line edge labels
Connection labels now accept line breaks. The label field in EdgeModal
is a textarea (Enter inserts a newline) and the rendered edge label uses
whitespace-pre-line so newlines display as multiple lines.

Closes #183

ha-relevant: yes
2026-06-10 12:04:10 +02:00
Rémy b34b40794d Merge pull request #198 from Pouzor/fix/status-ping-service-color
feat: per-service status checks + ping/service-color fixes (#196)
2026-06-10 11:13:06 +02:00
Pouzor ed310f0d8d fix(status): keep non-HTTP services grey instead of red
Per-service checks now only probe HTTP(S)-reachable services. SSH (22) and
other non-web ports (DB, mail, DNS, raw TCP) stay 'unknown' (grey category
colour) rather than going red — an open TCP socket doesn't prove the service
is healthy, and a firewalled port flapped red misleadingly.

ha-relevant: yes
2026-06-10 10:48:03 +02:00
Pouzor 2db173cd56 fix(ws): idempotent connection removal, release slot on any error
The status WebSocket pool removed connections with list.remove(), which
raises ValueError on a double-remove (broadcast already dropped a dead
socket, then disconnect tries again), and only released a slot on
WebSocketDisconnect — any other error leaked the socket into the
broadcast pool. Centralise removal in an idempotent _drop() called from
a finally block and from _broadcast.

ha-relevant: yes
2026-06-10 09:58:06 +02:00
Pouzor d032103b7d fix(types): add type params to broadcast_service_status services arg
ha-relevant: yes
2026-06-10 02:44:06 +02:00
Pouzor bcc488993d feat: per-service status checks with offline colouring
Adds optional live status checking per service (not just per node),
requested as a follow-up to issue #196.

Backend:
- New check_service / check_services: HTTP(S) GET for web services, TCP
  connect otherwise; UDP and port-less non-web services stay 'unknown'.
- New scheduler job 'service_checks', independent interval (default 300s),
  added/removed live via set_service_checks_enabled.
- Settings gain service_check_enabled + service_check_interval (>=30s),
  persisted to scan_config.json. New WS message type 'service_status'.

Frontend:
- Live per-service status overlay in canvasStore (not persisted, so it
  never round-trips through canvas save), fed by the WS message.
- DetailPanel + canvas node service rows: offline service turns red
  (#f85149), otherwise keeps its category colour.
- SettingsModal: toggle + interval input (default 300s / 5 min).

Off by default — no behaviour change until enabled.

ha-relevant: yes
2026-06-10 02:22:48 +02:00
Pouzor aa0a97c245 fix: reduce status flapping, add IPv6 ping, color manual web services
Addresses three reports from issue #196:

- Ping now sends 2 probes with a ~2s timeout (was 1 probe / 1s) so a
  single dropped packet or a slow IoT/ESPHome device no longer flaps a
  node offline (#196.1, #196.2).
- IPv6-only devices (e.g. Alexa) are now pinged over IPv6: ping6 on
  macOS, -6 flag on Linux/Windows, detected via inet_pton (#196.3).
- Manually-added services carry no category and so always rendered grey
  even when they were reachable HTTP/HTTPS. A resolvable web URL now
  falls back to the web colour (#196.9).

ha-relevant: yes
2026-06-09 21:20:05 +02:00
Rémy a39d4d4406 Merge pull request #197 from Pouzor/some-fixes
Security fixes: Dependabot + code scanning alerts
2026-06-09 17:26:56 +02:00
Pouzor 7c2417f5a9 fix(security): resolve code scanning alerts
Add least-privilege 'permissions: contents: read' to quality, security
and docker-ci workflows (actions/missing-workflow-permissions).

Harden markdown table cell escaping in exportMarkdown: escape backslash
before pipe and collapse newlines so untrusted values can't break the
table (js/incomplete-sanitization). Add regression tests.

ha-relevant: maybe
2026-06-09 17:15:26 +02:00
Pouzor ea66e6c9c7 chore: bump hono to 4.12.21+ and ignore versioned db backups
Add npm override forcing hono>=4.12.21 to resolve 4 Dependabot alerts
(transitive via shadcn CLI, dev-only). Add *.db.back-* to .gitignore
to exclude versioned database backups.

ha-relevant: no
2026-06-09 16:29:52 +02:00
Rémy f78bac6e6b Merge pull request #192 from Pouzor/chore/bump-2.4.0
chore: bump version to 2.4.0
2026-06-05 16:24:23 +02:00
Pouzor a0bfb97517 chore: bump version to 2.4.0
ha-relevant: no
2026-06-05 16:21:43 +02:00
Rémy ff9c4735e0 Merge pull request #191 from Pouzor/fix/persist-show-port-numbers
fix(nodes): persist show_port_numbers across reloads
2026-06-05 16:04:27 +02:00
Pouzor 2f3e986d4a fix(nodes): persist show_port_numbers across reloads
The Show Port Numbers toggle reset on every reload because the backend
never stored it: the column was missing from the Node model and the field
was stripped by NodeSave/NodeBase/NodeUpdate schemas, so canvas/save
silently dropped it.

Add the show_port_numbers column (idempotent migration), and the field to
the node schemas so it round-trips through save/load.

Add regression tests covering persistence and the default-false case.

Fixes #184

ha-relevant: yes
2026-06-05 15:53:58 +02:00
Rémy 0a4bac30ac Merge pull request #190 from Pouzor/fix/save-button-event-arg
fix(canvas): Save button no-op due to leaked click event
2026-06-05 15:29:51 +02:00
Pouzor ba2f6564ec fix(canvas): Save button no-op due to leaked click event
The Toolbar and Sidebar Save buttons wired onClick={onSave} directly, so
React passed the MouseEvent as the first argument. handleSave treats its
first arg as a designIdOverride, corrupting design_id and making the save
fail silently. Ctrl+S worked because it calls handleSave() with no args.

Wrap both handlers as onClick={() => onSave()} so no event leaks through.

Add regression tests asserting onSave is called with zero arguments.

Fixes #186

ha-relevant: no
2026-06-05 15:22:13 +02:00
Rémy 754a79d21f Merge pull request #189 from Pouzor/feat/cross-design-copy-paste
feat(canvas): cross-design copy/paste + persisted Hide-IP in Settings
2026-06-05 14:24:33 +02:00
Pouzor b52bbc6d9f feat(settings): move Hide IP toggle into Settings modal, persist it
Hide-IP was a sidebar button held only in memory, so it reset on reload.
Moved it into the Settings modal Canvas section and persist it to
localStorage (new ipDisplay util); the canvas store now seeds hideIp from
storage and writes through on toggleHideIp/setHideIp. Settings is now also
reachable in standalone (no-backend) builds, with the backend-only status
interval guarded so the modal still works there.

ha-relevant: yes
2026-06-05 11:26:28 +02:00
Pouzor c67b1775a5 feat(canvas): copy/paste nodes across designs
Clipboard now holds nodes + internal edges and survives design switches
(loadCanvas no longer clears it), so a selection copied in one design can
be pasted into another. Copy pulls in children of selected groups/
containers; paste remaps node/edge/parent IDs and lands the bounding-box
center under the cursor (or viewport center). Shortcut handling moved into
CanvasContainer for flow-coordinate projection.

ha-relevant: yes
2026-06-05 10:52:44 +02:00
Rémy 5940e11860 Merge pull request #188 from Pouzor/refactor/settings-modal
refactor(settings): move sidebar settings into a modal
2026-06-05 10:17:22 +02:00
Pouzor fa8f949b45 refactor(settings): move sidebar settings into a modal
Clicking Settings now opens a dialog with the status-check interval and
canvas snap options plus Save/Cancel, replacing the inline left-panel view.
Removes the 'settings' Sidebar view and SettingsPanel component.

ha-relevant: maybe
2026-06-05 10:00:39 +02:00
Rémy 6e17a2afc6 Merge pull request #187 from Pouzor/fix/edge-anim-css
refactor(edges): CSS animations for snake/flow edges (drop SVG SMIL)
2026-06-05 02:03:06 +02:00
Pouzor 2375efa6b7 refactor(edges): use CSS animations for snake/flow edges instead of SVG SMIL
Snake and flow edge animations used SVG SMIL <animate repeatCount="indefinite">.
SMIL keeps running while a tab is hidden (CSS animations pause) and grows memory
in Chrome over long-lived background tabs. Convert both to CSS @keyframes
(homelable-snake / homelable-flow), matching the existing basic-dash pattern.

Identical visuals and timings; bidirectional snake yo-yos via `alternate`.
Adds a regression test asserting animated edges emit zero <animate> nodes and a
CSS animation instead.

ha-relevant: yes
2026-06-05 01:54:54 +02:00
Rémy 61fbebb13e Merge pull request #185 from Pouzor/feat/liveview-design-param
feat(liveview): header View link opens active design as read-only canvas
2026-06-04 21:35:08 +02:00
Pouzor 05fb67e7f0 feat(liveview): header View link opens active design as read-only canvas
Add an authenticated GET /api/v1/liveview/config endpoint exposing the
configured LIVEVIEW_KEY to logged-in admins, so the UI builds a
ready-to-use share link. The header View button opens
/view?key=...&design=<activeDesignId>; LiveView forwards ?design=<id>
to the public endpoint, which renders that design's canvas.

ha-relevant: maybe
2026-06-04 14:10:29 +02:00
Rémy 1a426f6301 Merge pull request #177 from pranjal-joshi/feat/electrical
feat: Multi Design Canvas + new node type for electrical devices
2026-06-04 13:20:22 +02:00
Pouzor f56dfea838 fix(liveview): allow zoom-out to 0.25 so large infra fits
View-only mode set no minZoom, so React Flow defaulted to 0.5 and big
canvases couldn't zoom out far enough to fit. Match the editor's bounds
(minZoom 0.25, maxZoom 2.5). Add a regression test asserting the props.

ha-relevant: maybe
2026-06-04 13:00:29 +02:00
Pouzor 6784b8f6be refactor(ui): harden pending-devices keyboard handler
Address review nits on the close/Enter fix:
- Add statusFilter to the keydown effect deps so Enter dispatches the
  correct bulk action regardless of whether the device list changes
- Drop redundant title="Close" on the X (aria-label is enough)

ha-relevant: maybe
2026-06-03 01:31:50 +02:00
Pouzor 01fce64b2c fix(ui): Enter restores hidden devices instead of approving them
In the Hidden view the only bulk action is Restore, but the Enter
shortcut in select mode always called handleBulkApprove — approving
hidden devices. Dispatch on statusFilter: restore when hidden, approve
when pending. Add regression tests for both views.

ha-relevant: maybe
2026-06-03 01:09:36 +02:00
Pouzor 79093fdcfb fix(ui): close pending/hidden devices modal via the X on Firefox
The close X used a raw <button onClick={onClose}>, bypassing Base UI's
dismissal. Outside-click worked (it goes through onOpenChange) but the
synthetic click on the X was dropped on Firefox/Windows. Route the X
through Base UI's DialogClose, the same path as outside-click and the
library's default close button. Add a regression test.

ha-relevant: maybe
2026-06-03 00:59:28 +02:00
Pouzor 4ce8a12720 test: guard legacy -> multi-design migration (no data loss on upgrade)
Builds a real pre-designs database (nodes/edges without design_id, integer
canvas_state PK) and asserts init_db() adopts everything into a single
default Network Topology design with the viewport preserved, and that the
migration is idempotent across reboots. The rest of the suite uses create_all
and never exercised this in-place upgrade path.

ha-relevant: yes
2026-06-02 16:52:42 +02:00
Pouzor cc2cc72482 Merge remote-tracking branch 'origin/main' into feat/electrical 2026-06-02 16:10:46 +02:00
Rémy 73b479a408 Merge pull request #182 from Pouzor/chore/dependabot-qs-brace-expansion
chore(deps): fix Dependabot alerts (qs, brace-expansion)
2026-06-02 11:04:27 +02:00
Pouzor 16183285b5 chore(deps): bump qs to 6.15.2 and brace-expansion to 5.0.6
Fixes Dependabot alerts #1 (brace-expansion DoS) and #2 (qs stringify
DoS). Lockfile-only, transitive deps via shadcn/ts-morph/express.

ha-relevant: no
2026-06-02 10:10:04 +02:00
Rémy 3b3cc1eba0 Merge pull request #181 from Pouzor/chore/bump-2.3.0
chore: bump version to 2.3.0
2026-05-31 23:58:05 +02:00
Pouzor 3da5517996 chore: bump version to 2.3.0 2026-05-31 23:51:26 +02:00
Rémy cb26da3de5 Merge pull request #180 from Pouzor/feat/issue-174-mcp-full-node-schema
feat(mcp): expose full node schema in create_node/update_node (#174)
2026-05-31 23:14:31 +02:00
Pouzor 23a0a47a7f feat(mcp): expose full node schema in create_node/update_node (#174)
The MCP create_node/update_node inputSchema only advertised
type/label/ip/hostname/status (plus parent_id on update), so LLM
clients could not set documentation/hardware fields the backend
already validates and stores.

Mirror NodeBase/NodeUpdate by spreading a shared _NODE_FIELDS schema
into both tools: os, notes, mac, check_method, check_target, services,
cpu_count, cpu_model, ram_gb, disk_gb, show_hardware, container_mode,
custom_icon, properties. type stays an enum of canonical node types.
_dispatch already forwarded args verbatim, so no dispatch change.

Also extend _slim_canvas NODE_KEEP so get_canvas round-trips the new
documentation fields the LLM can now write.

Refactor tool definitions into a module-level TOOLS list for direct
schema assertions in tests.

ha-relevant: no
2026-05-31 22:53:00 +02:00
Rémy aac6c09a04 Merge pull request #178 from Pouzor/feat/issue-168-mac-in-properties
feat(scan): carry scanned MAC onto approved nodes (#168)
2026-05-31 16:39:50 +02:00
Pouzor bf90d6312b fix(canvas): include MAC property in approved node sent to canvas (#168)
The backend stored the MAC, but the frontend built the approved canvas
node locally with properties=[] (non-zigbee) and no mac field. On save,
canvas/save upserts every node field from the in-memory canvas, so the
empty properties overwrote the DB MAC — the address showed after a
no-save reload but vanished once saved.

Now both approve paths (single + bulk) attach a MAC property row and
the mac field to the node added to the canvas, mirroring the backend
build_mac_property. Hidden by default, toggleable from the right panel.

- new buildMacProperty util + unit tests
- bulk-approve modal test asserts MAC propagation (IP device) and
  absence for a zigbee device

ha-relevant: yes
2026-05-31 16:15:14 +02:00
Pouzor ec15c260e1 feat(scan): carry scanned MAC onto approved nodes (#168)
The network scan captures each device's MAC, but approving a pending
device dropped it: the new Node had mac=NULL and no MAC in its
properties pane. Now both approve paths (single + bulk) copy the
pending device's MAC to Node.mac and add a MAC row to the node
properties so it shows in the standard properties pane with toggleable
visibility (hidden by default, consistent with zigbee props).

- build_mac_property: MAC NodeProperty row (hidden by default)
- merge_mac_property: append MAC to user-supplied props, idempotent,
  preserves existing visibility
- single approve prefers the pending device MAC, falls back to payload

Adds unit tests for the helpers and API tests for both approve paths.

ha-relevant: yes
2026-05-31 14:47:15 +02:00
Rémy 47ab3a9a76 Merge pull request #176 from Pouzor/fix/issue-167-zigbee-pending-revival
fix(zigbee): revive orphaned approved devices on re-import (#167)
2026-05-31 14:21:24 +02:00
Pouzor e8bcf04b46 fix(zigbee): revive orphaned approved devices on re-import (#167)
When a Zigbee device was approved (PendingDevice.status=approved + Node
created) and the user later deleted the canvas Node, the PendingDevice row
was left orphaned as approved. On re-import _persist_pending_import found
the existing row, refreshed its fields but left status=approved, so it
never reappeared in the Pending list (which filters status==pending) —
the import reported devices found but Pending stayed empty.

Reset such orphaned approved rows back to pending on re-import. Hidden
devices stay hidden (explicit user intent). Adds regression tests for
both revive-approved and keep-hidden paths.

ha-relevant: yes
2026-05-31 01:54:24 +02:00
78 changed files with 4187 additions and 887 deletions
+3
View File
@@ -6,6 +6,9 @@ on:
pull_request: pull_request:
branches: [main] branches: [main]
permissions:
contents: read
jobs: jobs:
smoke-and-integration: smoke-and-integration:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+3
View File
@@ -6,6 +6,9 @@ on:
pull_request: pull_request:
branches: [main] branches: [main]
permissions:
contents: read
jobs: jobs:
lint-scripts: lint-scripts:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+3
View File
@@ -8,6 +8,9 @@ on:
schedule: schedule:
- cron: '0 9 * * 1' # Weekly on Monday - cron: '0 9 * * 1' # Weekly on Monday
permissions:
contents: read
jobs: jobs:
secrets-scan: secrets-scan:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+1
View File
@@ -46,6 +46,7 @@ htmlcov/
*.db-shm *.db-shm
*.db-wal *.db-wal
*.db.back *.db.back
*.db.back-*
# Docker # Docker
.docker/ .docker/
+1 -1
View File
@@ -1 +1 @@
2.2.0 2.5.1
+22
View File
@@ -2,9 +2,11 @@ import hmac
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.core.config import settings from app.core.config import settings
from app.db.database import get_db from app.db.database import get_db
from app.db.models import CanvasState, Design, Edge, Node from app.db.models import CanvasState, Design, Edge, Node
@@ -15,6 +17,26 @@ from app.schemas.nodes import NodeResponse
router = APIRouter() router = APIRouter()
class LiveViewConfigResponse(BaseModel):
"""Whether live view is enabled, plus the key (admin-only) to build share links."""
enabled: bool
key: str | None = None
@router.get("/config", response_model=LiveViewConfigResponse)
async def liveview_config(
_: str = Depends(get_current_user),
) -> LiveViewConfigResponse:
"""Authenticated: expose the configured live view key so the UI can build a
ready-to-use share link (e.g. /view?key=...&design=<id>).
Only reachable by a logged-in user — the key is never exposed publicly.
"""
key = settings.liveview_key or None
return LiveViewConfigResponse(enabled=bool(key), key=key)
@router.get("", response_model=CanvasStateResponse) @router.get("", response_model=CanvasStateResponse)
async def liveview_canvas( async def liveview_canvas(
key: str | None = Query(default=None), key: str | None = Query(default=None),
+35 -2
View File
@@ -20,6 +20,34 @@ from app.services.zigbee_service import build_zigbee_properties
_ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"} _ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"}
def build_mac_property(mac: str | None) -> list[dict[str, Any]]:
"""Build a NodeProperty list carrying a device MAC address.
Shape matches the frontend ``NodeProperty`` type
(``{key, value, icon, visible}``). Hidden by default — the user opts in to
showing it on the canvas card from the right panel. Returns an empty list
when no MAC is known.
"""
if not mac:
return []
return [{"key": "MAC", "value": mac, "icon": None, "visible": False}]
def merge_mac_property(
props: list[dict[str, Any]] | None, mac: str | None
) -> list[dict[str, Any]]:
"""Append a MAC NodeProperty to ``props`` unless one is already present.
Preserves any user-supplied properties (and an existing MAC row's
visibility) untouched. Used on approve so the scanned MAC is not lost.
"""
out = [dict(p) for p in (props or [])]
if not mac or any(p.get("key") == "MAC" for p in out):
return out
out.append({"key": "MAC", "value": mac, "icon": None, "visible": False})
return out
class BulkActionRequest(BaseModel): class BulkActionRequest(BaseModel):
device_ids: list[str] device_ids: list[str]
@@ -138,13 +166,14 @@ async def bulk_approve_devices(
label=device.hostname or device.friendly_name or device.ip or "device", label=device.hostname or device.friendly_name or device.ip or "device",
type=node_type, type=node_type,
ip=device.ip, ip=device.ip,
mac=device.mac,
hostname=device.hostname, hostname=device.hostname,
status="online" if is_zigbee else "unknown", status="online" if is_zigbee else "unknown",
services=device.services or [], services=device.services or [],
ieee_address=device.ieee_address, ieee_address=device.ieee_address,
properties=build_zigbee_properties( properties=build_zigbee_properties(
device.ieee_address, device.vendor, device.model, device.lqi device.ieee_address, device.vendor, device.model, device.lqi
) if is_zigbee else [], ) if is_zigbee else build_mac_property(device.mac),
# Default to ping so the status checker actually polls the new node. # Default to ping so the status checker actually polls the new node.
# Without this the scheduler skips it (check_method NULL → no check). # Without this the scheduler skips it (check_method NULL → no check).
check_method="none" if is_zigbee else ("ping" if device.ip else None), check_method="none" if is_zigbee else ("ping" if device.ip else None),
@@ -245,17 +274,21 @@ async def approve_device(
raise HTTPException(status_code=409, detail="Device already processed") raise HTTPException(status_code=409, detail="Device already processed")
device.status = "approved" device.status = "approved"
_is_zigbee = node_data.type in _ZIGBEE_TYPES _is_zigbee = node_data.type in _ZIGBEE_TYPES
# Prefer the MAC discovered during the scan (stored on the pending device);
# fall back to whatever the approve payload carried.
_mac = device.mac or node_data.mac
node = Node( node = Node(
label=node_data.label, label=node_data.label,
type=node_data.type, type=node_data.type,
ip=node_data.ip, ip=node_data.ip,
mac=_mac,
hostname=node_data.hostname, hostname=node_data.hostname,
status="online" if _is_zigbee else node_data.status, status="online" if _is_zigbee else node_data.status,
services=node_data.services or [], services=node_data.services or [],
ieee_address=device.ieee_address, ieee_address=device.ieee_address,
properties=build_zigbee_properties( properties=build_zigbee_properties(
device.ieee_address, device.vendor, device.model, device.lqi device.ieee_address, device.vendor, device.model, device.lqi
) if _is_zigbee else (node_data.properties or []), ) if _is_zigbee else merge_mac_property(node_data.properties, _mac),
check_method="none" if _is_zigbee else (node_data.check_method or ("ping" if node_data.ip else None)), check_method="none" if _is_zigbee else (node_data.check_method or ("ping" if node_data.ip else None)),
check_target=None if _is_zigbee else node_data.check_target, check_target=None if _is_zigbee else node_data.check_target,
design_id=node_design_id, design_id=node_design_id,
+15 -2
View File
@@ -1,20 +1,27 @@
"""App-level settings (status checker interval, etc.).""" """App-level settings (status checker interval, etc.)."""
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from app.api.deps import get_current_user from app.api.deps import get_current_user
from app.core.config import settings from app.core.config import settings
from app.core.scheduler import reschedule_service_checks, set_service_checks_enabled
router = APIRouter() router = APIRouter()
class AppSettings(BaseModel): class AppSettings(BaseModel):
interval_seconds: int interval_seconds: int
service_check_enabled: bool = False
service_check_interval: int = Field(default=300, ge=30)
@router.get("", response_model=AppSettings) @router.get("", response_model=AppSettings)
async def get_settings(_: str = Depends(get_current_user)) -> AppSettings: async def get_settings(_: str = Depends(get_current_user)) -> AppSettings:
return AppSettings(interval_seconds=settings.status_checker_interval) return AppSettings(
interval_seconds=settings.status_checker_interval,
service_check_enabled=settings.service_check_enabled,
service_check_interval=settings.service_check_interval,
)
@router.post("", response_model=AppSettings) @router.post("", response_model=AppSettings)
@@ -23,7 +30,13 @@ async def update_settings(
) -> AppSettings: ) -> AppSettings:
try: try:
settings.status_checker_interval = payload.interval_seconds settings.status_checker_interval = payload.interval_seconds
settings.service_check_enabled = payload.service_check_enabled
settings.service_check_interval = payload.service_check_interval
settings.save_overrides() settings.save_overrides()
# Apply the service-check schedule live.
set_service_checks_enabled(payload.service_check_enabled)
if payload.service_check_enabled:
reschedule_service_checks(payload.service_check_interval)
return payload return payload
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc raise HTTPException(status_code=500, detail=str(exc)) from exc
+22 -2
View File
@@ -1,3 +1,4 @@
import contextlib
import json import json
from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi import APIRouter, WebSocket, WebSocketDisconnect
@@ -10,6 +11,12 @@ router = APIRouter()
_connections: list[WebSocket] = [] _connections: list[WebSocket] = []
def _drop(websocket: WebSocket) -> None:
"""Remove a connection if still present — idempotent, never raises."""
with contextlib.suppress(ValueError):
_connections.remove(websocket)
@router.websocket("/ws/status") @router.websocket("/ws/status")
async def ws_status(websocket: WebSocket) -> None: async def ws_status(websocket: WebSocket) -> None:
# Accept first so we can send a close frame with a reason code # Accept first so we can send a close frame with a reason code
@@ -33,7 +40,11 @@ async def ws_status(websocket: WebSocket) -> None:
while True: while True:
await websocket.receive_text() await websocket.receive_text()
except WebSocketDisconnect: except WebSocketDisconnect:
_connections.remove(websocket) pass
finally:
# Any error (disconnect or otherwise) must release the slot, else the
# dead socket lingers in the broadcast pool.
_drop(websocket)
async def _broadcast(payload: str) -> None: async def _broadcast(payload: str) -> None:
@@ -41,7 +52,7 @@ async def _broadcast(payload: str) -> None:
try: try:
await conn.send_text(payload) await conn.send_text(payload)
except Exception: except Exception:
_connections.remove(conn) _drop(conn)
async def broadcast_status(node_id: str, status: str, checked_at: str, response_time_ms: int | None = None) -> None: async def broadcast_status(node_id: str, status: str, checked_at: str, response_time_ms: int | None = None) -> None:
@@ -54,6 +65,15 @@ async def broadcast_status(node_id: str, status: str, checked_at: str, response_
})) }))
async def broadcast_service_status(node_id: str, services: list[dict[str, object]], checked_at: str) -> None:
await _broadcast(json.dumps({
"type": "service_status",
"node_id": node_id,
"services": services,
"checked_at": checked_at,
}))
async def broadcast_scan_update(run_id: str, devices_found: int) -> None: async def broadcast_scan_update(run_id: str, devices_found: int) -> None:
await _broadcast(json.dumps({ await _broadcast(json.dumps({
"type": "scan_device_found", "type": "scan_device_found",
+7 -1
View File
@@ -228,7 +228,13 @@ async def _persist_pending_import(
pending.vendor = n.get("vendor") or pending.vendor pending.vendor = n.get("vendor") or pending.vendor
if n.get("lqi") is not None: if n.get("lqi") is not None:
pending.lqi = n.get("lqi") pending.lqi = n.get("lqi")
if pending.status == "hidden": if pending.status == "approved":
# The device was approved earlier but its canvas Node no longer
# exists (no Node matched the IEEE above) — it was deleted. Revive
# the row to "pending" so it reappears in the Pending list on
# re-import instead of being silently swallowed. (Issue #167)
pending.status = "pending"
elif pending.status == "hidden":
# Re-imported a hidden device → leave it hidden, just refresh fields. # Re-imported a hidden device → leave it hidden, just refresh fields.
pass pass
pending_updated += 1 pending_updated += 1
+10
View File
@@ -51,6 +51,10 @@ class Settings(BaseSettings):
# Status checker # Status checker
status_checker_interval: int = 60 status_checker_interval: int = 60
# Per-service status checker (independent of node checks). Off by default.
service_check_enabled: bool = False
service_check_interval: int = 300
# MCP service key — set MCP_SERVICE_KEY in .env # MCP service key — set MCP_SERVICE_KEY in .env
# Used by the MCP server to authenticate against the backend without a user password. # Used by the MCP server to authenticate against the backend without a user password.
# Leave empty to disable MCP service key auth. # Leave empty to disable MCP service key auth.
@@ -77,6 +81,10 @@ class Settings(BaseSettings):
self.scanner_ranges = data["scanner_ranges"] self.scanner_ranges = data["scanner_ranges"]
if "status_checker_interval" in data: if "status_checker_interval" in data:
self.status_checker_interval = int(data["status_checker_interval"]) self.status_checker_interval = int(data["status_checker_interval"])
if "service_check_enabled" in data:
self.service_check_enabled = bool(data["service_check_enabled"])
if "service_check_interval" in data:
self.service_check_interval = int(data["service_check_interval"])
except Exception: except Exception:
pass pass
@@ -86,6 +94,8 @@ class Settings(BaseSettings):
self._override_path().write_text(json.dumps({ self._override_path().write_text(json.dumps({
"scanner_ranges": self.scanner_ranges, "scanner_ranges": self.scanner_ranges,
"status_checker_interval": self.status_checker_interval, "status_checker_interval": self.status_checker_interval,
"service_check_enabled": self.service_check_enabled,
"service_check_interval": self.service_check_interval,
})) }))
+72 -1
View File
@@ -9,7 +9,7 @@ from sqlalchemy import select
from app.core.config import settings from app.core.config import settings
from app.db.database import AsyncSessionLocal from app.db.database import AsyncSessionLocal
from app.db.models import Node from app.db.models import Node
from app.services.status_checker import check_node from app.services.status_checker import check_node, check_services
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -73,6 +73,50 @@ async def _run_status_checks() -> None:
]) ])
def _node_host(ip: str | None, hostname: str | None) -> str | None:
"""Pick the address to probe services on: first IP, else hostname."""
if ip:
first = ip.split(",")[0].strip()
if first:
return first
return hostname or None
async def _run_service_checks() -> None:
"""Check every service of every node and broadcast per-service results."""
if not settings.service_check_enabled:
return
from app.api.routes.status import broadcast_service_status # avoid circular import
async with AsyncSessionLocal() as db:
result = await db.execute(select(Node))
nodes = result.scalars().all()
checkable = [
(n.id, _node_host(n.ip, n.hostname), list(n.services or []))
for n in nodes
if n.services
]
now = datetime.now(timezone.utc).isoformat()
for node_id, host, services in checkable:
try:
statuses = await check_services(host, services)
await broadcast_service_status(node_id=node_id, services=statuses, checked_at=now)
except Exception as exc:
logger.error("Service checks failed for node %s: %s", node_id, exc)
def _add_service_check_job() -> None:
scheduler.add_job(
_run_service_checks,
"interval",
seconds=settings.service_check_interval,
id="service_checks",
max_instances=1,
coalesce=True,
)
def start_scheduler() -> None: def start_scheduler() -> None:
global scheduler global scheduler
if scheduler.running: if scheduler.running:
@@ -89,6 +133,8 @@ def start_scheduler() -> None:
max_instances=1, max_instances=1,
coalesce=True, coalesce=True,
) )
if settings.service_check_enabled:
_add_service_check_job()
scheduler.start() scheduler.start()
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval) logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
@@ -104,6 +150,31 @@ def reschedule_status_checks(interval_seconds: int) -> None:
logger.info("Status checks rescheduled to every %ds", interval_seconds) logger.info("Status checks rescheduled to every %ds", interval_seconds)
def reschedule_service_checks(interval_seconds: int) -> None:
"""Update the service-check interval on the running scheduler (if enabled)."""
if interval_seconds < 30:
raise ValueError(f"interval_seconds must be >= 30, got {interval_seconds}")
if not scheduler.running:
logger.warning("Scheduler not running, skipping reschedule")
return
if scheduler.get_job("service_checks"):
scheduler.reschedule_job("service_checks", trigger="interval", seconds=interval_seconds)
logger.info("Service checks rescheduled to every %ds", interval_seconds)
def set_service_checks_enabled(enabled: bool) -> None:
"""Add or remove the service-check job on the running scheduler."""
if not scheduler.running:
return
job = scheduler.get_job("service_checks")
if enabled and not job:
_add_service_check_job()
logger.info("Service checks enabled — every %ds", settings.service_check_interval)
elif not enabled and job:
scheduler.remove_job("service_checks")
logger.info("Service checks disabled")
def stop_scheduler() -> None: def stop_scheduler() -> None:
if scheduler.running: if scheduler.running:
scheduler.shutdown(wait=False) scheduler.shutdown(wait=False)
+2
View File
@@ -91,6 +91,8 @@ async def init_db() -> None:
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN disk_gb REAL") await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN disk_gb REAL")
with suppress(OperationalError): with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_hardware BOOLEAN NOT NULL DEFAULT 0") await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_hardware BOOLEAN NOT NULL DEFAULT 0")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_port_numbers BOOLEAN NOT NULL DEFAULT 0")
with suppress(OperationalError): with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN width REAL") await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN width REAL")
with suppress(OperationalError): with suppress(OperationalError):
+1
View File
@@ -54,6 +54,7 @@ class Node(Base):
ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True) ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True) disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
show_hardware: Mapped[bool] = mapped_column(Boolean, default=False) show_hardware: Mapped[bool] = mapped_column(Boolean, default=False)
show_port_numbers: Mapped[bool] = mapped_column(Boolean, default=False)
properties: Mapped[list[Any]] = mapped_column(JSON, default=list) properties: Mapped[list[Any]] = mapped_column(JSON, default=list)
width: Mapped[float | None] = mapped_column(Float, nullable=True) width: Mapped[float | None] = mapped_column(Float, nullable=True)
height: Mapped[float | None] = mapped_column(Float, nullable=True) height: Mapped[float | None] = mapped_column(Float, nullable=True)
+1
View File
@@ -29,6 +29,7 @@ class NodeSave(BaseModel):
ram_gb: float | None = None ram_gb: float | None = None
disk_gb: float | None = None disk_gb: float | None = None
show_hardware: bool = False show_hardware: bool = False
show_port_numbers: bool = False
properties: list[Any] = [] properties: list[Any] = []
width: float | None = None width: float | None = None
height: float | None = None height: float | None = None
+2
View File
@@ -27,6 +27,7 @@ class NodeBase(BaseModel):
ram_gb: float | None = None ram_gb: float | None = None
disk_gb: float | None = None disk_gb: float | None = None
show_hardware: bool = False show_hardware: bool = False
show_port_numbers: bool = False
properties: list[dict[str, Any]] = [] properties: list[dict[str, Any]] = []
width: float | None = None width: float | None = None
height: float | None = None height: float | None = None
@@ -60,6 +61,7 @@ class NodeUpdate(BaseModel):
ram_gb: float | None = None ram_gb: float | None = None
disk_gb: float | None = None disk_gb: float | None = None
show_hardware: bool | None = None show_hardware: bool | None = None
show_port_numbers: bool | None = None
properties: list[dict[str, Any]] | None = None properties: list[dict[str, Any]] | None = None
width: float | None = None width: float | None = None
height: float | None = None height: float | None = None
+97 -7
View File
@@ -64,17 +64,35 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
return {"status": "offline", "response_time_ms": None} return {"status": "offline", "response_time_ms": None}
def _is_ipv6(host: str) -> bool:
"""True if host is a literal IPv6 address (bracketed or bare)."""
try:
socket.inet_pton(socket.AF_INET6, host.strip("[]"))
return True
except OSError:
return False
async def _ping(host: str) -> bool: async def _ping(host: str) -> bool:
# ping(8) -W flag units differ by OS: # Send 2 probes with a ~2s timeout so a single dropped packet or a slow
# Linux: seconds (-W 1 = 1s) # device (ESPHome, IoT) doesn't flap a node offline. Success = any reply.
# macOS: milliseconds (-W 1 = 1ms — fails for any RTT >1ms) #
# Windows: -w in ms (-w 1000 = 1s) # -W flag units differ by OS:
# Linux: seconds (-W 2 = 2s)
# macOS: milliseconds (-W 2000 = 2s)
# Windows: -w in ms (-w 2000 = 2s)
#
# IPv6-only hosts (e.g. Alexa) never answer IPv4 ping, so target the right
# stack: macOS ships a separate ping6; Linux/Windows take a -6 flag.
ipv6 = _is_ipv6(host)
if sys.platform == "win32": if sys.platform == "win32":
args = ["ping", "-n", "1", "-w", "1000", host] family = ["-6"] if ipv6 else ["-4"]
args = ["ping", *family, "-n", "2", "-w", "2000", host]
elif sys.platform == "darwin": elif sys.platform == "darwin":
args = ["ping", "-c", "1", "-W", "1000", host] args = ["ping6", "-c", "2", host] if ipv6 else ["ping", "-c", "2", "-W", "2000", host]
else: else:
args = ["ping", "-c", "1", "-W", "1", host] family = ["-6"] if ipv6 else []
args = ["ping", *family, "-c", "2", "-W", "2", host]
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
*args, *args,
stdout=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.DEVNULL,
@@ -100,3 +118,75 @@ async def _tcp_connect(host: str, port: int) -> bool:
return True return True
except (TimeoutError, OSError, socket.gaierror): except (TimeoutError, OSError, socket.gaierror):
return False return False
# --- Per-service status checks ---
# Ports that are not HTTP/web. These get NO status check — a service here stays
# grey (unknown) rather than going red. An open TCP socket doesn't prove the
# service is healthy, and a closed one flaps red misleadingly (e.g. SSH on a
# box that simply firewalls 22). Only HTTP(S)-reachable services are checked.
_NON_HTTP_PORTS = frozenset({
22, 21, 23, 25, 465, 587, 53, 110, 143, 993, 995, 389, 636, 445, 514,
1433, 3306, 5432, 5672, 6379, 9092, 11211, 27017, 27018,
})
_HTTPS_PORTS = frozenset({443, 8443})
def _service_host(svc: dict[str, Any], host: str) -> str:
"""Bracket bare IPv6 literals for use in a URL."""
return f"[{host}]" if _is_ipv6(host) else host
async def check_service(svc: dict[str, Any], host: str | None) -> str:
"""Check a single service. Returns 'online' | 'offline' | 'unknown'.
Only HTTP(S)-reachable services get a real check (an HTTP GET). Everything
else — SSH, databases, mail, DNS, raw TCP, UDP, port-less — stays 'unknown'
so it keeps its category colour instead of flashing red. An open TCP socket
doesn't prove a non-web service is healthy, so we don't pretend it does.
"""
if not host or host.startswith("-"):
return "unknown"
if str(svc.get("protocol", "")).lower() == "udp":
return "unknown"
port = svc.get("port")
port = int(port) if isinstance(port, int) or (isinstance(port, str) and port.isdigit()) else None
# Non-HTTP ports (SSH 22, DB, mail, …) are never checked — keep them grey.
if port is not None and port in _NON_HTTP_PORTS:
return "unknown"
name = str(svc.get("service_name", "")).lower()
is_web = port is not None or "http" in name
if not is_web:
return "unknown"
try:
scheme = "https" if (
port in _HTTPS_PORTS or "https" in name or "ssl" in name or "tls" in name
) else "http"
url_host = _service_host(svc, host)
url = f"{scheme}://{url_host}" + (f":{port}" if port is not None else "")
return "online" if await _http_get(url, verify=False) else "offline"
except Exception as exc:
logger.debug("Service check failed for %s:%s (%s)", host, port, exc)
return "offline"
async def check_services(
host: str | None, services: list[dict[str, Any]], concurrency: int = 10
) -> list[dict[str, Any]]:
"""Check every service against host concurrently (bounded).
Returns a list of {port, protocol, status} dicts, one per input service.
"""
sem = asyncio.Semaphore(concurrency)
async def _one(svc: dict[str, Any]) -> dict[str, Any]:
async with sem:
status = await check_service(svc, host)
return {"port": svc.get("port"), "protocol": svc.get("protocol"), "status": status}
return await asyncio.gather(*[_one(s) for s in services]) if services else []
+2 -2
View File
@@ -8,14 +8,14 @@ pydantic==2.9.2
pydantic-settings==2.5.2 pydantic-settings==2.5.2
python-jose[cryptography]==3.5.0 python-jose[cryptography]==3.5.0
bcrypt==4.2.1 bcrypt==4.2.1
python-multipart==0.0.27 python-multipart==0.0.31
apscheduler==3.10.4 apscheduler==3.10.4
python-nmap==0.7.1 python-nmap==0.7.1
pyyaml==6.0.2 pyyaml==6.0.2
types-PyYAML==6.0.12.20240917 types-PyYAML==6.0.12.20240917
websockets==13.1 websockets==13.1
httpx==0.27.2 httpx==0.27.2
zeroconf==0.149.7 zeroconf==0.149.12
aiomqtt==2.3.0 aiomqtt==2.3.0
# Dev # Dev
+18
View File
@@ -199,6 +199,24 @@ async def test_save_canvas_show_hardware_defaults_false(client: AsyncClient, hea
assert canvas["nodes"][0]["show_hardware"] is False assert canvas["nodes"][0]["show_hardware"] is False
# Regression (#184): show_port_numbers was dropped by the save schema, so the
# toggle reset on every reload.
async def test_save_canvas_persists_show_port_numbers(client: AsyncClient, headers: dict):
n1 = node_payload(show_port_numbers=True)
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]["show_port_numbers"] is True
async def test_save_canvas_show_port_numbers_defaults_false(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]["show_port_numbers"] is False
async def test_save_canvas_hardware_fields_cleared_on_update(client: AsyncClient, headers: dict): async def test_save_canvas_hardware_fields_cleared_on_update(client: AsyncClient, headers: dict):
n1 = node_payload(cpu_count=8, ram_gb=32.0) n1 = node_payload(cpu_count=8, ram_gb=32.0)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers) await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
+81
View File
@@ -146,3 +146,84 @@ async def test_liveview_disabled_after_key_cleared(client: AsyncClient):
res = await client.get("/api/v1/liveview?key=was-enabled") res = await client.get("/api/v1/liveview?key=was-enabled")
assert res.status_code == 403 assert res.status_code == 403
assert res.json()["detail"] == "Live view is disabled" assert res.json()["detail"] == "Live view is disabled"
# ── /config (authenticated) — key used to build share links ──────────────────
@pytest.mark.asyncio
async def test_liveview_config_requires_auth(client: AsyncClient):
"""The config endpoint exposes the key, so it must reject unauthenticated calls."""
settings.liveview_key = "secret"
res = await client.get("/api/v1/liveview/config")
assert res.status_code == 401
@pytest.mark.asyncio
async def test_liveview_config_returns_key_when_enabled(client: AsyncClient, auth_headers):
settings.liveview_key = "share-me"
headers = await auth_headers()
res = await client.get("/api/v1/liveview/config", headers=headers)
assert res.status_code == 200
body = res.json()
assert body == {"enabled": True, "key": "share-me"}
@pytest.mark.asyncio
async def test_liveview_config_disabled_hides_key(client: AsyncClient, auth_headers):
settings.liveview_key = None
headers = await auth_headers()
res = await client.get("/api/v1/liveview/config", headers=headers)
assert res.status_code == 200
assert res.json() == {"enabled": False, "key": None}
@pytest.mark.asyncio
async def test_liveview_config_empty_key_disabled(client: AsyncClient, auth_headers):
settings.liveview_key = ""
headers = await auth_headers()
res = await client.get("/api/v1/liveview/config", headers=headers)
assert res.status_code == 200
assert res.json() == {"enabled": False, "key": None}
# ── design_id selects which canvas is rendered ───────────────────────────────
@pytest.mark.asyncio
async def test_liveview_design_id_selects_canvas(client: AsyncClient, auth_headers):
"""?design_id=<id> renders that design's canvas, not the first one."""
settings.liveview_key = "test-key"
headers = await auth_headers()
# Create two designs
d1 = (await client.post("/api/v1/designs", json={"name": "Network"}, headers=headers)).json()
d2 = (await client.post("/api/v1/designs", json={"name": "Electrical"}, headers=headers)).json()
# Save a distinct node into each design
for design, node_id, label in ((d1, "n-net", "Net Node"), (d2, "n-elec", "Elec Node")):
payload = {
"nodes": [{
"id": node_id,
"type": "server",
"label": label,
"status": "online",
"services": [],
"pos_x": 0,
"pos_y": 0,
}],
"edges": [],
"viewport": {"x": 0, "y": 0, "zoom": 1},
"design_id": design["id"],
}
await client.post("/api/v1/canvas/save", json=payload, headers=headers)
# Requesting d2 returns only the electrical node
res = await client.get(f"/api/v1/liveview?key=test-key&design_id={d2['id']}")
assert res.status_code == 200
nodes = res.json()["nodes"]
assert [n["id"] for n in nodes] == ["n-elec"]
# Requesting d1 returns only the network node
res = await client.get(f"/api/v1/liveview?key=test-key&design_id={d1['id']}")
assert res.status_code == 200
nodes = res.json()["nodes"]
assert [n["id"] for n in nodes] == ["n-net"]
+134
View File
@@ -0,0 +1,134 @@
"""Backward-compatibility tests for the legacy → multi-design migration.
Simulates a database created by a pre-"designs" version of the app and asserts
that running init_db() adopts all existing nodes/edges/canvas into a single
default "Network Topology" design with no data loss. The rest of the test suite
builds the *current* schema via create_all and never exercises this upgrade
path, so this file guards real users upgrading in place.
"""
import os
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
import app.db.database as database
@pytest.fixture
def legacy_engine(tmp_path, monkeypatch):
"""Point the module-global engine + sqlite_path at a throwaway legacy DB."""
db_path = tmp_path / "legacy.db"
monkeypatch.setattr(database.settings, "sqlite_path", str(db_path))
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
monkeypatch.setattr(database, "engine", engine)
return db_path, engine
async def _build_legacy_schema(engine) -> None:
"""Create the pre-designs schema (no design_id, integer canvas_state PK)."""
async with engine.begin() as conn:
await conn.exec_driver_sql(
"CREATE TABLE nodes (id VARCHAR PRIMARY KEY, type VARCHAR, label VARCHAR, "
"status VARCHAR, services JSON, pos_x FLOAT, pos_y FLOAT)"
)
await conn.exec_driver_sql(
"CREATE TABLE edges (id VARCHAR PRIMARY KEY, source VARCHAR, target VARCHAR, type VARCHAR)"
)
await conn.exec_driver_sql(
"CREATE TABLE canvas_state (id INTEGER PRIMARY KEY, viewport JSON, "
"custom_style JSON, saved_at DATETIME)"
)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, type, label, status, services, pos_x, pos_y) "
"VALUES ('n1','server','Old Server','online','[]',10,20)"
)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, type, label, status, services, pos_x, pos_y) "
"VALUES ('n2','router','Old Router','offline','[]',30,40)"
)
await conn.exec_driver_sql(
"INSERT INTO edges (id, source, target, type) VALUES ('e1','n1','n2','ethernet')"
)
await conn.exec_driver_sql(
"INSERT INTO canvas_state (id, viewport, custom_style, saved_at) "
"VALUES (1, '{\"x\":5,\"y\":6,\"zoom\":2}', NULL, '2024-01-01 00:00:00')"
)
async def test_legacy_canvas_migrates_into_default_design(legacy_engine):
db_path, engine = legacy_engine
await _build_legacy_schema(engine)
await database.init_db()
check = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
try:
async with check.begin() as conn:
# Exactly one seeded default design.
designs = (await conn.exec_driver_sql(
"SELECT id, name, design_type, icon FROM designs"
)).fetchall()
assert len(designs) == 1
did, name, dtype, icon = designs[0]
assert name == "Network Topology"
assert dtype == "network"
assert icon == "dashboard"
# Every legacy node adopted into the default design, data preserved.
nodes = (await conn.exec_driver_sql(
"SELECT id, label, status, design_id FROM nodes ORDER BY id"
)).fetchall()
assert [(n[0], n[1], n[2]) for n in nodes] == [
("n1", "Old Server", "online"),
("n2", "Old Router", "offline"),
]
assert all(n[3] == did for n in nodes)
# Legacy edge adopted too.
edge = (await conn.exec_driver_sql(
"SELECT design_id FROM edges WHERE id='e1'"
)).fetchone()
assert edge[0] == did
# canvas_state rebuilt with design_id PK; the old id=1 row maps to the
# default design and the viewport survives.
cs = (await conn.exec_driver_sql(
"SELECT design_id, viewport FROM canvas_state"
)).fetchall()
assert len(cs) == 1
assert cs[0][0] == did
assert "zoom" in (cs[0][1] or "")
finally:
await check.dispose()
await engine.dispose()
async def test_migration_is_idempotent(legacy_engine):
"""Running init_db twice must not duplicate the design or drop any data."""
db_path, engine = legacy_engine
await _build_legacy_schema(engine)
await database.init_db()
await database.init_db() # second boot — should be a no-op
check = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
try:
async with check.begin() as conn:
designs = (await conn.exec_driver_sql("SELECT id FROM designs")).fetchall()
assert len(designs) == 1
did = designs[0][0]
nodes = (await conn.exec_driver_sql(
"SELECT design_id FROM nodes"
)).fetchall()
assert len(nodes) == 2
assert all(n[0] == did for n in nodes)
cs = (await conn.exec_driver_sql("SELECT design_id FROM canvas_state")).fetchall()
assert len(cs) == 1
assert cs[0][0] == did
finally:
await check.dispose()
await engine.dispose()
+138
View File
@@ -698,6 +698,144 @@ async def test_bulk_approve_zigbee_populates_properties(
assert node.check_method == "none" assert node.check_method == "none"
# --- MAC address propagation on approve (issue #168) ---
def test_build_mac_property_returns_hidden_row():
from app.api.routes.scan import build_mac_property
assert build_mac_property("aa:bb:cc:dd:ee:ff") == [
{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False}
]
def test_build_mac_property_empty_when_no_mac():
from app.api.routes.scan import build_mac_property
assert build_mac_property(None) == []
assert build_mac_property("") == []
def test_merge_mac_property_appends_when_absent():
from app.api.routes.scan import merge_mac_property
existing = [{"key": "Custom", "value": "x", "icon": None, "visible": True}]
merged = merge_mac_property(existing, "aa:bb:cc:dd:ee:ff")
assert {"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False} in merged
# Existing prop preserved untouched.
assert existing[0] in merged
def test_merge_mac_property_idempotent_and_preserves_visibility():
from app.api.routes.scan import merge_mac_property
existing = [{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": True}]
merged = merge_mac_property(existing, "aa:bb:cc:dd:ee:ff")
# No duplicate MAC row; user's visible=True choice kept.
macs = [p for p in merged if p["key"] == "MAC"]
assert len(macs) == 1
assert macs[0]["visible"] is True
def test_merge_mac_property_noop_without_mac():
from app.api.routes.scan import merge_mac_property
existing = [{"key": "Custom", "value": "x", "icon": None, "visible": True}]
assert merge_mac_property(existing, None) == existing
@pytest.mark.asyncio
async def test_approve_device_copies_mac_to_node_and_properties(
client: AsyncClient, headers, pending_device, db_session
):
"""Approving a scanned device must carry its MAC onto the node + properties."""
from sqlalchemy import select
from app.db.models import Node as NodeModel
# Payload intentionally omits mac — it must come from the pending device.
res = await client.post(
f"/api/v1/scan/pending/{pending_device.id}/approve",
json={"label": "My Server", "type": "server", "ip": "192.168.1.100", "status": "unknown", "services": []},
headers=headers,
)
assert res.status_code == 200
node = (
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.100"))
).scalar_one()
assert node.mac == "aa:bb:cc:dd:ee:ff"
mac_props = [p for p in node.properties if p["key"] == "MAC"]
assert mac_props == [
{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False}
]
@pytest.mark.asyncio
async def test_approve_device_does_not_duplicate_mac_property(
client: AsyncClient, headers, pending_device, db_session
):
"""If the approve payload already carries a MAC prop, don't add a second one."""
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": "My Server",
"type": "server",
"ip": "192.168.1.100",
"status": "unknown",
"services": [],
"properties": [
{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": True}
],
},
headers=headers,
)
assert res.status_code == 200
node = (
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.100"))
).scalar_one()
mac_props = [p for p in node.properties if p["key"] == "MAC"]
assert len(mac_props) == 1
# User's visibility choice is preserved.
assert mac_props[0]["visible"] is True
@pytest.mark.asyncio
async def test_bulk_approve_copies_mac_to_node_and_properties(
client: AsyncClient, headers, db_session
):
"""Bulk approve must also propagate the scanned MAC to node + properties."""
from sqlalchemy import select
from app.db.models import Node as NodeModel
device = PendingDevice(
id=str(uuid.uuid4()),
ip="192.168.1.55",
mac="11:22:33:44:55:66",
hostname="host-mac",
services=[],
suggested_type="generic",
status="pending",
)
db_session.add(device)
await db_session.commit()
res = await client.post(
"/api/v1/scan/pending/bulk-approve",
json={"device_ids": [device.id]},
headers=headers,
)
assert res.status_code == 200
node = (
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.55"))
).scalar_one()
assert node.mac == "11:22:33:44:55:66"
mac_props = [p for p in node.properties if p["key"] == "MAC"]
assert mac_props == [
{"key": "MAC", "value": "11:22:33:44:55:66", "icon": None, "visible": False}
]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_bulk_approve_sets_default_check_method(client: AsyncClient, headers, two_pending_devices, db_session): async def test_bulk_approve_sets_default_check_method(client: AsyncClient, headers, two_pending_devices, db_session):
"""Approved devices with an IP must default to ping; otherwise scheduler skips them.""" """Approved devices with an IP must default to ping; otherwise scheduler skips them."""
+95 -1
View File
@@ -5,7 +5,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.scheduler import _run_status_checks, start_scheduler, stop_scheduler from app.core.scheduler import (
_run_service_checks,
_run_status_checks,
set_service_checks_enabled,
start_scheduler,
stop_scheduler,
)
from app.db.database import Base from app.db.database import Base
from app.db.models import Node from app.db.models import Node
@@ -141,6 +147,7 @@ def test_scheduler_uses_settings_interval():
with patch("app.core.scheduler.settings") as mock_settings, \ with patch("app.core.scheduler.settings") as mock_settings, \
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched): patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
mock_settings.status_checker_interval = 45 mock_settings.status_checker_interval = 45
mock_settings.service_check_enabled = False
start_scheduler() start_scheduler()
_, kwargs = mock_sched.add_job.call_args _, kwargs = mock_sched.add_job.call_args
assert kwargs["seconds"] == 45 assert kwargs["seconds"] == 45
@@ -155,3 +162,90 @@ def test_start_and_stop_scheduler():
mock_sched.add_job.assert_called_once() mock_sched.add_job.assert_called_once()
mock_sched.start.assert_called_once() mock_sched.start.assert_called_once()
mock_sched.shutdown.assert_called_once() mock_sched.shutdown.assert_called_once()
# ---------------------------------------------------------------------------
# Service checks
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_run_service_checks_disabled_does_nothing(mem_db):
async with mem_db() as session:
session.add(_make_node(services=[{"port": 80, "protocol": "tcp", "service_name": "http"}]))
await session.commit()
with patch("app.core.scheduler.settings") as mock_settings, \
patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
patch("app.services.status_checker.check_services", new_callable=AsyncMock) as mock_cs:
mock_settings.service_check_enabled = False
await _run_service_checks()
mock_cs.assert_not_called()
@pytest.mark.asyncio
async def test_run_service_checks_broadcasts_per_node(mem_db):
async with mem_db() as session:
node = _make_node(
ip="10.0.0.5",
services=[{"port": 80, "protocol": "tcp", "service_name": "http"}],
)
session.add(node)
await session.commit()
node_id = node.id
statuses = [{"port": 80, "protocol": "tcp", "status": "offline"}]
with patch("app.core.scheduler.settings") as mock_settings, \
patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
patch("app.core.scheduler.check_services", new_callable=AsyncMock, return_value=statuses), \
patch("app.api.routes.status.broadcast_service_status", new_callable=AsyncMock) as mock_bcast:
mock_settings.service_check_enabled = True
await _run_service_checks()
mock_bcast.assert_awaited_once()
_, kwargs = mock_bcast.call_args
assert kwargs["node_id"] == node_id
assert kwargs["services"] == statuses
@pytest.mark.asyncio
async def test_run_service_checks_skips_nodes_without_services(mem_db):
async with mem_db() as session:
session.add(_make_node(ip="10.0.0.6", services=[]))
await session.commit()
with patch("app.core.scheduler.settings") as mock_settings, \
patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
patch("app.core.scheduler.check_services", new_callable=AsyncMock) as mock_cs:
mock_settings.service_check_enabled = True
await _run_service_checks()
mock_cs.assert_not_called()
def test_set_service_checks_enabled_adds_and_removes_job():
mock_sched = MagicMock()
mock_sched.running = True
with patch("app.core.scheduler.scheduler", mock_sched), \
patch("app.core.scheduler.settings") as mock_settings:
mock_settings.service_check_interval = 300
# Enable: no existing job -> add
mock_sched.get_job.return_value = None
set_service_checks_enabled(True)
mock_sched.add_job.assert_called_once()
# Disable: existing job -> remove
mock_sched.get_job.return_value = MagicMock()
set_service_checks_enabled(False)
mock_sched.remove_job.assert_called_once_with("service_checks")
def test_start_scheduler_adds_service_job_when_enabled():
mock_sched = MagicMock()
with patch("app.core.scheduler.settings") as mock_settings, \
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
mock_settings.status_checker_interval = 60
mock_settings.service_check_enabled = True
mock_settings.service_check_interval = 300
start_scheduler()
job_ids = [kw.get("id") for _, kw in mock_sched.add_job.call_args_list]
assert "status_checks" in job_ids
assert "service_checks" in job_ids
+39
View File
@@ -45,3 +45,42 @@ async def test_update_settings_saves_interval(client: AsyncClient, headers):
async def test_update_settings_requires_auth(client: AsyncClient): async def test_update_settings_requires_auth(client: AsyncClient):
res = await client.post("/api/v1/settings", json={"interval_seconds": 30}) res = await client.post("/api/v1/settings", json={"interval_seconds": 30})
assert res.status_code == 401 assert res.status_code == 401
@pytest.mark.asyncio
async def test_get_settings_returns_service_check_fields(client: AsyncClient, headers):
res = await client.get("/api/v1/settings", headers=headers)
data = res.json()
assert "service_check_enabled" in data
assert "service_check_interval" in data
assert isinstance(data["service_check_enabled"], bool)
assert isinstance(data["service_check_interval"], int)
@pytest.mark.asyncio
async def test_update_settings_saves_service_check_fields(client: AsyncClient, headers):
with patch("app.api.routes.settings.settings") as mock_settings:
mock_settings.save_overrides = lambda: None
res = await client.post(
"/api/v1/settings",
json={
"interval_seconds": 60,
"service_check_enabled": True,
"service_check_interval": 600,
},
headers=headers,
)
assert res.status_code == 200
body = res.json()
assert body["service_check_enabled"] is True
assert body["service_check_interval"] == 600
@pytest.mark.asyncio
async def test_update_settings_rejects_too_short_service_interval(client: AsyncClient, headers):
res = await client.post(
"/api/v1/settings",
json={"interval_seconds": 60, "service_check_enabled": True, "service_check_interval": 5},
headers=headers,
)
assert res.status_code == 422
+67 -1
View File
@@ -5,7 +5,13 @@ import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect from starlette.websockets import WebSocketDisconnect
from app.api.routes.status import _connections, broadcast_scan_update, broadcast_status from app.api.routes.status import (
_connections,
_drop,
broadcast_scan_update,
broadcast_service_status,
broadcast_status,
)
from app.main import app from app.main import app
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -155,3 +161,63 @@ async def test_broadcast_no_connections():
assert len(_connections) == 0 assert len(_connections) == 0
await broadcast_status(node_id="n", status="online", checked_at="t") await broadcast_status(node_id="n", status="online", checked_at="t")
await broadcast_scan_update(run_id="r", devices_found=0) await broadcast_scan_update(run_id="r", devices_found=0)
# ---------------------------------------------------------------------------
# broadcast_service_status
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_broadcast_service_status_payload():
received: list[str] = []
class FakeWS:
async def send_text(self, text: str) -> None:
received.append(text)
fake = FakeWS()
_connections.append(fake)
try:
await broadcast_service_status(
node_id="node-7",
services=[{"port": 80, "protocol": "tcp", "status": "offline"}],
checked_at="2024-01-01T00:00:00",
)
finally:
_drop(fake)
msg = json.loads(received[0])
assert msg["type"] == "service_status"
assert msg["node_id"] == "node-7"
assert msg["services"] == [{"port": 80, "protocol": "tcp", "status": "offline"}]
# ---------------------------------------------------------------------------
# _drop — idempotent connection removal (regression for double-remove crash)
# ---------------------------------------------------------------------------
def test_drop_is_idempotent():
"""Dropping a connection twice must not raise (was a ValueError crash)."""
class FakeWS:
pass
fake = FakeWS()
_connections.append(fake)
_drop(fake)
_drop(fake) # second drop must be a no-op
assert fake not in _connections
@pytest.mark.asyncio
async def test_broadcast_dead_connection_dropped_once_safely():
"""A send failure removes the dead socket without a double-remove crash."""
class DeadWS:
async def send_text(self, _: str) -> None:
raise RuntimeError("disconnected")
dead = DeadWS()
_connections.append(dead)
await broadcast_status(node_id="n", status="online", checked_at="t")
# A second broadcast must not raise even though dead is already gone.
await broadcast_status(node_id="n", status="online", checked_at="t")
assert dead not in _connections
+198 -4
View File
@@ -3,7 +3,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from app.services.status_checker import _ping, _tcp_connect, check_node from app.services.status_checker import (
_ping,
_tcp_connect,
check_node,
check_service,
check_services,
)
# --- check_node dispatcher --- # --- check_node dispatcher ---
@@ -169,9 +175,14 @@ async def test_ping_uses_unix_args_on_non_windows():
assert "-c" in captured["args"] assert "-c" in captured["args"]
assert "-W" in captured["args"] assert "-W" in captured["args"]
assert "-n" not in captured["args"] assert "-n" not in captured["args"]
# Linux: -W is in seconds; 1s is the intended timeout # 2 probes so a single dropped packet doesn't flap the node offline
c_idx = captured["args"].index("-c")
assert captured["args"][c_idx + 1] == "2"
# Linux: -W is in seconds; 2s is the intended timeout
w_idx = captured["args"].index("-W") w_idx = captured["args"].index("-W")
assert captured["args"][w_idx + 1] == "1" assert captured["args"][w_idx + 1] == "2"
# IPv4 target → no -6 flag
assert "-6" not in captured["args"]
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -193,7 +204,7 @@ async def test_ping_uses_macos_millisecond_timeout():
assert "-c" in captured["args"] assert "-c" in captured["args"]
assert "-W" in captured["args"] assert "-W" in captured["args"]
w_idx = captured["args"].index("-W") w_idx = captured["args"].index("-W")
assert captured["args"][w_idx + 1] == "1000" assert captured["args"][w_idx + 1] == "2000"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -216,6 +227,75 @@ async def test_ping_uses_windows_args_on_win32():
assert "-c" not in captured["args"] assert "-c" not in captured["args"]
# --- _ping IPv6 support ---
@pytest.mark.asyncio
async def test_ping_ipv6_linux_uses_dash6():
"""IPv6-only devices (e.g. Alexa) need ping -6 on Linux."""
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", "linux"), \
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
await _ping("fe80::1")
assert "-6" in captured["args"]
assert captured["args"][-1] == "fe80::1"
@pytest.mark.asyncio
async def test_ping_ipv6_macos_uses_ping6():
"""macOS ships a separate ping6 binary for IPv6 targets."""
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("2001:db8::1")
assert captured["args"][0] == "ping6"
@pytest.mark.asyncio
async def test_ping_ipv6_windows_uses_dash6():
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", "win32"), \
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
await _ping("2001:db8::1")
assert "-6" in captured["args"]
def test_is_ipv6_detection():
from app.services.status_checker import _is_ipv6
assert _is_ipv6("fe80::1") is True
assert _is_ipv6("2001:db8::1") is True
assert _is_ipv6("[2001:db8::1]") is True
assert _is_ipv6("192.168.1.1") is False
assert _is_ipv6("example.local") is False
# --- check_node target validation --- # --- check_node target validation ---
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -268,3 +348,117 @@ async def test_tcp_connect_os_error():
with patch("asyncio.open_connection", new_callable=AsyncMock, side_effect=OSError("refused")): with patch("asyncio.open_connection", new_callable=AsyncMock, side_effect=OSError("refused")):
result = await _tcp_connect("192.168.1.1", 9999) result = await _tcp_connect("192.168.1.1", 9999)
assert result is False assert result is False
# --- check_service ---
@pytest.mark.asyncio
async def test_check_service_no_host_is_unknown():
assert await check_service({"port": 80, "protocol": "tcp", "service_name": "http"}, None) == "unknown"
@pytest.mark.asyncio
async def test_check_service_flag_host_is_unknown():
assert await check_service({"port": 80, "protocol": "tcp", "service_name": "http"}, "-O") == "unknown"
@pytest.mark.asyncio
async def test_check_service_udp_is_unknown():
assert await check_service({"port": 53, "protocol": "udp", "service_name": "dns"}, "10.0.0.1") == "unknown"
@pytest.mark.asyncio
async def test_check_service_portless_non_web_is_unknown():
svc = {"protocol": "tcp", "service_name": "thing"}
assert await check_service(svc, "10.0.0.1") == "unknown"
@pytest.mark.asyncio
async def test_check_service_web_uses_http_get():
captured = {}
async def fake_http_get(url, verify=False):
captured["url"] = url
return True
svc = {"port": 8080, "protocol": "tcp", "service_name": "http"}
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
result = await check_service(svc, "10.0.0.1")
assert result == "online"
assert captured["url"] == "http://10.0.0.1:8080"
@pytest.mark.asyncio
async def test_check_service_https_port_uses_https_scheme():
captured = {}
async def fake_http_get(url, verify=False):
captured["url"] = url
return True
svc = {"port": 443, "protocol": "tcp", "service_name": "web"}
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
await check_service(svc, "10.0.0.1")
assert captured["url"].startswith("https://")
@pytest.mark.asyncio
async def test_check_service_web_offline_when_http_fails():
svc = {"port": 80, "protocol": "tcp", "service_name": "http"}
with patch("app.services.status_checker._http_get", new_callable=AsyncMock, return_value=False):
assert await check_service(svc, "10.0.0.1") == "offline"
@pytest.mark.asyncio
async def test_check_service_non_http_port_is_unknown():
"""Non-HTTP ports (DB, mail, …) stay grey — no TCP check, no red flap."""
svc = {"port": 5432, "protocol": "tcp", "service_name": "postgres"}
with patch("app.services.status_checker._tcp_connect", new_callable=AsyncMock) as mock_tcp, \
patch("app.services.status_checker._http_get", new_callable=AsyncMock) as mock_http:
result = await check_service(svc, "10.0.0.1")
assert result == "unknown"
mock_tcp.assert_not_called()
mock_http.assert_not_called()
@pytest.mark.asyncio
async def test_check_service_ssh_port_22_is_unknown():
"""SSH (port 22) is never checked — keep it grey, not red/green."""
svc = {"port": 22, "protocol": "tcp", "service_name": "ssh"}
with patch("app.services.status_checker._tcp_connect", new_callable=AsyncMock) as mock_tcp:
result = await check_service(svc, "10.0.0.1")
assert result == "unknown"
mock_tcp.assert_not_called()
@pytest.mark.asyncio
async def test_check_service_ipv6_brackets_url_host():
captured = {}
async def fake_http_get(url, verify=False):
captured["url"] = url
return True
svc = {"port": 80, "protocol": "tcp", "service_name": "http"}
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
await check_service(svc, "2001:db8::1")
assert captured["url"] == "http://[2001:db8::1]:80"
@pytest.mark.asyncio
async def test_check_services_returns_status_per_service():
services = [
{"port": 80, "protocol": "tcp", "service_name": "http"},
{"port": 5432, "protocol": "tcp", "service_name": "postgres"},
]
with patch("app.services.status_checker._http_get", new_callable=AsyncMock, return_value=True):
results = await check_services("10.0.0.1", services)
assert results == [
{"port": 80, "protocol": "tcp", "status": "online"},
{"port": 5432, "protocol": "tcp", "status": "unknown"},
]
@pytest.mark.asyncio
async def test_check_services_empty_list():
assert await check_services("10.0.0.1", []) == []
+86
View File
@@ -459,6 +459,92 @@ async def test_persist_pending_import_skips_pending_for_approved_node(
assert all(p["visible"] is False for p in refreshed.properties) assert all(p["visible"] is False for p in refreshed.properties)
@pytest.mark.asyncio
async def test_persist_pending_import_revives_orphaned_approved_device(
db_session,
) -> None:
"""Regression for #167: approve → delete node → re-import must re-list device.
When a device was approved (PendingDevice.status="approved") and its canvas
Node was later deleted, the orphaned "approved" row must be reset to
"pending" on re-import so it shows up in the Pending list again — instead of
being silently swallowed (re-import reports "found" but Pending stays empty).
"""
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import PendingDevice
# Simulate prior approve: a PendingDevice marked approved, but NO matching
# Node exists (the user deleted the canvas node afterwards).
orphan = PendingDevice(
ieee_address="0xR1",
friendly_name="router_1",
hostname="router_1",
suggested_type="zigbee_router",
device_subtype="Router",
model="CC2530",
vendor="TI",
lqi=220,
status="approved",
discovery_source="zigbee",
)
db_session.add(orphan)
await db_session.commit()
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
# No new row created for 0xR1 — the existing one was updated/revived.
revived = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
)
).scalar_one()
assert revived.status == "pending"
# End device 0xE1 is brand new → created as pending; router was updated.
assert result.pending_created == 1
assert result.pending_updated == 1
# It is now visible to the Pending list (status filter == "pending").
listed = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.status == "pending")
)
).scalars().all()
assert {p.ieee_address for p in listed} == {"0xR1", "0xE1"}
@pytest.mark.asyncio
async def test_persist_pending_import_keeps_hidden_hidden_on_reimport(
db_session,
) -> None:
"""A user-hidden device must stay hidden on re-import (not revived like #167)."""
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import PendingDevice
hidden = PendingDevice(
ieee_address="0xR1",
friendly_name="router_1",
suggested_type="zigbee_router",
device_subtype="Router",
status="hidden",
discovery_source="zigbee",
)
db_session.add(hidden)
await db_session.commit()
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
still_hidden = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
)
).scalar_one()
assert still_hidden.status == "hidden"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_persist_pending_import_preserves_user_visibility(db_session) -> None: async def test_persist_pending_import_preserves_user_visibility(db_session) -> None:
"""If user has already made props visible, re-import must not flip them back.""" """If user has already made props visible, re-import must not flip them back."""
+136 -136
View File
@@ -1,12 +1,12 @@
{ {
"name": "frontend", "name": "frontend",
"version": "2.2.0", "version": "2.5.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "frontend", "name": "frontend",
"version": "2.2.0", "version": "2.5.1",
"dependencies": { "dependencies": {
"@base-ui/react": "^1.2.0", "@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4", "@dagrejs/dagre": "^2.0.4",
@@ -52,7 +52,7 @@
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.48.0", "typescript-eslint": "^8.48.0",
"vite": "^7.3.1", "vite": "^7.3.5",
"vitest": "^4.0.18" "vitest": "^4.0.18"
} }
}, },
@@ -964,9 +964,9 @@
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -981,9 +981,9 @@
} }
}, },
"node_modules/@esbuild/android-arm": { "node_modules/@esbuild/android-arm": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -998,9 +998,9 @@
} }
}, },
"node_modules/@esbuild/android-arm64": { "node_modules/@esbuild/android-arm64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1015,9 +1015,9 @@
} }
}, },
"node_modules/@esbuild/android-x64": { "node_modules/@esbuild/android-x64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1032,9 +1032,9 @@
} }
}, },
"node_modules/@esbuild/darwin-arm64": { "node_modules/@esbuild/darwin-arm64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1049,9 +1049,9 @@
} }
}, },
"node_modules/@esbuild/darwin-x64": { "node_modules/@esbuild/darwin-x64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1066,9 +1066,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-arm64": { "node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1083,9 +1083,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-x64": { "node_modules/@esbuild/freebsd-x64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1100,9 +1100,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm": { "node_modules/@esbuild/linux-arm": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1117,9 +1117,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm64": { "node_modules/@esbuild/linux-arm64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1134,9 +1134,9 @@
} }
}, },
"node_modules/@esbuild/linux-ia32": { "node_modules/@esbuild/linux-ia32": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -1151,9 +1151,9 @@
} }
}, },
"node_modules/@esbuild/linux-loong64": { "node_modules/@esbuild/linux-loong64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@@ -1168,9 +1168,9 @@
} }
}, },
"node_modules/@esbuild/linux-mips64el": { "node_modules/@esbuild/linux-mips64el": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
@@ -1185,9 +1185,9 @@
} }
}, },
"node_modules/@esbuild/linux-ppc64": { "node_modules/@esbuild/linux-ppc64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -1202,9 +1202,9 @@
} }
}, },
"node_modules/@esbuild/linux-riscv64": { "node_modules/@esbuild/linux-riscv64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -1219,9 +1219,9 @@
} }
}, },
"node_modules/@esbuild/linux-s390x": { "node_modules/@esbuild/linux-s390x": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -1236,9 +1236,9 @@
} }
}, },
"node_modules/@esbuild/linux-x64": { "node_modules/@esbuild/linux-x64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1253,9 +1253,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-arm64": { "node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1270,9 +1270,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-x64": { "node_modules/@esbuild/netbsd-x64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1287,9 +1287,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-arm64": { "node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1304,9 +1304,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-x64": { "node_modules/@esbuild/openbsd-x64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1321,9 +1321,9 @@
} }
}, },
"node_modules/@esbuild/openharmony-arm64": { "node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1338,9 +1338,9 @@
} }
}, },
"node_modules/@esbuild/sunos-x64": { "node_modules/@esbuild/sunos-x64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1355,9 +1355,9 @@
} }
}, },
"node_modules/@esbuild/win32-arm64": { "node_modules/@esbuild/win32-arm64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1372,9 +1372,9 @@
} }
}, },
"node_modules/@esbuild/win32-ia32": { "node_modules/@esbuild/win32-ia32": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -1389,9 +1389,9 @@
} }
}, },
"node_modules/@esbuild/win32-x64": { "node_modules/@esbuild/win32-x64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -3237,9 +3237,9 @@
} }
}, },
"node_modules/@ts-morph/common/node_modules/brace-expansion": { "node_modules/@ts-morph/common/node_modules/brace-expansion": {
"version": "5.0.5", "version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^4.0.2" "balanced-match": "^4.0.2"
@@ -3651,9 +3651,9 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.5", "version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -4250,9 +4250,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.13", "version": "1.1.15",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -5216,9 +5216,9 @@
} }
}, },
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
@@ -5229,32 +5229,32 @@
"node": ">=18" "node": ">=18"
}, },
"optionalDependencies": { "optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.7", "@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.27.7", "@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.27.7", "@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.27.7", "@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.27.7", "@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.27.7", "@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.27.7", "@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.27.7", "@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.27.7", "@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.27.7", "@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.27.7", "@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.27.7" "@esbuild/win32-x64": "0.28.1"
} }
}, },
"node_modules/escalade": { "node_modules/escalade": {
@@ -5877,16 +5877,16 @@
} }
}, },
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.5", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.8", "combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0", "es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2", "hasown": "^2.0.4",
"mime-types": "^2.1.12" "mime-types": "^2.1.35"
}, },
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@@ -6161,9 +6161,9 @@
} }
}, },
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
@@ -6196,9 +6196,9 @@
} }
}, },
"node_modules/hono": { "node_modules/hono": {
"version": "4.12.18", "version": "4.12.25",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz",
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=16.9.0" "node": ">=16.9.0"
@@ -8040,9 +8040,9 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.15.0", "version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"side-channel": "^1.1.0" "side-channel": "^1.1.0"
@@ -9267,9 +9267,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "7.3.2", "version": "7.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
+7 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "2.2.0", "version": "2.5.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -37,6 +37,11 @@
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"overrides": {
"hono": "^4.12.21",
"esbuild": "^0.28.1",
"form-data": "^4.0.6"
},
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
@@ -58,7 +63,7 @@
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.48.0", "typescript-eslint": "^8.48.0",
"vite": "^7.3.1", "vite": "^7.3.5",
"vitest": "^4.0.18" "vitest": "^4.0.18"
} }
} }
+85 -20
View File
@@ -20,18 +20,21 @@ import { LoginPage } from '@/components/LoginPage'
import { NodeModal } from '@/components/modals/NodeModal' import { NodeModal } from '@/components/modals/NodeModal'
import { EdgeModal } from '@/components/modals/EdgeModal' import { EdgeModal } from '@/components/modals/EdgeModal'
import { ScanConfigModal } from '@/components/modals/ScanConfigModal' import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
import { SettingsModal } from '@/components/modals/SettingsModal'
import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal' import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal' import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
import { TextModal, type TextFormData } from '@/components/modals/TextModal' import { TextModal, type TextFormData } from '@/components/modals/TextModal'
import { ThemeModal } from '@/components/modals/ThemeModal' import { ThemeModal } from '@/components/modals/ThemeModal'
import { SearchModal } from '@/components/modals/SearchModal' import { SearchModal } from '@/components/modals/SearchModal'
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal' import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
import { ScanHistoryModal } from '@/components/modals/ScanHistoryModal'
import { ShortcutsModal } from '@/components/modals/ShortcutsModal' import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
import { ConfirmAddToGroupModal } from '@/components/modals/ConfirmAddToGroupModal'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { useDesignStore } from '@/stores/designStore' import { useDesignStore } from '@/stores/designStore'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
import { useThemeStore } from '@/stores/themeStore' import { useThemeStore } from '@/stores/themeStore'
import { canvasApi, designsApi } from '@/api/client' import { canvasApi, designsApi, liveviewApi } from '@/api/client'
import { demoNodes, demoEdges } from '@/utils/demoData' import { demoNodes, demoEdges } from '@/utils/demoData'
import { useStatusPolling } from '@/hooks/useStatusPolling' import { useStatusPolling } from '@/hooks/useStatusPolling'
import type { NodeData, EdgeData, CustomStyleDef } from '@/types' import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
@@ -41,7 +44,7 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const STANDALONE_STORAGE_KEY = 'homelable_canvas' const STANDALONE_STORAGE_KEY = 'homelable_canvas'
export default function App() { export default function App() {
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 { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer } = useCanvasStore()
const canvasRef = useRef<HTMLDivElement>(null) const canvasRef = useRef<HTMLDivElement>(null)
const { isAuthenticated } = useAuthStore() const { isAuthenticated } = useAuthStore()
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore() const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
@@ -51,7 +54,7 @@ export default function App() {
const [themeModalOpen, setThemeModalOpen] = useState(false) const [themeModalOpen, setThemeModalOpen] = useState(false)
const [searchOpen, setSearchOpen] = useState(false) const [searchOpen, setSearchOpen] = useState(false)
const [sidebarForceView, setSidebarForceView] = useState<'history' | undefined>(undefined) const [scanHistoryOpen, setScanHistoryOpen] = useState(false)
const [pendingModalOpen, setPendingModalOpen] = useState(false) const [pendingModalOpen, setPendingModalOpen] = useState(false)
const [pendingModalStatus, setPendingModalStatus] = useState<'pending' | 'hidden'>('pending') const [pendingModalStatus, setPendingModalStatus] = useState<'pending' | 'hidden'>('pending')
const [pendingHighlightId, setPendingHighlightId] = useState<string | undefined>(undefined) const [pendingHighlightId, setPendingHighlightId] = useState<string | undefined>(undefined)
@@ -67,8 +70,11 @@ export default function App() {
const [addTextOpen, setAddTextOpen] = useState(false) const [addTextOpen, setAddTextOpen] = useState(false)
const [editNodeId, setEditNodeId] = useState<string | null>(null) const [editNodeId, setEditNodeId] = useState<string | null>(null)
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null) const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
const [pendingGroupAdd, setPendingGroupAdd] = useState<{ nodeId: string; groupId: string } | null>(null)
const [pendingContainerAdd, setPendingContainerAdd] = useState<{ nodeId: string; containerId: string } | null>(null)
const [editEdgeId, setEditEdgeId] = useState<string | null>(null) const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
const [scanConfigOpen, setScanConfigOpen] = useState(false) const [scanConfigOpen, setScanConfigOpen] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
const [exportModalOpen, setExportModalOpen] = useState(false) const [exportModalOpen, setExportModalOpen] = useState(false)
const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false) const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false)
@@ -211,12 +217,8 @@ export default function App() {
// Keep refs for store actions so keydown handler is always up-to-date without re-registering // Keep refs for store actions so keydown handler is always up-to-date without re-registering
const undoRef = useRef(undo) const undoRef = useRef(undo)
const redoRef = useRef(redo) const redoRef = useRef(redo)
const copyRef = useRef(copySelectedNodes)
const pasteRef = useRef(pasteNodes)
useEffect(() => { undoRef.current = undo }, [undo]) useEffect(() => { undoRef.current = undo }, [undo])
useEffect(() => { redoRef.current = redo }, [redo]) useEffect(() => { redoRef.current = redo }, [redo])
useEffect(() => { copyRef.current = copySelectedNodes }, [copySelectedNodes])
useEffect(() => { pasteRef.current = pasteNodes }, [pasteNodes])
// Global keyboard shortcuts // Global keyboard shortcuts
useEffect(() => { useEffect(() => {
@@ -230,8 +232,8 @@ export default function App() {
if (ctrl && e.key === 'z') { e.preventDefault(); undoRef.current(); return } if (ctrl && e.key === 'z') { e.preventDefault(); undoRef.current(); return }
if (ctrl && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { e.preventDefault(); redoRef.current(); return } if (ctrl && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { e.preventDefault(); redoRef.current(); return }
if (ctrl && e.key === 'k') { e.preventDefault(); setSearchOpen(true); return } if (ctrl && e.key === 'k') { e.preventDefault(); setSearchOpen(true); return }
if (ctrl && e.key === 'c' && !isInput) { copyRef.current(); return } // Copy/paste (Ctrl/Cmd+C/V) handled in CanvasContainer so paste can place
if (ctrl && e.key === 'v' && !isInput) { pasteRef.current(); return } // nodes under the cursor / viewport center.
if (e.key === '?' && !isInput) { setShortcutsOpen(true); return } if (e.key === '?' && !isInput) { setShortcutsOpen(true); return }
} }
window.addEventListener('keydown', handler) window.addEventListener('keydown', handler)
@@ -243,9 +245,16 @@ export default function App() {
const id = generateUUID() const id = generateUUID()
const isContainerNode = data.container_mode === true const isContainerNode = data.container_mode === true
const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null 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 // Only nest when the parent is an actual container. For a non-container
const position = parentNode // parent the LXC/VM stays a free node (linked by a virtual edge) — setting
? { x: 20, y: 50 } // extent:'parent' on a non-container would trap it inside the parent's tiny
// bounding box with no way to drag it out (issue #205 follow-up).
const nestInParent = !!parentNode?.data.container_mode
// Seed an ABSOLUTE position near the container's top-left; addNode converts
// it to container-relative. addNode is the single authority for parentId /
// extent, so we don't set them here.
const position = nestInParent && parentNode
? { x: parentNode.position.x + 20, y: parentNode.position.y + 50 }
: { x: 300, y: 300 } : { x: 300, y: 300 }
const newNode: Node<NodeData> = { const newNode: Node<NodeData> = {
@@ -253,7 +262,6 @@ export default function App() {
type: data.type ?? 'generic', type: data.type ?? 'generic',
position, position,
data: { status: 'unknown', services: [], ...data } as NodeData, data: { status: 'unknown', services: [], ...data } as NodeData,
...(data.parent_id ? { parentId: data.parent_id, extent: 'parent' as const } : {}),
...(isContainerNode ? { width: 300, height: 200 } : {}), ...(isContainerNode ? { width: 300, height: 200 } : {}),
} }
addNode(newNode) addNode(newNode)
@@ -457,6 +465,28 @@ export default function App() {
} }
}, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved]) }, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved])
// Open the read-only live view of the currently active design in a new tab.
// Standalone has no backend/key — it reads localStorage, so just open /view.
// Otherwise fetch the configured live view key and build /view?key=...&design=<id>.
const handleViewOnly = useCallback(async () => {
if (STANDALONE) {
window.open('/view', '_blank', 'noopener,noreferrer')
return
}
try {
const res = await liveviewApi.getConfig()
if (!res.data.enabled || !res.data.key) {
toast.error('Live view is disabled — set LIVEVIEW_KEY in the backend .env')
return
}
const params = new URLSearchParams({ key: res.data.key })
if (activeDesignId) params.set('design', activeDesignId)
window.open(`/view?${params.toString()}`, '_blank', 'noopener,noreferrer')
} catch {
toast.error('Failed to open live view')
}
}, [activeDesignId])
const handleExport = useCallback(() => { const handleExport = useCallback(() => {
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow') const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
if (!el) { toast.error('Canvas not ready'); return } if (!el) { toast.error('Canvas not ready'); return }
@@ -586,7 +616,8 @@ export default function App() {
onScan={() => setScanConfigOpen(true)} onScan={() => setScanConfigOpen(true)}
onZigbeeImport={() => setZigbeeImportOpen(true)} onZigbeeImport={() => setZigbeeImportOpen(true)}
onSave={handleSave} onSave={handleSave}
forceView={sidebarForceView} onOpenSettings={() => setSettingsOpen(true)}
onOpenHistory={() => setScanHistoryOpen(true)}
onOpenPending={openPendingModal} onOpenPending={openPendingModal}
/> />
<div className="flex flex-col flex-1 min-w-0"> <div className="flex flex-col flex-1 min-w-0">
@@ -601,6 +632,7 @@ export default function App() {
onExportMd={handleExportMd} onExportMd={handleExportMd}
onExportYaml={handleExportYaml} onExportYaml={handleExportYaml}
onImportYaml={handleImportYaml} onImportYaml={handleImportYaml}
onViewOnly={handleViewOnly}
/> />
<div className="flex flex-1 min-h-0"> <div className="flex flex-1 min-h-0">
<div ref={canvasRef} className="flex-1 min-w-0 h-full"> <div ref={canvasRef} className="flex-1 min-w-0 h-full">
@@ -609,6 +641,8 @@ export default function App() {
onEdgeDoubleClick={handleEdgeDoubleClick} onEdgeDoubleClick={handleEdgeDoubleClick}
onNodeDoubleClick={handleNodeDoubleClick} onNodeDoubleClick={handleNodeDoubleClick}
onNodeDragStart={snapshotHistory} onNodeDragStart={snapshotHistory}
onRequestAddToGroup={setPendingGroupAdd}
onRequestAddToContainer={setPendingContainerAdd}
onOpenPending={(deviceId) => openPendingModal(deviceId)} onOpenPending={(deviceId) => openPendingModal(deviceId)}
/> />
</div> </div>
@@ -623,7 +657,7 @@ export default function App() {
onClose={() => setAddNodeOpen(false)} onClose={() => setAddNodeOpen(false)}
onSubmit={handleAddNode} onSubmit={handleAddNode}
title="Add Node" title="Add Node"
parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type }))} parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode }))}
/> />
{/* key forces re-mount when editing a different node, resetting form state */} {/* key forces re-mount when editing a different node, resetting form state */}
@@ -650,7 +684,7 @@ export default function App() {
} }
return nodes return nodes
.filter((n) => !descendants.has(n.id)) .filter((n) => !descendants.has(n.id))
.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type })) .map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode }))
})()} })()}
currentNodeId={editNodeId ?? undefined} currentNodeId={editNodeId ?? undefined}
/> />
@@ -684,8 +718,6 @@ export default function App() {
onClose={() => setScanConfigOpen(false)} onClose={() => setScanConfigOpen(false)}
onScanNow={() => { onScanNow={() => {
toast.success('Network scan started — check Scan History for results') toast.success('Network scan started — check Scan History for results')
setSidebarForceView(undefined)
setTimeout(() => setSidebarForceView('history'), 0)
}} }}
/> />
)} )}
@@ -696,12 +728,18 @@ export default function App() {
onClose={() => setZigbeeImportOpen(false)} onClose={() => setZigbeeImportOpen(false)}
onAddToCanvas={handleZigbeeAddToCanvas} onAddToCanvas={handleZigbeeAddToCanvas}
onPendingImported={() => { onPendingImported={() => {
setSidebarForceView(undefined) toast.success('Zigbee import started — check Scan History for results')
setTimeout(() => setSidebarForceView('history'), 0)
}} }}
/> />
)} )}
{!STANDALONE && (
<ScanHistoryModal
open={scanHistoryOpen}
onClose={() => setScanHistoryOpen(false)}
/>
)}
<GroupRectModal <GroupRectModal
open={addGroupRectOpen} open={addGroupRectOpen}
onClose={() => setAddGroupRectOpen(false)} onClose={() => setAddGroupRectOpen(false)}
@@ -782,6 +820,33 @@ export default function App() {
/> />
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} /> <ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
<ConfirmAddToGroupModal
open={!!pendingGroupAdd}
nodeLabel={pendingGroupAdd ? (nodes.find((n) => n.id === pendingGroupAdd.nodeId)?.data.label ?? '') : ''}
targetLabel={pendingGroupAdd ? (nodes.find((n) => n.id === pendingGroupAdd.groupId)?.data.label ?? '') : ''}
onConfirm={() => {
if (pendingGroupAdd) addToGroup(pendingGroupAdd.groupId, pendingGroupAdd.nodeId)
setPendingGroupAdd(null)
}}
onCancel={() => setPendingGroupAdd(null)}
/>
<ConfirmAddToGroupModal
open={!!pendingContainerAdd}
variant="container"
nodeLabel={pendingContainerAdd ? (nodes.find((n) => n.id === pendingContainerAdd.nodeId)?.data.label ?? '') : ''}
targetLabel={pendingContainerAdd ? (nodes.find((n) => n.id === pendingContainerAdd.containerId)?.data.label ?? '') : ''}
onConfirm={() => {
if (pendingContainerAdd) addToContainer(pendingContainerAdd.containerId, pendingContainerAdd.nodeId)
setPendingContainerAdd(null)
}}
onCancel={() => setPendingContainerAdd(null)}
/>
{!STANDALONE && (
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
)}
<PendingDevicesModal <PendingDevicesModal
open={pendingModalOpen} open={pendingModalOpen}
onClose={() => setPendingModalOpen(false)} onClose={() => setPendingModalOpen(false)}
+12 -2
View File
@@ -158,6 +158,16 @@ describe('api/client', () => {
expect(api.get).not.toHaveBeenCalled() expect(api.get).not.toHaveBeenCalled()
}) })
it('liveviewApi.load forwards design as design_id when provided', () => {
mod.liveviewApi.load('k-1', 'design-9')
expect(publicApi.get).toHaveBeenCalledWith('/liveview', { params: { key: 'k-1', design_id: 'design-9' } })
})
it('liveviewApi.getConfig hits the authenticated config endpoint', () => {
mod.liveviewApi.getConfig()
expect(api.get).toHaveBeenCalledWith('/liveview/config')
})
it('scanApi endpoints route correctly', () => { it('scanApi endpoints route correctly', () => {
mod.scanApi.trigger() mod.scanApi.trigger()
expect(api.post).toHaveBeenCalledWith('/scan/trigger') expect(api.post).toHaveBeenCalledWith('/scan/trigger')
@@ -194,8 +204,8 @@ describe('api/client', () => {
it('settingsApi get/save', () => { it('settingsApi get/save', () => {
mod.settingsApi.get() mod.settingsApi.get()
expect(api.get).toHaveBeenCalledWith('/settings') expect(api.get).toHaveBeenCalledWith('/settings')
mod.settingsApi.save({ interval_seconds: 30 }) mod.settingsApi.save({ interval_seconds: 30, service_check_enabled: true, service_check_interval: 600 })
expect(api.post).toHaveBeenCalledWith('/settings', { interval_seconds: 30 }) expect(api.post).toHaveBeenCalledWith('/settings', { interval_seconds: 30, service_check_enabled: true, service_check_interval: 600 })
}) })
it('zigbeeApi.testConnection/importNetwork/importToPending', () => { it('zigbeeApi.testConnection/importNetwork/importToPending', () => {
+11 -3
View File
@@ -53,7 +53,9 @@ export const edgesApi = {
} }
export const liveviewApi = { export const liveviewApi = {
load: (key: string) => publicApi.get('/liveview', { params: { key } }), load: (key: string, design?: string) =>
publicApi.get('/liveview', { params: { key, ...(design ? { design_id: design } : {}) } }),
getConfig: () => api.get<{ enabled: boolean; key: string | null }>('/liveview/config'),
} }
export const scanApi = { export const scanApi = {
@@ -88,9 +90,15 @@ export const scanApi = {
saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data), saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
} }
export interface AppSettings {
interval_seconds: number
service_check_enabled: boolean
service_check_interval: number
}
export const settingsApi = { export const settingsApi = {
get: () => api.get<{ interval_seconds: number }>('/settings'), get: () => api.get<AppSettings>('/settings'),
save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data), save: (data: AppSettings) => api.post<AppSettings>('/settings', data),
} }
export const designsApi = { export const designsApi = {
+8 -2
View File
@@ -68,10 +68,14 @@ function LiveViewCanvas() {
} }
// Already handled synchronously in useState initializer // Already handled synchronously in useState initializer
const key = new URLSearchParams(window.location.search).get('key') const search = new URLSearchParams(window.location.search)
const key = search.get('key')
if (!key) return if (!key) return
// Optional ?design=<id> selects which canvas to render; backend falls back
// to the first design when omitted.
const design = search.get('design') ?? undefined
liveviewApi.load(key) liveviewApi.load(key, design)
.then((res) => { .then((res) => {
const { nodes: apiNodes, edges: apiEdges } = res.data const { nodes: apiNodes, edges: apiEdges } = res.data
const proxmoxMap = new Map<string, boolean>( const proxmoxMap = new Map<string, boolean>(
@@ -158,6 +162,8 @@ function LiveViewCanvas() {
elementsSelectable={false} elementsSelectable={false}
panOnDrag panOnDrag
zoomOnScroll zoomOnScroll
minZoom={0.25}
maxZoom={2.5}
colorMode={theme.colors.reactFlowColorMode} colorMode={theme.colors.reactFlowColorMode}
connectionMode={ConnectionMode.Loose} connectionMode={ConnectionMode.Loose}
onNodeClick={onNodeClick} onNodeClick={onNodeClick}
@@ -5,9 +5,15 @@ import { useThemeStore } from '@/stores/themeStore'
// ── Mock heavy dependencies ──────────────────────────────────────────────── // ── Mock heavy dependencies ────────────────────────────────────────────────
// Capture props passed to ReactFlow so we can assert zoom bounds etc.
let rfProps: Record<string, unknown> = {}
vi.mock('@xyflow/react', () => ({ vi.mock('@xyflow/react', () => ({
ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>, ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
ReactFlow: () => <div data-testid="react-flow" />, ReactFlow: (props: Record<string, unknown>) => {
rfProps = props
return <div data-testid="react-flow" />
},
Background: () => null, Background: () => null,
Controls: () => null, Controls: () => null,
BackgroundVariant: { Dots: 'dots' }, BackgroundVariant: { Dots: 'dots' },
@@ -49,6 +55,7 @@ const canvasPayload = {
describe('LiveView (non-standalone)', () => { describe('LiveView (non-standalone)', () => {
beforeEach(() => { beforeEach(() => {
rfProps = {}
vi.mocked(liveviewApi.load).mockReset() vi.mocked(liveviewApi.load).mockReset()
useCanvasStore.setState({ nodes: [], edges: [] }) useCanvasStore.setState({ nodes: [], edges: [] })
}) })
@@ -111,7 +118,26 @@ describe('LiveView (non-standalone)', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('react-flow')).toBeDefined() expect(screen.getByTestId('react-flow')).toBeDefined()
}) })
expect(liveviewApi.load).toHaveBeenCalledWith('correct-key') expect(liveviewApi.load).toHaveBeenCalledWith('correct-key', undefined)
})
it('forwards ?design=<id> to the API so a specific canvas is loaded', async () => {
setSearch('?key=correct-key&design=elec-123')
vi.mocked(liveviewApi.load).mockResolvedValue(canvasPayload as never)
render(<LiveView />)
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
expect(liveviewApi.load).toHaveBeenCalledWith('correct-key', 'elec-123')
})
it('allows zooming out to 0.25 so large infra fits (matches the editor)', async () => {
setSearch('?key=correct-key')
vi.mocked(liveviewApi.load).mockResolvedValue(canvasPayload as never)
render(<LiveView />)
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
// Without an explicit minZoom, React Flow defaults to 0.5 and big canvases
// can't zoom out far enough to fit.
expect(rfProps.minZoom).toBe(0.25)
expect(rfProps.maxZoom).toBe(2.5)
}) })
it('loads nodes into the canvas store on success', async () => { it('loads nodes into the canvas store on success', async () => {
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { import {
ReactFlow, ReactFlow,
Background, Background,
@@ -30,18 +30,46 @@ interface CanvasContainerProps {
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
onNodeDoubleClick?: (node: Node<NodeData>) => void onNodeDoubleClick?: (node: Node<NodeData>) => void
onNodeDragStart?: () => void onNodeDragStart?: () => void
onRequestAddToGroup?: (payload: { nodeId: string; groupId: string }) => void
onRequestAddToContainer?: (payload: { nodeId: string; containerId: string }) => void
onOpenPending?: (deviceId: string) => void onOpenPending?: (deviceId: string) => void
} }
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onOpenPending }: CanvasContainerProps) { export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onRequestAddToGroup, onRequestAddToContainer, onOpenPending }: CanvasContainerProps) {
const [lassoMode, setLassoMode] = useState(true) const [lassoMode, setLassoMode] = useState(true)
const { const {
nodes, edges, nodes, edges,
onNodesChange, onEdgesChange, onNodesChange, onEdgesChange,
setSelectedNode, snapshotHistory, setSelectedNode, snapshotHistory,
fitViewPending, clearFitViewPending, fitViewPending, clearFitViewPending,
copySelectedNodes, pasteNodes,
} = useCanvasStore() } = useCanvasStore()
const { fitView } = useReactFlow() const { fitView, screenToFlowPosition, getIntersectingNodes } = useReactFlow<Node<NodeData>>()
// Track the last cursor position over the canvas so paste lands under it.
const cursorRef = useRef<{ x: number; y: number } | null>(null)
const onMouseMove = useCallback((e: React.MouseEvent) => {
cursorRef.current = { x: e.clientX, y: e.clientY }
}, [])
// Copy / paste shortcuts. Registered here (inside ReactFlowProvider) so paste
// can project the cursor / viewport center into flow coordinates.
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (!(e.ctrlKey || e.metaKey)) return
const el = e.target as HTMLElement
const isInput = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable
if (isInput) return
if (e.key === 'c') {
copySelectedNodes()
} else if (e.key === 'v') {
const screen = cursorRef.current ?? { x: window.innerWidth / 2, y: window.innerHeight / 2 }
pasteNodes(screenToFlowPosition(screen))
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [copySelectedNodes, pasteNodes, screenToFlowPosition])
// Fit view after canvas loads (fitViewPending is set by loadCanvas) // Fit view after canvas loads (fitViewPending is set by loadCanvas)
useEffect(() => { useEffect(() => {
@@ -99,8 +127,26 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides() const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides()
// Drop a top-level node onto a group → ask App to confirm adding it. Runs
// before the alignment snap so detection uses the dropped position.
const handleNodeDragStop = useCallback<NonNullable<typeof onNodeDragStop>>((event, dragNode, dragNodes) => {
if (dragNode && !dragNode.parentId &&
dragNode.data.type !== 'group' && dragNode.data.type !== 'groupRect') {
const intersecting = getIntersectingNodes(dragNode)
const group = intersecting.find((n) => n.data.type === 'group')
if (group) {
onRequestAddToGroup?.({ nodeId: dragNode.id, groupId: group.id })
} else {
// Any node in container_mode (proxmox, docker_host, …) accepts children.
const container = intersecting.find((n) => n.id !== dragNode.id && n.data.container_mode === true)
if (container) onRequestAddToContainer?.({ nodeId: dragNode.id, containerId: container.id })
}
}
onNodeDragStop(event, dragNode, dragNodes)
}, [onRequestAddToGroup, onRequestAddToContainer, getIntersectingNodes, onNodeDragStop])
return ( return (
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}> <div className="w-full h-full" style={{ background: theme.colors.canvasBackground }} onMouseMove={onMouseMove}>
<ReactFlow <ReactFlow
nodes={visibleNodes} nodes={visibleNodes}
edges={visibleEdges} edges={visibleEdges}
@@ -113,7 +159,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
onNodeDoubleClick={handleNodeDoubleClick} onNodeDoubleClick={handleNodeDoubleClick}
onNodeDragStart={onNodeDragStart} onNodeDragStart={onNodeDragStart}
onNodeDrag={onNodeDrag} onNodeDrag={onNodeDrag}
onNodeDragStop={onNodeDragStop} onNodeDragStop={handleNodeDragStop}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
edgeTypes={edgeTypes} edgeTypes={edgeTypes}
deleteKeyCode={['Backspace', 'Delete']} deleteKeyCode={['Backspace', 'Delete']}
@@ -20,7 +20,9 @@ vi.mock('@/stores/themeStore', () => ({
})) }))
vi.mock('@/stores/canvasStore', () => ({ vi.mock('@/stores/canvasStore', () => ({
useCanvasStore: (sel: (s: { hideIp: boolean }) => unknown) => sel({ hideIp: false }), useCanvasStore: (sel: (s: { hideIp: boolean; serviceStatuses: Record<string, string> }) => unknown) =>
sel({ hideIp: false, serviceStatuses: {} }),
serviceStatusKey: (nodeId: string, port?: number, protocol?: string) => `${nodeId}:${port ?? ''}/${protocol ?? ''}`,
})) }))
vi.mock('@/utils/themes', () => ({ vi.mock('@/utils/themes', () => ({
@@ -9,6 +9,9 @@ import type { NodeData, EdgeData } from '@/types'
// Capture props passed to ReactFlow so we can test the callbacks // Capture props passed to ReactFlow so we can test the callbacks
let rfProps: Record<string, unknown> = {} let rfProps: Record<string, unknown> = {}
// Hoisted holder so the mock factory can read the configurable intersection set.
const rf = vi.hoisted(() => ({ intersecting: [] as unknown[] }))
vi.mock('@xyflow/react', () => ({ vi.mock('@xyflow/react', () => ({
ReactFlow: (props: Record<string, unknown>) => { ReactFlow: (props: Record<string, unknown>) => {
rfProps = props rfProps = props
@@ -21,7 +24,13 @@ vi.mock('@xyflow/react', () => ({
ConnectionMode: { Loose: 'loose' }, ConnectionMode: { Loose: 'loose' },
SelectionMode: { Partial: 'partial' }, SelectionMode: { Partial: 'partial' },
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' }, Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
useReactFlow: () => ({ fitView: vi.fn() }), useReactFlow: () => ({
fitView: vi.fn(),
screenToFlowPosition: vi.fn(),
getIntersectingNodes: () => rf.intersecting,
setNodes: vi.fn(),
getNodes: () => [],
}),
})) }))
vi.mock('@xyflow/react/dist/style.css', () => ({})) vi.mock('@xyflow/react/dist/style.css', () => ({}))
@@ -42,6 +51,7 @@ function makeEdge(id: string): Edge<EdgeData> {
describe('CanvasContainer', () => { describe('CanvasContainer', () => {
beforeEach(() => { beforeEach(() => {
rfProps = {} rfProps = {}
rf.intersecting = []
useCanvasStore.setState({ nodes: [], edges: [], selectedNodeId: null }) useCanvasStore.setState({ nodes: [], edges: [], selectedNodeId: null })
useThemeStore.setState({ activeTheme: 'default' }) useThemeStore.setState({ activeTheme: 'default' })
}) })
@@ -154,6 +164,93 @@ describe('CanvasContainer', () => {
expect(rfProps.onNodeDragStart).toBe(onNodeDragStart) expect(rfProps.onNodeDragStart).toBe(onNodeDragStart)
}) })
// ── Drag onto group → onRequestAddToGroup ─────────────────────────────────
function groupNode(id: string): Node<NodeData> {
return { id, type: 'group', position: { x: 0, y: 0 }, data: { label: id, type: 'group', status: 'unknown', services: [] } }
}
it('fires onRequestAddToGroup when a node is dropped over a group', () => {
const onRequestAddToGroup = vi.fn()
const node = makeNode('n1')
const group = groupNode('g1')
rf.intersecting = [group]
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} />)
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
expect(onRequestAddToGroup).toHaveBeenCalledWith({ nodeId: 'n1', groupId: 'g1' })
})
it('does not fire onRequestAddToGroup when no group is under the node', () => {
const onRequestAddToGroup = vi.fn()
const node = makeNode('n1')
rf.intersecting = [makeNode('n2')]
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} />)
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
expect(onRequestAddToGroup).not.toHaveBeenCalled()
})
it('does not fire onRequestAddToGroup for an already-parented node', () => {
const onRequestAddToGroup = vi.fn()
const node = { ...makeNode('n1'), parentId: 'gOther' }
rf.intersecting = [groupNode('g1')]
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} />)
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
expect(onRequestAddToGroup).not.toHaveBeenCalled()
})
it('does not fire onRequestAddToGroup when the dragged node is itself a group', () => {
const onRequestAddToGroup = vi.fn()
const node = groupNode('g2')
rf.intersecting = [groupNode('g1')]
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} />)
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
expect(onRequestAddToGroup).not.toHaveBeenCalled()
})
// ── Drag onto container node → onRequestAddToContainer ────────────────────
function containerNode(id: string, type: NodeData['type'] = 'proxmox'): Node<NodeData> {
return { id, type, position: { x: 0, y: 0 }, data: { label: id, type, status: 'unknown', services: [], container_mode: true } }
}
it('fires onRequestAddToContainer when a node is dropped over a container_mode node', () => {
const onRequestAddToContainer = vi.fn()
const node = makeNode('n1')
rf.intersecting = [containerNode('px1')]
render(<CanvasContainer onRequestAddToContainer={onRequestAddToContainer} />)
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
expect(onRequestAddToContainer).toHaveBeenCalledWith({ nodeId: 'n1', containerId: 'px1' })
})
it('prefers a group over a container when both intersect', () => {
const onRequestAddToGroup = vi.fn()
const onRequestAddToContainer = vi.fn()
const node = makeNode('n1')
rf.intersecting = [containerNode('px1'), groupNode('g1')]
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} onRequestAddToContainer={onRequestAddToContainer} />)
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
expect(onRequestAddToGroup).toHaveBeenCalledWith({ nodeId: 'n1', groupId: 'g1' })
expect(onRequestAddToContainer).not.toHaveBeenCalled()
})
it('does not fire onRequestAddToContainer for an already-parented node', () => {
const onRequestAddToContainer = vi.fn()
const node = { ...makeNode('n1'), parentId: 'pxOther' }
rf.intersecting = [containerNode('px1')]
render(<CanvasContainer onRequestAddToContainer={onRequestAddToContainer} />)
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
expect(onRequestAddToContainer).not.toHaveBeenCalled()
})
it('does not fire onRequestAddToContainer when the target node is not in container_mode', () => {
const onRequestAddToContainer = vi.fn()
const node = makeNode('n1')
rf.intersecting = [makeNode('n2')]
render(<CanvasContainer onRequestAddToContainer={onRequestAddToContainer} />)
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
expect(onRequestAddToContainer).not.toHaveBeenCalled()
})
// ── Canvas settings ─────────────────────────────────────────────────────── // ── Canvas settings ───────────────────────────────────────────────────────
it('enables snapToGrid', () => { it('enables snapToGrid', () => {
@@ -0,0 +1,77 @@
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { ReactFlowProvider } from '@xyflow/react'
import type { EdgeProps, Edge } from '@xyflow/react'
import { HomelableEdge } from '../index'
import type { EdgeData } from '@/types'
/**
* Regression: edge flow animations must use CSS, never SVG SMIL <animate>.
*
* SMIL <animate> keeps running while the tab is hidden and leaks memory in
* Chrome over time (RAM climbed only when the canvas tab was backgrounded).
* CSS animations pause when the tab is hidden and don't leak — so the rendered
* output must contain a CSS `animation` on the path and zero <animate> nodes.
*/
function renderEdge(data: Partial<EdgeData> = {}) {
const props = {
id: 'e1',
source: 'a',
target: 'b',
sourceX: 0,
sourceY: 0,
targetX: 100,
targetY: 100,
sourcePosition: 'bottom',
targetPosition: 'top',
data: { type: 'ethernet', ...data } as EdgeData,
selected: false,
} as unknown as EdgeProps<Edge<EdgeData>>
return render(
<ReactFlowProvider>
<svg>
<HomelableEdge {...props} />
</svg>
</ReactFlowProvider>,
)
}
describe('HomelableEdge animation', () => {
it('renders snake animation as CSS, not SMIL <animate>', () => {
const { container } = renderEdge({ animated: 'snake' })
expect(container.querySelector('animate')).toBeNull()
const animated = Array.from(container.querySelectorAll('path')).find((p) =>
(p.getAttribute('style') ?? '').includes('homelable-snake'),
)
expect(animated).toBeTruthy()
})
it('renders flow animation as CSS, not SMIL <animate>', () => {
const { container } = renderEdge({ animated: 'flow' })
expect(container.querySelector('animate')).toBeNull()
const animated = Array.from(container.querySelectorAll('path')).find((p) =>
(p.getAttribute('style') ?? '').includes('homelable-flow'),
)
expect(animated).toBeTruthy()
})
it('legacy animated:true maps to snake CSS animation', () => {
const { container } = renderEdge({ animated: true })
expect(container.querySelector('animate')).toBeNull()
const animated = Array.from(container.querySelectorAll('path')).find((p) =>
(p.getAttribute('style') ?? '').includes('homelable-snake'),
)
expect(animated).toBeTruthy()
})
it('non-animated edge has no flow animation and no <animate>', () => {
const { container } = renderEdge({ animated: false })
expect(container.querySelector('animate')).toBeNull()
const animated = Array.from(container.querySelectorAll('path')).find((p) => {
const s = p.getAttribute('style') ?? ''
return s.includes('homelable-snake') || s.includes('homelable-flow')
})
expect(animated).toBeUndefined()
})
})
@@ -0,0 +1,70 @@
import { describe, it, expect, vi } from 'vitest'
import { render } from '@testing-library/react'
import { ReactFlowProvider } from '@xyflow/react'
import type { EdgeProps, Edge } from '@xyflow/react'
import type { EdgeData } from '@/types'
/**
* Issue #183 — connection labels must support multiple lines.
*
* The label is a free-text string; newlines entered in the EdgeModal textarea
* are stored verbatim. The rendered label div must preserve those newlines
* (`whitespace-pre-line`) instead of collapsing them into a single line.
*
* <EdgeLabelRenderer> normally portals into a node that only exists inside a
* full <ReactFlow> host, so we stub it to a passthrough to render the label
* markup directly.
*/
vi.mock('@xyflow/react', async (importOriginal) => {
const actual = await importOriginal<typeof import('@xyflow/react')>()
return {
...actual,
EdgeLabelRenderer: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}
})
const { HomelableEdge } = await import('../index')
function renderEdge(data: Partial<EdgeData> = {}) {
const props = {
id: 'e1',
source: 'a',
target: 'b',
sourceX: 0,
sourceY: 0,
targetX: 100,
targetY: 100,
sourcePosition: 'bottom',
targetPosition: 'top',
data: { type: 'ethernet', ...data } as EdgeData,
selected: false,
} as unknown as EdgeProps<Edge<EdgeData>>
return render(
<ReactFlowProvider>
<svg>
<HomelableEdge {...props} />
</svg>
</ReactFlowProvider>,
)
}
describe('HomelableEdge label', () => {
it('renders the label text', () => {
const { getByText } = renderEdge({ label: 'uplink' })
expect(getByText('uplink')).toBeTruthy()
})
it('preserves newlines in the rendered label (issue #183)', () => {
const { container } = renderEdge({ label: 'line one\nline two' })
const label = Array.from(container.querySelectorAll('div.whitespace-pre-line')).find((d) =>
d.textContent === 'line one\nline two',
)
expect(label).toBeTruthy()
})
it('renders no label div when label is empty', () => {
const { container } = renderEdge({ label: undefined })
expect(container.querySelector('div.whitespace-pre-line')).toBeNull()
})
})
+14 -13
View File
@@ -378,14 +378,14 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
strokeWidth={((style.strokeWidth as number ?? 2) + 1.5) * 2} strokeWidth={((style.strokeWidth as number ?? 2) + 1.5) * 2}
strokeDasharray="20 10000" strokeDasharray="20 10000"
strokeLinecap="round" strokeLinecap="round"
style={{ pointerEvents: 'none' }} style={{
> pointerEvents: 'none',
{isBidirectional ? ( // CSS (not SMIL) so it pauses when the tab is hidden — see index.css.
<animate attributeName="stroke-dashoffset" values="-10000;0;-10000" keyTimes="0;0.5;1" dur="20s" repeatCount="indefinite" /> // Bidirectional yo-yos via `alternate` (10s each way = 20s round trip,
) : ( // matching the old SMIL keyTimes); unidirectional loops in one direction.
<animate attributeName="stroke-dashoffset" from="-10000" to="0" dur="10s" repeatCount="indefinite" /> animation: `homelable-snake 10s linear infinite${isBidirectional ? ' alternate' : ''}`,
)} }}
</path> />
)} )}
{animMode === 'flow' && ( {animMode === 'flow' && (
<path <path
@@ -396,16 +396,17 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
strokeDasharray="6 12" strokeDasharray="6 12"
strokeLinecap="round" strokeLinecap="round"
strokeOpacity={0.85} strokeOpacity={0.85}
style={{ pointerEvents: 'none' }} style={{
> pointerEvents: 'none',
<animate attributeName="stroke-dashoffset" from="0" to="18" dur="1.2s" repeatCount="indefinite" /> animation: 'homelable-flow 1.2s linear infinite',
</path> }}
/>
)} )}
<EdgeLabelRenderer> <EdgeLabelRenderer>
{data?.label && ( {data?.label && (
<div <div
className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded" className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded whitespace-pre-line text-center"
style={{ style={{
transform: `translate(-50%, -50%) translate(${labelPosition.x}px, ${labelPosition.y}px)`, transform: `translate(-50%, -50%) translate(${labelPosition.x}px, ${labelPosition.y}px)`,
background: theme.colors.edgeLabelBackground, background: theme.colors.edgeLabelBackground,
@@ -8,7 +8,7 @@ import { NodeIcon } from '@/components/ui/NodeIcon'
import { resolvePropertyIcon } from '@/utils/propertyIcons' import { resolvePropertyIcon } from '@/utils/propertyIcons'
import { useThemeStore } from '@/stores/themeStore' import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes' import { THEMES } from '@/utils/themes'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore, serviceStatusKey } from '@/stores/canvasStore'
import { maskIp, primaryIp, splitIps } from '@/utils/maskIp' import { maskIp, primaryIp, splitIps } from '@/utils/maskIp'
import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils' import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils'
import { getServiceUrl } from '@/utils/serviceUrl' import { getServiceUrl } from '@/utils/serviceUrl'
@@ -31,6 +31,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
const activeTheme = useThemeStore((s) => s.activeTheme) const activeTheme = useThemeStore((s) => s.activeTheme)
const hideIp = useCanvasStore((s) => s.hideIp) const hideIp = useCanvasStore((s) => s.hideIp)
const serviceStatuses = useCanvasStore((s) => s.serviceStatuses)
const theme = THEMES[activeTheme] const theme = THEMES[activeTheme]
const resolvedIcon = resolveNodeIcon(typeIcon, data.custom_icon) const resolvedIcon = resolveNodeIcon(typeIcon, data.custom_icon)
@@ -151,6 +152,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
<div className="flex flex-col gap-1 px-2.5 py-1.5 overflow-hidden"> <div className="flex flex-col gap-1 px-2.5 py-1.5 overflow-hidden">
{services.map((svc, idx) => { {services.map((svc, idx) => {
const url = getServiceUrl(svc, serviceHost) const url = getServiceUrl(svc, serviceHost)
const svcOffline = serviceStatuses[serviceStatusKey(id, svc.port, svc.protocol)] === 'offline'
const row = ( const row = (
<div <div
className="nodrag flex items-center justify-between gap-2 px-1.5 py-1 rounded text-[10px] min-w-0 overflow-hidden" className="nodrag flex items-center justify-between gap-2 px-1.5 py-1 rounded text-[10px] min-w-0 overflow-hidden"
@@ -164,7 +166,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
{/* LEFT: service name */} {/* LEFT: service name */}
<span <span
className="font-medium truncate" className="font-medium truncate"
style={{ minWidth: 0 }} style={{ minWidth: 0, color: svcOffline ? '#f85149' : undefined }}
title={svc.service_name} title={svc.service_name}
> >
{svc.service_name} {svc.service_name}
@@ -0,0 +1,59 @@
import { Layers } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
interface ConfirmAddToGroupModalProps {
open: boolean
nodeLabel: string
/** Label of the destination group/container. */
targetLabel: string
/** Destination kind — drives the wording. Defaults to 'group'. */
variant?: 'group' | 'container'
onConfirm: () => void
onCancel: () => void
}
export function ConfirmAddToGroupModal({
open,
nodeLabel,
targetLabel,
variant = 'group',
onConfirm,
onCancel,
}: ConfirmAddToGroupModalProps) {
const action = variant === 'container' ? 'Add to container' : 'Add to group'
const noun = variant === 'container' ? 'container' : 'group'
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onCancel() }}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Layers size={16} className="text-[#00d4ff]" />
{action}
</DialogTitle>
<DialogDescription>
Add <span className="font-medium text-foreground">{nodeLabel}</span> to the {noun}{' '}
<span className="font-medium text-foreground">{targetLabel}</span>?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={onCancel}>Cancel</Button>
<Button
size="sm"
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
onClick={onConfirm}
>
{action}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+7 -5
View File
@@ -4,6 +4,7 @@ import { RotateCcw } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { EDGE_TYPE_LABELS, type EdgeData, type EdgePathStyle, type EdgeType } from '@/types' import { EDGE_TYPE_LABELS, type EdgeData, type EdgePathStyle, type EdgeType } from '@/types'
@@ -44,7 +45,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
e.preventDefault() e.preventDefault()
onSubmit({ onSubmit({
type, type,
label: label || undefined, label: label.trim() || undefined,
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined, vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
custom_color: customColor, custom_color: customColor,
path_style: pathStyle, path_style: pathStyle,
@@ -96,12 +97,13 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
)} )}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Label <span className="text-muted-foreground/50">(optional)</span></Label> <Label className="text-xs text-muted-foreground">Label <span className="text-muted-foreground/50">(optional, multi-line)</span></Label>
<Input <Textarea
value={label} value={label}
onChange={(e) => setLabel(e.target.value)} onChange={(e) => setLabel(e.target.value)}
placeholder="e.g. 1G, trunk..." placeholder={'e.g. 1G, trunk...\nsecond line'}
className={`bg-[#21262d] border-[#30363d] text-sm h-8 ${modalStyles['modal-radius']}`} rows={2}
className={`bg-[#21262d] border-[#30363d] text-sm min-h-16 resize-none ${modalStyles['modal-radius']}`}
/> />
</div> </div>
+19 -7
View File
@@ -55,6 +55,8 @@ interface ParentCandidate {
id: string id: string
label: string label: string
type: NodeType type: NodeType
/** True when the node has container mode on, so any node can nest inside it. */
container_mode?: boolean
} }
interface NodeModalProps { interface NodeModalProps {
@@ -98,12 +100,14 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
const selectedType = (form.type ?? 'generic') as NodeType const selectedType = (form.type ?? 'generic') as NodeType
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType) const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
const validParentTypes = getValidParentTypes(selectedType) const validParentTypes = getValidParentTypes(selectedType)
// A parent is valid either by the type rules (lxc/vm/docker_container) or
// because the candidate is a container-mode node (any child can nest in it).
const isValidParent = (p: ParentCandidate) =>
validParentTypes.includes(p.type) || p.container_mode === true
let safeParentId = form.parent_id let safeParentId = form.parent_id
if (validParentTypes.length === 0) { if (safeParentId) {
safeParentId = undefined
} else if (safeParentId) {
const parent = parentCandidates.find((n) => n.id === safeParentId) const parent = parentCandidates.find((n) => n.id === safeParentId)
if (!parent || !validParentTypes.includes(parent.type)) safeParentId = undefined if (!parent || !isValidParent(parent)) safeParentId = undefined
} }
onSubmit({ onSubmit({
...form, ...form,
@@ -130,7 +134,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
setForm((f) => { setForm((f) => {
const next: Partial<NodeData> = { ...f, type: t } const next: Partial<NodeData> = { ...f, type: t }
if (ZIGBEE_TYPES.includes(t)) next.check_method = 'none' as CheckMethod if (ZIGBEE_TYPES.includes(t)) next.check_method = 'none' as CheckMethod
if (getValidParentTypes(t).length === 0) next.parent_id = undefined // Drop the parent only if it's no longer a valid target for the
// new type — keep container-mode parents (any node can nest).
const parent = parentCandidates.find((n) => n.id === f.parent_id)
if (f.parent_id && !(parent && (getValidParentTypes(t).includes(parent.type) || parent.container_mode === true))) {
next.parent_id = undefined
}
return next return next
}) })
}}> }}>
@@ -349,9 +358,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
{(() => { {(() => {
const childType = (form.type ?? 'generic') as NodeType const childType = (form.type ?? 'generic') as NodeType
const validParentTypes = getValidParentTypes(childType) const validParentTypes = getValidParentTypes(childType)
if (validParentTypes.length === 0) return null // Candidates: type-based parents (lxc/vm/docker_container) plus any
// container-mode node. The current parent is always kept so an
// already-nested node can be re-targeted or detached here.
const validParents = parentCandidates.filter( const validParents = parentCandidates.filter(
(n) => n.id !== currentNodeId && validParentTypes.includes(n.type), (n) => n.id !== currentNodeId &&
(validParentTypes.includes(n.type) || n.container_mode === true || n.id === form.parent_id),
) )
if (validParents.length === 0) return null if (validParents.length === 0) return null
return ( return (
@@ -3,13 +3,14 @@ import {
Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network, Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network,
Search, RefreshCw, X, CheckCircle2, EyeOff, Trash2, Loader2, Search, RefreshCw, X, CheckCircle2, EyeOff, Trash2, Loader2,
} from 'lucide-react' } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { scanApi } from '@/api/client' import { scanApi } from '@/api/client'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal' import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
import type { NodeType, ServiceInfo } from '@/types' import type { NodeType, ServiceInfo } from '@/types'
import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties' import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties'
import { buildMacProperty } from '@/utils/macProperty'
interface PendingDevicesModalProps { interface PendingDevicesModalProps {
open: boolean open: boolean
@@ -255,11 +256,12 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
const fallbackLabel = deviceLabel(device) const fallbackLabel = deviceLabel(device)
const type = (device.suggested_type ?? 'generic') as NodeType const type = (device.suggested_type ?? 'generic') as NodeType
const zigbee = isZigbeeType(type) const zigbee = isZigbeeType(type)
const properties = zigbee ? buildZigbeeProperties(device) : [] const properties = zigbee ? buildZigbeeProperties(device) : buildMacProperty(device.mac)
const nodeData = { const nodeData = {
label: fallbackLabel, label: fallbackLabel,
type, type,
ip: device.ip ?? undefined, ip: device.ip ?? undefined,
mac: device.mac ?? undefined,
hostname: device.hostname ?? undefined, hostname: device.hostname ?? undefined,
status: zigbee ? 'online' : 'unknown', status: zigbee ? 'online' : 'unknown',
services: (device.services ?? []) as ServiceInfo[], services: (device.services ?? []) as ServiceInfo[],
@@ -325,10 +327,11 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
label: deviceLabel(d), label: deviceLabel(d),
type, type,
ip: d.ip ?? undefined, ip: d.ip ?? undefined,
mac: d.mac ?? undefined,
hostname: d.hostname ?? undefined, hostname: d.hostname ?? undefined,
status: zigbee ? ('online' as const) : ('unknown' as const), status: zigbee ? ('online' as const) : ('unknown' as const),
services: (d.services ?? []) as ServiceInfo[], services: (d.services ?? []) as ServiceInfo[],
properties: zigbee ? buildZigbeeProperties(d) : [], properties: zigbee ? buildZigbeeProperties(d) : buildMacProperty(d.mac),
}, },
}) })
}) })
@@ -370,12 +373,20 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
if (e.key === '/') { e.preventDefault(); searchRef.current?.focus() } 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() === 's') { e.preventDefault(); if (selectMode) exitSelectMode(); else enterSelectMode() }
else if (e.key.toLowerCase() === 'a' && selectMode) { e.preventDefault(); selectAllVisible() } else if (e.key.toLowerCase() === 'a' && selectMode) { e.preventDefault(); selectAllVisible() }
else if (e.key === 'Enter' && selectMode && selectedIds.size > 0) { e.preventDefault(); handleBulkApprove() } else if (e.key === 'Enter' && selectMode && selectedIds.size > 0) {
// Enter confirms the bulk action for the current view: approving
// hidden devices would be wrong — they restore.
e.preventDefault()
if (statusFilter === 'hidden') handleBulkRestore()
else handleBulkApprove()
}
} }
window.addEventListener('keydown', handler) window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler)
// statusFilter is included so Enter dispatches the correct bulk action
// (approve vs restore) even if the device list doesn't change on switch.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, selectMode, selectedIds, filtered]) }, [open, selectMode, selectedIds, filtered, statusFilter])
return ( return (
<> <>
@@ -405,9 +416,19 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
<Trash2 size={14} /> <Trash2 size={14} />
</button> </button>
)} )}
<button onClick={onClose} className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors" title="Close"> {/* Route the close X through Base UI's DialogClose (same path as
outside-click) instead of a raw onClick — the latter's synthetic
click was being dropped on Firefox/Windows. */}
<DialogClose
render={
<button
className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors"
aria-label="Close"
/>
}
>
<X size={14} /> <X size={14} />
</button> </DialogClose>
</div> </div>
</div> </div>
</DialogHeader> </DialogHeader>
@@ -0,0 +1,311 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { RefreshCw, X, Loader2, StopCircle, Clock, ScanLine, Network, Inbox } from 'lucide-react'
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { scanApi } from '@/api/client'
import { useCanvasStore } from '@/stores/canvasStore'
import { toast } from 'sonner'
export interface ScanRun {
id: string
status: string
kind?: string
ranges: string[]
devices_found: number
started_at: string
finished_at: string | null
error: string | null
}
interface ScanHistoryModalProps {
open: boolean
onClose: () => void
}
type KindFilter = 'all' | 'ip' | 'zigbee'
type StatusFilter = 'all' | 'running' | 'done' | 'error' | 'cancelled'
const STATUS_FILTERS: { key: StatusFilter; label: string }[] = [
{ key: 'all', label: 'All' },
{ key: 'running', label: 'Running' },
{ key: 'done', label: 'Done' },
{ key: 'error', label: 'Error' },
{ key: 'cancelled', label: 'Cancelled' },
]
const KIND_FILTERS: { key: KindFilter; label: string }[] = [
{ key: 'all', label: 'All' },
{ key: 'ip', label: 'IP' },
{ key: 'zigbee', label: 'Zigbee' },
]
function statusColor(s: string): string {
return s === 'done' ? '#39d353'
: s === 'running' ? '#e3b341'
: s === 'error' ? '#f85149'
: s === 'cancelled' ? '#8b949e'
: '#8b949e'
}
function parseUtc(ts: string): number {
return new Date(ts.endsWith('Z') ? ts : ts + 'Z').getTime()
}
function formatDuration(ms: number): string {
if (ms < 0) ms = 0
const s = Math.floor(ms / 1000)
if (s < 60) return `${s}s`
const m = Math.floor(s / 60)
const rem = s % 60
if (m < 60) return rem ? `${m}m ${rem}s` : `${m}m`
const h = Math.floor(m / 60)
return `${h}h ${m % 60}m`
}
function runDuration(r: ScanRun, now: number): string {
const start = parseUtc(r.started_at)
const end = r.finished_at ? parseUtc(r.finished_at) : now
return formatDuration(end - start)
}
export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
const [runs, setRuns] = useState<ScanRun[]>([])
const [loading, setLoading] = useState(false)
const [stopping, setStopping] = useState<string | null>(null)
const [kindFilter, setKindFilter] = useState<KindFilter>('all')
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const [now, setNow] = useState(() => Date.now())
const prevRunsRef = useRef<ScanRun[]>([])
const load = useCallback(async () => {
setLoading(true)
try {
const res = await scanApi.runs()
const next: ScanRun[] = res.data
// 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' : ''}`)
}
useCanvasStore.getState().notifyScanDeviceFound()
}
}
prevRunsRef.current = next
setRuns(next)
} catch {
toast.error('Failed to load scan history')
} finally {
setLoading(false)
}
}, [])
// Load when opened; reset prior-state tracker so we don't replay old transitions
useEffect(() => {
if (!open) return
prevRunsRef.current = []
load()
}, [open, load])
// Auto-refresh every 3s while any run is still running (only when open)
useEffect(() => {
if (!open) return
const hasRunning = runs.some((r) => r.status === 'running')
if (!hasRunning) return
const id = setInterval(load, 3000)
return () => clearInterval(id)
}, [open, runs, load])
// Tick the clock every second while a scan is running (for live elapsed duration)
useEffect(() => {
if (!open) return
const hasRunning = runs.some((r) => r.status === 'running')
if (!hasRunning) return
const id = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(id)
}, [open, runs])
const handleStop = async (runId: string) => {
setStopping(runId)
try {
await scanApi.stop(runId)
toast.success('Scan stop requested')
} catch {
toast.error('Failed to stop scan')
} finally {
setStopping(null)
}
}
const filtered = runs.filter((r) => {
const k = r.kind === 'zigbee' ? 'zigbee' : 'ip'
if (kindFilter !== 'all' && k !== kindFilter) return false
if (statusFilter !== 'all' && r.status !== statusFilter) return false
return true
})
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<DialogContent
showCloseButton={false}
className="!max-w-none w-[90vw] max-w-2xl h-[80vh] 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">
<Clock size={16} className="text-[#00d4ff]" />
Scan History
<span className="text-muted-foreground font-normal text-xs">
({filtered.length}{filtered.length !== runs.length && ` of ${runs.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} className={loading ? 'animate-spin' : undefined} />
</button>
<DialogClose
render={
<button
className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors"
aria-label="Close"
/>
}
>
<X size={14} />
</DialogClose>
</div>
</div>
</DialogHeader>
{/* Filters */}
<div className="px-4 py-2 border-b border-border bg-[#161b22] shrink-0 flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Type</span>
{KIND_FILTERS.map((f) => (
<FilterChip key={f.key} active={kindFilter === f.key} onClick={() => setKindFilter(f.key)}>
{f.label}
</FilterChip>
))}
</div>
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Status</span>
{STATUS_FILTERS.map((f) => (
<FilterChip key={f.key} active={statusFilter === f.key} onClick={() => setStatusFilter(f.key)}>
{f.label}
</FilterChip>
))}
</div>
</div>
{/* List */}
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-2">
{loading && runs.length === 0 && (
<div className="flex justify-center py-10">
<Loader2 size={18} className="animate-spin text-muted-foreground" />
</div>
)}
{!loading && filtered.length === 0 && (
<div className="flex flex-col items-center gap-2 py-12 text-muted-foreground">
<Inbox size={28} className="opacity-50" />
<p className="text-sm">{runs.length === 0 ? 'No scans yet' : 'No scans match the filters'}</p>
</div>
)}
{filtered.map((r) => {
const isZigbee = r.kind === 'zigbee'
return (
<div key={r.id} className="rounded-lg border border-border bg-[#161b22] p-3">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: statusColor(r.status) }} />
<span className="font-mono text-sm text-foreground capitalize">{r.status}</span>
{r.status === 'running' && <Loader2 size={12} className="animate-spin text-[#e3b341]" />}
<span
className="inline-flex items-center gap-1 text-[10px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
style={isZigbee
? { background: '#00d4ff22', color: '#00d4ff' }
: { background: '#a855f722', color: '#a855f7' }}
>
{isZigbee ? <Network size={10} /> : <ScanLine size={10} />}
{isZigbee ? 'Zigbee' : 'IP'}
</span>
<span className="ml-auto text-xs text-muted-foreground font-mono">
{r.devices_found} found
</span>
{r.status === 'running' && (
<Tooltip>
<TooltipTrigger>
<button
aria-label="Stop scan"
onClick={() => handleStop(r.id)}
disabled={stopping === r.id}
className="p-1 text-[#f85149] hover:bg-[#f85149]/10 rounded transition-colors disabled:opacity-50"
>
{stopping === r.id
? <Loader2 size={13} className="animate-spin" />
: <StopCircle size={13} />}
</button>
</TooltipTrigger>
<TooltipContent side="left">Stop scan</TooltipContent>
</Tooltip>
)}
</div>
{/* Meta grid */}
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-[11px]">
<Meta label="Started" value={new Date(parseUtc(r.started_at)).toLocaleString()} />
<Meta
label="Finished"
value={r.finished_at ? new Date(parseUtc(r.finished_at)).toLocaleString() : '—'}
/>
<Meta label="Duration" value={runDuration(r, now)} mono />
<Meta label="Devices" value={`${r.devices_found}`} mono />
</div>
{r.ranges.length > 0 && (
<div className="mt-2 text-[11px]">
<span className="text-muted-foreground">Ranges: </span>
<span className="text-[#8b949e] font-mono break-all">{r.ranges.join(', ')}</span>
</div>
)}
{r.error && (
<div className="mt-2 text-[11px] text-[#f85149] leading-tight whitespace-pre-wrap break-words rounded bg-[#f85149]/10 px-2 py-1.5">
{r.error}
</div>
)}
</div>
)
})}
</div>
</DialogContent>
</Dialog>
)
}
function FilterChip({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
return (
<button
onClick={onClick}
className={`text-[11px] px-2 py-0.5 rounded-full border transition-colors cursor-pointer ${
active
? 'bg-[#00d4ff]/10 border-[#00d4ff]/40 text-[#00d4ff]'
: 'border-border text-muted-foreground hover:text-foreground hover:border-[#30363d]'
}`}
>
{children}
</button>
)
}
function Meta({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex flex-col">
<span className="text-muted-foreground text-[10px] uppercase tracking-wider">{label}</span>
<span className={`text-foreground ${mono ? 'font-mono' : ''}`}>{value}</span>
</div>
)
}
@@ -0,0 +1,192 @@
import { useState, useEffect } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { settingsApi } from '@/api/client'
import { useCanvasStore } from '@/stores/canvasStore'
import { toast } from 'sonner'
import {
type AlignmentSettings,
readAlignmentSettings,
writeAlignmentSettings,
subscribeAlignmentSettings,
} from '@/utils/alignmentSettings'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
interface SettingsModalProps {
open: boolean
onClose: () => void
}
export function SettingsModal({ open, onClose }: SettingsModalProps) {
const [interval, setIntervalValue] = useState(60)
const [serviceCheckEnabled, setServiceCheckEnabled] = useState(false)
const [serviceInterval, setServiceInterval] = useState(300)
const [saving, setSaving] = useState(false)
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
const hideIp = useCanvasStore((s) => s.hideIp)
const setHideIp = useCanvasStore((s) => s.setHideIp)
useEffect(() => {
if (!open || STANDALONE) return
settingsApi.get()
.then((res) => {
setIntervalValue(res.data.interval_seconds)
setServiceCheckEnabled(res.data.service_check_enabled)
setServiceInterval(res.data.service_check_interval)
})
.catch(() => {/* use default */})
}, [open])
useEffect(() => subscribeAlignmentSettings(setAlignment), [])
const updateAlignment = (patch: Partial<AlignmentSettings>) => {
const next = { ...alignment, ...patch }
setAlignment(next)
writeAlignmentSettings(next)
}
const handleSave = async () => {
// Canvas prefs (alignment, hide-IP) persist on change; only the backend
// status-check interval needs an API round-trip.
if (STANDALONE) {
onClose()
return
}
setSaving(true)
try {
await settingsApi.save({
interval_seconds: interval,
service_check_enabled: serviceCheckEnabled,
service_check_interval: serviceInterval,
})
toast.success('Settings saved')
onClose()
} catch {
toast.error('Failed to save settings')
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="bg-[#161b22] border-border max-w-md">
<DialogHeader>
<DialogTitle className="text-foreground">Settings</DialogTitle>
</DialogHeader>
<div className="space-y-5 py-2">
{/* Status checker */}
{!STANDALONE && (
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">Status check interval (s)</label>
<div className="flex items-center gap-2">
<input
type="number"
min={10}
max={3600}
value={interval}
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>
</div>
<p className="text-[10px] text-muted-foreground leading-tight">
How often node health is polled (ping, HTTP, SSH)
</p>
<label className="flex items-center justify-between gap-2 cursor-pointer pt-2">
<span className="text-xs text-foreground">Check services individually</span>
<input
type="checkbox"
checked={serviceCheckEnabled}
onChange={(e) => setServiceCheckEnabled(e.target.checked)}
className="cursor-pointer accent-[#00d4ff]"
aria-label="Toggle per-service status checks"
/>
</label>
<div className={serviceCheckEnabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
<label className="text-xs text-muted-foreground">Service check interval (s)</label>
<div className="flex items-center gap-2">
<input
type="number"
min={30}
max={3600}
value={serviceInterval}
onChange={(e) => { const v = Number(e.target.value); if (!isNaN(v)) setServiceInterval(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]"
aria-label="Service check interval"
/>
<span className="text-xs text-muted-foreground">seconds</span>
</div>
<p className="text-[10px] text-muted-foreground leading-tight">
Probes each service port. Offline services turn red. Default 300s (5 min).
</p>
</div>
</div>
)}
{/* Canvas */}
<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>
<label className="flex items-center justify-between gap-2 cursor-pointer">
<span className="text-xs text-foreground">Hide IP addresses</span>
<input
type="checkbox"
checked={hideIp}
onChange={(e) => setHideIp(e.target.checked)}
className="cursor-pointer accent-[#00d4ff]"
aria-label="Toggle IP address masking"
/>
</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>
<DialogFooter className="gap-2">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button
onClick={handleSave}
disabled={saving}
style={{ background: '#00d4ff', color: '#0d1117' }}
>
{saving ? 'Saving…' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,46 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { ConfirmAddToGroupModal } from '../ConfirmAddToGroupModal'
describe('ConfirmAddToGroupModal', () => {
it('renders nothing when closed', () => {
render(
<ConfirmAddToGroupModal open={false} nodeLabel="Router" targetLabel="DMZ" onConfirm={vi.fn()} onCancel={vi.fn()} />,
)
expect(screen.queryByText('Add to group')).toBeNull()
})
it('shows node and group labels when open', () => {
render(
<ConfirmAddToGroupModal open nodeLabel="Router" targetLabel="DMZ" onConfirm={vi.fn()} onCancel={vi.fn()} />,
)
expect(screen.getByText('Router')).toBeDefined()
expect(screen.getByText('DMZ')).toBeDefined()
})
it('calls onConfirm when the confirm button is clicked', () => {
const onConfirm = vi.fn()
render(
<ConfirmAddToGroupModal open nodeLabel="Router" targetLabel="DMZ" onConfirm={onConfirm} onCancel={vi.fn()} />,
)
fireEvent.click(screen.getByRole('button', { name: /add to group/i }))
expect(onConfirm).toHaveBeenCalledOnce()
})
it('calls onCancel when the cancel button is clicked', () => {
const onCancel = vi.fn()
render(
<ConfirmAddToGroupModal open nodeLabel="Router" targetLabel="DMZ" onConfirm={vi.fn()} onCancel={onCancel} />,
)
fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
expect(onCancel).toHaveBeenCalledOnce()
})
it('uses container wording when variant is container', () => {
render(
<ConfirmAddToGroupModal open variant="container" nodeLabel="VM" targetLabel="Proxmox" onConfirm={vi.fn()} onCancel={vi.fn()} />,
)
expect(screen.getByRole('button', { name: /add to container/i })).toBeDefined()
expect(screen.queryByText('Add to group')).toBeNull()
})
})
@@ -47,11 +47,32 @@ describe('EdgeModal', () => {
it('calls onSubmit with label when filled', () => { it('calls onSubmit with label when filled', () => {
const onSubmit = vi.fn() const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />) render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.change(screen.getByPlaceholderText('e.g. 1G, trunk...'), { target: { value: 'uplink' } }) fireEvent.change(screen.getByPlaceholderText(/e\.g\. 1G, trunk/), { target: { value: 'uplink' } })
fireEvent.click(screen.getByRole('button', { name: 'Connect' })) fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].label).toBe('uplink') expect(onSubmit.mock.calls[0][0].label).toBe('uplink')
}) })
it('preserves newlines in label so it can span multiple lines (issue #183)', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.change(screen.getByPlaceholderText(/e\.g\. 1G, trunk/), { target: { value: 'line one\nline two' } })
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].label).toBe('line one\nline two')
})
it('trims surrounding whitespace/blank lines from label on submit', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.change(screen.getByPlaceholderText(/e\.g\. 1G, trunk/), { target: { value: ' a\nb\n\n' } })
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].label).toBe('a\nb')
})
it('renders the label field as a textarea (multi-line input)', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByPlaceholderText(/e\.g\. 1G, trunk/).tagName).toBe('TEXTAREA')
})
it('omits label from payload when empty', () => { it('omits label from payload when empty', () => {
const onSubmit = vi.fn() const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />) render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
@@ -171,7 +192,7 @@ describe('EdgeModal', () => {
it('pre-fills label from initial prop', () => { it('pre-fills label from initial prop', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ label: 'trunk' }} />) render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ label: 'trunk' }} />)
const input = screen.getByPlaceholderText('e.g. 1G, trunk...') as HTMLInputElement const input = screen.getByPlaceholderText(/e\.g\. 1G, trunk/) as HTMLTextAreaElement
expect(input.value).toBe('trunk') expect(input.value).toBe('trunk')
}) })
@@ -353,6 +353,48 @@ describe('NodeModal', () => {
expect(screen.getByText('Parent Container')).toBeDefined() expect(screen.getByText('Parent Container')).toBeDefined()
}) })
it('renders Parent Container for a plain node when a container-mode candidate exists', () => {
renderModal({
initial: { ...BASE, type: 'server' },
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: true }],
})
expect(screen.getByText('Parent Container')).toBeDefined()
})
it('still hides Parent Container for a plain node when the candidate is not in container mode', () => {
renderModal({
initial: { ...BASE, type: 'server' },
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: false }],
})
expect(screen.queryByText('Parent Container')).toBeNull()
})
it('renders Parent Container for an already-nested plain node so it can be detached', () => {
renderModal({
initial: { ...BASE, type: 'server', parent_id: 'px1' },
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: true }],
})
expect(screen.getByText('Parent Container')).toBeDefined()
})
it('keeps a container-mode parent_id on submit for a plain node', () => {
const { onSubmit } = renderModal({
initial: { ...BASE, type: 'server', parent_id: 'px1' },
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: true }],
})
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).parent_id).toBe('px1')
})
it('drops a parent_id that is not a valid container on submit', () => {
const { onSubmit } = renderModal({
initial: { ...BASE, type: 'server', parent_id: 'px1' },
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: false }],
})
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).parent_id).toBeUndefined()
})
// ── Appearance ──────────────────────────────────────────────────────── // ── Appearance ────────────────────────────────────────────────────────
it('renders 3 color swatch labels (border, background, icon)', () => { it('renders 3 color swatch labels (border, background, icon)', () => {
@@ -13,6 +13,7 @@ const mockApprove = vi.fn()
const mockHide = vi.fn() const mockHide = vi.fn()
const mockPending = vi.fn() const mockPending = vi.fn()
const mockHidden = vi.fn() const mockHidden = vi.fn()
const mockAddNode = vi.fn()
vi.mock('@/api/client', () => ({ vi.mock('@/api/client', () => ({
scanApi: { scanApi: {
@@ -69,7 +70,7 @@ const DEVICE_ZIGBEE = {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.mocked(useCanvasStore).mockReturnValue({ vi.mocked(useCanvasStore).mockReturnValue({
addNode: vi.fn(), addNode: mockAddNode,
scanEventTs: 0, scanEventTs: 0,
} as unknown as ReturnType<typeof useCanvasStore>) } as unknown as ReturnType<typeof useCanvasStore>)
// setState is used by injectAutoEdges // setState is used by injectAutoEdges
@@ -98,6 +99,14 @@ describe('PendingDevicesModal', () => {
expect(screen.getByText('living-room-bulb')).toBeInTheDocument() expect(screen.getByText('living-room-bulb')).toBeInTheDocument()
}) })
it('closes via the X button (routes through DialogClose, not a raw onClick)', async () => {
const onClose = vi.fn()
render(<PendingDevicesModal open onClose={onClose} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('shows source chip ZIGBEE for zigbee device', async () => { it('shows source chip ZIGBEE for zigbee device', async () => {
render(<PendingDevicesModal {...baseProps} />) render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()) await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
@@ -174,6 +183,34 @@ describe('PendingDevicesModal', () => {
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b'])) await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b']))
}) })
it('bulk approve carries the scanned MAC onto the canvas node (#168)', 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(mockAddNode).toHaveBeenCalledTimes(2))
// dev-a is an IP device with a MAC → node carries mac + a MAC property row.
const ipNode = mockAddNode.mock.calls
.map((c) => c[0])
.find((n) => n.id === 'n1')
expect(ipNode.data.mac).toBe('aa:bb:cc:dd:ee:01')
expect(ipNode.data.properties).toContainEqual({
key: 'MAC',
value: 'aa:bb:cc:dd:ee:01',
icon: null,
visible: false,
})
// dev-b is zigbee with no MAC → no MAC property row.
const zbNode = mockAddNode.mock.calls
.map((c) => c[0])
.find((n) => n.id === 'n2')
expect(zbNode.data.properties.some((p: { key: string }) => p.key === 'MAC')).toBe(false)
})
it('bulk hide calls API with selected ids', async () => { it('bulk hide calls API with selected ids', async () => {
render(<PendingDevicesModal {...baseProps} />) render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()) await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
@@ -213,4 +250,25 @@ describe('PendingDevicesModal', () => {
fireEvent.click(screen.getByRole('button', { name: /Restore \(1\)/ })) fireEvent.click(screen.getByRole('button', { name: /Restore \(1\)/ }))
await waitFor(() => expect(mockBulkRestore).toHaveBeenCalledWith(['dev-a'])) await waitFor(() => expect(mockBulkRestore).toHaveBeenCalledWith(['dev-a']))
}) })
it('Enter confirms approve in pending select mode', 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.keyDown(window, { key: 'Enter' })
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a']))
expect(mockBulkRestore).not.toHaveBeenCalled()
})
it('Enter restores (not approves) in hidden select mode', 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.keyDown(window, { key: 'Enter' })
await waitFor(() => expect(mockBulkRestore).toHaveBeenCalledWith(['dev-a']))
expect(mockBulkApprove).not.toHaveBeenCalled()
})
}) })
@@ -1,21 +1,17 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { Sidebar } from '../Sidebar' import { ScanHistoryModal } from '../ScanHistoryModal'
import * as canvasStore from '@/stores/canvasStore'
import { TooltipProvider } from '@/components/ui/tooltip' import { TooltipProvider } from '@/components/ui/tooltip'
vi.mock('@/stores/canvasStore')
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('@/stores/canvasStore', () => ({
useCanvasStore: { getState: () => ({ notifyScanDeviceFound: vi.fn() }) },
}))
vi.mock('@/api/client', () => ({ vi.mock('@/api/client', () => ({
scanApi: { scanApi: {
trigger: vi.fn(),
pending: vi.fn().mockResolvedValue({ data: [] }),
hidden: vi.fn().mockResolvedValue({ data: [] }),
runs: vi.fn().mockResolvedValue({ data: [] }), runs: vi.fn().mockResolvedValue({ data: [] }),
stop: vi.fn(), stop: vi.fn(),
getConfig: vi.fn().mockResolvedValue({ data: { ranges: [] } }),
}, },
settingsApi: { get: vi.fn(), save: vi.fn() },
})) }))
import { scanApi } from '@/api/client' import { scanApi } from '@/api/client'
@@ -24,9 +20,10 @@ import { toast } from 'sonner'
const RUNNING_RUN = { const RUNNING_RUN = {
id: 'run-1', id: 'run-1',
status: 'running', status: 'running',
kind: 'ip',
ranges: ['192.168.1.0/24'], ranges: ['192.168.1.0/24'],
devices_found: 2, devices_found: 2,
started_at: new Date().toISOString(), started_at: new Date(Date.now() - 5000).toISOString(),
finished_at: null, finished_at: null,
error: null, error: null,
} }
@@ -34,16 +31,18 @@ const RUNNING_RUN = {
const DONE_RUN = { const DONE_RUN = {
id: 'run-2', id: 'run-2',
status: 'done', status: 'done',
kind: 'ip',
ranges: ['192.168.1.0/24'], ranges: ['192.168.1.0/24'],
devices_found: 3, devices_found: 3,
started_at: new Date().toISOString(), started_at: new Date(Date.now() - 60000).toISOString(),
finished_at: new Date().toISOString(), finished_at: new Date(Date.now() - 30000).toISOString(),
error: null, error: null,
} }
const CANCELLED_RUN = { const CANCELLED_RUN = {
id: 'run-3', id: 'run-3',
status: 'cancelled', status: 'cancelled',
kind: 'ip',
ranges: ['192.168.1.0/24'], ranges: ['192.168.1.0/24'],
devices_found: 1, devices_found: 1,
started_at: new Date().toISOString(), started_at: new Date().toISOString(),
@@ -51,36 +50,26 @@ const CANCELLED_RUN = {
error: null, error: null,
} }
function renderSidebar() { const ZIGBEE_RUN = {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ id: 'run-4',
nodes: [], status: 'done',
hasUnsavedChanges: false, kind: 'zigbee',
hideIp: false, ranges: [],
toggleHideIp: vi.fn(), devices_found: 7,
addNode: vi.fn(), started_at: new Date().toISOString(),
scanEventTs: 0, finished_at: new Date().toISOString(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>) error: null,
}
function renderModal() {
return render( return render(
<TooltipProvider> <TooltipProvider>
<Sidebar <ScanHistoryModal open onClose={vi.fn()} />
onAddNode={vi.fn()}
onAddGroupRect={vi.fn()}
onScan={vi.fn()}
onSave={vi.fn()}
onNodeApproved={vi.fn()}
/>
</TooltipProvider> </TooltipProvider>
) )
} }
async function openHistory() { describe('ScanHistoryModal', () => {
fireEvent.click(screen.getByRole('button', { name: 'Scan History' }))
// Wait for runs to load
await waitFor(() => expect(scanApi.runs).toHaveBeenCalled())
}
describe('ScanHistoryPanel — stop scan', () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(toast.success).mockReset() vi.mocked(toast.success).mockReset()
vi.mocked(toast.error).mockReset() vi.mocked(toast.error).mockReset()
@@ -88,68 +77,82 @@ describe('ScanHistoryPanel — stop scan', () => {
vi.mocked(scanApi.runs).mockResolvedValue({ data: [] } as never) vi.mocked(scanApi.runs).mockResolvedValue({ data: [] } as never)
}) })
it('loads runs when opened', async () => {
vi.mocked(scanApi.runs).mockResolvedValue({ data: [DONE_RUN] } as never)
renderModal()
await waitFor(() => expect(scanApi.runs).toHaveBeenCalled())
expect(await screen.findByText('done')).toBeDefined()
})
it('shows empty state when no scans', async () => {
renderModal()
expect(await screen.findByText('No scans yet')).toBeDefined()
})
it('shows stop button only for running scans', async () => { it('shows stop button only for running scans', async () => {
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN, DONE_RUN] } as never) vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN, DONE_RUN] } as never)
renderSidebar() renderModal()
await openHistory()
await waitFor(() => expect(screen.getByText('running')).toBeDefined()) await waitFor(() => expect(screen.getByText('running')).toBeDefined())
expect(screen.getAllByRole('button', { name: 'Stop scan' })).toHaveLength(1)
// Exactly one stop button rendered (for the running scan only)
const stopButtons = screen.getAllByRole('button', { name: 'Stop scan' })
expect(stopButtons).toHaveLength(1)
}) })
it('calls scanApi.stop with the correct run ID on click', async () => { it('calls scanApi.stop with the correct run ID', async () => {
vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never) vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never)
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never) vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
renderSidebar() renderModal()
await openHistory()
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' }) const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
fireEvent.click(stopBtn) fireEvent.click(stopBtn)
await waitFor(() => expect(scanApi.stop).toHaveBeenCalledWith('run-1'))
await waitFor(() => {
expect(scanApi.stop).toHaveBeenCalledWith('run-1')
})
}) })
it('shows success toast when stop succeeds', async () => { it('shows success toast when stop succeeds', async () => {
vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never) vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never)
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never) vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
renderSidebar() renderModal()
await openHistory()
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' }) const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
fireEvent.click(stopBtn) fireEvent.click(stopBtn)
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Scan stop requested'))
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith('Scan stop requested')
})
}) })
it('shows error toast when stop fails', async () => { it('shows error toast when stop fails', async () => {
vi.mocked(scanApi.stop).mockRejectedValue(new Error('network')) vi.mocked(scanApi.stop).mockRejectedValue(new Error('network'))
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never) vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
renderSidebar() renderModal()
await openHistory()
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' }) const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
fireEvent.click(stopBtn) fireEvent.click(stopBtn)
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Failed to stop scan'))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Failed to stop scan')
})
}) })
it('renders cancelled status without stop button or spinner', async () => { it('renders cancelled status without a stop button', async () => {
vi.mocked(scanApi.runs).mockResolvedValue({ data: [CANCELLED_RUN] } as never) vi.mocked(scanApi.runs).mockResolvedValue({ data: [CANCELLED_RUN] } as never)
renderSidebar() renderModal()
await openHistory()
await waitFor(() => expect(screen.getByText('cancelled')).toBeDefined()) await waitFor(() => expect(screen.getByText('cancelled')).toBeDefined())
// No stop button
expect(screen.queryByRole('button', { name: 'Stop scan' })).toBeNull() expect(screen.queryByRole('button', { name: 'Stop scan' })).toBeNull()
}) })
it('shows duration for a finished run', async () => {
vi.mocked(scanApi.runs).mockResolvedValue({ data: [DONE_RUN] } as never)
renderModal()
// DONE_RUN ran 30s
expect(await screen.findByText('30s')).toBeDefined()
})
it('filters by status', async () => {
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN, DONE_RUN] } as never)
renderModal()
await waitFor(() => expect(screen.getByText('done')).toBeDefined())
fireEvent.click(screen.getByRole('button', { name: 'Running' }))
expect(screen.queryByText('done')).toBeNull()
expect(screen.getByText('running')).toBeDefined()
})
it('filters by kind', async () => {
vi.mocked(scanApi.runs).mockResolvedValue({ data: [DONE_RUN, ZIGBEE_RUN] } as never)
renderModal()
await waitFor(() => expect(screen.getAllByText('done').length).toBe(2))
fireEvent.click(screen.getByRole('button', { name: 'Zigbee' }))
// Only the zigbee run (7 found) remains
expect(screen.getByText('7 found')).toBeDefined()
expect(screen.queryByText('3 found')).toBeNull()
})
}) })
@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { SettingsModal } from '../SettingsModal'
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('@/api/client', () => ({
settingsApi: {
get: vi.fn(),
save: vi.fn(),
},
}))
import { settingsApi } from '@/api/client'
import { toast } from 'sonner'
import { useCanvasStore } from '@/stores/canvasStore'
describe('SettingsModal', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60, service_check_enabled: false, service_check_interval: 300 } } as never)
vi.mocked(settingsApi.save).mockResolvedValue({ data: { interval_seconds: 60, service_check_enabled: false, service_check_interval: 300 } } as never)
vi.mocked(toast.success).mockReset()
vi.mocked(toast.error).mockReset()
})
it('loads interval from API when opened', async () => {
render(<SettingsModal open onClose={vi.fn()} />)
await waitFor(() => expect(settingsApi.get).toHaveBeenCalledOnce())
expect(screen.getByText('Status check interval (s)')).toBeDefined()
})
it('does not fetch when closed', () => {
render(<SettingsModal open={false} onClose={vi.fn()} />)
expect(settingsApi.get).not.toHaveBeenCalled()
})
it('displays interval loaded from API', async () => {
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 120 } } as never)
render(<SettingsModal open onClose={vi.fn()} />)
expect(await screen.findByDisplayValue('120')).toBeDefined()
})
it('saves interval and closes on Save click', async () => {
const onClose = vi.fn()
render(<SettingsModal open onClose={onClose} />)
const input = await screen.findByDisplayValue('60')
fireEvent.change(input, { target: { value: '180' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(settingsApi.save).toHaveBeenCalledWith({ interval_seconds: 180, service_check_enabled: false, service_check_interval: 300 })
expect(toast.success).toHaveBeenCalledWith('Settings saved')
expect(onClose).toHaveBeenCalled()
})
})
it('shows error toast and stays open when save fails', async () => {
vi.mocked(settingsApi.save).mockRejectedValue(new Error('network'))
const onClose = vi.fn()
render(<SettingsModal open onClose={onClose} />)
await screen.findByDisplayValue('60')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Failed to save settings')
})
expect(onClose).not.toHaveBeenCalled()
})
it('reflects and persists the hide-IP preference', async () => {
useCanvasStore.setState({ hideIp: false })
localStorage.removeItem('homelable.hideIp')
render(<SettingsModal open onClose={vi.fn()} />)
const checkbox = screen.getByLabelText('Toggle IP address masking') as HTMLInputElement
expect(checkbox.checked).toBe(false)
fireEvent.click(checkbox)
expect(useCanvasStore.getState().hideIp).toBe(true)
expect(localStorage.getItem('homelable.hideIp')).toBe('true')
})
it('loads and toggles the per-service check setting, saving its interval', async () => {
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60, service_check_enabled: true, service_check_interval: 600 } } as never)
render(<SettingsModal open onClose={vi.fn()} />)
const toggle = await screen.findByLabelText('Toggle per-service status checks') as HTMLInputElement
// The toggle label renders immediately; its checked state only flips once
// settingsApi.get() resolves, so wait for that before asserting.
await waitFor(() => expect(toggle.checked).toBe(true))
expect(await screen.findByDisplayValue('600')).toBeDefined()
fireEvent.click(toggle) // disable
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(settingsApi.save).toHaveBeenCalledWith({ interval_seconds: 60, service_check_enabled: false, service_check_interval: 600 })
})
})
it('calls onClose on Cancel', async () => {
const onClose = vi.fn()
render(<SettingsModal open onClose={onClose} />)
await screen.findByDisplayValue('60')
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onClose).toHaveBeenCalledOnce()
})
})
+142 -10
View File
@@ -1,10 +1,10 @@
import { createElement, useState } from 'react' import { createElement, useRef, useState } from 'react'
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react' import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore, serviceStatusKey } from '@/stores/canvasStore'
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types' import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type ServiceStatus, type NodeData, type NodeProperty } from '@/types'
import { getServiceUrl } from '@/utils/serviceUrl' import { getServiceUrl } from '@/utils/serviceUrl'
import { splitIps } from '@/utils/maskIp' import { splitIps } from '@/utils/maskIp'
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons' import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
@@ -21,7 +21,8 @@ type PropForm = { key: string; value: string; icon: string | null; visible: bool
const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true } const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true }
export function DetailPanel({ onEdit }: DetailPanelProps) { export function DetailPanel({ onEdit }: DetailPanelProps) {
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup } = useCanvasStore() const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup, removeFromGroup, setNodeSize } = useCanvasStore()
const serviceStatuses = useCanvasStore((s) => s.serviceStatuses)
const [addingForNode, setAddingForNode] = useState<string | null>(null) const [addingForNode, setAddingForNode] = useState<string | null>(null)
const [newSvc, setNewSvc] = useState<SvcForm>(EMPTY_FORM) const [newSvc, setNewSvc] = useState<SvcForm>(EMPTY_FORM)
@@ -64,6 +65,9 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
node={node} node={node}
nodes={nodes} nodes={nodes}
onUngroup={() => { ungroup(node.id) }} onUngroup={() => { ungroup(node.id) }}
onRemoveChild={(id) => { snapshotHistory(); removeFromGroup(node.id, id) }}
onChangeDescription={(value) => updateNode(node.id, { notes: value })}
onSnapshotBeforeEdit={snapshotHistory}
onToggleBorder={() => { onToggleBorder={() => {
snapshotHistory() snapshotHistory()
updateNode(node.id, { updateNode(node.id, {
@@ -251,6 +255,13 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
{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()} />} {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> </div>
{/* Size section — manual width/height entry for pixel-exact sizing */}
<SizeFields
key={node.id}
node={node}
onCommit={(size) => { snapshotHistory(); setNodeSize(node.id, size) }}
/>
{/* Properties section */} {/* Properties section */}
<div className="px-4 py-3 border-t border-border"> <div className="px-4 py-3 border-t border-border">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
@@ -314,7 +325,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
editingIndex === i ? ( editingIndex === i ? (
<ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus /> <ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus />
) : ( ) : (
<ServiceBadge key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`} svc={svc} host={host} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} /> <ServiceBadge key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`} svc={svc} host={host} status={serviceStatuses[serviceStatusKey(node.id, svc.port, svc.protocol)]} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
) )
)} )}
</div> </div>
@@ -341,6 +352,79 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
) )
} }
// --- Size fields (manual width/height entry) ---
const round = (v: number | undefined): string => (v != null ? String(Math.round(v)) : '')
function SizeFields({ node, onCommit }: { node: Node<NodeData>; onCommit: (size: { width?: number; height?: number }) => void }) {
// Live size from the explicit dimension, falling back to the DOM-measured
// size so the field shows the node's current footprint even before resize.
const liveWidth = round(node.width ?? node.measured?.width)
const liveHeight = round(node.height ?? node.measured?.height)
const [width, setWidth] = useState(liveWidth)
const [height, setHeight] = useState(liveHeight)
// Resync the fields when the node is resized by dragging its corner handle
// (which mutates node.width/height in the store). Adjusting state during
// render — the React-blessed alternative to an effect — keeps it in step
// without cascading renders. The field the user is actively editing is left
// alone so we don't clobber their keystrokes mid-type.
const [editing, setEditing] = useState<'width' | 'height' | null>(null)
const [prev, setPrev] = useState({ w: liveWidth, h: liveHeight })
if (prev.w !== liveWidth || prev.h !== liveHeight) {
setPrev({ w: liveWidth, h: liveHeight })
if (editing !== 'width') setWidth(liveWidth)
if (editing !== 'height') setHeight(liveHeight)
}
const commit = (raw: string, axis: 'width' | 'height') => {
const n = Number(raw)
if (!raw.trim() || Number.isNaN(n) || n <= 0) {
// Reset the field to the live value on invalid input — no mutation.
if (axis === 'width') setWidth(round(node.width ?? node.measured?.width))
else setHeight(round(node.height ?? node.measured?.height))
return
}
onCommit({ [axis]: n })
}
return (
<div className="px-4 py-3 border-t border-border">
<span className="text-xs text-muted-foreground">Size</span>
<div className="mt-2 flex items-center gap-2">
<label className="flex flex-1 items-center gap-1.5">
<span className="text-[10px] text-muted-foreground/70 w-3">W</span>
<Input
type="number"
min={140}
aria-label="Width"
value={width}
onFocus={() => setEditing('width')}
onChange={(e) => setWidth(e.target.value)}
onBlur={() => { setEditing(null); commit(width, 'width') }}
onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur() }}
className="h-7 text-xs font-mono"
/>
</label>
<label className="flex flex-1 items-center gap-1.5">
<span className="text-[10px] text-muted-foreground/70 w-3">H</span>
<Input
type="number"
min={50}
aria-label="Height"
value={height}
onFocus={() => setEditing('height')}
onChange={(e) => setHeight(e.target.value)}
onBlur={() => { setEditing(null); commit(height, 'height') }}
onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur() }}
className="h-7 text-xs font-mono"
/>
</label>
</div>
</div>
)
}
// --- Multi-select panel --- // --- Multi-select panel ---
interface MultiSelectPanelProps { interface MultiSelectPanelProps {
@@ -425,17 +509,33 @@ interface GroupDetailPanelProps {
node: Node<NodeData> node: Node<NodeData>
nodes: Node<NodeData>[] nodes: Node<NodeData>[]
onUngroup: () => void onUngroup: () => void
onRemoveChild: (id: string) => void
onChangeDescription: (value: string) => void
onSnapshotBeforeEdit: () => void
onToggleBorder: () => void onToggleBorder: () => void
onClose: () => void onClose: () => void
onSelectChild: (id: string) => void onSelectChild: (id: string) => void
} }
function GroupDetailPanel({ node, nodes, onUngroup, onToggleBorder, onClose, onSelectChild }: GroupDetailPanelProps) { function GroupDetailPanel({ node, nodes, onUngroup, onRemoveChild, onChangeDescription, onSnapshotBeforeEdit, onToggleBorder, onClose, onSelectChild }: GroupDetailPanelProps) {
const children = nodes.filter((n) => n.parentId === node.id) const children = nodes.filter((n) => n.parentId === node.id)
const onlineCount = children.filter((n) => n.data.status === 'online').length const onlineCount = children.filter((n) => n.data.status === 'online').length
const offlineCount = children.filter((n) => n.data.status === 'offline').length const offlineCount = children.filter((n) => n.data.status === 'offline').length
const showBorder = node.data.custom_colors?.show_border !== false const showBorder = node.data.custom_colors?.show_border !== false
// Description reuses data.notes, which already round-trips to the backend.
// Controlled + committed on every keystroke so ANY save path (incl. Ctrl+S,
// which never blurs the field) captures it. History is snapshotted once at the
// start of an edit session so the whole edit is a single undo step.
const snappedRef = useRef(false)
const handleDescriptionChange = (value: string) => {
if (!snappedRef.current) {
onSnapshotBeforeEdit()
snappedRef.current = true
}
onChangeDescription(value)
}
const handleUngroup = () => { const handleUngroup = () => {
if (confirm(`Ungroup "${node.data.label}"? Nodes will be released to the canvas.`)) { if (confirm(`Ungroup "${node.data.label}"? Nodes will be released to the canvas.`)) {
onUngroup() onUngroup()
@@ -461,20 +561,48 @@ function GroupDetailPanel({ node, nodes, onUngroup, onToggleBorder, onClose, onS
{offlineCount > 0 && <span style={{ color: STATUS_COLORS.offline }}> {offlineCount} offline</span>} {offlineCount > 0 && <span style={{ color: STATUS_COLORS.offline }}> {offlineCount} offline</span>}
</div> </div>
{/* Description */}
<div className="px-4 py-3 border-b border-border">
<label htmlFor="group-description" className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">
Description
</label>
<textarea
id="group-description"
value={node.data.notes ?? ''}
onFocus={() => { snappedRef.current = false }}
onChange={(e) => handleDescriptionChange(e.target.value)}
placeholder="Add a description for this group…"
rows={3}
className="mt-1.5 w-full resize-y rounded-md bg-[#21262d] border border-[#30363d] px-2 py-1.5 text-xs text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:border-[#00d4ff]/50"
/>
</div>
{/* Children list */} {/* Children list */}
<div className="flex-1 px-4 py-3 space-y-1.5 overflow-y-auto"> <div className="flex-1 px-4 py-3 space-y-1.5 overflow-y-auto">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Members</span> <span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Members</span>
{children.length === 0 && <p className="text-xs text-muted-foreground/50">No nodes in this group.</p>} {children.length === 0 && <p className="text-xs text-muted-foreground/50">No nodes in this group.</p>}
{children.map((child) => ( {children.map((child) => (
<button <div
key={child.id} key={child.id}
className="group/member w-full flex items-center gap-2 px-2 py-1.5 rounded-md bg-[#21262d] text-xs hover:bg-[#30363d] transition-colors"
>
<button
onClick={() => onSelectChild(child.id)} onClick={() => onSelectChild(child.id)}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md bg-[#21262d] text-xs hover:bg-[#30363d] transition-colors text-left" className="flex items-center gap-2 min-w-0 flex-1 text-left cursor-pointer"
> >
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: STATUS_COLORS[child.data.status] }} /> <span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: STATUS_COLORS[child.data.status] }} />
<span className="truncate text-foreground font-medium">{child.data.label}</span> <span className="truncate text-foreground font-medium">{child.data.label}</span>
<span className="ml-auto text-muted-foreground shrink-0">{NODE_TYPE_LABELS[child.data.type] ?? child.data.type}</span> <span className="ml-auto text-muted-foreground shrink-0">{NODE_TYPE_LABELS[child.data.type] ?? child.data.type}</span>
</button> </button>
<button
onClick={() => onRemoveChild(child.id)}
aria-label={`Remove ${child.data.label} from group`}
title="Remove from group"
className="shrink-0 opacity-0 group-hover/member:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] cursor-pointer"
>
<X size={12} />
</button>
</div>
))} ))}
</div> </div>
@@ -673,9 +801,13 @@ const CATEGORY_COLORS: Record<string, string> = {
web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e', web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
} }
function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) { function ServiceBadge({ svc, host, status, onEdit, onRemove }: { svc: ServiceInfo; host?: string; status?: ServiceStatus; onEdit: () => void; onRemove: () => void }) {
const url = getServiceUrl(svc, host) const url = getServiceUrl(svc, host)
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e' // Manually-added services carry no category, so they fell back to grey even
// when they're reachable HTTP/HTTPS. Treat any resolvable web URL as `web`.
const categoryColor = CATEGORY_COLORS[svc.category ?? ''] ?? (url ? CATEGORY_COLORS.web : '#8b949e')
// A live offline service overrides the category colour with red.
const color = status === 'offline' ? '#f85149' : categoryColor
const pathLabel = svc.path?.trim() ? svc.path.trim() : '' const pathLabel = svc.path?.trim() ? svc.path.trim() : ''
return ( return (
+12 -293
View File
@@ -1,43 +1,24 @@
import { useState, useCallback, useEffect, useRef } from 'react' import { useState, useCallback } from 'react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, LogOut, Network, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react' import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
import { Logo } from '@/components/ui/Logo' import { Logo } from '@/components/ui/Logo'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { useDesignStore } from '@/stores/designStore' import { useDesignStore } from '@/stores/designStore'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
import { designsApi, scanApi, settingsApi } from '@/api/client' import { designsApi } from '@/api/client'
import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons' import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal' import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal'
import type { Design } from '@/types' import type { Design } from '@/types'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useLatestRelease } from '@/hooks/useLatestRelease' import { useLatestRelease } from '@/hooks/useLatestRelease'
import {
type AlignmentSettings,
readAlignmentSettings,
writeAlignmentSettings,
subscribeAlignmentSettings,
} from '@/utils/alignmentSettings'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
type SidebarView = 'canvas' | 'history' | 'settings'
const PENDING_TRIGGERS: { kind: 'pending' | 'hidden'; icon: typeof ScanLine; label: string }[] = [ const PENDING_TRIGGERS: { kind: 'pending' | 'hidden'; icon: typeof ScanLine; label: string }[] = [
{ kind: 'pending', icon: ScanLine, label: 'Pending Devices' }, { kind: 'pending', icon: ScanLine, label: 'Pending Devices' },
{ kind: 'hidden', icon: EyeOff, label: 'Hidden Devices' }, { kind: 'hidden', icon: EyeOff, label: 'Hidden Devices' },
] ]
interface ScanRun {
id: string
status: string
kind?: string
ranges: string[]
devices_found: number
started_at: string
finished_at: string | null
error: string | null
}
interface SidebarProps { interface SidebarProps {
onAddNode: () => void onAddNode: () => void
onAddGroupRect: () => void onAddGroupRect: () => void
@@ -45,14 +26,13 @@ interface SidebarProps {
onScan: () => void onScan: () => void
onZigbeeImport: () => void onZigbeeImport: () => void
onSave: () => void onSave: () => void
forceView?: SidebarView onOpenSettings: () => void
onOpenHistory: () => void
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
} }
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onSave, forceView, onOpenPending }: SidebarProps) { export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onSave, onOpenSettings, onOpenHistory, onOpenPending }: SidebarProps) {
const [collapsed, setCollapsed] = useState(false) const [collapsed, setCollapsed] = useState(false)
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
const [prevForceView, setPrevForceView] = useState(forceView)
const logout = useAuthStore((s) => s.logout) const logout = useAuthStore((s) => s.logout)
const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore() const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore()
const [designSwitcherOpen, setDesignSwitcherOpen] = useState(false) const [designSwitcherOpen, setDesignSwitcherOpen] = useState(false)
@@ -86,16 +66,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
} }
}, [designs.length, removeDesign]) }, [designs.length, removeDesign])
// forceView acts as a one-shot trigger from parent; user clicks afterwards still control view. const { nodes, hasUnsavedChanges } = useCanvasStore()
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' && n.data.type !== 'text') const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
@@ -198,8 +169,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
icon={LayoutDashboard} icon={LayoutDashboard}
label="Canvas" label="Canvas"
collapsed={collapsed} collapsed={collapsed}
active={activeView === 'canvas'} active
onClick={() => setActiveView('canvas')}
/> />
{!STANDALONE && PENDING_TRIGGERS.map((t) => ( {!STANDALONE && PENDING_TRIGGERS.map((t) => (
<SidebarItem <SidebarItem
@@ -215,24 +185,12 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
icon={Clock} icon={Clock}
label="Scan History" label="Scan History"
collapsed={collapsed} collapsed={collapsed}
active={activeView === 'history'} onClick={onOpenHistory}
onClick={() => setActiveView('history')}
/> />
)} )}
</nav> </nav>
{/* View content (only when expanded) */} {!collapsed && <div className="flex-1" />}
{!collapsed && activeView !== 'canvas' && (
<div className="flex-1 min-h-0 overflow-y-auto border-t border-border">
{activeView === 'history' && <ScanHistoryPanel />}
{activeView === 'settings' && <SettingsPanel />}
</div>
)}
{/* Stats (only on canvas view) */}
{!collapsed && activeView === 'canvas' && (
<div className="flex-1" />
)}
{/* Stats footer */} {/* Stats footer */}
{!collapsed && ( {!collapsed && (
@@ -259,30 +217,20 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} /> <SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} />
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />} {!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />} {!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
<SidebarItem
icon={hideIp ? EyeOff : Eye}
label={hideIp ? 'Show IPs' : 'Hide IPs'}
collapsed={collapsed}
onClick={toggleHideIp}
active={hideIp}
/>
<SidebarItem <SidebarItem
icon={Save} icon={Save}
label="Save Canvas" label="Save Canvas"
collapsed={collapsed} collapsed={collapsed}
onClick={onSave} onClick={() => onSave()}
badge={hasUnsavedChanges} badge={hasUnsavedChanges}
accent accent
/> />
{!STANDALONE && (
<SidebarItem <SidebarItem
icon={Settings} icon={Settings}
label="Settings" label="Settings"
collapsed={collapsed} collapsed={collapsed}
active={activeView === 'settings'} onClick={onOpenSettings}
onClick={() => setActiveView((v) => v === 'settings' ? 'canvas' : 'settings')}
/> />
)}
{!STANDALONE && ( {!STANDALONE && (
<SidebarItem <SidebarItem
icon={LogOut} icon={LogOut}
@@ -310,235 +258,6 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
) )
} }
function ScanHistoryPanel() {
const [runs, setRuns] = useState<ScanRun[]>([])
const [loading, setLoading] = useState(false)
const prevRunsRef = useRef<ScanRun[]>([])
const load = useCallback(async () => {
setLoading(true)
try {
const res = await scanApi.runs()
const next: ScanRun[] = res.data
// 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)
} catch {
toast.error('Failed to load scan history')
} finally {
setLoading(false)
}
}, [])
// Initial load
useEffect(() => { load() }, [load])
// Auto-refresh every 3s while any run is still running
useEffect(() => {
const hasRunning = runs.some((r) => r.status === 'running')
if (!hasRunning) return
const id = setInterval(load, 3000)
return () => clearInterval(id)
}, [runs, load])
const [stopping, setStopping] = useState<string | null>(null)
const handleStop = async (runId: string) => {
setStopping(runId)
try {
await scanApi.stop(runId)
toast.success('Scan stop requested')
} catch {
toast.error('Failed to stop scan')
} finally {
setStopping(null)
}
}
const statusColor = (s: string) =>
s === 'done' ? '#39d353'
: s === 'running' ? '#e3b341'
: s === 'error' ? '#f85149'
: s === 'cancelled' ? '#8b949e'
: '#8b949e'
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">History</span>
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5">
<RefreshCw size={12} />
</button>
</div>
{loading && runs.length === 0 && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
{!loading && runs.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-4">No scans yet</p>
)}
{runs.map((r) => (
<div key={r.id} className="mb-2 p-2 rounded-md bg-[#21262d] text-xs">
<div className="flex items-center gap-1.5">
<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>
<TooltipTrigger>
<button
aria-label="Stop scan"
onClick={() => handleStop(r.id)}
disabled={stopping === r.id}
className="p-0.5 text-[#f85149] hover:bg-[#f85149]/10 rounded transition-colors disabled:opacity-50"
>
{stopping === r.id
? <Loader2 size={11} className="animate-spin" />
: <StopCircle size={11} />
}
</button>
</TooltipTrigger>
<TooltipContent side="left">Stop scan</TooltipContent>
</Tooltip>
)}
</div>
<div className="text-muted-foreground text-[10px] mt-0.5">
{new Date(r.started_at.endsWith('Z') ? r.started_at : r.started_at + 'Z').toLocaleString()}
</div>
{r.ranges.length > 0 && (
<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 wrap-break-word whitespace-pre-wrap">
{r.error}
</div>
)}
</div>
))}
</div>
)
}
function SettingsPanel() {
const [interval, setIntervalValue] = useState(60)
const [saving, setSaving] = useState(false)
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
useEffect(() => {
settingsApi.get()
.then((res) => setIntervalValue(res.data.interval_seconds))
.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 {
await settingsApi.save({ interval_seconds: interval })
toast.success('Settings saved')
} catch {
toast.error('Failed to save settings')
} finally {
setSaving(false)
}
}
return (
<div className="p-3 space-y-4">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Settings</span>
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">Status check interval (s)</label>
<div className="flex items-center gap-2">
<input
type="number"
min={10}
max={3600}
value={interval}
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>
</div>
<p className="text-[10px] text-muted-foreground leading-tight">
How often node health is polled (ping, HTTP, SSH)
</p>
</div>
<button
onClick={handleSave}
disabled={saving}
className="w-full py-1.5 rounded-md text-xs font-medium bg-[#00d4ff]/10 text-[#00d4ff] border border-[#00d4ff]/30 hover:bg-[#00d4ff]/20 transition-colors disabled:opacity-50"
>
{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>
)
}
function VersionBadge() { function VersionBadge() {
const current = __APP_VERSION__ const current = __APP_VERSION__
const { latest, hasUpdate } = useLatestRelease(current) const { latest, hasUpdate } = useLatestRelease(current)
+7 -3
View File
@@ -1,5 +1,5 @@
import { useRef } from 'react' import { useRef } from 'react'
import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Table2, FileDown, Upload } from 'lucide-react' import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Table2, FileDown, Upload, Eye } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Logo } from '@/components/ui/Logo' import { Logo } from '@/components/ui/Logo'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
@@ -15,9 +15,10 @@ interface ToolbarProps {
onExportMd: () => void onExportMd: () => void
onExportYaml: () => void onExportYaml: () => void
onImportYaml: (content: string) => void onImportYaml: (content: string) => void
onViewOnly: () => void
} }
export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd, onExportYaml, onImportYaml }: ToolbarProps) { export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd, onExportYaml, onImportYaml, onViewOnly }: ToolbarProps) {
const { hasUnsavedChanges, past, future } = useCanvasStore() const { hasUnsavedChanges, past, future } = useCanvasStore()
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
@@ -81,6 +82,9 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={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 <Table2 size={14} /> MD
</Button> </Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onViewOnly} title="Open read-only live view of this canvas">
<Eye size={14} /> View
</Button>
<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 (?)"> <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} /> <HelpCircle size={14} />
</Button> </Button>
@@ -91,7 +95,7 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
background: hasUnsavedChanges ? '#00d4ff' : undefined, background: hasUnsavedChanges ? '#00d4ff' : undefined,
color: hasUnsavedChanges ? '#0d1117' : undefined, color: hasUnsavedChanges ? '#0d1117' : undefined,
}} }}
onClick={onSave} onClick={() => onSave()}
> >
{hasUnsavedChanges && ( {hasUnsavedChanges && (
<span className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-[#e3b341] border border-[#161b22]" /> <span className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-[#e3b341] border border-[#161b22]" />
@@ -5,7 +5,10 @@ import * as canvasStore from '@/stores/canvasStore'
import type { NodeData } from '@/types' import type { NodeData } from '@/types'
import type { Node } from '@xyflow/react' import type { Node } from '@xyflow/react'
vi.mock('@/stores/canvasStore') vi.mock('@/stores/canvasStore', async (importActual) => ({
...(await importActual<typeof canvasStore>()),
useCanvasStore: vi.fn(),
}))
function makeNode(data: Partial<NodeData>): Node<NodeData> { function makeNode(data: Partial<NodeData>): Node<NodeData> {
return { return {
@@ -22,8 +25,8 @@ function makeNode(data: Partial<NodeData>): Node<NodeData> {
} }
} }
function setupStore(nodeData: Partial<NodeData> = {}) { function setupStore(nodeData: Partial<NodeData> = {}, serviceStatuses: Record<string, string> = {}) {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ const state = {
nodes: [makeNode(nodeData)], nodes: [makeNode(nodeData)],
selectedNodeId: 'n1', selectedNodeId: 'n1',
selectedNodeIds: [], selectedNodeIds: [],
@@ -33,7 +36,13 @@ function setupStore(nodeData: Partial<NodeData> = {}) {
snapshotHistory: vi.fn(), snapshotHistory: vi.fn(),
createGroup: vi.fn(), createGroup: vi.fn(),
ungroup: vi.fn(), ungroup: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>) setNodeSize: vi.fn(),
serviceStatuses,
}
// Support both the bare destructure call and the selector-based call.
vi.mocked(canvasStore.useCanvasStore).mockImplementation(
((sel?: (s: typeof state) => unknown) => (sel ? sel(state) : state)) as unknown as typeof canvasStore.useCanvasStore,
)
} }
describe('DetailPanel', () => { describe('DetailPanel', () => {
@@ -502,6 +511,43 @@ describe('DetailPanel', () => {
render(<DetailPanel onEdit={vi.fn()} />) render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('health').tagName).not.toBe('A') expect(screen.getByText('health').tagName).not.toBe('A')
}) })
it('colors a categoryless but reachable web service blue, not grey', () => {
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.style.color).toBe('rgb(0, 212, 255)') // #00d4ff (web)
})
it('keeps a categoryless unreachable service grey', () => {
setupStore({ ip: undefined, services: [{ protocol: 'tcp', service_name: 'health', path: '' }] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('health').style.color).toBe('rgb(139, 148, 158)') // #8b949e
})
it('respects an explicit category over the url fallback', () => {
setupStore({ ip: '192.168.1.10', services: [{ port: 5432, protocol: 'tcp', service_name: 'pg', category: 'database', path: '' }] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('pg').style.color).toBe('rgb(168, 85, 247)') // #a855f7 (database)
})
it('paints a service red when its live status is offline', () => {
setupStore(
{ ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] },
{ 'n1:8080/tcp': 'offline' },
)
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByRole('link', { name: 'nginx' }).style.color).toBe('rgb(248, 81, 73)') // #f85149
})
it('keeps the category colour when the live status is online', () => {
setupStore(
{ ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] },
{ 'n1:8080/tcp': 'online' },
)
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByRole('link', { name: 'nginx' }).style.color).toBe('rgb(0, 212, 255)') // #00d4ff (web)
})
}) })
describe('Last Seen formatting', () => { describe('Last Seen formatting', () => {
@@ -526,4 +572,69 @@ describe('DetailPanel', () => {
expect(row?.textContent).not.toMatch(/Invalid Date/) expect(row?.textContent).not.toMatch(/Invalid Date/)
}) })
}) })
describe('Size section', () => {
function setupSized(node: Partial<Node<NodeData>>, setNodeSize = vi.fn()) {
const state = {
nodes: [{ ...makeNode({}), ...node }],
selectedNodeId: 'n1',
selectedNodeIds: [],
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode: vi.fn(),
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
setNodeSize,
serviceStatuses: {},
}
vi.mocked(canvasStore.useCanvasStore).mockImplementation(
((sel?: (s: typeof state) => unknown) => (sel ? sel(state) : state)) as unknown as typeof canvasStore.useCanvasStore,
)
return setNodeSize
}
it('shows the current width/height, preferring explicit over measured', () => {
setupSized({ width: 220, height: 130, measured: { width: 999, height: 999 } })
render(<DetailPanel onEdit={vi.fn()} />)
expect((screen.getByLabelText('Width') as HTMLInputElement).value).toBe('220')
expect((screen.getByLabelText('Height') as HTMLInputElement).value).toBe('130')
})
it('falls back to the measured size before a resize', () => {
setupSized({ measured: { width: 187, height: 73 } })
render(<DetailPanel onEdit={vi.fn()} />)
expect((screen.getByLabelText('Width') as HTMLInputElement).value).toBe('187')
expect((screen.getByLabelText('Height') as HTMLInputElement).value).toBe('73')
})
it('commits a manual width on blur', () => {
const setNodeSize = setupSized({ width: 200, height: 100 })
render(<DetailPanel onEdit={vi.fn()} />)
const w = screen.getByLabelText('Width')
fireEvent.change(w, { target: { value: '260' } })
fireEvent.blur(w)
expect(setNodeSize).toHaveBeenCalledWith('n1', { width: 260 })
})
it('reverts invalid input without committing', () => {
const setNodeSize = setupSized({ width: 200, height: 100 })
render(<DetailPanel onEdit={vi.fn()} />)
const w = screen.getByLabelText('Width') as HTMLInputElement
fireEvent.change(w, { target: { value: 'abc' } })
fireEvent.blur(w)
expect(setNodeSize).not.toHaveBeenCalled()
expect(w.value).toBe('200')
})
it('resyncs the field when the node is resized by corner drag', () => {
setupSized({ width: 200, height: 100 })
const { rerender } = render(<DetailPanel onEdit={vi.fn()} />)
expect((screen.getByLabelText('Width') as HTMLInputElement).value).toBe('200')
// Simulate a corner-drag resize updating the store dimension.
setupSized({ width: 340, height: 100 })
rerender(<DetailPanel onEdit={vi.fn()} />)
expect((screen.getByLabelText('Width') as HTMLInputElement).value).toBe('340')
})
})
}) })
@@ -42,6 +42,7 @@ const mockStore = {
snapshotHistory: vi.fn(), snapshotHistory: vi.fn(),
createGroup: vi.fn(), createGroup: vi.fn(),
ungroup: vi.fn(), ungroup: vi.fn(),
removeFromGroup: vi.fn(),
} }
function setupStore(overrides = {}) { function setupStore(overrides = {}) {
@@ -230,4 +231,46 @@ describe('GroupDetailPanel', () => {
fireEvent.click(screen.getByText('Child Node Alpha')) fireEvent.click(screen.getByText('Child Node Alpha'))
expect(setSelectedNode).toHaveBeenCalledWith('c1') expect(setSelectedNode).toHaveBeenCalledWith('c1')
}) })
it('removes a child from the group via the remove button', () => {
const removeFromGroup = vi.fn()
const snapshotHistory = vi.fn()
const group = makeGroupNode()
const child = makeNode('c1', { parentId: 'g1', data: { label: 'Router', type: 'router', status: 'online', services: [] } })
setupStore({ nodes: [group, child], selectedNodeId: 'g1', selectedNodeIds: ['g1'], removeFromGroup, snapshotHistory })
renderPanel()
fireEvent.click(screen.getByRole('button', { name: /remove router from group/i }))
expect(removeFromGroup).toHaveBeenCalledWith('g1', 'c1')
expect(snapshotHistory).toHaveBeenCalled()
})
it('renders the existing group description from notes', () => {
const group = makeGroupNode()
group.data = { ...group.data, notes: 'Critical DMZ hosts' } as typeof group.data
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
renderPanel()
expect((screen.getByLabelText('Description') as HTMLTextAreaElement).value).toBe('Critical DMZ hosts')
})
it('commits the description to the store on each change (so Ctrl+S captures it)', () => {
const updateNode = vi.fn()
const group = makeGroupNode()
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], updateNode })
renderPanel()
fireEvent.change(screen.getByLabelText('Description'), { target: { value: 'New notes' } })
expect(updateNode).toHaveBeenCalledWith('g1', { notes: 'New notes' })
})
it('snapshots history once at the start of an edit, not on every keystroke', () => {
const updateNode = vi.fn()
const snapshotHistory = vi.fn()
const group = makeGroupNode()
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], updateNode, snapshotHistory })
renderPanel()
const textarea = screen.getByLabelText('Description')
fireEvent.change(textarea, { target: { value: 'a' } })
fireEvent.change(textarea, { target: { value: 'ab' } })
fireEvent.change(textarea, { target: { value: 'abc' } })
expect(snapshotHistory).toHaveBeenCalledTimes(1)
})
}) })
@@ -1,104 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { Sidebar } from '../Sidebar'
import * as canvasStore from '@/stores/canvasStore'
import { TooltipProvider } from '@/components/ui/tooltip'
vi.mock('@/stores/canvasStore')
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('@/api/client', () => ({
scanApi: {
trigger: vi.fn(),
pending: vi.fn().mockResolvedValue({ data: [] }),
hidden: vi.fn().mockResolvedValue({ data: [] }),
runs: vi.fn().mockResolvedValue({ data: [] }),
getConfig: vi.fn().mockResolvedValue({ data: { ranges: [] } }),
},
settingsApi: {
get: vi.fn(),
save: vi.fn(),
},
}))
import { settingsApi } from '@/api/client'
import { toast } from 'sonner'
function renderSidebar() {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [],
hasUnsavedChanges: false,
hideIp: false,
toggleHideIp: vi.fn(),
addNode: vi.fn(),
scanEventTs: 0,
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
return render(
<TooltipProvider>
<Sidebar
onAddNode={vi.fn()}
onAddGroupRect={vi.fn()}
onScan={vi.fn()}
onSave={vi.fn()}
onNodeApproved={vi.fn()}
/>
</TooltipProvider>
)
}
describe('SettingsPanel', () => {
beforeEach(() => {
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60 } } as never)
vi.mocked(settingsApi.save).mockResolvedValue({ data: { interval_seconds: 60 } } as never)
vi.mocked(toast.success).mockReset()
vi.mocked(toast.error).mockReset()
})
it('opens when Settings item is clicked', async () => {
renderSidebar()
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
await waitFor(() => {
expect(settingsApi.get).toHaveBeenCalledOnce()
})
expect(screen.getByText('Status check interval (s)')).toBeDefined()
})
it('displays interval loaded from API', async () => {
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 120 } } as never)
renderSidebar()
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
const input = await screen.findByDisplayValue('120')
expect(input).toBeDefined()
})
it('saves interval via settingsApi on Save click', async () => {
renderSidebar()
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
const input = await screen.findByDisplayValue('60')
fireEvent.change(input, { target: { value: '180' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(settingsApi.save).toHaveBeenCalledWith({ interval_seconds: 180 })
expect(toast.success).toHaveBeenCalledWith('Settings saved')
})
})
it('shows error toast when save fails', async () => {
vi.mocked(settingsApi.save).mockRejectedValue(new Error('network'))
renderSidebar()
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
await screen.findByDisplayValue('60')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Failed to save settings')
})
})
it('closes panel when Settings is clicked again', async () => {
renderSidebar()
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
await screen.findByText('Status check interval (s)')
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
expect(screen.queryByText('Status check interval (s)')).toBeNull()
})
})
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest' import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { render, screen, fireEvent } from '@testing-library/react'
import { Sidebar } from '../Sidebar' import { Sidebar } from '../Sidebar'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
@@ -46,15 +46,12 @@ const makeNode = (id: string, status: NodeData['status'], type: NodeData['type']
data: { label: id, type, status, services: [] }, data: { label: id, type, status, services: [] },
}) })
const mockToggleHideIp = vi.fn()
const mockLogout = vi.fn() const mockLogout = vi.fn()
function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) { function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
vi.mocked(useCanvasStore).mockReturnValue({ vi.mocked(useCanvasStore).mockReturnValue({
nodes: [], nodes: [],
hasUnsavedChanges: false, hasUnsavedChanges: false,
hideIp: false,
toggleHideIp: mockToggleHideIp,
addNode: vi.fn(), addNode: vi.fn(),
scanEventTs: 0, scanEventTs: 0,
...overrides, ...overrides,
@@ -70,9 +67,12 @@ function mockAuth() {
const defaultProps = { const defaultProps = {
onAddNode: vi.fn(), onAddNode: vi.fn(),
onAddGroupRect: vi.fn(), onAddGroupRect: vi.fn(),
onAddText: vi.fn(),
onScan: vi.fn(), onScan: vi.fn(),
onZigbeeImport: vi.fn(), onZigbeeImport: vi.fn(),
onSave: vi.fn(), onSave: vi.fn(),
onOpenSettings: vi.fn(),
onOpenHistory: vi.fn(),
onOpenPending: vi.fn(), onOpenPending: vi.fn(),
} }
@@ -190,16 +190,19 @@ describe('Sidebar', () => {
expect(defaultProps.onSave).toHaveBeenCalledOnce() expect(defaultProps.onSave).toHaveBeenCalledOnce()
}) })
it('calls toggleHideIp when Hide IPs is clicked', () => { // Regression (#186): the click handler must not forward the MouseEvent as an
// argument — handleSave treats its first arg as a designIdOverride, so leaking
// the event corrupts design_id and the save silently fails.
it('calls onSave with no arguments (does not leak the click event)', () => {
render(<Sidebar {...defaultProps} />) render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Hide IPs')) fireEvent.click(screen.getByText('Save Canvas'))
expect(mockToggleHideIp).toHaveBeenCalledOnce() expect(defaultProps.onSave).toHaveBeenCalledWith()
}) })
it('shows Show IPs label when hideIp is true', () => { it('calls onOpenSettings when Settings is clicked', () => {
mockStore({ hideIp: true })
render(<Sidebar {...defaultProps} />) render(<Sidebar {...defaultProps} />)
expect(screen.getByText('Show IPs')).toBeInTheDocument() fireEvent.click(screen.getByText('Settings'))
expect(defaultProps.onOpenSettings).toHaveBeenCalledOnce()
}) })
// ── Unsaved changes badge ────────────────────────────────────────────────── // ── Unsaved changes badge ──────────────────────────────────────────────────
@@ -242,29 +245,16 @@ describe('Sidebar', () => {
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'hidden') expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'hidden')
}) })
it('shows History panel when Scan History nav item is clicked', async () => { it('calls onOpenHistory when Scan History nav item is clicked', () => {
render(<Sidebar {...defaultProps} />) render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Scan History')) fireEvent.click(screen.getByText('Scan History'))
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument()) expect(defaultProps.onOpenHistory).toHaveBeenCalledOnce()
}) })
// Regression: forceView must not freeze local state across rerenders. it('calls onOpenSettings when Settings is clicked', () => {
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} />) render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Settings'))
await waitFor(() =>
expect(screen.getByText('Status check interval (s)')).toBeInTheDocument(),
)
fireEvent.click(screen.getByRole('button', { name: 'Settings' })) fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument() expect(defaultProps.onOpenSettings).toHaveBeenCalledOnce()
}) })
// ── Logout ───────────────────────────────────────────────────────────────── // ── Logout ─────────────────────────────────────────────────────────────────
@@ -0,0 +1,59 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { Toolbar } from '../Toolbar'
import { useCanvasStore } from '@/stores/canvasStore'
// ── Mocks ────────────────────────────────────────────────────────────────────
vi.mock('@/stores/canvasStore')
vi.mock('@/components/ui/Logo', () => ({
Logo: () => <div data-testid="logo" />,
}))
function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
vi.mocked(useCanvasStore).mockReturnValue({
hasUnsavedChanges: false,
past: [],
future: [],
...overrides,
} as ReturnType<typeof useCanvasStore>)
}
const defaultProps = {
onSave: vi.fn(),
onAutoLayout: vi.fn(),
onExport: vi.fn(),
onChangeStyle: vi.fn(),
onUndo: vi.fn(),
onRedo: vi.fn(),
onShortcuts: vi.fn(),
onExportMd: vi.fn(),
onExportYaml: vi.fn(),
onImportYaml: vi.fn(),
onViewOnly: vi.fn(),
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('Toolbar', () => {
beforeEach(() => {
mockStore()
vi.clearAllMocks()
})
it('calls onSave when Save is clicked', () => {
render(<Toolbar {...defaultProps} />)
fireEvent.click(screen.getByText('Save'))
expect(defaultProps.onSave).toHaveBeenCalledOnce()
})
// Regression (#186): the click handler must not forward the MouseEvent as an
// argument — handleSave treats its first arg as a designIdOverride, so leaking
// the event corrupts design_id and the save silently fails.
it('calls onSave with no arguments (does not leak the click event)', () => {
render(<Toolbar {...defaultProps} />)
fireEvent.click(screen.getByText('Save'))
expect(defaultProps.onSave).toHaveBeenCalledWith()
})
})
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"min-h-16 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1.5 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
@@ -9,6 +9,7 @@ vi.mock('@/stores/authStore')
const mockUpdateNode = vi.fn() const mockUpdateNode = vi.fn()
const mockNotifyScanDeviceFound = vi.fn() const mockNotifyScanDeviceFound = vi.fn()
const mockSetServiceStatuses = vi.fn()
class MockWebSocket { class MockWebSocket {
static instances: MockWebSocket[] = [] static instances: MockWebSocket[] = []
@@ -33,6 +34,7 @@ describe('useStatusPolling', () => {
vi.mocked(useCanvasStore).mockReturnValue({ vi.mocked(useCanvasStore).mockReturnValue({
updateNode: mockUpdateNode, updateNode: mockUpdateNode,
notifyScanDeviceFound: mockNotifyScanDeviceFound, notifyScanDeviceFound: mockNotifyScanDeviceFound,
setServiceStatuses: mockSetServiceStatuses,
} as ReturnType<typeof useCanvasStore>) } as ReturnType<typeof useCanvasStore>)
vi.mocked(useAuthStore).mockReturnValue({ vi.mocked(useAuthStore).mockReturnValue({
@@ -50,6 +52,7 @@ describe('useStatusPolling', () => {
vi.restoreAllMocks() vi.restoreAllMocks()
mockUpdateNode.mockClear() mockUpdateNode.mockClear()
mockNotifyScanDeviceFound.mockClear() mockNotifyScanDeviceFound.mockClear()
mockSetServiceStatuses.mockClear()
}) })
it('does not open WebSocket when not authenticated', () => { it('does not open WebSocket when not authenticated', () => {
@@ -147,6 +150,17 @@ describe('useStatusPolling', () => {
expect(mockUpdateNode).not.toHaveBeenCalled() expect(mockUpdateNode).not.toHaveBeenCalled()
}) })
it('routes service_status messages to setServiceStatuses', () => {
renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
const services = [{ port: 80, protocol: 'tcp', status: 'offline' }]
ws.onmessage?.({
data: JSON.stringify({ type: 'service_status', node_id: 'node-9', services }),
})
expect(mockSetServiceStatuses).toHaveBeenCalledWith('node-9', services)
expect(mockUpdateNode).not.toHaveBeenCalled()
})
it('ignores malformed JSON without throwing', () => { it('ignores malformed JSON without throwing', () => {
renderHook(() => useStatusPolling()) renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0] const ws = MockWebSocket.instances[0]
+12 -2
View File
@@ -1,6 +1,13 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
import type { ServiceStatus } from '@/types'
interface ServiceStatusEntry {
port?: number
protocol?: string
status: ServiceStatus
}
interface StatusMessage { interface StatusMessage {
type?: string type?: string
@@ -10,13 +17,14 @@ interface StatusMessage {
response_time_ms?: number | null response_time_ms?: number | null
run_id?: string run_id?: string
devices_found?: number devices_found?: number
services?: ServiceStatusEntry[]
} }
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
export function useStatusPolling() { export function useStatusPolling() {
const wsRef = useRef<WebSocket | null>(null) const wsRef = useRef<WebSocket | null>(null)
const { updateNode, notifyScanDeviceFound } = useCanvasStore() const { updateNode, notifyScanDeviceFound, setServiceStatuses } = useCanvasStore()
const { isAuthenticated, token } = useAuthStore() const { isAuthenticated, token } = useAuthStore()
useEffect(() => { useEffect(() => {
@@ -39,6 +47,8 @@ export function useStatusPolling() {
const msg: StatusMessage = JSON.parse(event.data) const msg: StatusMessage = JSON.parse(event.data)
if (msg.type === 'scan_device_found') { if (msg.type === 'scan_device_found') {
notifyScanDeviceFound() notifyScanDeviceFound()
} else if (msg.type === 'service_status' && msg.node_id && msg.services) {
setServiceStatuses(msg.node_id, msg.services)
} else if (msg.node_id && msg.status) { } else if (msg.node_id && msg.status) {
updateNode(msg.node_id, { updateNode(msg.node_id, {
status: msg.status, status: msg.status,
@@ -59,5 +69,5 @@ export function useStatusPolling() {
ws.close() ws.close()
wsRef.current = null wsRef.current = null
} }
}, [isAuthenticated, token, updateNode, notifyScanDeviceFound]) }, [isAuthenticated, token, updateNode, notifyScanDeviceFound, setServiceStatuses])
} }
+12
View File
@@ -11,6 +11,18 @@
to { stroke-dashoffset: 0; } to { stroke-dashoffset: 0; }
} }
/* Edge flow animations CSS (not SVG SMIL) so the browser pauses them when the
tab is hidden. SMIL <animate> keeps running in background tabs and leaks
memory in Chrome over time; CSS animations do neither. */
@keyframes homelable-snake {
from { stroke-dashoffset: -10000; }
to { stroke-dashoffset: 0; }
}
@keyframes homelable-flow {
from { stroke-dashoffset: 0; }
to { stroke-dashoffset: 18; }
}
/* Disable React Flow's built-in edgeupdater entirely HomelableEdge renders /* Disable React Flow's built-in edgeupdater entirely HomelableEdge renders
its own interactive endpoint dots in EdgeLabelRenderer (above the node its own interactive endpoint dots in EdgeLabelRenderer (above the node
layer) so the node Handle DOM cannot steal the reconnection drag. */ layer) so the node Handle DOM cannot steal the reconnection drag. */
+370 -14
View File
@@ -30,10 +30,37 @@ describe('canvasStore', () => {
editingTextId: null, editingTextId: null,
past: [], past: [],
future: [], future: [],
clipboard: [], clipboard: { nodes: [], edges: [] },
serviceStatuses: {},
}) })
}) })
it('setServiceStatuses stores live status keyed by node/port/protocol', () => {
const { setServiceStatuses } = useCanvasStore.getState()
setServiceStatuses('node-1', [
{ port: 80, protocol: 'tcp', status: 'offline' },
{ port: 443, protocol: 'tcp', status: 'online' },
])
const { serviceStatuses } = useCanvasStore.getState()
expect(serviceStatuses['node-1:80/tcp']).toBe('offline')
expect(serviceStatuses['node-1:443/tcp']).toBe('online')
})
it('setServiceStatuses merges without dropping other nodes', () => {
const { setServiceStatuses } = useCanvasStore.getState()
setServiceStatuses('node-1', [{ port: 80, protocol: 'tcp', status: 'online' }])
setServiceStatuses('node-2', [{ port: 22, protocol: 'tcp', status: 'offline' }])
const { serviceStatuses } = useCanvasStore.getState()
expect(serviceStatuses['node-1:80/tcp']).toBe('online')
expect(serviceStatuses['node-2:22/tcp']).toBe('offline')
})
it('does not mark canvas unsaved on a service status update', () => {
useCanvasStore.setState({ hasUnsavedChanges: false })
useCanvasStore.getState().setServiceStatuses('n', [{ port: 80, protocol: 'tcp', status: 'offline' }])
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
})
it('setEditingTextId sets and clears editing text id', () => { it('setEditingTextId sets and clears editing text id', () => {
const { setEditingTextId } = useCanvasStore.getState() const { setEditingTextId } = useCanvasStore.getState()
setEditingTextId('t1') setEditingTextId('t1')
@@ -123,6 +150,23 @@ describe('canvasStore', () => {
expect(nested?.extent).toBe('parent') expect(nested?.extent).toBe('parent')
}) })
it('addNode strips parentId/extent when the parent is not a container', () => {
// Regression: a stray extent:'parent' on a non-container parent traps the
// node in the parent's tiny box with no way to drag it out (issue #205).
const parent = { ...makeNode('p1', { container_mode: false }), position: { x: 100, y: 100 } }
useCanvasStore.getState().addNode(parent)
const trapped: Node<NodeData> = {
...makeNode('c1', { parent_id: 'p1' }),
position: { x: 150, y: 180 },
parentId: 'p1',
extent: 'parent',
}
useCanvasStore.getState().addNode(trapped)
const child = useCanvasStore.getState().nodes.find((n) => n.id === 'c1')
expect(child?.parentId).toBeUndefined()
expect(child?.extent).toBeUndefined()
})
it('docker_container nests under docker_host with container_mode on', () => { it('docker_container nests under docker_host with container_mode on', () => {
const host = { ...makeNode('dh1', { type: 'docker_host', container_mode: true }), position: { x: 100, y: 100 } } const host = { ...makeNode('dh1', { type: 'docker_host', container_mode: true }), position: { x: 100, y: 100 } }
const container = { ...makeNode('dc1', { type: 'docker_container' }), position: { x: 160, y: 180 } } const container = { ...makeNode('dc1', { type: 'docker_container' }), position: { x: 160, y: 180 } }
@@ -487,6 +531,186 @@ describe('canvasStore', () => {
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
}) })
// ── addToGroup ──────────────────────────────────────────────────────────────
it('addToGroup nests a top-level node with parent-relative position', () => {
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 76, y: 52 }, width: 448, height: 252 }
const child = { ...makeNode('n1'), position: { x: 300, y: 200 } }
useCanvasStore.setState({ nodes: [group, child] })
useCanvasStore.getState().addToGroup('g1', 'n1')
const moved = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
expect(moved?.parentId).toBe('g1')
expect(moved?.extent).toBe('parent')
expect(moved?.data.parent_id).toBe('g1')
// 300-76=224, 200-52=148
expect(moved?.position).toEqual({ x: 224, y: 148 })
})
it('addToGroup places the group before the child in the array', () => {
const child = { ...makeNode('n1'), position: { x: 300, y: 200 } }
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
// child first to prove reordering
useCanvasStore.setState({ nodes: [child, group] })
useCanvasStore.getState().addToGroup('g1', 'n1')
const { nodes } = useCanvasStore.getState()
expect(nodes.findIndex((n) => n.id === 'g1')).toBeLessThan(nodes.findIndex((n) => n.id === 'n1'))
})
it('addToGroup is a no-op when target is not a group', () => {
const notGroup = { ...makeNode('s1'), position: { x: 0, y: 0 } }
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
useCanvasStore.setState({ nodes: [notGroup, child] })
useCanvasStore.getState().markSaved()
useCanvasStore.getState().addToGroup('s1', 'n1')
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBeUndefined()
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
})
it('addToGroup is a no-op when child already belongs to the group', () => {
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
const child = { ...makeNode('n1'), position: { x: 50, y: 50 }, parentId: 'g1', extent: 'parent' as const }
useCanvasStore.setState({ nodes: [group, child] })
useCanvasStore.getState().markSaved()
useCanvasStore.getState().addToGroup('g1', 'n1')
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
})
it('addToGroup snapshots history and marks unsaved', () => {
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
useCanvasStore.setState({ nodes: [group, child] })
useCanvasStore.getState().markSaved()
useCanvasStore.getState().addToGroup('g1', 'n1')
expect(useCanvasStore.getState().past).toHaveLength(1)
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
// ── addToContainer ──────────────────────────────────────────────────────────
it('addToContainer nests a top-level node under a container_mode node', () => {
const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true, label: 'PX' }), position: { x: 76, y: 52 }, width: 448, height: 252 }
const child = { ...makeNode('n1'), position: { x: 300, y: 200 } }
useCanvasStore.setState({ nodes: [container, child] })
useCanvasStore.getState().addToContainer('px1', 'n1')
const moved = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
expect(moved?.parentId).toBe('px1')
expect(moved?.extent).toBe('parent')
expect(moved?.data.parent_id).toBe('px1')
// 300-76=224, 200-52=148
expect(moved?.position).toEqual({ x: 224, y: 148 })
})
it('addToContainer works for any container_mode type (docker_host)', () => {
const host = { ...makeNode('dh1', { type: 'docker_host', container_mode: true }), position: { x: 0, y: 0 } }
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
useCanvasStore.setState({ nodes: [host, child] })
useCanvasStore.getState().addToContainer('dh1', 'n1')
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBe('dh1')
})
it('addToContainer places the container before the child in the array', () => {
const child = { ...makeNode('n1'), position: { x: 300, y: 200 } }
const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } }
useCanvasStore.setState({ nodes: [child, container] })
useCanvasStore.getState().addToContainer('px1', 'n1')
const { nodes } = useCanvasStore.getState()
expect(nodes.findIndex((n) => n.id === 'px1')).toBeLessThan(nodes.findIndex((n) => n.id === 'n1'))
})
it('addToContainer is a no-op when target is not in container_mode', () => {
const notContainer = { ...makeNode('px1', { type: 'proxmox', container_mode: false }), position: { x: 0, y: 0 } }
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
useCanvasStore.setState({ nodes: [notContainer, child] })
useCanvasStore.getState().markSaved()
useCanvasStore.getState().addToContainer('px1', 'n1')
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBeUndefined()
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
})
it('addToContainer is a no-op when child already belongs to the container', () => {
const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } }
const child = { ...makeNode('n1'), position: { x: 50, y: 50 }, parentId: 'px1', extent: 'parent' as const }
useCanvasStore.setState({ nodes: [container, child] })
useCanvasStore.getState().markSaved()
useCanvasStore.getState().addToContainer('px1', 'n1')
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
})
it('addToContainer snapshots history and marks unsaved', () => {
const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } }
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
useCanvasStore.setState({ nodes: [container, child] })
useCanvasStore.getState().markSaved()
useCanvasStore.getState().addToContainer('px1', 'n1')
expect(useCanvasStore.getState().past).toHaveLength(1)
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
// ── removeFromGroup ─────────────────────────────────────────────────────────
it('removeFromGroup releases the child to absolute coords and keeps the group', () => {
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 76, y: 52 } }
const child = { ...makeNode('n1'), position: { x: 224, y: 148 }, parentId: 'g1', extent: 'parent' as const }
useCanvasStore.setState({ nodes: [group, child] })
useCanvasStore.getState().removeFromGroup('g1', 'n1')
const { nodes } = useCanvasStore.getState()
const released = nodes.find((n) => n.id === 'n1')
expect(released?.parentId).toBeUndefined()
expect(released?.extent).toBeUndefined()
expect(released?.data.parent_id).toBeUndefined()
// 224+76=300, 148+52=200
expect(released?.position).toEqual({ x: 300, y: 200 })
// group survives
expect(nodes.find((n) => n.id === 'g1')).toBeDefined()
})
it('removeFromGroup is a no-op when child is not in the group', () => {
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
useCanvasStore.setState({ nodes: [group, child] })
useCanvasStore.getState().markSaved()
useCanvasStore.getState().removeFromGroup('g1', 'n1')
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
})
it('removeFromGroup snapshots history and marks unsaved', () => {
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
const child = { ...makeNode('n1'), position: { x: 50, y: 50 }, parentId: 'g1', extent: 'parent' as const }
useCanvasStore.setState({ nodes: [group, child] })
useCanvasStore.getState().markSaved()
useCanvasStore.getState().removeFromGroup('g1', 'n1')
expect(useCanvasStore.getState().past).toHaveLength(1)
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
it('updateEdge updates edge data and marks unsaved', () => { it('updateEdge updates edge data and marks unsaved', () => {
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] })) useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] }))
useCanvasStore.getState().markSaved() useCanvasStore.getState().markSaved()
@@ -690,29 +914,136 @@ describe('canvasStore', () => {
}) })
useCanvasStore.getState().copySelectedNodes() useCanvasStore.getState().copySelectedNodes()
const { clipboard } = useCanvasStore.getState() const { clipboard } = useCanvasStore.getState()
expect(clipboard).toHaveLength(1) expect(clipboard.nodes).toHaveLength(1)
expect(clipboard[0].id).toBe('a') expect(clipboard.nodes[0].id).toBe('a')
}) })
it('pasteNodes creates new nodes with new IDs and offset position', () => { it('copySelectedNodes captures edges whose endpoints are both selected', () => {
const node = { ...makeNode('src'), position: { x: 100, y: 100 }, selected: true } useCanvasStore.setState({
useCanvasStore.setState({ nodes: [node], edges: [], clipboard: [node] }) nodes: [
{ ...makeNode('a'), selected: true },
{ ...makeNode('b'), selected: true },
{ ...makeNode('c'), selected: false },
],
edges: [makeEdge('e-ab', 'a', 'b'), makeEdge('e-bc', 'b', 'c')],
})
useCanvasStore.getState().copySelectedNodes()
const { clipboard } = useCanvasStore.getState()
expect(clipboard.nodes.map((n) => n.id).sort()).toEqual(['a', 'b'])
expect(clipboard.edges).toHaveLength(1)
expect(clipboard.edges[0].id).toBe('e-ab')
})
it('copySelectedNodes pulls in children of a selected group', () => {
useCanvasStore.setState({
nodes: [
{ ...makeNode('g', { type: 'group' }), type: 'group', selected: true },
{ ...makeNode('child', { parent_id: 'g' }), parentId: 'g', selected: false },
],
edges: [],
})
useCanvasStore.getState().copySelectedNodes()
expect(useCanvasStore.getState().clipboard.nodes.map((n) => n.id).sort()).toEqual(['child', 'g'])
})
it('pasteNodes creates new nodes with new IDs and a cascade offset by default', () => {
const node = { ...makeNode('src'), position: { x: 100, y: 100 } }
useCanvasStore.setState({ nodes: [], edges: [], clipboard: { nodes: [node], edges: [] } })
useCanvasStore.getState().pasteNodes() useCanvasStore.getState().pasteNodes()
const { nodes } = useCanvasStore.getState() const { nodes } = useCanvasStore.getState()
expect(nodes).toHaveLength(2) expect(nodes).toHaveLength(1)
const pasted = nodes.find((n) => n.id !== 'src')! const pasted = nodes[0]
expect(pasted).toBeDefined() expect(pasted.id).not.toBe('src')
expect(pasted.position.x).toBe(150) expect(pasted.position).toEqual({ x: 150, y: 150 })
expect(pasted.position.y).toBe(150) expect(pasted.selected).toBe(true)
expect(pasted.selected).toBe(false) })
it('pasteNodes centers the pasted bounding box on the target point', () => {
const node = { ...makeNode('src'), position: { x: 0, y: 0 }, width: 100, height: 100 }
useCanvasStore.setState({ nodes: [], edges: [], clipboard: { nodes: [node], edges: [] } })
useCanvasStore.getState().pasteNodes({ x: 500, y: 300 })
const pasted = useCanvasStore.getState().nodes[0]
// bbox center (50,50) shifted onto (500,300) → top-left at (450,250)
expect(pasted.position).toEqual({ x: 450, y: 250 })
})
it('pasteNodes remaps edge endpoints to the new node IDs', () => {
const a = { ...makeNode('a') }
const b = { ...makeNode('b') }
useCanvasStore.setState({
nodes: [],
edges: [],
clipboard: { nodes: [a, b], edges: [makeEdge('e-ab', 'a', 'b')] },
})
useCanvasStore.getState().pasteNodes()
const { nodes, edges } = useCanvasStore.getState()
expect(edges).toHaveLength(1)
const ids = nodes.map((n) => n.id)
expect(ids).toContain(edges[0].source)
expect(ids).toContain(edges[0].target)
expect(edges[0].source).not.toBe('a')
expect(edges[0].id).not.toBe('e-ab')
})
it('pasteNodes preserves parent-child relationship under remapped IDs', () => {
const group = { ...makeNode('g', { type: 'group' }), type: 'group', position: { x: 0, y: 0 } }
const child = { ...makeNode('child', { parent_id: 'g' }), parentId: 'g', extent: 'parent' as const, position: { x: 20, y: 30 } }
useCanvasStore.setState({ nodes: [], edges: [], clipboard: { nodes: [group, child], edges: [] } })
useCanvasStore.getState().pasteNodes()
const { nodes } = useCanvasStore.getState()
const newGroup = nodes.find((n) => n.data.type === 'group')!
const newChild = nodes.find((n) => n.id !== newGroup.id)!
expect(newChild.parentId).toBe(newGroup.id)
expect(newChild.data.parent_id).toBe(newGroup.id)
// Child keeps its parent-relative position (no offset applied to children)
expect(newChild.position).toEqual({ x: 20, y: 30 })
// Group (the root) precedes its child in the array
expect(nodes.findIndex((n) => n.id === newGroup.id)).toBeLessThan(
nodes.findIndex((n) => n.id === newChild.id),
)
})
it('clipboard survives loadCanvas so nodes can be pasted into another design', () => {
useCanvasStore.setState({
nodes: [{ ...makeNode('a'), selected: true }],
edges: [],
})
useCanvasStore.getState().copySelectedNodes()
// Switch to another design: loadCanvas replaces nodes/edges.
useCanvasStore.getState().loadCanvas([makeNode('other')], [])
expect(useCanvasStore.getState().clipboard.nodes).toHaveLength(1)
useCanvasStore.getState().pasteNodes()
const ids = useCanvasStore.getState().nodes.map((n) => n.id)
expect(ids).toContain('other')
expect(ids).toHaveLength(2)
}) })
it('pasteNodes does nothing when clipboard is empty', () => { it('pasteNodes does nothing when clipboard is empty', () => {
useCanvasStore.setState({ nodes: [makeNode('n1')], edges: [], clipboard: [] }) useCanvasStore.setState({ nodes: [makeNode('n1')], edges: [], clipboard: { nodes: [], edges: [] } })
useCanvasStore.getState().pasteNodes() useCanvasStore.getState().pasteNodes()
expect(useCanvasStore.getState().nodes).toHaveLength(1) expect(useCanvasStore.getState().nodes).toHaveLength(1)
}) })
// --- Hide IP preference (persisted to localStorage) ---
it('toggleHideIp flips the flag and persists it', () => {
localStorage.removeItem('homelable.hideIp')
useCanvasStore.setState({ hideIp: false })
useCanvasStore.getState().toggleHideIp()
expect(useCanvasStore.getState().hideIp).toBe(true)
expect(localStorage.getItem('homelable.hideIp')).toBe('true')
useCanvasStore.getState().toggleHideIp()
expect(useCanvasStore.getState().hideIp).toBe(false)
expect(localStorage.getItem('homelable.hideIp')).toBe('false')
})
it('setHideIp sets the flag and persists it', () => {
localStorage.removeItem('homelable.hideIp')
useCanvasStore.getState().setHideIp(true)
expect(useCanvasStore.getState().hideIp).toBe(true)
expect(localStorage.getItem('homelable.hideIp')).toBe('true')
})
// --- Node resizing (width / height) --- // --- Node resizing (width / height) ---
it('addNode preserves explicit width and height', () => { it('addNode preserves explicit width and height', () => {
@@ -867,7 +1198,7 @@ describe('canvasStore — custom style apply', () => {
editingTextId: null, editingTextId: null,
past: [], past: [],
future: [], future: [],
clipboard: [], clipboard: { nodes: [], edges: [] },
}) })
}) })
@@ -949,4 +1280,29 @@ describe('canvasStore — custom style apply', () => {
expect(e.data?.custom_color).toBe('#aabbcc') expect(e.data?.custom_color).toBe('#aabbcc')
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
}) })
it('setNodeSize sets explicit width/height and marks unsaved', () => {
useCanvasStore.setState({ nodes: [makeNode('n1')], hasUnsavedChanges: false })
useCanvasStore.getState().setNodeSize('n1', { width: 220, height: 130 })
const n = useCanvasStore.getState().nodes.find((x) => x.id === 'n1')!
expect(n.width).toBe(220)
expect(n.height).toBe(130)
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
it('setNodeSize clamps below the minimum box', () => {
useCanvasStore.setState({ nodes: [makeNode('n1')] })
useCanvasStore.getState().setNodeSize('n1', { width: 10, height: 10 })
const n = useCanvasStore.getState().nodes.find((x) => x.id === 'n1')!
expect(n.width).toBe(140)
expect(n.height).toBe(50)
})
it('setNodeSize updates only the provided axis', () => {
useCanvasStore.setState({ nodes: [{ ...makeNode('n1'), width: 200, height: 100 }] })
useCanvasStore.getState().setNodeSize('n1', { width: 300 })
const n = useCanvasStore.getState().nodes.find((x) => x.id === 'n1')!
expect(n.width).toBe(300)
expect(n.height).toBe(100)
})
}) })
+275 -25
View File
@@ -9,12 +9,21 @@ import {
applyEdgeChanges, applyEdgeChanges,
addEdge, addEdge,
} from '@xyflow/react' } from '@xyflow/react'
import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef } from '@/types' import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, ServiceStatus } from '@/types'
import { generateUUID } from '@/utils/uuid' import { generateUUID } from '@/utils/uuid'
import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils' import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
import { applyOpacity } from '@/utils/colorUtils' import { applyOpacity } from '@/utils/colorUtils'
import { readHideIp, writeHideIp } from '@/utils/ipDisplay'
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] } type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
type Clipboard = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
/** Resolve a node's effective parent id from either the RF field or domain data. */
const parentIdOf = (n: Node<NodeData>): string | undefined => n.parentId ?? n.data.parent_id ?? undefined
/** Key for the live per-service status overlay. */
export const serviceStatusKey = (nodeId: string, port?: number, protocol?: string): string =>
`${nodeId}:${port ?? ''}/${protocol ?? ''}`
interface CanvasState { interface CanvasState {
nodes: Node<NodeData>[] nodes: Node<NodeData>[]
@@ -23,6 +32,8 @@ interface CanvasState {
selectedNodeId: string | null selectedNodeId: string | null
selectedNodeIds: string[] selectedNodeIds: string[]
scanEventTs: number scanEventTs: number
// Live per-service status overlay (not persisted), keyed via serviceStatusKey.
serviceStatuses: Record<string, ServiceStatus>
// History // History
past: HistoryEntry[] past: HistoryEntry[]
@@ -31,10 +42,12 @@ interface CanvasState {
undo: () => void undo: () => void
redo: () => void redo: () => void
// Clipboard // Clipboard — survives design switches so nodes can be pasted into another design
clipboard: Node<NodeData>[] clipboard: Clipboard
copySelectedNodes: () => void copySelectedNodes: () => void
pasteNodes: () => void /** Paste clipboard into the current canvas. `center` (flow coords) lands the
* pasted bounding-box center under the cursor / viewport center. */
pasteNodes: (center?: { x: number; y: number }) => void
onNodesChange: (changes: NodeChange<Node<NodeData>>[]) => void onNodesChange: (changes: NodeChange<Node<NodeData>>[]) => void
onEdgesChange: (changes: EdgeChange<Edge<EdgeData>>[]) => void onEdgesChange: (changes: EdgeChange<Edge<EdgeData>>[]) => void
@@ -48,6 +61,7 @@ interface CanvasState {
deleteEdge: (id: string) => void deleteEdge: (id: string) => void
setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void
setNodeZIndex: (id: string, zIndex: number) => void setNodeZIndex: (id: string, zIndex: number) => void
setNodeSize: (id: string, size: { width?: number; height?: number }) => void
editingGroupRectId: string | null editingGroupRectId: string | null
setEditingGroupRectId: (id: string | null) => void setEditingGroupRectId: (id: string | null) => void
editingTextId: string | null editingTextId: string | null
@@ -55,14 +69,19 @@ interface CanvasState {
toggleNodeCollapsed: (id: string) => void toggleNodeCollapsed: (id: string) => void
createGroup: (nodeIds: string[], name: string) => void createGroup: (nodeIds: string[], name: string) => void
ungroup: (groupId: string) => void ungroup: (groupId: string) => void
addToGroup: (groupId: string, childId: string) => void
addToContainer: (containerId: string, childId: string) => void
removeFromGroup: (groupId: string, childId: string) => void
markSaved: () => void markSaved: () => void
markUnsaved: () => void markUnsaved: () => void
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
fitViewPending: boolean fitViewPending: boolean
clearFitViewPending: () => void clearFitViewPending: () => void
notifyScanDeviceFound: () => void notifyScanDeviceFound: () => void
setServiceStatuses: (nodeId: string, statuses: { port?: number; protocol?: string; status: ServiceStatus }[]) => void
hideIp: boolean hideIp: boolean
toggleHideIp: () => void toggleHideIp: () => void
setHideIp: (value: boolean) => void
applyTypeNodeStyle: (nodeType: NodeType, style: NodeTypeStyle) => void applyTypeNodeStyle: (nodeType: NodeType, style: NodeTypeStyle) => void
applyTypeEdgeStyle: (edgeType: EdgeType, style: EdgeTypeStyle) => void applyTypeEdgeStyle: (edgeType: EdgeType, style: EdgeTypeStyle) => void
applyAllCustomStyles: (def: CustomStyleDef) => void applyAllCustomStyles: (def: CustomStyleDef) => void
@@ -76,13 +95,14 @@ export const useCanvasStore = create<CanvasState>((set) => ({
selectedNodeIds: [], selectedNodeIds: [],
editingGroupRectId: null, editingGroupRectId: null,
editingTextId: null, editingTextId: null,
hideIp: false, hideIp: readHideIp(),
scanEventTs: 0, scanEventTs: 0,
serviceStatuses: {},
fitViewPending: false, fitViewPending: false,
past: [], past: [],
future: [], future: [],
clipboard: [], clipboard: { nodes: [], edges: [] },
snapshotHistory: () => snapshotHistory: () =>
set((state) => ({ set((state) => ({
@@ -117,24 +137,100 @@ export const useCanvasStore = create<CanvasState>((set) => ({
}), }),
copySelectedNodes: () => copySelectedNodes: () =>
set((state) => ({
clipboard: state.nodes.filter((n) => n.selected),
})),
pasteNodes: () =>
set((state) => { set((state) => {
if (state.clipboard.length === 0) return state // Start from explicitly selected nodes, then pull in all descendants so a
const newNodes = state.clipboard.map((n) => ({ // copied group / container brings its children along.
...n, const ids = new Set(state.nodes.filter((n) => n.selected).map((n) => n.id))
id: generateUUID(), if (ids.size === 0) return { clipboard: { nodes: [], edges: [] } }
position: { x: n.position.x + 50, y: n.position.y + 50 }, let grew = true
selected: false, while (grew) {
parentId: undefined, grew = false
extent: undefined, for (const n of state.nodes) {
data: { ...n.data, parent_id: undefined }, const pid = parentIdOf(n)
})) if (pid && ids.has(pid) && !ids.has(n.id)) {
ids.add(n.id)
grew = true
}
}
}
const nodes = state.nodes.filter((n) => ids.has(n.id))
// Keep only edges whose both endpoints are inside the copied set.
const edges = state.edges.filter((e) => ids.has(e.source) && ids.has(e.target))
return { clipboard: { nodes, edges } }
}),
pasteNodes: (center) =>
set((state) => {
const clip = state.clipboard
if (clip.nodes.length === 0) return state
// Fresh ids for every copied node; edges/parent links are remapped through it.
const idMap = new Map<string, string>()
clip.nodes.forEach((n) => idMap.set(n.id, generateUUID()))
// A "root" is a copied node whose parent was not also copied — these carry
// absolute positions and receive the paste offset; children move with them.
const isRoot = (n: Node<NodeData>) => {
const pid = parentIdOf(n)
return !pid || !idMap.has(pid)
}
const roots = clip.nodes.filter(isRoot)
// Default cascade offset; when a target center is given, shift the root
// bounding-box center onto it instead.
let offsetX = 50
let offsetY = 50
if (center && roots.length > 0) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
for (const n of roots) {
const w = n.width ?? n.measured?.width ?? 200
const h = n.height ?? n.measured?.height ?? 80
minX = Math.min(minX, n.position.x)
minY = Math.min(minY, n.position.y)
maxX = Math.max(maxX, n.position.x + w)
maxY = Math.max(maxY, n.position.y + h)
}
offsetX = center.x - (minX + maxX) / 2
offsetY = center.y - (minY + maxY) / 2
}
const pasted = clip.nodes.map((n) => {
const root = isRoot(n)
const newParentId = root ? undefined : idMap.get(parentIdOf(n)!)
return { return {
nodes: [...state.nodes, ...newNodes], ...n,
id: idMap.get(n.id)!,
position: root
? { x: n.position.x + offsetX, y: n.position.y + offsetY }
: { ...n.position },
selected: true,
parentId: newParentId,
extent: newParentId ? ('parent' as const) : undefined,
data: { ...n.data, parent_id: newParentId },
}
})
const pastedEdges = clip.edges.map((e) => ({
...e,
id: generateUUID(),
source: idMap.get(e.source)!,
target: idMap.get(e.target)!,
selected: false,
}))
// React Flow requires parents before children within the appended block.
const parents = pasted.filter((n) => !n.parentId)
const children = pasted.filter((n) => !!n.parentId)
const pastedNodes = [...parents, ...children]
// Deselect everything already on the canvas so only the paste is selected.
const existing = state.nodes.map((n) => (n.selected ? { ...n, selected: false } : n))
return {
nodes: [...existing, ...pastedNodes],
edges: [...state.edges, ...pastedEdges],
selectedNodeId: null,
selectedNodeIds: pastedNodes.map((n) => n.id),
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }], past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
future: [], future: [],
hasUnsavedChanges: true, hasUnsavedChanges: true,
@@ -193,7 +289,9 @@ export const useCanvasStore = create<CanvasState>((set) => ({
y: Math.max(10, node.position.y - parent.position.y), y: Math.max(10, node.position.y - parent.position.y),
}, },
} }
: node // Not nesting: strip any parentId/extent a caller may have set so a
// non-container parent can't trap the node in its bounding box.
: { ...node, parentId: undefined, extent: undefined }
// Parents must come before children in the array (React Flow requirement) // Parents must come before children in the array (React Flow requirement)
const withoutNew = state.nodes.filter((n) => n.id !== node.id) const withoutNew = state.nodes.filter((n) => n.id !== node.id)
if (enriched.parentId) { if (enriched.parentId) {
@@ -371,6 +469,22 @@ export const useCanvasStore = create<CanvasState>((set) => ({
hasUnsavedChanges: true, hasUnsavedChanges: true,
})), })),
// Manual width/height entry. Lets the user type an exact size instead of
// dragging the resize handle (which lands on fractional content-fit pixels).
// A clamp matches the NodeResizer minimums so the box can't collapse.
setNodeSize: (id, size) =>
set((state) => ({
nodes: state.nodes.map((n) => {
if (n.id !== id) return n
return {
...n,
...(size.width != null ? { width: Math.max(140, size.width) } : {}),
...(size.height != null ? { height: Math.max(50, size.height) } : {}),
}
}),
hasUnsavedChanges: true,
})),
setEditingGroupRectId: (id) => set({ editingGroupRectId: id }), setEditingGroupRectId: (id) => set({ editingGroupRectId: id }),
setEditingTextId: (id) => set({ editingTextId: id }), setEditingTextId: (id) => set({ editingTextId: id }),
@@ -491,19 +605,155 @@ export const useCanvasStore = create<CanvasState>((set) => ({
} }
}), }),
// Nest an existing top-level node inside a group. Inverse of removeFromGroup.
addToGroup: (groupId, childId) =>
set((state) => {
const group = state.nodes.find((n) => n.id === groupId)
const child = state.nodes.find((n) => n.id === childId)
if (!group || !child || group.data.type !== 'group') return state
if (child.id === groupId || child.parentId === groupId) return state
const updatedNodes = state.nodes.map((n) => {
if (n.id !== childId) return n
return {
...n,
parentId: groupId,
extent: 'parent' as const,
// Absolute → group-relative. Clamp so the node stays inside the box.
position: {
x: Math.max(8, n.position.x - group.position.x),
y: Math.max(8, n.position.y - group.position.y),
},
selected: false,
data: { ...n.data, parent_id: groupId },
}
})
// React Flow requires the parent to precede its children in the array.
const others = updatedNodes.filter((n) => n.id !== childId)
const movedChild = updatedNodes.find((n) => n.id === childId)!
const groupIdx = others.findIndex((n) => n.id === groupId)
const nodes = [
...others.slice(0, groupIdx + 1),
movedChild,
...others.slice(groupIdx + 1),
]
return {
nodes,
hasUnsavedChanges: true,
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
future: [],
}
}),
// Nest an existing top-level node inside a container node (proxmox /
// docker_host / … in container_mode). Mirrors addToGroup but the target is
// any node with data.container_mode === true rather than a group.
addToContainer: (containerId, childId) =>
set((state) => {
const container = state.nodes.find((n) => n.id === containerId)
const child = state.nodes.find((n) => n.id === childId)
if (!container || !child || container.data.container_mode !== true) return state
if (child.id === containerId || child.parentId === containerId) return state
const updatedNodes = state.nodes.map((n) => {
if (n.id !== childId) return n
return {
...n,
parentId: containerId,
extent: 'parent' as const,
// Absolute → container-relative. Clamp so the node stays inside.
position: {
x: Math.max(8, n.position.x - container.position.x),
y: Math.max(8, n.position.y - container.position.y),
},
selected: false,
data: { ...n.data, parent_id: containerId },
}
})
// React Flow requires the parent to precede its children in the array.
const others = updatedNodes.filter((n) => n.id !== childId)
const movedChild = updatedNodes.find((n) => n.id === childId)!
const containerIdx = others.findIndex((n) => n.id === containerId)
const nodes = [
...others.slice(0, containerIdx + 1),
movedChild,
...others.slice(containerIdx + 1),
]
return {
nodes,
hasUnsavedChanges: true,
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
future: [],
}
}),
// Release a single child from a group back to the canvas. Group stays.
removeFromGroup: (groupId, childId) =>
set((state) => {
const group = state.nodes.find((n) => n.id === groupId)
const child = state.nodes.find((n) => n.id === childId)
if (!group || !child || child.parentId !== groupId) return state
const nodes = state.nodes.map((n) => {
if (n.id !== childId) return n
return {
...n,
parentId: undefined,
extent: undefined,
position: {
x: n.position.x + group.position.x,
y: n.position.y + group.position.y,
},
data: { ...n.data, parent_id: undefined },
}
})
return {
nodes,
hasUnsavedChanges: true,
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
future: [],
}
}),
markSaved: () => set({ hasUnsavedChanges: false }), markSaved: () => set({ hasUnsavedChanges: false }),
markUnsaved: () => set({ hasUnsavedChanges: true }), markUnsaved: () => set({ hasUnsavedChanges: true }),
notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }), notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }),
toggleHideIp: () => set((s) => ({ hideIp: !s.hideIp })), setServiceStatuses: (nodeId, statuses) =>
set((state) => {
// Live overlay only — never touches node data, so it stays out of saves.
const next = { ...state.serviceStatuses }
for (const s of statuses) {
next[serviceStatusKey(nodeId, s.port, s.protocol)] = s.status
}
return { serviceStatuses: next }
}),
toggleHideIp: () => set((s) => {
const hideIp = !s.hideIp
writeHideIp(hideIp)
return { hideIp }
}),
setHideIp: (value) => {
writeHideIp(value)
set({ hideIp: value })
},
loadCanvas: (nodes, edges) => { loadCanvas: (nodes, edges) => {
// React Flow requires parents before children in the array // React Flow requires parents before children in the array
const parents = nodes.filter((n) => !n.parentId) const parents = nodes.filter((n) => !n.parentId)
const children = nodes.filter((n) => !!n.parentId) const children = nodes.filter((n) => !!n.parentId)
set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], clipboard: [], fitViewPending: true }) // NOTE: clipboard is intentionally preserved here so nodes copied in one
// design can be pasted after switching to another design.
set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], fitViewPending: true })
}, },
clearFitViewPending: () => set({ fitViewPending: false }), clearFitViewPending: () => set({ fitViewPending: false }),
+2
View File
@@ -78,6 +78,8 @@ export interface ServiceInfo {
category?: string category?: string
} }
export type ServiceStatus = 'online' | 'offline' | 'unknown'
export interface NodeProperty { export interface NodeProperty {
key: string key: string
value: string value: string
@@ -102,6 +102,20 @@ describe('serializeNode — regular node', () => {
expect(result.height).toBeNull() expect(result.height).toBeNull()
}) })
it('prefers explicit width/height over the measured (content-fit) value', () => {
const node: Node<NodeData> = { ...makeRfNode({ width: 200, height: 100 }), measured: { width: 187, height: 73 } }
const result = serializeNode(node)
expect(result.width).toBe(200)
expect(result.height).toBe(100)
})
it('falls back to measured when no explicit dimension is set', () => {
const node: Node<NodeData> = { ...makeRfNode(), measured: { width: 187, height: 73 } }
const result = serializeNode(node)
expect(result.width).toBe(187)
expect(result.height).toBe(73)
})
it('serializes hardware fields', () => { it('serializes hardware fields', () => {
const node = makeRfNode({ const node = makeRfNode({
data: { data: {
@@ -162,13 +176,14 @@ describe('serializeNode — groupRect', () => {
expect((result.custom_colors as Record<string, unknown>).height).toBe(250) expect((result.custom_colors as Record<string, unknown>).height).toBe(250)
}) })
it('falls back to measured dimensions over explicit width/height', () => { it('prefers explicit width/height over measured, falling back to measured per-axis', () => {
const node: Node<NodeData> = { const node: Node<NodeData> = {
...makeRfNode({ type: 'groupRect', data: { label: 'Z', type: 'groupRect', status: 'unknown', services: [] }, width: 400 }), ...makeRfNode({ type: 'groupRect', data: { label: 'Z', type: 'groupRect', status: 'unknown', services: [] }, width: 400 }),
measured: { width: 420, height: 260 }, measured: { width: 420, height: 260 },
} }
const result = serializeNode(node) const result = serializeNode(node)
expect((result.custom_colors as Record<string, unknown>).width).toBe(420) // Explicit width wins; height has no explicit value so it uses measured.
expect((result.custom_colors as Record<string, unknown>).width).toBe(400)
expect((result.custom_colors as Record<string, unknown>).height).toBe(260) expect((result.custom_colors as Record<string, unknown>).height).toBe(260)
}) })
@@ -323,6 +338,21 @@ describe('deserializeApiNode — regular node', () => {
expect(result.width).toBeUndefined() expect(result.width).toBeUndefined()
expect(result.height).toBeUndefined() expect(result.height).toBeUndefined()
}) })
// Regression: issue #205 — a leaf vm/lxc nested in a container would lose its
// saved size on reload because the restore branch excluded those types.
it.each(['vm', 'lxc', 'docker_host'] as const)(
'restores saved width/height for a leaf %s node (container_mode false)',
(type) => {
const map = new Map([['px1', true]])
const result = deserializeApiNode(
makeApiNode({ type, container_mode: false, parent_id: 'px1', width: 240, height: 90 }),
map,
)
expect(result.width).toBe(240)
expect(result.height).toBe(90)
},
)
}) })
// ── deserializeApiNode — groupRect ──────────────────────────────────────────── // ── deserializeApiNode — groupRect ────────────────────────────────────────────
@@ -53,6 +53,33 @@ describe('generateMarkdownTable', () => {
expect(md).toContain('A\\|B') expect(md).toContain('A\\|B')
}) })
it('escapes backslashes before pipes so the escape char is not ambiguous', () => {
const nodes = [makeNode({ label: 'A\\|B' })]
const md = generateMarkdownTable(nodes)
// backslash doubled, then the literal pipe escaped
expect(md).toContain('A\\\\\\|B')
})
it('collapses newlines in cell values so they do not break the table', () => {
const nodes = [makeNode({ label: 'line1\nline2', hostname: 'a\r\nb' })]
const lines = generateMarkdownTable(nodes).split('\n')
// header + separator + exactly one data row (no extra line from the value)
expect(lines).toHaveLength(3)
expect(lines[2]).toContain('line1 line2')
expect(lines[2]).toContain('a b')
})
it('escapes pipe characters inside service names', () => {
const nodes = [makeNode({
label: 'Server',
services: [{ port: 80, protocol: 'tcp', service_name: 'web|proxy' }],
})]
const lines = generateMarkdownTable(nodes).split('\n')
// header + separator + exactly one data row — the pipe must not add a column
expect(lines).toHaveLength(3)
expect(lines[2]).toContain('web\\|proxy')
})
it('generates one row per non-groupRect node', () => { it('generates one row per non-groupRect node', () => {
const nodes = [ const nodes = [
makeNode({ type: 'server', label: 'A' }, '1'), makeNode({ type: 'server', label: 'A' }, '1'),
@@ -0,0 +1,22 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { readHideIp, writeHideIp } from '@/utils/ipDisplay'
describe('ipDisplay persistence', () => {
beforeEach(() => localStorage.clear())
it('defaults to false when nothing is stored', () => {
expect(readHideIp()).toBe(false)
})
it('round-trips true', () => {
writeHideIp(true)
expect(localStorage.getItem('homelable.hideIp')).toBe('true')
expect(readHideIp()).toBe(true)
})
it('round-trips false', () => {
writeHideIp(true)
writeHideIp(false)
expect(readHideIp()).toBe(false)
})
})
@@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest'
import { buildMacProperty } from '../macProperty'
describe('buildMacProperty', () => {
it('returns a hidden MAC property row for a MAC', () => {
expect(buildMacProperty('aa:bb:cc:dd:ee:ff')).toEqual([
{ key: 'MAC', value: 'aa:bb:cc:dd:ee:ff', icon: null, visible: false },
])
})
it('returns an empty array when MAC is null/undefined/empty', () => {
expect(buildMacProperty(null)).toEqual([])
expect(buildMacProperty(undefined)).toEqual([])
expect(buildMacProperty('')).toEqual([])
})
})
+16 -7
View File
@@ -75,8 +75,8 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
pos_y: n.position.y, pos_y: n.position.y,
custom_colors: { custom_colors: {
...n.data.custom_colors, ...n.data.custom_colors,
width: n.measured?.width ?? n.width ?? 360, width: n.width ?? n.measured?.width ?? 360,
height: n.measured?.height ?? n.height ?? 240, height: n.height ?? n.measured?.height ?? 240,
// Stash collapse state inside custom_colors so the API/YAML blob does // Stash collapse state inside custom_colors so the API/YAML blob does
// not need a new column. Hoisted back to `data.collapsed` on load. // not need a new column. Hoisted back to `data.collapsed` on load.
collapsed: n.data.collapsed ?? false, collapsed: n.data.collapsed ?? false,
@@ -112,8 +112,11 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
disk_gb: n.data.disk_gb ?? null, disk_gb: n.data.disk_gb ?? null,
show_hardware: n.data.show_hardware ?? false, show_hardware: n.data.show_hardware ?? false,
properties: n.data.properties ?? [], properties: n.data.properties ?? [],
width: n.measured?.width ?? n.width ?? null, // Prefer the explicit (resized) dimension over the DOM-measured one so a
height: n.measured?.height ?? n.height ?? null, // manual resize persists its exact target instead of drifting to the
// fractional content-fit value.
width: n.width ?? n.measured?.width ?? null,
height: n.height ?? n.measured?.height ?? null,
bottom_handles: clampBottomHandles(n.data.bottom_handles ?? 1), bottom_handles: clampBottomHandles(n.data.bottom_handles ?? 1),
show_port_numbers: n.data.show_port_numbers ?? false, show_port_numbers: n.data.show_port_numbers ?? false,
pos_x: n.position.x, pos_x: n.position.x,
@@ -179,11 +182,17 @@ export function deserializeApiNode(
collapsed: Boolean(n.custom_colors?.collapsed), collapsed: Boolean(n.custom_colors?.collapsed),
} as unknown as NodeData, } as unknown as NodeData,
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}), ...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
// Container hosts (Proxmox/VM/LXC/docker in container_mode) get a default
// box if none was saved. Every other node — including LEAF vm/lxc/docker
// nodes nested inside a container — restores its own saved width/height.
// Gating on container_mode (not type) is what keeps a resized nested node
// from snapping back to content-fit on reload.
...(['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) && n.container_mode !== false ...(['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) && n.container_mode !== false
? { width: n.width ?? 300, height: n.height ?? 200 } ? { width: n.width ?? 300, height: n.height ?? 200 }
: {}), : {
...(n.width && !['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) ? { width: n.width } : {}), ...(n.width ? { width: n.width } : {}),
...(n.height && !['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) ? { height: n.height } : {}), ...(n.height ? { height: n.height } : {}),
}),
} }
} }
+6 -3
View File
@@ -5,8 +5,11 @@ const EMPTY = '—'
function cell(v: string | null | undefined): string { function cell(v: string | null | undefined): string {
if (!v) return EMPTY if (!v) return EMPTY
// Escape pipe chars so they don't break the table // Escape backslashes first, then pipes, and collapse newlines so they don't break the table
return v.replace(/\|/g, '\\|') return v
.replace(/\\/g, '\\\\')
.replace(/\|/g, '\\|')
.replace(/\r?\n/g, ' ')
} }
export function generateMarkdownTable(nodes: Node<NodeData>[]): string { export function generateMarkdownTable(nodes: Node<NodeData>[]): string {
@@ -27,7 +30,7 @@ export function generateMarkdownTable(nodes: Node<NodeData>[]): string {
cell(d.ip), cell(d.ip),
cell(d.hostname), cell(d.hostname),
cell(d.status), cell(d.status),
services, cell(services),
] ]
}) })
+21
View File
@@ -0,0 +1,21 @@
// Persisted client-side preference for masking IP addresses on the canvas.
// Kept in localStorage (per-user UI preference, not canvas data) so it
// survives a page reload.
const KEY = 'homelable.hideIp'
export function readHideIp(): boolean {
try {
return localStorage.getItem(KEY) === 'true'
} catch {
return false
}
}
export function writeHideIp(value: boolean): void {
try {
localStorage.setItem(KEY, String(value))
} catch {
/* quota / SSR */
}
}
+9
View File
@@ -0,0 +1,9 @@
import type { NodeProperty } from '@/types'
/** Build the MAC address property row shown in the right panel.
* Hidden by default the user opts in to showing it on the canvas card.
* Matches backend `build_mac_property`. Returns an empty array when no MAC. */
export function buildMacProperty(mac?: string | null): NodeProperty[] {
if (!mac) return []
return [{ key: 'MAC', value: mac, icon: null, visible: false }]
}
+71 -21
View File
@@ -4,33 +4,69 @@ from mcp.types import Tool, TextContent
from .backend_client import backend from .backend_client import backend
def register_tools(server: Server): NODE_TYPES = ["isp", "router", "switch", "server", "proxmox", "vm", "lxc", "nas", "iot", "ap", "generic"]
# Shared field schemas mirroring backend NodeBase / NodeUpdate (backend/app/schemas/nodes.py).
# create_node and update_node both expose these so the MCP is symmetric with what the
# backend already validates and stores. _dispatch forwards args verbatim, so any field
# advertised here is accepted by the backend.
_NODE_FIELDS = {
"label": {"type": "string"},
"ip": {"type": "string"},
"hostname": {"type": "string"},
"mac": {"type": "string", "description": "MAC address."},
"os": {"type": "string", "description": "Operating system / distribution."},
"status": {"type": "string", "enum": ["online", "offline", "unknown", "pending"]},
"check_method": {"type": "string", "description": "Status check method (ping, http, https, ssh, prometheus, tcp)."},
"check_target": {"type": "string", "description": "Target host/URL used by the status check."},
"services": {"type": "array", "items": {"type": "object"}, "description": "Running services detected or documented on the node."},
"notes": {"type": "string", "description": "Free-text notes / documentation for the node."},
"parent_id": {"type": "string", "description": "ID of the parent node (e.g. Proxmox host for a VM/LXC). Pass null to detach."},
"container_mode": {"type": "boolean", "description": "Render this node as a container/group that can hold children."},
"custom_icon": {"type": "string", "description": "Override icon name for the node."},
"cpu_count": {"type": "integer", "description": "Number of CPU cores/threads."},
"cpu_model": {"type": "string", "description": "CPU model name."},
"ram_gb": {"type": "number", "description": "RAM in gigabytes."},
"disk_gb": {"type": "number", "description": "Disk capacity in gigabytes."},
"show_hardware": {"type": "boolean", "description": "Display hardware specs on the node card."},
"properties": {
"type": "array",
"description": "Arbitrary key/value metadata shown on the node.",
"items": {
"type": "object",
"required": ["name", "value"],
"properties": {
"name": {"type": "string"},
"value": {"type": "string"},
},
},
},
}
def _build_tools() -> list[Tool]:
create_node_props = {
"type": {"type": "string", "enum": NODE_TYPES},
**_NODE_FIELDS,
}
create_node_props["status"] = {**_NODE_FIELDS["status"], "default": "unknown"}
update_node_props = {
"id": {"type": "string"},
"type": {"type": "string", "enum": NODE_TYPES},
**_NODE_FIELDS,
}
@server.list_tools()
async def list_tools():
return [ return [
Tool(name="create_node", description="Add a new node to the homelab canvas", inputSchema={ Tool(name="create_node", description="Add a new node to the homelab canvas", inputSchema={
"type": "object", "type": "object",
"required": ["type", "label"], "required": ["type", "label"],
"properties": { "properties": create_node_props,
"type": {"type": "string", "enum": ["isp","router","switch","server","proxmox","vm","lxc","nas","iot","ap","generic"]},
"label": {"type": "string"},
"ip": {"type": "string"},
"hostname": {"type": "string"},
"status": {"type": "string", "enum": ["online","offline","unknown","pending"], "default": "unknown"},
},
}), }),
Tool(name="update_node", description="Update an existing node", inputSchema={ Tool(name="update_node", description="Update an existing node", inputSchema={
"type": "object", "type": "object",
"required": ["id"], "required": ["id"],
"properties": { "properties": update_node_props,
"id": {"type": "string"},
"label": {"type": "string"},
"ip": {"type": "string"},
"hostname": {"type": "string"},
"status": {"type": "string"},
"parent_id": {"type": "string", "description": "ID of the parent node (e.g. Proxmox host for a VM/LXC). Pass null to detach."},
},
}), }),
Tool(name="delete_node", description="Delete a node from the canvas", inputSchema={ Tool(name="delete_node", description="Delete a node from the canvas", inputSchema={
"type": "object", "type": "object",
@@ -43,7 +79,7 @@ def register_tools(server: Server):
"properties": { "properties": {
"source": {"type": "string"}, "source": {"type": "string"},
"target": {"type": "string"}, "target": {"type": "string"},
"type": {"type": "string", "enum": ["ethernet","wifi","iot","vlan","virtual"], "default": "ethernet"}, "type": {"type": "string", "enum": ["ethernet", "wifi", "iot", "vlan", "virtual"], "default": "ethernet"},
"label": {"type": "string"}, "label": {"type": "string"},
}, },
}), }),
@@ -63,7 +99,7 @@ def register_tools(server: Server):
"required": ["id"], "required": ["id"],
"properties": { "properties": {
"id": {"type": "string"}, "id": {"type": "string"},
"type": {"type": "string", "enum": ["isp","router","switch","server","proxmox","vm","lxc","nas","iot","ap","generic"], "default": "generic"}, "type": {"type": "string", "enum": NODE_TYPES, "default": "generic"},
"label": {"type": "string"}, "label": {"type": "string"},
}, },
}), }),
@@ -86,6 +122,16 @@ def register_tools(server: Server):
}), }),
] ]
TOOLS = _build_tools()
def register_tools(server: Server):
@server.list_tools()
async def list_tools():
return TOOLS
@server.call_tool() @server.call_tool()
async def call_tool(name: str, arguments: dict): async def call_tool(name: str, arguments: dict):
result = await _dispatch(name, arguments) result = await _dispatch(name, arguments)
@@ -94,7 +140,11 @@ def register_tools(server: Server):
def _slim_canvas(raw: dict) -> dict: def _slim_canvas(raw: dict) -> dict:
"""Strip React Flow layout/style fields — keep only semantic data for AI use.""" """Strip React Flow layout/style fields — keep only semantic data for AI use."""
NODE_KEEP = {"id", "type", "label", "ip", "hostname", "status", "services", "description", "parentId"} NODE_KEEP = {
"id", "type", "label", "ip", "hostname", "mac", "os", "status", "services",
"notes", "description", "properties", "cpu_count", "cpu_model", "ram_gb",
"disk_gb", "parentId",
}
EDGE_KEEP = {"id", "source", "target", "type", "label"} EDGE_KEEP = {"id", "source", "target", "type", "label"}
def slim_node(n: dict) -> dict: def slim_node(n: dict) -> dict:
+83 -1
View File
@@ -1,6 +1,6 @@
import pytest import pytest
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from app.tools import _dispatch from app.tools import TOOLS, _dispatch
@pytest.fixture @pytest.fixture
@@ -32,6 +32,39 @@ async def test_update_node_parent_id(mock_backend):
mock_backend.patch.assert_called_once_with("/api/v1/nodes/42", {"parent_id": "proxmox-1"}) mock_backend.patch.assert_called_once_with("/api/v1/nodes/42", {"parent_id": "proxmox-1"})
@pytest.mark.anyio
async def test_create_node_full_properties(mock_backend):
args = {
"type": "proxmox",
"label": "pve1",
"os": "Proxmox VE 8",
"notes": "Main hypervisor",
"services": [{"name": "ssh", "port": 22}],
"cpu_count": 16,
"cpu_model": "Ryzen 9 5950X",
"ram_gb": 64,
"disk_gb": 2000,
"show_hardware": True,
"properties": [{"name": "rack", "value": "A1"}],
}
await _dispatch("create_node", dict(args))
# All extra fields forwarded to the backend unchanged.
mock_backend.post.assert_called_once_with("/api/v1/nodes", args)
@pytest.mark.anyio
async def test_update_node_properties(mock_backend):
await _dispatch("update_node", {
"id": "42",
"os": "Debian 12",
"properties": [{"name": "role", "value": "db"}],
})
mock_backend.patch.assert_called_once_with("/api/v1/nodes/42", {
"os": "Debian 12",
"properties": [{"name": "role", "value": "db"}],
})
@pytest.mark.anyio @pytest.mark.anyio
async def test_delete_node(mock_backend): async def test_delete_node(mock_backend):
await _dispatch("delete_node", {"id": "42"}) await _dispatch("delete_node", {"id": "42"})
@@ -100,6 +133,55 @@ async def test_get_canvas(mock_backend):
assert "viewport" not in result assert "viewport" not in result
@pytest.mark.anyio
async def test_get_canvas_keeps_documentation_fields(mock_backend):
mock_backend.get = AsyncMock(return_value={
"nodes": [
{
"id": "n1",
"type": "proxmox",
"position": {"x": 0, "y": 0},
"data": {
"label": "pve1",
"os": "Proxmox VE 8",
"notes": "Main hypervisor",
"cpu_count": 16,
"ram_gb": 64,
"properties": [{"name": "rack", "value": "A1"}],
},
}
],
"edges": [],
})
result = await _dispatch("get_canvas", {})
node = result["nodes"][0]
assert node["os"] == "Proxmox VE 8"
assert node["notes"] == "Main hypervisor"
assert node["cpu_count"] == 16
assert node["ram_gb"] == 64
assert node["properties"] == [{"name": "rack", "value": "A1"}]
def _tool_schema(name: str) -> dict:
tool = next(t for t in TOOLS if t.name == name)
return tool.inputSchema["properties"]
def test_create_node_schema_exposes_full_node_fields():
props = _tool_schema("create_node")
for field in ("os", "notes", "services", "cpu_count", "ram_gb", "disk_gb", "properties", "mac"):
assert field in props, f"create_node schema missing {field}"
# type stays an enum of the canonical node types
assert "enum" in props["type"]
def test_update_node_schema_exposes_full_node_fields():
props = _tool_schema("update_node")
for field in ("os", "notes", "services", "cpu_count", "ram_gb", "disk_gb", "properties", "mac"):
assert field in props, f"update_node schema missing {field}"
assert "id" in props
@pytest.mark.anyio @pytest.mark.anyio
async def test_list_nodes(mock_backend): async def test_list_nodes(mock_backend):
mock_backend.get = AsyncMock(return_value=[{"id": "1", "label": "Freebox"}]) mock_backend.get = AsyncMock(return_value=[{"id": "1", "label": "Freebox"}])