Compare commits

...

49 Commits

Author SHA1 Message Date
Pouzor - Rémy Jardient d9f3b4c18a Merge pull request #303 from Pouzor/release/3.1.1
chore: bump version to 3.1.1
2026-07-19 22:23:57 +02:00
Pouzor 80ebbca804 chore: bump version to 3.1.1 2026-07-19 22:17:59 +02:00
Pouzor - Rémy Jardient 959cd4a3ae Merge pull request #302 from Isilla/feat/services-drag-reorder
feat(detail-panel): make services list draggable to reorder
2026-07-19 22:07:20 +02:00
Marianne JOSEPH 084a8e23e7 feat(detail-panel): make services list draggable to reorder
Mirrors the existing properties drag-and-drop (native HTML5 drag)
so services can be reordered the same way.
2026-07-19 21:59:16 +02:00
Pouzor - Rémy Jardient f5dd46a729 Merge pull request #301 from Pouzor/fix/yaml-export-port-numbers
fix: round-trip Show Port Numbers on YAML export/import
2026-07-19 21:12:18 +02:00
Pouzor 121873f41c test(settings): wait for async config before asserting Save payload
The Proxmox/Zigbee auto-sync Save tests clicked Save after only
waiting for an unrelated field, so under CI load getConfig could
resolve after the click and Save fired with the default
sync_enabled=false. Wait for the toggle to reflect the loaded
state first.
2026-07-19 15:34:19 +02:00
Pouzor 16d6348cec fix(yaml): round-trip show_port_numbers on export/import
YAML export wrote per-side handle counts but never the
show_port_numbers toggle, so the option was lost on re-import
while connection points survived.

ha-relevant: maybe

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

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

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

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

ha-relevant: yes
2026-07-18 23:02:44 +02:00
Pouzor 3f030959be docs(readme): rework header with logo, badges, nav links 2026-07-18 20:06:22 +02:00
Pouzor - Rémy Jardient 0b8b9760bc Merge pull request #297 from Pouzor/release/3.1.0
chore: bump version to 3.1.0
2026-07-18 02:27:33 +02:00
Pouzor f1fe82646d chore: bump version to 3.1.0 2026-07-18 02:22:29 +02:00
Pouzor - Rémy Jardient b7e03142e9 Merge pull request #296 from Pouzor/feat/reorder-node-properties
feat: drag to reorder node properties
2026-07-18 02:04:08 +02:00
Pouzor af3dc018c7 feat(properties): drag to reorder node properties
Add native HTML5 drag-and-drop reorder to the property list in the
detail panel. A grip handle appears when more than one property exists;
dragging updates property order (persisted with canvas save) and is
undoable via history snapshot.

Closes #292

ha-relevant: yes
2026-07-18 01:51:57 +02:00
Pouzor - Rémy Jardient 79bd0d4efb Merge pull request #294 from Floyddotnet/main
feat: search for nodes and properties
2026-07-18 01:26:17 +02:00
Pouzor a364a95cb5 fix(search): clean up node/property search matches
- add tests for notes, property key/value, hidden-property exclusion, matched-value display
- fix flatMap indentation and stray semicolon
- use stable match key instead of display_value (avoids dup-key collisions)
- keep label searchable but not repeated in matched-values column
2026-07-18 01:17:36 +02:00
Pouzor - Rémy Jardient 5b0c064e68 Merge pull request #295 from Pouzor/fix/scanner-two-stage-version-scan
fix(scanner): decouple port discovery from version detection (#277)
2026-07-18 00:50:46 +02:00
Pouzor 6f01d5550d fix(scanner): decouple port discovery from version detection
nmap -sV sends plaintext probes to TLS ports (e.g. Proxmox 8006). They
stall until the host crosses --host-timeout, at which point nmap skips
the whole host and discards ports it already found — a reachable host is
returned with 0 open ports (issue #277).

Split Phase 2 into two passes: Pass A (-sS/-sT --open) discovers ports
fast with no version detection; its ports are authoritative. Pass B
(-sV --host-timeout, scoped to the found ports) enriches banners
best-effort — on timeout/failure the discovered ports are kept with
empty banners instead of losing the host.

Healthy hosts still get full-intensity -sV banners (no quality drop).
Version host-timeout is now configurable via SCANNER_VERSION_HOST_TIMEOUT
(default 60s).

ha-relevant: yes
2026-07-18 00:35:27 +02:00
Peter Becker 9000f82213 add search for nodes and properties 2026-07-17 23:00:20 +02:00
Pouzor - Rémy Jardient 587f7f3e61 Merge pull request #293 from Pouzor/feat/zone-modal-two-column
feat: widen zone modal into two columns
2026-07-17 22:05:45 +02:00
Pouzor 2f91ca3dfd feat(canvas): widen zone modal into two columns
The add/edit zone modal grew tall enough to overflow the viewport.
Apply the same layout as NodeModal: widen to sm:max-w-3xl and split
fields into a Content column (label, font, text/label position, text
size) and a Style column (colors, border style/width, z-order).

ha-relevant: yes
2026-07-17 21:57:07 +02:00
Pouzor - Rémy Jardient 93e9b68821 Merge pull request #289 from nicolabottini/feat/autosave-canvas
feat: autosave canvas after configurable inactivity delay
2026-07-17 20:41:12 +02:00
Pouzor d63c9e6451 fix(canvas): drive autosave debounce off an edit counter
useAutosave reset its debounce whenever the nodes/edges array reference
changed. Live status polling (setNodeStatus) returns a fresh nodes array
on every message without dirtying the canvas, so during active
monitoring the status churn kept re-arming the timer faster than the
delay and the silent save could be starved indefinitely — the user
thinks autosave ran, but it never fired. Select-only changes reset it
the same way.

Add a monotonic editSeq counter that bumps only on real user edits. The
store's set() is wrapped to increment editSeq whenever an update flips
hasUnsavedChanges to true, so it stays centralised instead of touching
every mutating action. onNodesChange/onEdgesChange now set the dirty
flag only when a genuine edit occurred (not on select/measure), so those
no longer bump the counter. App keys the autosave changeSignals on
[editSeq] instead of [nodes, edges].

Add a regression test asserting editSeq bumps on add/update but not on
setNodeStatus, select, or initial dimensions measure.

ha-relevant: maybe
2026-07-17 20:29:48 +02:00
Pouzor 71ab3bf391 fix(canvas): stop non-edits from marking the canvas unsaved
Autosave saves whenever hasUnsavedChanges is set, so any source that
dirties the canvas without a user edit turns into a spurious silent
save. Two such sources:

1. Live status updates. useStatusPolling routed backend status pushes
   through updateNode, which always sets hasUnsavedChanges. With the
   60s status cycle, an idle opted-in tab saved itself every cycle and
   could clobber edits made elsewhere with its stale in-memory canvas.
   Add setNodeStatus, a live-overlay action that updates a node's
   status/response_time/last_seen without dirtying (mirrors
   setServiceStatuses), and route polling through it.

2. Initial dimensions measure. onNodesChange treated React Flow's
   first-measure `dimensions` change as an edit, marking a freshly
   loaded canvas dirty before any interaction — autosave then saved on
   every load/switch. Only count a `dimensions` change as an edit when
   it is a real resize (resizing === true).

Add regression tests: setNodeStatus updates status without dirtying;
onNodesChange ignores initial-measure dimensions and select-only
changes but still dirties on a user resize.

ha-relevant: maybe
2026-07-17 13:02:14 +02:00
Pouzor 9c0ed32485 Merge remote-tracking branch 'origin/main' into feat/autosave-canvas
# Conflicts:
#	frontend/src/App.tsx
2026-07-17 11:58:56 +02:00
Pouzor 8d752e0bd8 fix(canvas): gate autosave on canvas provenance, not selection
The previous guard compared the pinned design against the live
activeDesignId (the selection). On a design switch the selection flips
synchronously while the new canvas loads asynchronously, so the
on-screen nodes briefly still belong to the previous design — arming the
timer with the newly-selected id would save those stale nodes under the
wrong design. Track the design the canvas was actually loaded as
(loadedDesignId provenance) and gate autosave on that instead: pin it
when the timer is armed and re-check the live provenance at fire time,
skipping the save if a different canvas has since loaded.

Also validate the persisted `enabled` flag by type (mirror of `delay`),
and add the matching regression tests (provenance switch skip, enabled
type validation).

ha-relevant: maybe
2026-07-17 11:43:42 +02:00
Pouzor 310b9cb3fd fix(canvas): guard autosave against design-switch clobber, add tests
Extract the debounced autosave into a useAutosave hook. Pin the active
design id when the timer is armed and re-check it at fire time: if the
user switched designs while the timer was pending, the in-memory canvas
belongs to a different design, so skip the save instead of writing it
under the wrong design id. Also skip when there is no active design.

Add unit tests for autosaveSettings (persistence, validation, pub/sub)
and useAutosave (debounce, enable/unsaved/design guards, switch skip).

ha-relevant: maybe
2026-07-17 11:16:56 +02:00
Pouzor - Rémy Jardient fceefe6d4d Merge pull request #290 from Pouzor/fix/undo-auto-layout-and-yaml-import
fix: allow undo after Auto Layout and YAML import
2026-07-16 19:39:40 +02:00
Pouzor ba4f8b0be4 fix(canvas): allow undo after Auto Layout and YAML import
Auto Layout and YAML import routed through loadCanvas, which wipes
past/future history and clears the unsaved flag (design-switch
semantics). Undo was therefore impossible after either action (#280).

Add an in-place applyLayout store action that snapshots history, marks
the canvas unsaved and re-fits the view without discarding undo history,
and wire both Auto Layout and YAML import to it.

ha-relevant: yes
2026-07-16 19:29:41 +02:00
Nicola Bottini a7b7327ce5 feat: autosave canvas after configurable inactivity delay
Adds an opt-in autosave feature (disabled by default) that silently
saves the canvas after a configurable period of inactivity.

- frontend/src/utils/autosaveSettings.ts (new) — localStorage-backed
  setting {enabled, delay} with pub/sub CustomEvent pattern (mirrors
  alignmentSettings.ts). Key: homelable.autosave.
- frontend/src/App.tsx:
  * handleSave accepts optional { silent?: boolean } — skips the success
    toast when called from autosave to avoid noise on every periodic save
  * autosave state (useState + subscribeAutosaveSettings)
  * debounce useEffect: resets on nodes/edges change; when quiet for
    autosave.delay seconds and hasUnsavedChanges is true, calls
    handleSaveRef.current silently
- frontend/src/components/modals/SettingsModal.tsx:
  * Canvas section: Autosave canvas toggle + Save after delay selector
    (3/5/10/30/60 s). Changes persist immediately to localStorage via
    writeAutosaveSettings + propagate cross-tab via CustomEvent.

Default: disabled — no behaviour change for existing users.
Manual Ctrl+S and the Save button continue to work normally.
Errors always show a toast regardless of the silent flag.
2026-07-16 12:22:18 -04:00
Pouzor - Rémy Jardient 6ae8b91934 Merge pull request #288 from Pouzor/fix/edge-waypoints-follow-dragged-node
fix(canvas): move edge waypoints when the connected node or its container is dragged
2026-07-16 17:36:24 +02:00
Pouzor 3cab115a80 fix(canvas): move edge waypoints when the connected node or its container is dragged
Waypoints are stored as absolute canvas coords, so they stayed put when a
connected node was dragged, kinking a previously clean route. A dragged
container moved its children on screen too (their stored parent-relative
position is unchanged), so their edges were missed entirely.

onNodesChange now translates the waypoints of every edge touching a moved
node by that node's delta, propagating a container's delta to all
descendants and translating once when both endpoints ride along.

ha-relevant: yes

Fixes #279
2026-07-16 17:24:52 +02:00
Pouzor - Rémy Jardient 9e088ca7ac Merge pull request #281 from nicolabottini/feat/mcp-approve-delete-design
feat(mcp): design_id on approve_device + delete_design tool
2026-07-16 17:04:25 +02:00
Pouzor - Rémy Jardient 6cc8399dca Merge pull request #287 from Pouzor/fix/container-host-edit-height-reset
fix(canvas): keep container host height when editing an unrelated field
2026-07-16 17:02:18 +02:00
Pouzor 4956e8ab90 fix(canvas): keep container host height when editing an unrelated field
Editing any field on a container-mode vm/lxc/docker_host node (e.g. just
renaming it or adding a property) reset its manually-set height to
undefined, snapping the container back to auto-fit content size and
scrambling/overflowing its nested children.

Root cause: updateNode clears height on every 'properties' change and the
node modal always includes properties in its payload. Only proxmox was
excluded from the reset; vm/lxc/docker_host in container mode were not.

- export CONTAINER_MODE_TYPES from virtualEdgeParent (reuse, no re-declare)
- updateNode: skip the height reset for any container-mode host type;
  proxmox stays always-excluded (legacy), plain leaf nodes still auto-fit
- regression tests: manual height preserved for container-mode
  vm/lxc/docker_host and proxmox; still reset for a non-container vm

Fixes #278

ha-relevant: yes
2026-07-16 16:53:07 +02:00
Pouzor - Rémy Jardient d5cbdce870 Merge pull request #286 from nicolabottini/fix/container-host-edit-child-positions
fix(canvas): keep container children in place when editing a host node
2026-07-16 16:11:46 +02:00
Nicola Bottini 15b7ce4481 fix(canvas): keep container children in place when editing a host node
Editing any field on a container-mode host node (Proxmox/VM/LXC/docker)
collapsed all of its nested children into the top-left corner.

Root cause: the node modal always includes container_mode in its update
payload, and handleUpdateNode gated setProxmoxContainerMode on the field's
presence rather than an actual change. So every edit (e.g. changing the
icon) re-fired the container ON transition, which re-applied the
absolute->relative position conversion to children that were already
parent-relative -- piling them up in a corner.

- App.tsx: only call setProxmoxContainerMode when container_mode actually
  changes (compare against the pre-update value).
- canvasStore: make setProxmoxContainerMode per-child idempotent so a
  redundant ON/OFF call can never re-convert an already-nested child's
  position.
- add regression test for the idempotent ON transition.
2026-07-14 19:32:29 -04:00
Nicola Bottini 8df7f682e0 feat(mcp): add delete_design tool
Wires DELETE /api/v1/designs/{id} into the MCP layer.
The backend already implements the endpoint and prevents
deleting the last remaining design.

- mcp/app/tools.py: Tool schema + _dispatch case
- mcp/tests/test_tools.py: 2 new tests (dispatch + schema)
30/30 tests passing.
2026-07-13 15:15:10 -04:00
Nicola Bottini 30619caf0f feat(mcp): add design_id to approve_device tool
The backend approve endpoint already accepts design_id via NodeCreate,
but the MCP schema didn't expose the field so callers couldn't target
a specific canvas when approving a discovered device. Add _DESIGN_ID_FIELD
to approve_device so AI clients (and the MCP bridge) can pass it through.
2026-07-13 15:15:10 -04:00
Pouzor - Rémy Jardient 47e144ac1f Merge pull request #275 from Pouzor/fix/mcp-readme-transport-http
docs: fix MCP client transport to http (was sse)
2026-07-12 00:07:49 +02:00
Pouzor 5fada99e25 docs: fix MCP client transport to http (was sse)
Server uses Streamable HTTP (StreamableHTTPSessionManager), but README
told users to register the client with --transport sse / "type": "sse".
An SSE client waits for an endpoint event the server never emits, so
Claude Code reports "Failed to connect" despite a healthy container.

Switch all client config snippets to http transport and point at the
trailing-slash /mcp/ URL to avoid the 307 redirect that can drop the
auth header.

Fixes #274

ha-relevant: no
2026-07-12 00:04:59 +02:00
Pouzor - Rémy Jardient 2e4b353fcd Merge pull request #273 from MikeSviblov/fix/mcp-list-pending-status-filter
fix: list_pending_devices MCP tool leaked approved/hidden inventory rows
2026-07-11 23:54:46 +02:00
Pouzor d0e5c105ec feat: add inventory, hidden-device and restore MCP tools
The status filter on list_pending_devices closed the leak but left the
MCP surface unable to see anything beyond pending: approved inventory
and hidden devices were no longer reachable, and a wrongly hidden device
could not be recovered through the MCP at all.

Add three tools mirroring existing backend endpoints:
- list_inventory      GET  /scan/pending  (pending + approved, optional status filter)
- list_hidden_devices GET  /scan/hidden
- restore_device      POST /scan/pending/{id}/restore  (undo hide_device)

Bulk approve/hide/restore and scan-config endpoints are intentionally
left out: bulk mutation is the same blast radius that caused the mass-hide
incident this PR addresses.

Tests: 5 new (47 passed total).
2026-07-11 23:40:03 +02:00
Mike Sviblov 9a03097f80 fix: list_pending_devices MCP tool leaked approved/hidden inventory rows
The backend GET /scan/pending endpoint intentionally returns the whole
inventory — approved devices stay listed so the frontend can show the
canvas-presence badge. The MCP tool proxied that response verbatim while
its description promises devices "not yet approved or hidden", so MCP
clients saw every approved device as if it were still awaiting triage
(59 "pending" entries on a fully-triaged canvas, 58 of them approved).

Filter the tool response to status == "pending", keeping legacy rows
that predate the status field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udrvtwi3cqcxUNHZtBHkBg
2026-07-11 15:17:45 +03:00
62 changed files with 2913 additions and 168 deletions
+39
View File
@@ -5,6 +5,45 @@ All notable changes to **Homelable** are documented here.
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/),
and this project adheres to [Semantic Versioning](https://semver.org/).
## [3.1.1] - 2026-07-19
### Features
- Drag to reorder services in the node detail panel. Thanks @Isilla. (#302)
- Getting Started walkthrough tour with first-run canvas detection. (#300)
### Fixes
- Round-trip Show Port Numbers on YAML export/import. (#301)
- Render error toasts on a red surface. (#300)
### Docs
- Rework README header with logo, badges, and nav links. (#300)
## [3.1.0] - 2026-07-18
### Features
- Drag to reorder node properties. (#296)
- Search for nodes and properties. Thanks @Floyddotnet. (#294)
- Widen zone modal into two columns. (#293)
- Autosave canvas after a configurable inactivity delay (opt-in). Thanks @nicolabottini. (#289)
- MCP: `design_id` on approve_device + `delete_design` tool. Thanks @nicolabottini. (#281)
### Fixes
- Scanner: decouple port discovery from version detection. (#295, #277)
- Allow undo after Auto Layout and YAML import. (#290)
- Move edge waypoints when the connected node or its container is dragged. (#288)
- Keep container host height when editing an unrelated field. (#287)
- Keep container children in place when editing a host node. Thanks @nicolabottini. (#286)
- `list_pending_devices` MCP tool leaked approved/hidden inventory rows. Thanks @MikeSviblov. (#273)
### Docs
- Fix MCP client transport to http (was sse). (#275)
## [3.0.0] - 2026-07-10
### Added
+36 -7
View File
@@ -1,4 +1,34 @@
# Homelable
<h1 align="center"><img src="docs/logo/icon-inline.svg" alt="Homelable" width="58" align="middle" />&nbsp;Homelable</h1>
<p align="center">
<strong>Self-hosted homelab infrastructure visualization, scanning &amp; live monitoring</strong>
</p>
<p align="center">
<a href="https://github.com/Pouzor/homelable/releases/latest"><img src="https://img.shields.io/github/v/release/Pouzor/homelable" alt="Latest release" /></a>
<a href="https://github.com/Pouzor/homelable/actions/workflows/docker-ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/Pouzor/homelable/docker-ci.yml?branch=main&amp;label=build" alt="Build status" /></a>
<a href="https://github.com/Pouzor/homelable/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" /></a>
<a href="https://github.com/Pouzor/homelable/issues"><img src="https://img.shields.io/github/issues/Pouzor/homelable" alt="Issues" /></a>
<a href="https://github.com/Pouzor/homelable/stargazers"><img src="https://img.shields.io/github/stars/Pouzor/homelable?style=social" alt="Stars" /></a>
<a href="https://github.com/Pouzor/homelable/network/members"><img src="https://img.shields.io/github/forks/Pouzor/homelable?style=social" alt="Forks" /></a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/24461?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-24461" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/24461/daily?language=TypeScript" alt="Pouzor%2Fhomelable | Trendshift" width="130" /></a>
</p>
<p align="center">
<a href="#screenshots">Screenshots</a> ·
<a href="#features">Features</a> ·
<a href="#installation">Installation</a> ·
<a href="#network-scanner">Network Scanner</a> ·
<a href="#zigbee2mqtt-import">Zigbee / Z-Wave</a> ·
<a href="#proxmox-ve-import">Proxmox</a> ·
<a href="#live-view-read-only-public-canvas">Live View</a> ·
<a href="#mcp-server-ai-integration-optional">MCP Server</a>
</p>
## About
Homelable is a self-hosted infrastructure visualization solution. It provides a network/zigbee scanning feature to accelerate the identification of machines, devices and services deployed on your local infrastructure.
@@ -10,7 +40,6 @@ If you just like the design, you can only run the frontend and export your desig
If you are running <img width="22" height="22" align="top" alt="New_Home_Assistant_logo" src="https://github.com/user-attachments/assets/3bb17686-c706-40ce-a2d3-57e02378f37c" /> Homeassistant, check the [Homelable HA version](https://github.com/Pouzor/homelable-hacs) (via HACS)
<a href="https://trendshift.io/repositories/24461?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-24461" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/24461/daily?language=TypeScript" alt="Pouzor%2Fhomelable | Trendshift" width="250" height="55"/></a>
---
## Screenshots
@@ -317,7 +346,7 @@ docker compose up -d mcp
**Claude Code** — run this command in your terminal:
```bash
claude mcp add --transport sse homelable http://<your-homelab-ip>:8001/mcp \
claude mcp add --transport http homelable http://<your-homelab-ip>:8001/mcp/ \
--header "X-API-Key: mcp_sk_yourkey"
```
@@ -326,8 +355,8 @@ Or add it manually to `~/.claude.json`:
{
"mcpServers": {
"homelable": {
"type": "sse",
"url": "http://<your-homelab-ip>:8001/mcp",
"type": "http",
"url": "http://<your-homelab-ip>:8001/mcp/",
"headers": {
"X-API-Key": "mcp_sk_yourkey"
}
@@ -341,8 +370,8 @@ Or add it manually to `~/.claude.json`:
{
"mcpServers": {
"homelable": {
"type": "sse",
"url": "http://<your-homelab-ip>:8001/mcp",
"type": "http",
"url": "http://<your-homelab-ip>:8001/mcp/",
"headers": {
"X-API-Key": "mcp_sk_yourkey"
}
+1 -1
View File
@@ -1 +1 @@
3.0.0
3.1.1
+3
View File
@@ -37,6 +37,9 @@ async def load_canvas(
edges=[EdgeResponse.model_validate(e) for e in edges],
viewport=viewport,
custom_style=state.custom_style if state else None,
# A CanvasState row exists only after a save (or explicit design create),
# so its presence marks an intentional canvas vs. a never-touched one.
initialized=state is not None,
)
+5
View File
@@ -52,6 +52,11 @@ class Settings(BaseSettings):
# Scanner
scanner_ranges: list[str] = ["192.168.1.0/24"]
# Phase-2 version-detection (-sV) host timeout, seconds. Bounds the version
# pass so a stalling TLS port (e.g. Proxmox 8006) can't hang it. Discovered
# ports survive a timeout regardless; raise this on slow/overlay networks.
scanner_version_host_timeout: int = 60
# Deep scan — persisted defaults (overridable per-scan from the scan dialog).
# http_ranges: extra nmap port ranges, opt-in, no default. Probe + TLS off by default.
scanner_http_ranges: list[str] = []
+5
View File
@@ -84,3 +84,8 @@ class CanvasStateResponse(BaseModel):
edges: list[EdgeResponse]
viewport: dict[str, Any]
custom_style: dict[str, Any] | None = None
# True once this design's canvas has ever been persisted (a CanvasState row
# exists). Lets the frontend tell a brand-new user (show demo) apart from one
# who intentionally cleared their canvas (keep it empty). False also for a
# missing/uninitialized design.
initialized: bool = False
+61 -24
View File
@@ -14,6 +14,7 @@ from typing import Any
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.db.models import Node, PendingDevice, ScanRun
from app.services.discovery_sources import add_source
from app.services.fingerprint import fingerprint_ports, suggest_node_type
@@ -240,6 +241,14 @@ def _nmap_scan_single(host_dict: dict[str, Any], port_spec: str = _EXTRA_PORTS)
"""
Phase 2 — single-IP port scan with service detection.
Runs in a thread (blocking). Returns the host dict enriched with open_ports.
Two decoupled nmap passes (issue #277):
Pass A — port discovery only (``--open``, no ``-sV``). Fast and reliable;
its open ports are authoritative and are never discarded.
Pass B — version detection (``-sV``) scoped to the ports Pass A found,
bounded by ``--host-timeout``. Best-effort: if it times out or
fails (e.g. a TLS port stalls plaintext probes), the Pass A ports
are kept with empty banners instead of the whole host being lost.
"""
ip = host_dict["ip"]
logger.info("[Phase 2] Scanning %s ...", ip)
@@ -248,48 +257,76 @@ def _nmap_scan_single(host_dict: dict[str, Any], port_spec: str = _EXTRA_PORTS)
logger.warning("[Phase 2] nmap not available, skipping %s", ip)
return host_dict
is_root = os.geteuid() == 0
if is_root:
# SYN scan + version detection (fastest, most accurate)
scan_args = f"-sS -sV --open -T4 -Pn --host-timeout 60s -p {port_spec}"
else:
# TCP connect scan (-sT) — no raw sockets needed, works without root.
# nmap auto-selects -sT without root but being explicit avoids edge cases.
scan_args = f"-sT -sV --open -T4 -Pn --host-timeout 60s -p {port_spec}"
# -sS (SYN) needs root; -sT (connect) works without it. nmap auto-selects
# -sT without root but being explicit avoids edge cases.
scan_type = "-sS" if os.geteuid() == 0 else "-sT"
logger.debug("[Phase 2] %s args: %s", ip, scan_args)
nm = nmap.PortScanner()
# --- Pass A: port discovery (no -sV, no host-timeout) ---
discovery_args = f"{scan_type} --open -T4 -Pn -p {port_spec}"
logger.debug("[Phase 2] %s discovery args: %s", ip, discovery_args)
nm_disc = nmap.PortScanner()
try:
nm.scan(hosts=ip, arguments=scan_args)
nm_disc.scan(hosts=ip, arguments=discovery_args)
except Exception as exc:
logger.warning("[Phase 2] nmap FAILED for %s (%s: %s) — skipping port scan", ip, type(exc).__name__, exc)
logger.warning("[Phase 2] nmap discovery FAILED for %s (%s: %s) — skipping port scan",
ip, type(exc).__name__, exc)
return host_dict
all_scanned = nm.all_hosts()
logger.debug("[Phase 2] %s — nmap returned %d host(s) in results", ip, len(all_scanned))
if ip not in all_scanned:
if ip not in nm_disc.all_hosts():
logger.info("[Phase 2] %s — no open ports found (all closed/filtered or nmap had no results)", ip)
return host_dict
open_ports = []
for proto in nm[ip].all_protocols():
for port, info in nm[ip][proto].items():
for proto in nm_disc[ip].all_protocols():
for port, info in nm_disc[ip][proto].items():
if info["state"] == "open":
banner = (info.get("product", "") + " " + info.get("version", "")).strip()
open_ports.append({"port": port, "protocol": proto, "banner": banner})
open_ports.append({"port": port, "protocol": proto, "banner": ""})
if not open_ports:
logger.info("[Phase 2] %s — 0 open ports detected", ip)
host_dict["open_ports"] = []
if not host_dict["mac"]:
host_dict["mac"] = nm_disc[ip].get("addresses", {}).get("mac")
return host_dict
# --- Pass B: version detection on the discovered ports (best-effort) ---
port_list = ",".join(str(p["port"]) for p in open_ports)
timeout = settings.scanner_version_host_timeout
version_args = f"{scan_type} -sV -T4 -Pn --host-timeout {timeout}s -p {port_list}"
logger.debug("[Phase 2] %s version args: %s", ip, version_args)
nm_ver = nmap.PortScanner()
banners: dict[tuple[str, int], str] = {}
ver_os = None
ver_mac = None
try:
nm_ver.scan(hosts=ip, arguments=version_args)
if ip in nm_ver.all_hosts():
for proto in nm_ver[ip].all_protocols():
for port, info in nm_ver[ip][proto].items():
banner = (info.get("product", "") + " " + info.get("version", "")).strip()
if banner:
banners[(proto, port)] = banner
ver_os = _extract_os(nm_ver, ip)
ver_mac = nm_ver[ip].get("addresses", {}).get("mac")
else:
logger.info("[Phase 2] %s — version detection returned no results, keeping %d port(s) without banners",
ip, len(open_ports))
except Exception as exc:
logger.info("[Phase 2] %s — version detection failed (%s: %s), keeping %d port(s) without banners",
ip, type(exc).__name__, exc, len(open_ports))
for p in open_ports:
p["banner"] = banners.get((p["protocol"], p["port"]), "")
if open_ports:
port_summary = ", ".join(
f"{p['port']}/{p['protocol']} ({p['banner'] or 'unknown'})" for p in open_ports
)
logger.info("[Phase 2] %s%d open port(s): %s", ip, len(open_ports), port_summary)
else:
logger.info("[Phase 2] %s — 0 open ports detected", ip)
host_dict["open_ports"] = open_ports
if not host_dict["mac"]:
host_dict["mac"] = nm[ip].get("addresses", {}).get("mac")
host_dict["os"] = _extract_os(nm, ip)
host_dict["mac"] = ver_mac or nm_disc[ip].get("addresses", {}).get("mac")
host_dict["os"] = ver_os
return host_dict
+17
View File
@@ -22,6 +22,23 @@ async def test_load_canvas_empty(client: AsyncClient, headers: dict):
assert data["viewport"] == {"x": 0, "y": 0, "zoom": 1}
async def test_load_canvas_uninitialized_reports_initialized_false(client: AsyncClient, headers: dict):
# A never-saved canvas has no CanvasState row → initialized False. The frontend
# uses this to show the demo canvas only to genuinely new users.
data = (await client.get("/api/v1/canvas", headers=headers)).json()
assert data["initialized"] is False
async def test_load_canvas_initialized_true_after_save(client: AsyncClient, headers: dict):
# Saving an EMPTY canvas still creates a CanvasState row, so a subsequently
# loaded empty canvas is reported initialized — the user cleared it on purpose
# and must not get the demo re-seeded.
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}}, headers=headers)
data = (await client.get("/api/v1/canvas", headers=headers)).json()
assert data["nodes"] == []
assert data["initialized"] is True
async def test_load_canvas_requires_auth(client: AsyncClient):
res = await client.get("/api/v1/canvas")
assert res.status_code == 401
+122
View File
@@ -253,6 +253,128 @@ def test_nmap_scan_single_returns_host_unchanged_when_no_results():
assert result["mac"] == "34:94:54:aa:bb:cc" # preserved from Phase 1
# ---------------------------------------------------------------------------
# _nmap_scan_single — two-pass discovery/version split (issue #277)
# ---------------------------------------------------------------------------
def _fake_scanner(ip, port_info, mac=None):
"""Mock nmap.PortScanner whose results contain `ip` with tcp `port_info`."""
host = MagicMock()
host.all_protocols.return_value = ["tcp"]
host.__getitem__ = MagicMock(return_value=port_info)
host.get.return_value = {"mac": mac} if mac else {}
nm = MagicMock()
nm.all_hosts.return_value = [ip]
nm.__getitem__ = MagicMock(return_value=host)
return nm
def _empty_scanner():
nm = MagicMock()
nm.all_hosts.return_value = []
return nm
def _failing_scanner(exc=Exception("host timeout")):
nm = MagicMock()
nm.scan.side_effect = exc
return nm
def test_nmap_scan_single_two_pass_merges_banners():
"""Pass A discovers ports; Pass B enriches them with -sV banners."""
from app.services.scanner import _nmap_scan_single
host = {"ip": "192.168.1.10", "hostname": None, "mac": None, "os": None, "open_ports": []}
disc = _fake_scanner("192.168.1.10", {22: {"state": "open"}, 8006: {"state": "open"}})
ver = _fake_scanner("192.168.1.10", {
22: {"state": "open", "product": "OpenSSH", "version": "9.0"},
8006: {"state": "open", "product": "", "version": ""},
})
with patch("app.services.scanner.nmap.PortScanner", side_effect=[disc, ver]), \
patch("app.services.scanner.os.geteuid", return_value=0), \
patch("app.services.scanner._extract_os", return_value=None):
result = _nmap_scan_single(host)
banners = {p["port"]: p["banner"] for p in result["open_ports"]}
assert banners == {22: "OpenSSH 9.0", 8006: ""}
# Pass A: discovery only, no -sV / host-timeout. Pass B: -sV, bounded.
disc_args = disc.scan.call_args.kwargs["arguments"]
ver_args = ver.scan.call_args.kwargs["arguments"]
assert "-sV" not in disc_args and "--host-timeout" not in disc_args
assert "-sV" in ver_args and "--host-timeout 60s" in ver_args
assert ver_args.endswith("-p 22,8006") # version pass scoped to found ports
def test_nmap_scan_single_keeps_ports_when_version_pass_fails():
"""Regression #277: a stalling version pass must not drop discovered ports."""
from app.services.scanner import _nmap_scan_single
host = {"ip": "192.168.100.3", "hostname": None, "mac": None, "os": None, "open_ports": []}
disc = _fake_scanner("192.168.100.3", {22: {"state": "open"}, 8006: {"state": "open"}})
ver = _failing_scanner() # -sV blows past --host-timeout on the TLS port
with patch("app.services.scanner.nmap.PortScanner", side_effect=[disc, ver]), \
patch("app.services.scanner.os.geteuid", return_value=0):
result = _nmap_scan_single(host)
ports = {p["port"] for p in result["open_ports"]}
assert ports == {22, 8006} # both survive despite the version failure
assert all(p["banner"] == "" for p in result["open_ports"])
def test_nmap_scan_single_keeps_ports_when_version_pass_empty():
"""Version pass returns no results for the host — keep the discovered ports."""
from app.services.scanner import _nmap_scan_single
host = {"ip": "192.168.1.11", "hostname": None, "mac": None, "os": None, "open_ports": []}
disc = _fake_scanner("192.168.1.11", {443: {"state": "open"}})
ver = _empty_scanner()
with patch("app.services.scanner.nmap.PortScanner", side_effect=[disc, ver]), \
patch("app.services.scanner.os.geteuid", return_value=0):
result = _nmap_scan_single(host)
assert [p["port"] for p in result["open_ports"]] == [443]
assert result["open_ports"][0]["banner"] == ""
def test_nmap_scan_single_no_open_ports_skips_version_pass():
"""Host reachable but nothing open — no version pass, empty ports."""
from app.services.scanner import _nmap_scan_single
host = {"ip": "192.168.1.12", "hostname": None, "mac": None, "os": None, "open_ports": []}
disc = _fake_scanner("192.168.1.12", {80: {"state": "filtered"}})
# Only one PortScanner instance may be created (Pass B must be skipped);
# a second would raise StopIteration from side_effect.
with patch("app.services.scanner.nmap.PortScanner", side_effect=[disc]), \
patch("app.services.scanner.os.geteuid", return_value=0):
result = _nmap_scan_single(host)
assert result["open_ports"] == []
def test_nmap_scan_single_non_root_uses_connect_scan():
"""Without root, both passes use -sT (connect) instead of -sS (SYN)."""
from app.services.scanner import _nmap_scan_single
host = {"ip": "192.168.1.13", "hostname": None, "mac": None, "os": None, "open_ports": []}
disc = _fake_scanner("192.168.1.13", {80: {"state": "open"}})
ver = _fake_scanner("192.168.1.13", {80: {"state": "open", "product": "nginx", "version": "1.24"}})
with patch("app.services.scanner.nmap.PortScanner", side_effect=[disc, ver]), \
patch("app.services.scanner.os.geteuid", return_value=1000), \
patch("app.services.scanner._extract_os", return_value=None):
result = _nmap_scan_single(host)
assert disc.scan.call_args.kwargs["arguments"].startswith("-sT")
assert ver.scan.call_args.kwargs["arguments"].startswith("-sT")
assert result["open_ports"][0]["banner"] == "nginx 1.24"
# ---------------------------------------------------------------------------
# _nmap_scan
# ---------------------------------------------------------------------------
+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 82" width="64" height="82" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "3.0.0",
"version": "3.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "3.0.0",
"version": "3.1.1",
"dependencies": {
"@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "3.0.0",
"version": "3.1.1",
"type": "module",
"scripts": {
"dev": "vite",
+189 -24
View File
@@ -1,4 +1,4 @@
import { useEffect, useCallback, useRef, useState } from 'react'
import { useEffect, useCallback, useMemo, useRef, useState } from 'react'
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
import { type Node } from '@xyflow/react'
import { applyDagreLayout } from '@/utils/layout'
@@ -37,12 +37,19 @@ import { ScanHistoryModal } from '@/components/modals/ScanHistoryModal'
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
import { ConfirmAddToGroupModal } from '@/components/modals/ConfirmAddToGroupModal'
import { useCanvasStore } from '@/stores/canvasStore'
import { readAutosaveSettings, subscribeAutosaveSettings, type AutosaveSettings } from '@/utils/autosaveSettings'
import { useAutosave } from '@/hooks/useAutosave'
import { useDesignStore } from '@/stores/designStore'
import { useAuthStore } from '@/stores/authStore'
import { useThemeStore } from '@/stores/themeStore'
import { canvasApi, designsApi, liveviewApi } from '@/api/client'
import * as standaloneStorage from '@/utils/standaloneStorage'
import { demoNodes, demoEdges } from '@/utils/demoData'
import { decideCanvasLoad, isNewUserCanvas } from '@/utils/canvasLoadDecision'
import { WalkthroughActionsProvider, type WalkthroughActionApi } from '@/walkthrough/actions'
import { WalkthroughInvite } from '@/walkthrough/WalkthroughInvite'
import { WalkthroughOverlay } from '@/walkthrough/WalkthroughOverlay'
import { DEMO_SCAN_RUNS, DEMO_PENDING_DEVICES } from '@/walkthrough/demoTourData'
import { useStatusPolling } from '@/hooks/useStatusPolling'
import type { NodeData, EdgeData, CustomStyleDef, FloorMapConfig, NodeType } from '@/types'
import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
@@ -53,7 +60,7 @@ import { buildProxmoxClusterEdges } from '@/components/proxmox/clusterEdges'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
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, addToGroup, addToContainer, floorMap, setFloorMap } = useCanvasStore()
const { loadCanvas, applyLayout, markSaved, markUnsaved, hasUnsavedChanges, editSeq, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer, floorMap, setFloorMap } = useCanvasStore()
const canvasRef = useRef<HTMLDivElement>(null)
const { isAuthenticated } = useAuthStore()
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
@@ -61,6 +68,29 @@ export default function App() {
useStatusPolling()
const [autosave, setAutosave] = useState<AutosaveSettings>(readAutosaveSettings)
useEffect(() => subscribeAutosaveSettings(setAutosave), [])
// Provenance: which design the in-memory canvas was loaded as. Differs from
// activeDesignId (the selection) during a switch, so autosave gates on this to
// avoid writing one design's canvas under another's id. Ref mirror for the
// fire-time guard, which must read the live value without re-arming the timer.
const [loadedDesignId, setLoadedDesignId] = useState<string | null>(null)
const loadedDesignIdRef = useRef<string | null>(loadedDesignId)
useEffect(() => { loadedDesignIdRef.current = loadedDesignId }, [loadedDesignId])
// True while the last canvas load failed (backend down/error). Drives the error
// banner and keeps us from masking a real failure with the demo canvas.
const [loadError, setLoadError] = useState(false)
// True when the loaded canvas is the demo (a brand-new user). Reserved as the
// entry signal for the upcoming Getting Started walkthrough.
const [isNewUser, setIsNewUser] = useState(false)
// Getting Started tour: when true, the corresponding modal renders injected demo
// data instead of hitting the backend.
const [tourScanHistoryDemo, setTourScanHistoryDemo] = useState(false)
const [tourInventoryDemo, setTourInventoryDemo] = useState(false)
const [themeModalOpen, setThemeModalOpen] = useState(false)
const [styleEditorType, setStyleEditorType] = useState<NodeType | null>(null)
const [searchOpen, setSearchOpen] = useState(false)
@@ -93,7 +123,7 @@ export default function App() {
// Declare handleSave before the Ctrl+S effect so it is in scope.
// Returns true on success, false on failure — the design-switch effect relies
// on this to avoid loading (and clobbering) the canvas when a save fails.
const handleSave = useCallback(async (designIdOverride?: string): Promise<boolean> => {
const handleSave = useCallback(async (designIdOverride?: string, options?: { silent?: boolean }): Promise<boolean> => {
try {
const saveDesignId = designIdOverride ?? activeDesignId
if (STANDALONE) {
@@ -101,7 +131,7 @@ export default function App() {
// Floor plans are backend-only (upload/serve), so standalone never persists one.
standaloneStorage.saveCanvas(saveDesignId, { nodes, edges, theme_id: activeTheme, custom_style: customStyle })
markSaved()
toast.success('Canvas saved')
if (!options?.silent) toast.success('Canvas saved')
return true
}
const nodesToSave = nodes.map(serializeNode)
@@ -110,7 +140,7 @@ export default function App() {
if (floorMap) viewport.floor_map = floorMap
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport, custom_style: customStyle, design_id: saveDesignId })
markSaved()
toast.success('Canvas saved')
if (!options?.silent) toast.success('Canvas saved')
return true
} catch {
toast.error('Save failed')
@@ -122,11 +152,37 @@ export default function App() {
const handleSaveRef = useRef(handleSave)
useEffect(() => { handleSaveRef.current = handleSave }, [handleSave])
// Debounced, opt-in autosave. Pins the active design when armed and re-checks
// it at fire time so a mid-switch save can't clobber the wrong design.
useAutosave({
enabled: autosave.enabled,
delaySeconds: autosave.delay,
hasUnsavedChanges,
designId: loadedDesignId,
// Debounce resets on each real user edit (editSeq), not on raw nodes/edges
// identity — live status polling churns those arrays without a user edit and
// would otherwise keep re-arming (and starving) the timer during monitoring.
changeSignals: [editSeq],
getLiveDesignId: () => loadedDesignIdRef.current,
onSave: (designId) => { void handleSaveRef.current(designId, { silent: true }) },
})
const loadCanvasFromApi = useCallback(async (designId?: string) => {
let res
try {
const res = await canvasApi.load(designId)
res = await canvasApi.load(designId)
} catch {
// Backend down / errored. Surface it and STOP — never fall back to the demo
// canvas, which would hide the real error and look like a fresh account.
// Leave the on-screen canvas and provenance untouched so an autosave can't
// clobber real data with an empty canvas.
setLoadError(true)
toast.error('Could not load canvas — backend not responding')
return
}
const { nodes: apiNodes, edges: apiEdges } = res.data
if (apiNodes.length > 0) {
const mode = decideCanvasLoad(apiNodes.length > 0, res.data.initialized === true)
if (mode === 'real') {
const proxmoxContainerMap = new Map<string, boolean>(
(apiNodes as ApiNode[])
.filter((n) => n.type === 'group' || n.container_mode === true)
@@ -144,31 +200,53 @@ export default function App() {
// across canvases when switching designs.
setFloorMap(savedFloorMap ?? null)
loadCanvas(rfNodes, rfEdges)
} else if (mode === 'empty') {
// Initialized but no nodes: the user cleared this canvas on purpose — respect
// it and keep it empty instead of re-seeding the demo.
const savedTheme = res.data.viewport?.theme_id
if (savedTheme) setTheme(savedTheme)
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
setFloorMap(null)
loadCanvas([], [])
} else {
// Brand-new, never-saved canvas: seed the demo.
setFloorMap(null)
loadCanvas(demoNodes, demoEdges)
}
} catch {
setFloorMap(null)
loadCanvas(demoNodes, demoEdges)
}
setLoadError(false)
setIsNewUser(isNewUserCanvas(mode))
// Record provenance so autosave writes back under the design just loaded.
setLoadedDesignId(designId ?? null)
}, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
// Standalone counterpart of loadCanvasFromApi — reads a design's canvas from
// localStorage, falling back to the demo canvas when it has never been saved.
const loadStandaloneCanvas = useCallback((designId: string) => {
const saved = standaloneStorage.loadCanvas(designId)
if (saved && saved.nodes.length > 0) {
// A stored entry (even with zero nodes) means the user has saved this canvas,
// so treat it as initialized and don't re-seed the demo on top of a canvas
// they deliberately cleared.
const mode = decideCanvasLoad((saved?.nodes.length ?? 0) > 0, saved !== null)
if (mode === 'real' && saved) {
if (saved.theme_id) setTheme(saved.theme_id)
if (saved.custom_style) setCustomStyle(saved.custom_style)
// Floor plans are backend-only; keep the store clear in standalone mode.
setFloorMap(null)
const migrated = migrateClusterHandles(saved.nodes, saved.edges)
loadCanvas(migrated.nodes, migrated.edges)
} else if (mode === 'empty' && saved) {
if (saved.theme_id) setTheme(saved.theme_id)
if (saved.custom_style) setCustomStyle(saved.custom_style)
setFloorMap(null)
loadCanvas([], [])
} else {
setFloorMap(null)
loadCanvas(demoNodes, demoEdges)
}
setLoadError(false)
setIsNewUser(isNewUserCanvas(mode))
// Record provenance so autosave writes back under the design just loaded.
setLoadedDesignId(designId)
}, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
const loadDesignsAndCanvas = useCallback(async () => {
@@ -198,16 +276,73 @@ export default function App() {
await loadCanvasFromApi(targetId)
}
} catch {
// If API fails (e.g. fresh DB with no designs), fall back to demo data
loadCanvas(demoNodes, demoEdges)
// Backend unreachable/errored — surface it. Do NOT seed the demo: that would
// hide a real outage behind a fake "new account" canvas.
setLoadError(true)
toast.error('Could not reach backend — check the server and retry')
}
}, [setDesigns, setActiveDesign, loadCanvasFromApi, loadStandaloneCanvas, activeDesignId, loadCanvas])
}, [setDesigns, setActiveDesign, loadCanvasFromApi, loadStandaloneCanvas, activeDesignId])
// Keep a ref so the auth effect can call the latest loader without listing it
// as a dependency (which would re-fire on every design switch).
const loadDesignsAndCanvasRef = useRef(loadDesignsAndCanvas)
useEffect(() => { loadDesignsAndCanvasRef.current = loadDesignsAndCanvas }, [loadDesignsAndCanvas])
// Retry a failed load from the error banner.
const handleRetryLoad = useCallback(() => {
setLoadError(false)
void loadDesignsAndCanvasRef.current()
}, [])
// Bridge the Getting Started tour steps to the App's modal controls. The overlay
// calls closeAll() before each step, then the step's action to open the target.
const walkthroughActions = useMemo<WalkthroughActionApi>(() => ({
closeAll: () => {
setScanConfigOpen(false)
setScanHistoryOpen(false)
setTourScanHistoryDemo(false)
setPendingModalOpen(false)
setTourInventoryDemo(false)
setEditNodeId(null)
setThemeModalOpen(false)
setZigbeeImportOpen(false)
// Clear any tour-driven multi-selection so the DetailPanel closes.
useCanvasStore.setState((s) => ({
nodes: s.nodes.map((n) => (n.selected ? { ...n, selected: false } : n)),
selectedNodeIds: [],
selectedNodeId: null,
}))
},
openScanConfig: () => setScanConfigOpen(true),
openScanHistoryDemo: () => {
setTourScanHistoryDemo(true)
setScanHistoryOpen(true)
},
openInventoryDemo: () => {
setTourInventoryDemo(true)
openPendingModal(undefined, 'pending')
},
editFirstNode: () => {
const first = useCanvasStore.getState().nodes.find((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
if (first) setEditNodeId(first.id)
},
selectTwoNodes: () => {
const ids = useCanvasStore.getState().nodes
.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
.slice(0, 2)
.map((n) => n.id)
if (ids.length < 2) return
const set = new Set(ids)
useCanvasStore.setState((s) => ({
nodes: s.nodes.map((n) => ({ ...n, selected: set.has(n.id) })),
selectedNodeIds: ids,
selectedNodeId: null,
}))
},
openStyle: () => setThemeModalOpen(true),
openZigbeeImport: () => setZigbeeImportOpen(true),
}), [openPendingModal])
// Load designs + canvas on auth (or immediately in standalone mode, which has
// no auth gate).
useEffect(() => {
@@ -459,8 +594,13 @@ export default function App() {
snapshotHistory()
const existingNode = nodes.find((n) => n.id === editNodeId)
updateNode(editNodeId, data)
// If container_mode changed, apply structural changes (children parentId, node dimensions)
if (typeof data.container_mode === 'boolean') {
// Only run the structural container transition when container_mode ACTUALLY
// changed. The modal always includes container_mode in its payload, so firing
// on presence alone re-ran the absolute<->relative child-position conversion on
// every edit (e.g. an icon change), corrupting nested child positions (they pile
// up in a corner). Gate on a real toggle instead.
const prevContainerMode = !!existingNode?.data.container_mode
if (typeof data.container_mode === 'boolean' && data.container_mode !== prevContainerMode) {
setProxmoxContainerMode(editNodeId, data.container_mode)
}
// Sync virtual edge when parent_id changes on an LXC/VM node
@@ -493,9 +633,11 @@ export default function App() {
const handleAutoLayout = useCallback(() => {
const laid = applyDagreLayout(nodes, edges)
loadCanvas(laid, edges)
// applyLayout keeps undo history so the user can revert an accidental
// Auto Layout (#280); loadCanvas would wipe it.
applyLayout(laid, edges)
toast.success('Canvas auto-arranged')
}, [nodes, edges, loadCanvas])
}, [nodes, edges, applyLayout])
const handleExportMd = useCallback(async () => {
const md = generateMarkdownTable(nodes)
@@ -517,14 +659,14 @@ export default function App() {
const handleImportYaml = useCallback((content: string) => {
try {
const { nodes: merged, edges: mergedEdges, imported } = parseYamlToCanvas(content, nodes, edges)
snapshotHistory()
loadCanvas(merged, mergedEdges)
markUnsaved()
// applyLayout keeps undo history so an import can be reverted; loadCanvas
// would wipe it (#280).
applyLayout(merged, mergedEdges)
toast.success(`Imported ${imported} node${imported !== 1 ? 's' : ''}`)
} catch (err) {
toast.error(`Import failed: ${err instanceof Error ? err.message : String(err)}`)
}
}, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved])
}, [nodes, edges, applyLayout])
// 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.
@@ -783,9 +925,12 @@ export default function App() {
if (!STANDALONE && !isAuthenticated) return <LoginPage />
return (
<WalkthroughActionsProvider value={walkthroughActions}>
<TooltipProvider>
<ReactFlowProvider>
<div className="flex h-screen w-screen overflow-hidden bg-[#0d1117]">
{/* data-new-user marks a first-time (demo) canvas — the hook the upcoming
Getting Started walkthrough keys off. */}
<div className="flex h-screen w-screen overflow-hidden bg-[#0d1117]" data-new-user={isNewUser}>
<Sidebar
onAddNode={() => setAddNodeOpen(true)}
onAddGroupRect={() => setAddGroupRectOpen(true)}
@@ -800,6 +945,21 @@ export default function App() {
onOpenPending={openPendingModal}
/>
<div className="flex flex-col flex-1 min-w-0">
{loadError && (
<div
role="alert"
className="flex items-center justify-between gap-3 bg-[#3d1418] border-b border-[#f85149] px-4 py-2 text-sm text-[#ffa198]"
>
<span>Backend not responding the canvas could not be loaded.</span>
<button
type="button"
onClick={handleRetryLoad}
className="rounded border border-[#f85149] px-2 py-0.5 text-[#ffa198] hover:bg-[#f85149]/20"
>
Retry
</button>
</div>
)}
<Toolbar
onSave={handleSave}
onAutoLayout={handleAutoLayout}
@@ -935,6 +1095,7 @@ export default function App() {
<ScanHistoryModal
open={scanHistoryOpen}
onClose={() => setScanHistoryOpen(false)}
demoRuns={tourScanHistoryDemo ? DEMO_SCAN_RUNS : undefined}
/>
)}
@@ -1059,6 +1220,7 @@ export default function App() {
onClose={() => setPendingModalOpen(false)}
highlightId={pendingHighlightId}
initialStatus={pendingModalStatus}
demoDevices={tourInventoryDemo ? DEMO_PENDING_DEVICES : undefined}
/>
<ExportModal
@@ -1068,7 +1230,10 @@ export default function App() {
/>
<Toaster theme="dark" position="bottom-right" />
<WalkthroughInvite />
<WalkthroughOverlay />
</ReactFlowProvider>
</TooltipProvider>
</WalkthroughActionsProvider>
)
}
+50 -16
View File
@@ -43,15 +43,45 @@ export function SearchBar({ onOpenPending }: SearchBarProps) {
}, [open])
const q = query.toLowerCase().trim()
const nodeResults = q
? nodes.filter((n) => {
if (n.data.type === 'groupRect') return false
return (
n.data.label?.toLowerCase().includes(q) ||
n.data.ip?.toLowerCase().includes(q) ||
n.data.hostname?.toLowerCase().includes(q) ||
(n.data.services ?? []).some((s) => s.service_name?.toLowerCase().includes(q))
)
? nodes.flatMap((n) => {
if (n.data.type === 'groupRect') return []
const contains = (v?: string | null) => v?.toLowerCase().includes(q)
const matches: Array<{ key: string; display_value: string }> = []
// Label matches make the node a result, but it is already shown in the
// header — don't repeat it in the matched-values column.
const labelMatch = contains(n.data.label)
for (const [key, value] of [
['notes', n.data.notes],
['ip', n.data.ip],
['hostname', n.data.hostname],
] as const) {
if (contains(value)) {
matches.push({ key, display_value: value ?? '' })
}
}
for (const s of n.data.services ?? []) {
if (contains(s.service_name)) {
matches.push({ key: 'services', display_value: s.service_name ?? '' })
}
}
for (const p of (n.data.properties ?? []).filter((p) => p.visible)) {
if (contains(p.key) || contains(p.value)) {
matches.push({
key: 'properties',
display_value: `${p.key ?? ''} = ${p.value ?? ''}`,
})
}
}
return labelMatch || matches.length > 0 ? [{ node: n, matches }] : []
})
: []
@@ -142,10 +172,10 @@ export function SearchBar({ onOpenPending }: SearchBarProps) {
{totalResults > 0 && (
<div style={{ borderTop: '1px solid #30363d', maxHeight: 260, overflowY: 'auto' }}>
{nodeResults.map((n) => (
{nodeResults.map((n, idx) => (
<button
key={n.id}
onClick={() => goToNode(n.id)}
key={`${n.node.id}-${idx}`}
onClick={() => goToNode(n.node.id)}
style={{
width: '100%',
display: 'flex',
@@ -161,15 +191,19 @@ export function SearchBar({ onOpenPending }: SearchBarProps) {
onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}
>
<span style={{ fontSize: 12, fontWeight: 600, color: '#e6edf3', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{n.data.label}
{n.node.data.label}
</span>
{n.data.ip && (
<span style={{ fontSize: 11, color: '#8b949e', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>
{n.data.ip}
{n.matches.length > 0 && (
<span style={{ maxWidth: "50%", display: 'flex', flexDirection: 'column', gap: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{n.matches.map((m, mIdx) => (
<span key={`${m.key}-${mIdx}`} style={{ fontSize: 11, color: '#8b949e', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0, display: 'inline-block' }}>
{m.display_value}
</span>
))}
</span>
)}
<span style={{ fontSize: 10, color: '#6e7681', flexShrink: 0 }}>
{NODE_TYPE_LABELS[n.data.type] ?? n.data.type}
{NODE_TYPE_LABELS[n.node.data.type] ?? n.node.data.type}
</span>
</button>
))}
@@ -103,6 +103,50 @@ describe('SearchBar', () => {
expect(screen.queryByText('DB Server')).toBeNull()
})
it('filters by notes', () => {
setupStore([
makeNode('n1', { data: { label: 'Server A', type: 'server', status: 'online', services: [], ip: null, hostname: null, notes: 'backup target every night' } }),
makeNode('n2', { data: { label: 'Server B', type: 'server', status: 'online', services: [], ip: null, hostname: null, notes: 'primary web host' } }),
])
render(<SearchBar />)
openSearch()
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'backup' } })
expect(screen.getByText('Server A')).toBeDefined()
expect(screen.queryByText('Server B')).toBeNull()
})
it('filters by visible property key or value', () => {
setupStore([
makeNode('n1', { data: { label: 'Server A', type: 'server', status: 'online', services: [], ip: null, hostname: null, properties: [{ key: 'rack', value: 'B12', icon: null, visible: true }] } }),
makeNode('n2', { data: { label: 'Server B', type: 'server', status: 'online', services: [], ip: null, hostname: null, properties: [{ key: 'rack', value: 'A01', icon: null, visible: true }] } }),
])
render(<SearchBar />)
openSearch()
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'b12' } })
expect(screen.getByText('Server A')).toBeDefined()
expect(screen.queryByText('Server B')).toBeNull()
})
it('ignores hidden properties', () => {
setupStore([
makeNode('n1', { data: { label: 'Server A', type: 'server', status: 'online', services: [], ip: null, hostname: null, properties: [{ key: 'secret', value: 'hidden-val', icon: null, visible: false }] } }),
])
render(<SearchBar />)
openSearch()
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'hidden-val' } })
expect(screen.queryByText('Server A')).toBeNull()
})
it('shows the matched value alongside the node label', () => {
setupStore([
makeNode('n1', { data: { label: 'Server A', type: 'server', status: 'online', services: [], ip: null, hostname: null, properties: [{ key: 'rack', value: 'B12', icon: null, visible: true }] } }),
])
render(<SearchBar />)
openSearch()
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'b12' } })
expect(screen.getByText('rack = B12')).toBeDefined()
})
it('excludes groupRect nodes from results', () => {
setupStore([
makeNode('gr1', { data: { label: 'DMZ Zone', type: 'groupRect', status: 'unknown', services: [], ip: null, hostname: null } }),
@@ -120,12 +120,17 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-sm">
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-[calc(100%-2rem)] sm:max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
{/* ── LEFT column: content & text ── */}
<div className="flex flex-col gap-4 min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pb-1 border-b border-[#30363d]">Content</div>
{/* Label */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Label</Label>
@@ -209,6 +214,38 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
</div>
</div>
{/* Text size */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Text Size</Label>
<div className="grid grid-cols-6 gap-1">
{TEXT_SIZES.map(({ value, label }) => {
const isSelected = form.text_size === value
return (
<button
key={value}
type="button"
onClick={() => set('text_size', value)}
className={`flex items-center justify-center h-8 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
aria-label={`Text size ${label}`}
style={{
background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
color: isSelected ? '#00d4ff' : '#8b949e',
fontSize: value,
}}
>
{label}
</button>
)
})}
</div>
</div>
</div>{/* ── end LEFT column ── */}
{/* ── RIGHT column: style ── */}
<div className="flex flex-col gap-4 min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pb-1 border-b border-[#30363d]">Style</div>
{/* Colors */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Colors</Label>
@@ -245,33 +282,6 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
</div>
</div>
{/* Text size */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Text Size</Label>
<div className="grid grid-cols-6 gap-1">
{TEXT_SIZES.map(({ value, label }) => {
const isSelected = form.text_size === value
return (
<button
key={value}
type="button"
onClick={() => set('text_size', value)}
className={`flex items-center justify-center h-8 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
aria-label={`Text size ${label}`}
style={{
background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
color: isSelected ? '#00d4ff' : '#8b949e',
fontSize: value,
}}
>
{label}
</button>
)
})}
</div>
</div>
{/* Border style */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Border Style</Label>
@@ -342,6 +352,8 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
</SelectContent>
</Select>
</div>
</div>{/* ── end RIGHT column ── */}
</div>{/* ── end 2-column grid ── */}
<div className="flex justify-between gap-2 pt-1">
{onDelete && (
@@ -25,6 +25,8 @@ interface PendingDevicesModalProps {
onClose: () => void
highlightId?: string
initialStatus?: 'pending' | 'hidden'
/** Getting Started tour: render these canned devices and skip the backend fetch. */
demoDevices?: PendingDevice[]
}
const PORT_COLORS: Record<number, string> = {
@@ -113,7 +115,7 @@ function injectAutoEdges(edges: AutoEdge[] | undefined) {
}))
}
export function PendingDevicesModal({ open, onClose, highlightId, initialStatus = 'pending' }: PendingDevicesModalProps) {
export function PendingDevicesModal({ open, onClose, highlightId, initialStatus = 'pending', demoDevices }: PendingDevicesModalProps) {
const [devices, setDevices] = useState<PendingDevice[]>([])
const [loading, setLoading] = useState(false)
const [selected, setSelected] = useState<PendingDevice | null>(null)
@@ -136,6 +138,8 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
const [dupPrompt, setDupPrompt] = useState<{ device: PendingDevice; conflict: DuplicateNodeConflict } | null>(null)
const load = useCallback(async () => {
// Tour/demo mode: show injected devices, never touch the backend.
if (demoDevices) { setDevices(statusFilter === 'pending' ? demoDevices : []); return }
setLoading(true)
try {
const res = statusFilter === 'pending' ? await scanApi.pending() : await scanApi.hidden()
@@ -145,7 +149,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
} finally {
setLoading(false)
}
}, [statusFilter])
}, [statusFilter, demoDevices])
useEffect(() => { if (open) load() }, [open, load])
useEffect(() => { if (open && scanEventTs > 0) load() }, [scanEventTs, open, load])
@@ -20,6 +20,8 @@ export interface ScanRun {
interface ScanHistoryModalProps {
open: boolean
onClose: () => void
/** Getting Started tour: render these canned runs and skip the backend fetch. */
demoRuns?: ScanRun[]
}
type KindFilter = 'all' | 'ip' | 'zigbee' | 'zwave' | 'proxmox'
@@ -85,7 +87,7 @@ function runDuration(r: ScanRun, now: number): string {
return formatDuration(end - start)
}
export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
export function ScanHistoryModal({ open, onClose, demoRuns }: ScanHistoryModalProps) {
const [runs, setRuns] = useState<ScanRun[]>([])
const [loading, setLoading] = useState(false)
const [stopping, setStopping] = useState<string | null>(null)
@@ -95,6 +97,8 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
const prevRunsRef = useRef<ScanRun[]>([])
const load = useCallback(async () => {
// Tour/demo mode: show injected runs, never touch the backend.
if (demoRuns) { setRuns(demoRuns); prevRunsRef.current = demoRuns; return }
setLoading(true)
try {
const res = await scanApi.runs()
@@ -127,7 +131,7 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
} finally {
setLoading(false)
}
}, [])
}, [demoRuns])
// Load when opened; reset prior-state tracker so we don't replay old transitions
useEffect(() => {
@@ -155,6 +159,7 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
}, [open, runs])
const handleStop = async (runId: string) => {
if (demoRuns) return // tour mode — no backend
setStopping(runId)
try {
await scanApi.stop(runId)
@@ -11,6 +11,7 @@ import {
type ZwaveConfigData,
} from '@/api/client'
import { useCanvasStore } from '@/stores/canvasStore'
import { useWalkthroughStore } from '@/stores/walkthroughStore'
import { toast } from 'sonner'
import {
type AlignmentSettings,
@@ -18,6 +19,12 @@ import {
writeAlignmentSettings,
subscribeAlignmentSettings,
} from '@/utils/alignmentSettings'
import {
type AutosaveSettings,
readAutosaveSettings,
writeAutosaveSettings,
subscribeAutosaveSettings,
} from '@/utils/autosaveSettings'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
@@ -124,6 +131,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
const [zwInterval, setZwInterval] = useState(3600)
const [zwSyncing, setZwSyncing] = useState(false)
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
const [autosave, setAutosave] = useState<AutosaveSettings>(readAutosaveSettings)
const hideIp = useCanvasStore((s) => s.hideIp)
const setHideIp = useCanvasStore((s) => s.setHideIp)
@@ -160,6 +168,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
}, [open])
useEffect(() => subscribeAlignmentSettings(setAlignment), [])
useEffect(() => subscribeAutosaveSettings(setAutosave), [])
const updateAlignment = (patch: Partial<AlignmentSettings>) => {
const next = { ...alignment, ...patch }
@@ -167,6 +176,12 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
writeAlignmentSettings(next)
}
const updateAutosave = (patch: Partial<AutosaveSettings>) => {
const next = { ...autosave, ...patch }
setAutosave(next)
writeAutosaveSettings(next)
}
const handleSyncNow = async () => {
setPmSyncing(true)
try {
@@ -353,6 +368,52 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
Distance at which dragged nodes snap to neighbours. Hold Alt while dragging to disable.
</p>
</div>
<label className="flex items-center justify-between gap-2 cursor-pointer">
<span className="text-xs text-foreground">Autosave canvas</span>
<input
type="checkbox"
checked={autosave.enabled}
onChange={(e) => updateAutosave({ enabled: e.target.checked })}
className="cursor-pointer accent-[#00d4ff]"
aria-label="Toggle autosave"
/>
</label>
<div className={autosave.enabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
<label className="text-xs text-muted-foreground">Save after</label>
<div className="flex items-center gap-2">
<select
value={autosave.delay}
onChange={(e) => updateAutosave({ delay: Number(e.target.value) })}
disabled={!autosave.enabled}
className="px-2 py-1 rounded-md text-xs bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]"
aria-label="Autosave delay"
>
<option value={3}>3 s</option>
<option value={5}>5 s</option>
<option value={10}>10 s</option>
<option value={30}>30 s</option>
<option value={60}>60 s</option>
</select>
<span className="text-xs text-muted-foreground">of inactivity</span>
</div>
<p className="text-[10px] text-muted-foreground leading-tight">
Saves silently after this many seconds with no changes. Manual Ctrl+S still works.
</p>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-foreground">Getting started tour</span>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => { useWalkthroughStore.getState().start(); onClose() }}
>
Restart walkthrough
</Button>
</div>
</div>
</div>
@@ -19,6 +19,12 @@ describe('GroupRectModal', () => {
expect(screen.getByText('Z-Order (1 = furthest back)')).toBeDefined()
})
it('renders two-column Content and Style section headers', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByText('Content')).toBeDefined()
expect(screen.getByText('Style')).toBeDefined()
})
it('renders Edit Zone title when provided', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Zone" />)
expect(screen.getByText('Edit Zone')).toBeDefined()
@@ -131,6 +131,9 @@ describe('SettingsModal', () => {
vi.mocked(proxmoxApi.saveConfig).mockResolvedValue({ data: {} } as never)
render(<SettingsModal open onClose={vi.fn()} />)
await screen.findByDisplayValue('60')
// Wait for the async proxmox getConfig to hydrate the toggle before saving —
// otherwise Save can fire with the default sync_enabled=false under CI load.
await waitFor(() => expect(screen.getByLabelText('Toggle Proxmox auto-sync')).toBeChecked())
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(proxmoxApi.saveConfig).toHaveBeenCalledWith({ sync_enabled: true, sync_interval: 3600 })
@@ -174,6 +177,9 @@ describe('SettingsModal', () => {
render(<SettingsModal open onClose={vi.fn()} />)
await screen.findByDisplayValue('60')
await screen.findByText('Zigbee auto-sync')
// Wait for the async zigbee getConfig to hydrate the toggle before saving —
// otherwise Save can fire with the default sync_enabled=false under CI load.
await waitFor(() => expect(screen.getByLabelText('Toggle Zigbee auto-sync')).toBeChecked())
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(zigbeeApi.saveConfig).toHaveBeenCalledWith({ sync_enabled: true, sync_interval: 1800 })
+111 -6
View File
@@ -1,5 +1,5 @@
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, GripVertical } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -37,6 +37,10 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
const [newProp, setNewProp] = useState<PropForm>(EMPTY_PROP)
const [editingPropIndex, setEditingPropIndex] = useState<number | null>(null)
const [editProp, setEditProp] = useState<PropForm>(EMPTY_PROP)
const [dragPropIndex, setDragPropIndex] = useState<number | null>(null)
const [dragOverPropIndex, setDragOverPropIndex] = useState<number | null>(null)
const [dragSvcIndex, setDragSvcIndex] = useState<number | null>(null)
const [dragOverSvcIndex, setDragOverSvcIndex] = useState<number | null>(null)
// Multi-select panel
const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id))
@@ -156,6 +160,15 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
setEditingFor(null)
}
const handleReorderService = (from: number, to: number) => {
if (from === to || from < 0 || to < 0 || from >= services.length || to >= services.length) return
snapshotHistory()
const reordered = [...services]
const [moved] = reordered.splice(from, 1)
reordered.splice(to, 0, moved)
updateNode(node.id, { services: reordered })
}
// --- Property handlers ---
const properties: NodeProperty[] = data.properties ?? []
@@ -202,6 +215,15 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
setEditingPropIndex(null)
}
const handleReorderProp = (from: number, to: number) => {
if (from === to || from < 0 || to < 0 || from >= properties.length || to >= properties.length) return
snapshotHistory()
const reordered = [...properties]
const [moved] = reordered.splice(from, 1)
reordered.splice(to, 0, moved)
updateNode(node.id, { properties: reordered })
}
return (
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
@@ -302,6 +324,17 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
<PropertyBadge
key={`${prop.key}-${i}`}
prop={prop}
draggable={properties.length > 1}
isDragging={dragPropIndex === i}
isDragOver={dragOverPropIndex === i && dragPropIndex !== i}
onDragStart={() => setDragPropIndex(i)}
onDragEnter={() => { if (dragPropIndex !== null) setDragOverPropIndex(i) }}
onDragEnd={() => { setDragPropIndex(null); setDragOverPropIndex(null) }}
onDrop={() => {
if (dragPropIndex !== null) handleReorderProp(dragPropIndex, i)
setDragPropIndex(null)
setDragOverPropIndex(null)
}}
onToggleVisible={() => handleTogglePropVisible(i)}
onEdit={() => handleStartEditProp(i)}
onRemove={() => handleRemoveProp(i)}
@@ -329,7 +362,25 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
editingIndex === i ? (
<ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus />
) : (
<ServiceBadge key={`${svc.port ?? '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)} />
<ServiceBadge
key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`}
svc={svc}
host={host}
status={serviceStatuses[serviceStatusKey(node.id, svc.port, svc.protocol)]}
draggable={services.length > 1}
isDragging={dragSvcIndex === i}
isDragOver={dragOverSvcIndex === i && dragSvcIndex !== i}
onDragStart={() => setDragSvcIndex(i)}
onDragEnter={() => { if (dragSvcIndex !== null) setDragOverSvcIndex(i) }}
onDragEnd={() => { setDragSvcIndex(null); setDragOverSvcIndex(null) }}
onDrop={() => {
if (dragSvcIndex !== null) handleReorderService(dragSvcIndex, i)
setDragSvcIndex(null)
setDragOverSvcIndex(null)
}}
onEdit={() => handleStartEdit(i)}
onRemove={() => handleRemoveService(i)}
/>
)
)}
</div>
@@ -494,6 +545,7 @@ function MultiSelectPanel({ nodeIds, nodes, groupName, setGroupName, creatingGro
</>
) : (
<Button
data-tour="create-group"
size="sm"
className="w-full gap-2 bg-[#00d4ff]/10 text-[#00d4ff] border border-[#00d4ff]/30 hover:bg-[#00d4ff]/20"
variant="ghost"
@@ -768,16 +820,41 @@ function PropertyForm({ form, onChange, onConfirm, onCancel, confirmLabel }: {
)
}
function PropertyBadge({ prop, onToggleVisible, onEdit, onRemove }: {
function PropertyBadge({ prop, draggable, isDragging, isDragOver, onDragStart, onDragEnter, onDragEnd, onDrop, onToggleVisible, onEdit, onRemove }: {
prop: NodeProperty
draggable: boolean
isDragging: boolean
isDragOver: boolean
onDragStart: () => void
onDragEnter: () => void
onDragEnd: () => void
onDrop: () => void
onToggleVisible: () => void
onEdit: () => void
onRemove: () => void
}) {
const Icon = resolvePropertyIcon(prop.icon)
return (
<div className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors" style={{ background: '#21262d', borderColor: '#30363d' }}>
<div
draggable={draggable}
onDragStart={onDragStart}
onDragEnter={onDragEnter}
onDragOver={(e) => e.preventDefault()}
onDragEnd={onDragEnd}
onDrop={(e) => { e.preventDefault(); onDrop() }}
className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors"
style={{
background: '#21262d',
borderColor: isDragOver ? '#00d4ff' : '#30363d',
opacity: isDragging ? 0.4 : 1,
}}
>
<div className="flex items-center gap-1.5 min-w-0">
{draggable && (
<span className="shrink-0 cursor-grab active:cursor-grabbing text-[#8b949e] hover:text-[#00d4ff]" title="Drag to reorder">
{createElement(GripVertical, { size: 11 })}
</span>
)}
{Icon && createElement(Icon, { size: 11, className: 'shrink-0 text-muted-foreground' })}
<span className="font-medium truncate text-foreground" title={prop.key}>{prop.key}</span>
<span className="text-muted-foreground truncate" title={prop.value}>· {prop.value}</span>
@@ -805,7 +882,20 @@ const CATEGORY_COLORS: Record<string, string> = {
web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
}
function ServiceBadge({ svc, host, status, onEdit, onRemove }: { svc: ServiceInfo; host?: string; status?: ServiceStatus; onEdit: () => void; onRemove: () => void }) {
function ServiceBadge({ svc, host, status, draggable, isDragging, isDragOver, onDragStart, onDragEnter, onDragEnd, onDrop, onEdit, onRemove }: {
svc: ServiceInfo
host?: string
status?: ServiceStatus
draggable: boolean
isDragging: boolean
isDragOver: boolean
onDragStart: () => void
onDragEnter: () => void
onDragEnd: () => void
onDrop: () => void
onEdit: () => void
onRemove: () => void
}) {
const url = getServiceUrl(svc, host)
// Manually-added services carry no category, so they fell back to grey even
// when they're reachable HTTP/HTTPS. Treat any resolvable web URL as `web`.
@@ -816,10 +906,25 @@ function ServiceBadge({ svc, host, status, onEdit, onRemove }: { svc: ServiceInf
return (
<div
draggable={draggable}
onDragStart={onDragStart}
onDragEnter={onDragEnter}
onDragOver={(e) => e.preventDefault()}
onDragEnd={onDragEnd}
onDrop={(e) => { e.preventDefault(); onDrop() }}
className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors min-w-0"
style={{ background: '#21262d', borderColor: '#30363d' }}
style={{
background: '#21262d',
borderColor: isDragOver ? '#00d4ff' : '#30363d',
opacity: isDragging ? 0.4 : 1,
}}
>
<div className="flex items-center gap-1.5 min-w-0 flex-1">
{draggable && (
<span className="shrink-0 cursor-grab active:cursor-grabbing text-[#8b949e] hover:text-[#00d4ff]" title="Drag to reorder">
{createElement(GripVertical, { size: 11 })}
</span>
)}
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
{url ? (
+11 -8
View File
@@ -268,13 +268,13 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
{/* Actions */}
<div className="flex flex-col gap-0.5 p-2 border-t border-border">
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} />
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} />
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
{!STANDALONE && <SidebarItem icon={RadioTower} label="Z-Wave Import" collapsed={collapsed} onClick={onZwaveImport} />}
{!STANDALONE && <SidebarItem icon={Server} label="Proxmox Import" collapsed={collapsed} onClick={onProxmoxImport} />}
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} dataTour="add-node" />
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} dataTour="add-zone" />
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} dataTour="add-text" />
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} dataTour="scan-network" />}
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} dataTour="zigbee-import" />}
{!STANDALONE && <SidebarItem icon={RadioTower} label="Z-Wave Import" collapsed={collapsed} onClick={onZwaveImport} dataTour="zwave-import" />}
{!STANDALONE && <SidebarItem icon={Server} label="Proxmox Import" collapsed={collapsed} onClick={onProxmoxImport} dataTour="proxmox-import" />}
<SidebarItem
icon={Save}
label="Save Canvas"
@@ -360,12 +360,15 @@ interface SidebarItemProps {
badge?: boolean
accent?: boolean
onClick?: () => void
/** Stable anchor for the Getting Started walkthrough spotlight. */
dataTour?: string
}
function SidebarItem({ icon: Icon, label, collapsed, active, badge, accent, onClick }: SidebarItemProps) {
function SidebarItem({ icon: Icon, label, collapsed, active, badge, accent, onClick, dataTour }: SidebarItemProps) {
const btn = (
<button
onClick={onClick}
data-tour={dataTour}
className={`relative flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm transition-colors cursor-pointer ${
active
? 'bg-[#00d4ff]/10 text-[#00d4ff]'
+1 -1
View File
@@ -62,7 +62,7 @@ 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={onAutoLayout}>
<LayoutDashboard size={14} /> Auto Layout
</Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onChangeStyle}>
<Button data-tour="style" size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onChangeStyle}>
<Palette size={14} /> Style
</Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={() => fileInputRef.current?.click()} title="Import from YAML">
@@ -187,6 +187,44 @@ describe('DetailPanel', () => {
expect(payload.properties).toHaveLength(0)
})
it('reorders properties on drag and drop', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ properties: [
{ key: 'A', value: '1', icon: null, visible: true },
{ key: 'B', value: '2', icon: null, visible: true },
{ key: 'C', value: '3', icon: null, visible: true },
] })],
selectedNodeId: 'n1',
selectedNodeIds: [],
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode,
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
const rows = screen.getAllByTitle('Drag to reorder').map(
(g) => g.closest('[draggable="true"]') as HTMLElement,
)
// Drag last (C) onto first (A) position
fireEvent.dragStart(rows[2])
fireEvent.dragEnter(rows[0])
fireEvent.drop(rows[0])
expect(updateNode).toHaveBeenCalledOnce()
const [, payload] = updateNode.mock.calls[0]
expect(payload.properties.map((p: { key: string }) => p.key)).toEqual(['C', 'A', 'B'])
})
it('is not draggable with a single property', () => {
setupStore({ properties: [{ key: 'A', value: '1', icon: null, visible: true }] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.queryByTitle('Drag to reorder')).toBeNull()
})
it('does not submit add form when key is empty', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
@@ -392,6 +430,44 @@ describe('DetailPanel', () => {
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
expect(() => render(<DetailPanel onEdit={vi.fn()} />)).not.toThrow()
})
it('reorders services on drag and drop', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ services: [
{ port: 80, protocol: 'tcp', service_name: 'A' },
{ port: 81, protocol: 'tcp', service_name: 'B' },
{ port: 82, protocol: 'tcp', service_name: 'C' },
] })],
selectedNodeId: 'n1',
selectedNodeIds: [],
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode,
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
const rows = screen.getAllByTitle('Drag to reorder').map(
(g) => g.closest('[draggable="true"]') as HTMLElement,
)
// Drag last (C) onto first (A) position
fireEvent.dragStart(rows[2])
fireEvent.dragEnter(rows[0])
fireEvent.drop(rows[0])
expect(updateNode).toHaveBeenCalledOnce()
const [, payload] = updateNode.mock.calls[0]
expect(payload.services.map((s: { service_name: string }) => s.service_name)).toEqual(['C', 'A', 'B'])
})
it('is not draggable with a single service', () => {
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'A' }] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.queryByTitle('Drag to reorder')).toBeNull()
})
})
describe('Services — edit', () => {
@@ -0,0 +1,46 @@
import { describe, it, expect, afterEach, beforeAll, vi } from 'vitest'
import { render, cleanup, waitFor } from '@testing-library/react'
import { toast } from 'sonner'
import { Toaster } from '../sonner'
beforeAll(() => {
// sonner reads matchMedia for theme detection; jsdom doesn't provide it.
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}))
})
afterEach(() => cleanup())
describe('Toaster', () => {
it('renders error toasts on a red surface so failures are visible', async () => {
render(<Toaster />)
toast.error('Save failed')
// The style (with the CSS vars) lands on the toast list, mounted on first toast.
const list = await waitFor(() => {
const el = document.querySelector('[data-sonner-toaster]') as HTMLElement | null
expect(el).not.toBeNull()
return el!
})
const style = list.getAttribute('style') ?? ''
expect(style).toContain('--error-bg: #f85149')
expect(style).toContain('--error-text: #ffffff')
// richColors must be on for sonner to apply the per-type surface, and the
// toast itself must be tagged as an error so it picks up --error-bg.
const item = await waitFor(() => {
const el = document.querySelector('[data-sonner-toast]') as HTMLElement | null
expect(el).not.toBeNull()
return el!
})
expect(item.getAttribute('data-rich-colors')).toBe('true')
expect(item.getAttribute('data-type')).toBe('error')
})
})
+18
View File
@@ -11,6 +11,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
richColors
icons={{
success: (
<CircleCheckIcon className="size-4" />
@@ -34,6 +35,23 @@ const Toaster = ({ ...props }: ToasterProps) => {
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
// richColors (below) is required for sonner to honor per-type surfaces.
// Errors get a red surface so failures (e.g. a save while the backend
// is down) stand out instead of reading like a normal toast...
"--error-bg": "#f85149",
"--error-text": "#ffffff",
"--error-border": "#da3633",
// ...while success/info/warning stay on the neutral popover surface so
// the rest of the toast UX is unchanged.
"--success-bg": "var(--popover)",
"--success-text": "var(--popover-foreground)",
"--success-border": "var(--border)",
"--info-bg": "var(--popover)",
"--info-text": "var(--popover-foreground)",
"--info-border": "var(--border)",
"--warning-bg": "var(--popover)",
"--warning-text": "var(--popover-foreground)",
"--warning-border": "var(--border)",
} as React.CSSProperties
}
toastOptions={{
@@ -0,0 +1,97 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useAutosave } from '../useAutosave'
interface Args {
enabled?: boolean
delaySeconds?: number
hasUnsavedChanges?: boolean
designId?: string | null
changeSignals?: unknown[]
getLiveDesignId?: () => string | null
onSave?: (designId: string) => void
}
function args(over: Args = {}) {
const designId = 'designId' in over ? (over.designId ?? null) : 'design-a'
return {
enabled: over.enabled ?? true,
delaySeconds: over.delaySeconds ?? 5,
hasUnsavedChanges: over.hasUnsavedChanges ?? true,
designId,
changeSignals: over.changeSignals ?? [[], []],
getLiveDesignId: over.getLiveDesignId ?? (() => designId),
onSave: over.onSave ?? vi.fn(),
}
}
describe('useAutosave', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('saves after the inactivity delay when enabled with unsaved changes', () => {
const onSave = vi.fn()
renderHook(() => useAutosave(args({ onSave, delaySeconds: 5 })))
expect(onSave).not.toHaveBeenCalled()
vi.advanceTimersByTime(4999)
expect(onSave).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(onSave).toHaveBeenCalledTimes(1)
expect(onSave).toHaveBeenCalledWith('design-a')
})
it('does nothing when disabled', () => {
const onSave = vi.fn()
renderHook(() => useAutosave(args({ onSave, enabled: false })))
vi.advanceTimersByTime(60_000)
expect(onSave).not.toHaveBeenCalled()
})
it('does nothing when there are no unsaved changes', () => {
const onSave = vi.fn()
renderHook(() => useAutosave(args({ onSave, hasUnsavedChanges: false })))
vi.advanceTimersByTime(60_000)
expect(onSave).not.toHaveBeenCalled()
})
it('does nothing when the canvas has no known provenance', () => {
const onSave = vi.fn()
renderHook(() => useAutosave(args({ onSave, designId: null })))
vi.advanceTimersByTime(60_000)
expect(onSave).not.toHaveBeenCalled()
})
it('debounces: a change before the delay resets the timer', () => {
const onSave = vi.fn()
const { rerender } = renderHook((p: Args) => useAutosave(args(p)), {
initialProps: { onSave, changeSignals: [1] },
})
vi.advanceTimersByTime(4000)
rerender({ onSave, changeSignals: [2] }) // edit resets debounce
vi.advanceTimersByTime(4000)
expect(onSave).not.toHaveBeenCalled()
vi.advanceTimersByTime(1000)
expect(onSave).toHaveBeenCalledTimes(1)
})
it('skips the save if a different canvas loaded while the timer was pending', () => {
const onSave = vi.fn()
// Pinned provenance at arm time = 'design-a', but a switch loaded 'design-b'
// before the timer fired — the on-screen canvas is no longer design-a's.
renderHook(() =>
useAutosave(args({ onSave, designId: 'design-a', getLiveDesignId: () => 'design-b' })),
)
vi.advanceTimersByTime(5000)
expect(onSave).not.toHaveBeenCalled()
})
it('saves under the pinned provenance id when it still matches at fire time', () => {
const onSave = vi.fn()
renderHook(() =>
useAutosave(args({ onSave, designId: 'design-a', getLiveDesignId: () => 'design-a' })),
)
vi.advanceTimersByTime(5000)
expect(onSave).toHaveBeenCalledTimes(1)
expect(onSave).toHaveBeenCalledWith('design-a')
})
})
@@ -7,7 +7,7 @@ import { useAuthStore } from '@/stores/authStore'
vi.mock('@/stores/canvasStore')
vi.mock('@/stores/authStore')
const mockUpdateNode = vi.fn()
const mockSetNodeStatus = vi.fn()
const mockNotifyScanDeviceFound = vi.fn()
const mockSetServiceStatuses = vi.fn()
@@ -32,7 +32,7 @@ describe('useStatusPolling', () => {
vi.stubGlobal('WebSocket', MockWebSocket)
vi.mocked(useCanvasStore).mockReturnValue({
updateNode: mockUpdateNode,
setNodeStatus: mockSetNodeStatus,
notifyScanDeviceFound: mockNotifyScanDeviceFound,
setServiceStatuses: mockSetServiceStatuses,
} as ReturnType<typeof useCanvasStore>)
@@ -50,7 +50,7 @@ describe('useStatusPolling', () => {
afterEach(() => {
vi.restoreAllMocks()
mockUpdateNode.mockClear()
mockSetNodeStatus.mockClear()
mockNotifyScanDeviceFound.mockClear()
mockSetServiceStatuses.mockClear()
})
@@ -95,7 +95,7 @@ describe('useStatusPolling', () => {
expect(ws.send).toHaveBeenCalledWith(JSON.stringify({ token: 'test-token' }))
})
it('calls updateNode with correct data on status message', () => {
it('calls setNodeStatus with correct data on status message', () => {
renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
ws.onmessage?.({
@@ -106,7 +106,7 @@ describe('useStatusPolling', () => {
response_time_ms: 42,
}),
})
expect(mockUpdateNode).toHaveBeenCalledWith('node-1', {
expect(mockSetNodeStatus).toHaveBeenCalledWith('node-1', {
status: 'online',
response_time_ms: 42,
last_seen: '2024-01-01T12:00:00Z',
@@ -123,7 +123,7 @@ describe('useStatusPolling', () => {
checked_at: '2024-01-01T12:00:00Z',
}),
})
expect(mockUpdateNode).toHaveBeenCalledWith('node-1', {
expect(mockSetNodeStatus).toHaveBeenCalledWith('node-1', {
status: 'offline',
response_time_ms: undefined,
last_seen: undefined,
@@ -136,7 +136,7 @@ describe('useStatusPolling', () => {
ws.onmessage?.({
data: JSON.stringify({ node_id: 'node-1', status: 'online', response_time_ms: null }),
})
expect(mockUpdateNode).toHaveBeenCalledWith(
expect(mockSetNodeStatus).toHaveBeenCalledWith(
'node-1',
expect.objectContaining({ response_time_ms: undefined }),
)
@@ -147,7 +147,7 @@ describe('useStatusPolling', () => {
const ws = MockWebSocket.instances[0]
ws.onmessage?.({ data: JSON.stringify({ type: 'scan_device_found' }) })
expect(mockNotifyScanDeviceFound).toHaveBeenCalledOnce()
expect(mockUpdateNode).not.toHaveBeenCalled()
expect(mockSetNodeStatus).not.toHaveBeenCalled()
})
it('routes service_status messages to setServiceStatuses', () => {
@@ -158,21 +158,21 @@ describe('useStatusPolling', () => {
data: JSON.stringify({ type: 'service_status', node_id: 'node-9', services }),
})
expect(mockSetServiceStatuses).toHaveBeenCalledWith('node-9', services)
expect(mockUpdateNode).not.toHaveBeenCalled()
expect(mockSetNodeStatus).not.toHaveBeenCalled()
})
it('ignores malformed JSON without throwing', () => {
renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
expect(() => ws.onmessage?.({ data: 'not-valid-json{{' })).not.toThrow()
expect(mockUpdateNode).not.toHaveBeenCalled()
expect(mockSetNodeStatus).not.toHaveBeenCalled()
})
it('ignores messages with no node_id or status', () => {
renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
ws.onmessage?.({ data: JSON.stringify({ some: 'unknown-field' }) })
expect(mockUpdateNode).not.toHaveBeenCalled()
expect(mockSetNodeStatus).not.toHaveBeenCalled()
})
it('closes WebSocket on unmount', () => {
+72
View File
@@ -0,0 +1,72 @@
import { useEffect, useRef } from 'react'
interface UseAutosaveOptions {
/** Whether autosave is enabled (user opt-in). */
enabled: boolean
/** Inactivity delay in seconds before firing a save. */
delaySeconds: number
/** True when the canvas has edits not yet persisted. */
hasUnsavedChanges: boolean
/**
* The design the in-memory canvas actually belongs to its *provenance*, set
* when a canvas is loaded, NOT the currently-selected design. These differ
* during a design switch: the selection flips synchronously while the new
* canvas loads asynchronously, so for a brief window the on-screen nodes still
* belong to the previous design. Gating on provenance (not selection) is what
* prevents saving one design's canvas under another design's id.
*/
designId: string | null
/**
* Values that represent canvas edits (e.g. nodes, edges). Any change to one
* of these resets the debounce timer, so the save only fires after a quiet
* period. Its length MUST stay constant across renders (React requires a
* stable dependency-array size) pass a fixed-shape array like [nodes, edges].
*/
changeSignals: readonly unknown[]
/**
* Reads the *live* canvas provenance at fire time. If it no longer matches the
* id pinned when the timer was armed, a different canvas has since loaded, so
* the save is skipped rather than written under the wrong (now-stale) id.
*/
getLiveDesignId: () => string | null
/** Persist the canvas under the given design id. */
onSave: (designId: string) => void
}
/**
* Debounced canvas autosave. Fires `onSave(designId)` after `delaySeconds` of
* inactivity when enabled and there are unsaved changes. Opt-in only the
* caller decides whether `enabled` is set (see ADR: autosave defaults to off).
*/
export function useAutosave({
enabled,
delaySeconds,
hasUnsavedChanges,
designId,
changeSignals,
getLiveDesignId,
onSave,
}: UseAutosaveOptions): void {
// Keep the latest callbacks in refs so the timer always calls the current
// versions without re-arming (which would reset the debounce) on every render.
const onSaveRef = useRef(onSave)
const getLiveDesignIdRef = useRef(getLiveDesignId)
useEffect(() => {
onSaveRef.current = onSave
getLiveDesignIdRef.current = getLiveDesignId
})
useEffect(() => {
if (!enabled || !hasUnsavedChanges || !designId) return
const pinnedDesignId = designId
const t = setTimeout(() => {
// Skip if a different canvas has loaded while the timer was pending.
if (getLiveDesignIdRef.current() !== pinnedDesignId) return
onSaveRef.current(pinnedDesignId)
}, delaySeconds * 1000)
return () => clearTimeout(t)
// changeSignals is spread so any canvas edit resets the debounce; its length
// must stay constant (documented on the option).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, delaySeconds, hasUnsavedChanges, designId, ...changeSignals])
}
+5 -3
View File
@@ -24,7 +24,7 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
export function useStatusPolling() {
const wsRef = useRef<WebSocket | null>(null)
const { updateNode, notifyScanDeviceFound, setServiceStatuses } = useCanvasStore()
const { setNodeStatus, notifyScanDeviceFound, setServiceStatuses } = useCanvasStore()
const { isAuthenticated, token } = useAuthStore()
useEffect(() => {
@@ -50,7 +50,9 @@ export function useStatusPolling() {
} else if (msg.type === 'service_status' && msg.node_id && msg.services) {
setServiceStatuses(msg.node_id, msg.services)
} else if (msg.node_id && msg.status) {
updateNode(msg.node_id, {
// Live status is monitoring data, not a user edit — must not dirty the
// canvas (otherwise autosave rewrites an untouched canvas every cycle).
setNodeStatus(msg.node_id, {
status: msg.status,
response_time_ms: msg.response_time_ms ?? undefined,
last_seen: msg.status === 'online' ? msg.checked_at : undefined,
@@ -69,5 +71,5 @@ export function useStatusPolling() {
ws.close()
wsRef.current = null
}
}, [isAuthenticated, token, updateNode, notifyScanDeviceFound, setServiceStatuses])
}, [isAuthenticated, token, setNodeStatus, notifyScanDeviceFound, setServiceStatuses])
}
@@ -169,6 +169,23 @@ describe('canvasStore — containers & nesting', () => {
expect(updatedChild?.extent).toBeUndefined()
})
// Regression: editing any field on a container host (e.g. its icon) used to
// re-fire setProxmoxContainerMode(true) because the modal always re-sends
// container_mode. Re-running the ON transition re-applied the
// absolute->relative conversion to children that were ALREADY relative,
// collapsing them into a corner. A redundant ON call must now be a no-op for
// already-nested children.
it('setProxmoxContainerMode ON is idempotent for already-nested children', () => {
const proxmox: Node<NodeData> = { id: 'px', type: 'proxmox', position: { x: 500, y: -300 }, width: 852, height: 212, data: { label: 'px', type: 'proxmox', status: 'unknown', services: [], container_mode: true } }
const child: Node<NodeData> = { id: 'vm1', type: 'vm', position: { x: 10, y: 62 }, data: { label: 'vm1', type: 'vm', status: 'unknown', services: [], parent_id: 'px' }, parentId: 'px', extent: 'parent' }
useCanvasStore.setState({ nodes: [proxmox, child] })
useCanvasStore.getState().setProxmoxContainerMode('px', true)
const c = useCanvasStore.getState().nodes.find((n) => n.id === 'vm1')
expect(c?.position).toEqual({ x: 10, y: 62 })
expect(c?.parentId).toBe('px')
expect(c?.extent).toBe('parent')
})
it('loadCanvas sorts parents before children', () => {
const parent = makeNode('p1')
const child: Node<NodeData> = { ...makeNode('c1', { parent_id: 'p1' }), parentId: 'p1', extent: 'parent' }
@@ -85,4 +85,31 @@ describe('canvasStore — history', () => {
addNode(makeNode('n3'))
expect(useCanvasStore.getState().future).toHaveLength(0)
})
// --- Auto Layout keeps history so it can be undone (#280) ---
it('applyLayout snapshots history and can be undone', () => {
const { addNode, applyLayout, undo } = useCanvasStore.getState()
addNode(makeNode('n1'))
const before = useCanvasStore.getState().nodes
// simulate an auto-layout that moves the node
applyLayout([{ ...before[0], position: { x: 500, y: 500 } }], [])
expect(useCanvasStore.getState().nodes[0].position).toEqual({ x: 500, y: 500 })
expect(useCanvasStore.getState().past).toHaveLength(1)
undo()
expect(useCanvasStore.getState().nodes[0].position).toEqual({ x: 0, y: 0 })
})
it('applyLayout marks the canvas unsaved and clears future', () => {
const { addNode, snapshotHistory, undo, applyLayout } = useCanvasStore.getState()
addNode(makeNode('n1'))
snapshotHistory()
addNode(makeNode('n2'))
undo() // creates a future entry
expect(useCanvasStore.getState().future).toHaveLength(1)
applyLayout(useCanvasStore.getState().nodes, [])
const s = useCanvasStore.getState()
expect(s.hasUnsavedChanges).toBe(true)
expect(s.future).toHaveLength(0)
})
})
@@ -9,6 +9,7 @@ function resetStore() {
nodes: [],
edges: [],
hasUnsavedChanges: false,
editSeq: 0,
selectedNodeId: null,
selectedNodeIds: [],
editingGroupRectId: null,
@@ -52,6 +53,79 @@ describe('canvasStore — nodes', () => {
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
})
it('setNodeStatus updates node status fields', () => {
useCanvasStore.getState().addNode(makeNode('n1'))
useCanvasStore.getState().setNodeStatus('n1', {
status: 'online',
response_time_ms: 42,
last_seen: '2024-01-01T12:00:00Z',
})
const node = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
expect(node?.data.status).toBe('online')
expect(node?.data.response_time_ms).toBe(42)
expect(node?.data.last_seen).toBe('2024-01-01T12:00:00Z')
})
it('setNodeStatus does NOT mark the canvas unsaved (live status is not a user edit)', () => {
useCanvasStore.getState().addNode(makeNode('n1'))
useCanvasStore.setState({ hasUnsavedChanges: false })
useCanvasStore.getState().setNodeStatus('n1', { status: 'offline' })
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
})
it('onNodesChange does NOT dirty the canvas on an initial dimensions measure', () => {
useCanvasStore.getState().addNode(makeNode({ id: 'n1' }))
useCanvasStore.setState({ hasUnsavedChanges: false })
useCanvasStore.getState().onNodesChange([
{ id: 'n1', type: 'dimensions', dimensions: { width: 120, height: 40 } },
])
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
})
it('onNodesChange DOES dirty the canvas on a user resize (resizing: true)', () => {
useCanvasStore.getState().addNode(makeNode({ id: 'n1' }))
useCanvasStore.setState({ hasUnsavedChanges: false })
useCanvasStore.getState().onNodesChange([
{ id: 'n1', type: 'dimensions', dimensions: { width: 200, height: 80 }, resizing: true },
])
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
it('onNodesChange does NOT dirty the canvas on a select-only change', () => {
useCanvasStore.getState().addNode(makeNode({ id: 'n1' }))
useCanvasStore.setState({ hasUnsavedChanges: false })
useCanvasStore.getState().onNodesChange([{ id: 'n1', type: 'select', selected: true }])
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
})
it('editSeq bumps on a real user edit but not on status/select/measure churn', () => {
const seq = () => useCanvasStore.getState().editSeq
// Real edit bumps.
const before = seq()
useCanvasStore.getState().addNode(makeNode('n1'))
expect(seq()).toBe(before + 1)
// Live status update: no bump (not a user edit).
const afterAdd = seq()
useCanvasStore.getState().setNodeStatus('n1', { status: 'online' })
expect(seq()).toBe(afterAdd)
// Select-only change: no bump.
useCanvasStore.getState().onNodesChange([{ id: 'n1', type: 'select', selected: true }])
expect(seq()).toBe(afterAdd)
// Initial dimensions measure: no bump.
useCanvasStore.getState().onNodesChange([
{ id: 'n1', type: 'dimensions', dimensions: { width: 120, height: 40 } },
])
expect(seq()).toBe(afterAdd)
// Another real edit bumps again.
useCanvasStore.getState().updateNode('n1', { label: 'renamed' })
expect(seq()).toBe(afterAdd + 1)
})
it('setEditingTextId sets and clears editing text id', () => {
const { setEditingTextId } = useCanvasStore.getState()
setEditingTextId('t1')
@@ -118,4 +118,53 @@ describe('canvasStore — sizing & z-order', () => {
useCanvasStore.getState().updateNode('r1', { properties: [] })
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'r1')?.height).toBe(240)
})
// Regression (#278): editing any field on a container-mode vm/lxc/docker_host host used to
// reset its manually-set height to undefined, snapping it back to auto-fit size and
// scrambling nested children. Only proxmox was excluded. Keep the manual height for every
// container-mode host type.
it.each(['vm', 'lxc', 'docker_host'] as const)(
'updateNode preserves manual height for a container-mode %s when properties change',
(type) => {
const host: Node<NodeData> = {
id: 'h1',
type,
position: { x: 0, y: 0 },
width: 400,
height: 300,
data: { label: type, type, status: 'unknown', services: [], container_mode: true },
}
useCanvasStore.setState({ nodes: [host], edges: [] })
useCanvasStore.getState().updateNode('h1', { properties: [], label: 'renamed' })
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'h1')?.height).toBe(300)
}
)
it('updateNode still resets height for a vm NOT in container mode when properties change', () => {
const leaf: Node<NodeData> = {
id: 'v1',
type: 'vm',
position: { x: 0, y: 0 },
width: 200,
height: 120,
data: { label: 'vm', type: 'vm', status: 'unknown', services: [], container_mode: false },
}
useCanvasStore.setState({ nodes: [leaf], edges: [] })
useCanvasStore.getState().updateNode('v1', { properties: [] })
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'v1')?.height).toBeUndefined()
})
it('updateNode preserves height for a proxmox host when properties change', () => {
const px: Node<NodeData> = {
id: 'px1',
type: 'proxmox',
position: { x: 0, y: 0 },
width: 500,
height: 350,
data: { label: 'px', type: 'proxmox', status: 'unknown', services: [], container_mode: true },
}
useCanvasStore.setState({ nodes: [px], edges: [] })
useCanvasStore.getState().updateNode('px1', { properties: [] })
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'px1')?.height).toBe(350)
})
})
@@ -0,0 +1,105 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useCanvasStore } from '@/stores/canvasStore'
import type { NodeChange, Node } from '@xyflow/react'
import type { NodeData } from '@/types'
import { makeNode, makeEdge } from '@/test/factories'
function resetStore() {
useCanvasStore.setState({
nodes: [],
edges: [],
hasUnsavedChanges: false,
selectedNodeId: null,
selectedNodeIds: [],
editingGroupRectId: null,
editingTextId: null,
past: [],
future: [],
clipboard: { nodes: [], edges: [] },
serviceStatuses: {},
floorMap: null,
})
}
/** A `position` NodeChange as React Flow emits during/after a drag. */
function posChange(id: string, x: number, y: number): NodeChange<Node<NodeData>> {
return { type: 'position', id, position: { x, y }, dragging: false }
}
describe('canvasStore — edge waypoints follow dragged nodes (#279)', () => {
beforeEach(resetStore)
it('translates waypoints by the delta when a connected node is dragged', () => {
useCanvasStore.setState({
nodes: [makeNode('a', {}), makeNode('b', {})],
edges: [makeEdge('e1', 'a', 'b', { data: { type: 'ethernet', waypoints: [{ x: 100, y: 100 }, { x: 150, y: 120 }] } })],
})
// node 'a' starts at (0,0), dragged to (40,-10) → delta (40,-10)
useCanvasStore.getState().onNodesChange([posChange('a', 40, -10)])
const wps = useCanvasStore.getState().edges[0].data!.waypoints
expect(wps).toEqual([{ x: 140, y: 90 }, { x: 190, y: 110 }])
})
it('leaves waypoints untouched when an unrelated node is dragged', () => {
useCanvasStore.setState({
nodes: [makeNode('a', {}), makeNode('b', {}), makeNode('c', {})],
edges: [makeEdge('e1', 'a', 'b', { data: { type: 'ethernet', waypoints: [{ x: 100, y: 100 }] } })],
})
useCanvasStore.getState().onNodesChange([posChange('c', 40, 40)])
expect(useCanvasStore.getState().edges[0].data!.waypoints).toEqual([{ x: 100, y: 100 }])
})
it('translates waypoints of edges between children when their container is dragged', () => {
// Children are parent-relative; their stored position does NOT change when
// the container moves, so the fix must propagate the container delta down.
useCanvasStore.setState({
nodes: [
makeNode({ id: 'box', type: 'group', position: { x: 0, y: 0 }, data: { label: 'box', type: 'lxc', container_mode: true } }),
makeNode({ id: 'a', position: { x: 20, y: 20 }, parentId: 'box' }),
makeNode({ id: 'b', position: { x: 80, y: 60 }, parentId: 'box' }),
],
edges: [makeEdge('e1', 'a', 'b', { data: { type: 'ethernet', waypoints: [{ x: 200, y: 200 }] } })],
})
// Drag the container from (0,0) to (30,15) → delta (30,15)
useCanvasStore.getState().onNodesChange([posChange('box', 30, 15)])
expect(useCanvasStore.getState().edges[0].data!.waypoints).toEqual([{ x: 230, y: 215 }])
})
it('translates once (not doubled) when both endpoints move by the same delta', () => {
// A container drag reports the container moving; both children ride along.
useCanvasStore.setState({
nodes: [
makeNode({ id: 'box', type: 'group', position: { x: 0, y: 0 }, data: { label: 'box', type: 'lxc', container_mode: true } }),
makeNode({ id: 'a', position: { x: 20, y: 20 }, parentId: 'box' }),
makeNode({ id: 'b', position: { x: 80, y: 60 }, parentId: 'box' }),
],
edges: [makeEdge('e1', 'a', 'b', { data: { type: 'ethernet', waypoints: [{ x: 100, y: 100 }] } })],
})
useCanvasStore.getState().onNodesChange([posChange('box', 10, 10)])
// +10 once, not +20
expect(useCanvasStore.getState().edges[0].data!.waypoints).toEqual([{ x: 110, y: 110 }])
})
it('does not alter edges without waypoints', () => {
useCanvasStore.setState({
nodes: [makeNode('a', {}), makeNode('b', {})],
edges: [makeEdge('e1', 'a', 'b')],
})
const before = useCanvasStore.getState().edges[0]
useCanvasStore.getState().onNodesChange([posChange('a', 40, 40)])
expect(useCanvasStore.getState().edges[0]).toBe(before)
})
it('ignores select-only changes (no movement)', () => {
useCanvasStore.setState({
nodes: [makeNode('a', {}), makeNode('b', {})],
edges: [makeEdge('e1', 'a', 'b', { data: { type: 'ethernet', waypoints: [{ x: 100, y: 100 }] } })],
})
useCanvasStore.getState().onNodesChange([{ type: 'select', id: 'a', selected: true }])
expect(useCanvasStore.getState().edges[0].data!.waypoints).toEqual([{ x: 100, y: 100 }])
})
})
@@ -0,0 +1,58 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useWalkthroughStore } from '../walkthroughStore'
import { readWalkthrough, WALKTHROUGH_VERSION } from '@/utils/walkthrough'
beforeEach(() => {
localStorage.clear()
useWalkthroughStore.setState({ isActive: false, stepIndex: 0, total: 0 })
})
describe('walkthroughStore', () => {
it('start activates at step 0', () => {
useWalkthroughStore.getState().start()
const s = useWalkthroughStore.getState()
expect(s.isActive).toBe(true)
expect(s.stepIndex).toBe(0)
})
it('next advances and finishes past the last step', () => {
const store = useWalkthroughStore.getState()
store.start()
store.setTotal(3)
store.next()
expect(useWalkthroughStore.getState().stepIndex).toBe(1)
store.next()
expect(useWalkthroughStore.getState().stepIndex).toBe(2)
// At the last step, next() finishes the tour and stamps it completed.
store.next()
expect(useWalkthroughStore.getState().isActive).toBe(false)
expect(readWalkthrough()).toEqual({ seenVersion: WALKTHROUGH_VERSION, status: 'completed' })
})
it('prev clamps at 0', () => {
const store = useWalkthroughStore.getState()
store.start()
store.setTotal(3)
store.prev()
expect(useWalkthroughStore.getState().stepIndex).toBe(0)
})
it('goTo clamps within [0, total-1]', () => {
const store = useWalkthroughStore.getState()
store.start()
store.setTotal(3)
store.goTo(9)
expect(useWalkthroughStore.getState().stepIndex).toBe(2)
store.goTo(-1)
expect(useWalkthroughStore.getState().stepIndex).toBe(0)
})
it('skip deactivates and stamps skipped', () => {
const store = useWalkthroughStore.getState()
store.start()
store.skip()
expect(useWalkthroughStore.getState().isActive).toBe(false)
expect(readWalkthrough().status).toBe('skipped')
expect(readWalkthrough().seenVersion).toBe(WALKTHROUGH_VERSION)
})
})
+186 -8
View File
@@ -3,6 +3,7 @@ import {
type Node,
type Edge,
type NodeChange,
type NodePositionChange,
type EdgeChange,
type Connection,
applyNodeChanges,
@@ -13,6 +14,7 @@ import { generateUUID } from '@/utils/uuid'
import { normalizeHandle, removedHandleIds, handleCountField, sideDefault, handleId, SIDES } from '@/utils/handleUtils'
import { applyOpacity } from '@/utils/colorUtils'
import { readHideIp, writeHideIp } from '@/utils/ipDisplay'
import { CONTAINER_MODE_TYPES } from '@/utils/virtualEdgeParent'
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
type Clipboard = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
@@ -20,6 +22,85 @@ 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
/**
* Whether a node change represents a real user edit that should dirty the canvas.
* Excludes:
* - 'select': selecting a node changes nothing persisted.
* - 'dimensions' without resizing: React Flow emits these when it first measures
* a node's size after mount/load. Counting them as edits marks a freshly
* loaded canvas dirty before the user touches anything (autosave would then
* save on every load). A user-driven resize sets `resizing === true` and still
* dirties.
*/
function isUserNodeEdit(c: NodeChange<Node<NodeData>>): boolean {
if (c.type === 'select') return false
if (c.type === 'dimensions' && c.resizing !== true) return false
return true
}
/**
* Keep manually-routed edge waypoints attached to their nodes on drag (#279).
*
* Waypoints live in absolute canvas coords, so they don't move when a connected
* node is dragged. For every node that moved on screen we translate the
* waypoints of edges touching it by the same delta. A dragged container moves
* its children on screen too their stored (parent-relative) position is
* unchanged, so we propagate the container's delta down to every descendant.
*/
function translateWaypointsForMovedNodes(
changes: NodeChange<Node<NodeData>>[],
prevNodes: Node<NodeData>[],
nextNodes: Node<NodeData>[],
edges: Edge<EdgeData>[],
): Edge<EdgeData>[] {
const positionChanges = changes.filter(
(c): c is NodePositionChange => c.type === 'position' && !!c.position,
)
if (positionChanges.length === 0) return edges
const prevById = new Map(prevNodes.map((n) => [n.id, n]))
// node id -> absolute screen delta it moved by
const deltaById = new Map<string, { dx: number; dy: number }>()
for (const ch of positionChanges) {
const prev = prevById.get(ch.id)
if (!prev) continue
const dx = ch.position!.x - prev.position.x
const dy = ch.position!.y - prev.position.y
if (dx === 0 && dy === 0) continue
deltaById.set(ch.id, { dx, dy })
}
if (deltaById.size === 0) return edges
const childrenByParent = new Map<string, string[]>()
for (const n of nextNodes) {
const pid = parentIdOf(n)
if (!pid) continue
const arr = childrenByParent.get(pid) ?? []
arr.push(n.id)
childrenByParent.set(pid, arr)
}
const propagate = (id: string, d: { dx: number; dy: number }) => {
for (const childId of childrenByParent.get(id) ?? []) {
// A directly-dragged child keeps its own delta; don't overwrite it.
if (!deltaById.has(childId)) deltaById.set(childId, d)
propagate(childId, d)
}
}
for (const [id, d] of [...deltaById.entries()]) propagate(id, d)
return edges.map((e) => {
const data = e.data
if (!data?.waypoints?.length) return e
// Both endpoints may have moved (container drag): translate once.
const d = deltaById.get(e.source) ?? deltaById.get(e.target)
if (!d) return e
return {
...e,
data: { ...data, waypoints: data.waypoints.map((wp) => ({ x: wp.x + d.dx, y: wp.y + d.dy })) },
}
})
}
/** Key for the live per-service status overlay. */
export const serviceStatusKey = (nodeId: string, port?: number, protocol?: string): string =>
`${nodeId}:${port ?? ''}/${protocol ?? ''}`
@@ -28,6 +109,14 @@ interface CanvasState {
nodes: Node<NodeData>[]
edges: Edge<EdgeData>[]
hasUnsavedChanges: boolean
/**
* Monotonic counter incremented on every real user edit (auto-bumped whenever
* an action sets hasUnsavedChanges to true). Consumers that need to react to
* *edits specifically* e.g. the autosave debounce key off this instead of
* the nodes/edges array identity, which also churns on live status updates and
* selection changes that must NOT reset the debounce.
*/
editSeq: number
selectedNodeId: string | null
selectedNodeIds: string[]
scanEventTs: number
@@ -62,6 +151,14 @@ interface CanvasState {
setSelectedNode: (id: string | null) => void
addNode: (node: Node<NodeData>) => void
updateNode: (id: string, data: Partial<NodeData>) => void
/**
* Apply a live status update to a node WITHOUT marking the canvas unsaved.
* Status (online/offline, response time, last seen) is transient monitoring
* data pushed by the backend, not a user edit dirtying the canvas here would
* make autosave rewrite an untouched canvas on every status cycle and could
* clobber edits made elsewhere. Mirrors setServiceStatuses' live-overlay rule.
*/
setNodeStatus: (id: string, status: Pick<NodeData, 'status' | 'response_time_ms' | 'last_seen'>) => void
deleteNode: (id: string) => void
updateEdge: (id: string, data: Partial<EdgeData>) => void
reconnectEdge: (id: string, connection: Connection) => void
@@ -82,6 +179,10 @@ interface CanvasState {
markSaved: () => void
markUnsaved: () => void
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
/** In-place canvas replacement (e.g. Auto Layout) that KEEPS undo history and
* marks the canvas unsaved. Unlike loadCanvas, it does not wipe past/future
* loadCanvas is for switching designs, this is for transforming the current one. */
applyLayout: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
fitViewPending: boolean
clearFitViewPending: () => void
notifyScanDeviceFound: () => void
@@ -94,10 +195,33 @@ interface CanvasState {
applyAllCustomStyles: (def: CustomStyleDef) => void
}
export const useCanvasStore = create<CanvasState>((set) => ({
export const useCanvasStore = create<CanvasState>((rawSet) => {
// Wrap set so any update that flips hasUnsavedChanges to true also bumps
// editSeq. This centralises the "an edit happened" signal instead of touching
// every one of the ~two dozen mutating actions. Actions that update state
// without dirtying (setNodeStatus, markSaved, loadCanvas, selection) omit
// hasUnsavedChanges or set it false, so they never bump the counter.
const set: typeof rawSet = ((partial, replace) => {
rawSet((state) => {
const next = typeof partial === 'function'
? (partial as (s: CanvasState) => Partial<CanvasState>)(state)
: partial
if (
next &&
typeof next === 'object' &&
(next as Partial<CanvasState>).hasUnsavedChanges === true &&
!('editSeq' in next)
) {
return { ...next, editSeq: state.editSeq + 1 }
}
return next
}, replace as false | undefined)
}) as typeof rawSet
return {
nodes: [],
edges: [],
hasUnsavedChanges: false,
editSeq: 0,
selectedNodeId: null,
selectedNodeIds: [],
editingGroupRectId: null,
@@ -250,18 +374,30 @@ export const useCanvasStore = create<CanvasState>((set) => ({
set((state) => {
const nodes = applyNodeChanges(changes, state.nodes)
const selectedNodeIds = nodes.filter((n) => n.selected).map((n) => n.id)
// Manually-placed edge waypoints are stored as absolute canvas coords, so
// they don't follow a moved node on their own. Translate them by the same
// delta the node moved so a clean routing stays clean after a drag (#279).
const edges = translateWaypointsForMovedNodes(changes, state.nodes, nodes, state.edges)
// Only set hasUnsavedChanges when a real edit occurred, so the set() wrapper
// bumps editSeq only then. Selection- or measure-only changes leave the flag
// untouched (carried over) and must not reset the autosave debounce.
const edited = changes.some(isUserNodeEdit)
return {
nodes,
edges,
selectedNodeIds,
hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'),
...(edited ? { hasUnsavedChanges: true } : {}),
}
}),
onEdgesChange: (changes) =>
set((state) => ({
set((state) => {
const edited = changes.some((c) => c.type !== 'select')
return {
edges: applyEdgeChanges(changes, state.edges),
hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'),
})),
...(edited ? { hasUnsavedChanges: true } : {}),
}
}),
onConnect: (connection) =>
set((state) => {
@@ -325,8 +461,13 @@ export const useCanvasStore = create<CanvasState>((set) => ({
let nodes = state.nodes.map((n) => {
if (n.id !== id) return n
const updated: Node<NodeData> = { ...n, data: { ...n.data, ...data } }
// When properties change, clear stored height so the node auto-sizes to fit new content
if ('properties' in data && n.data.type !== 'proxmox' && n.data.type !== 'groupRect' && n.data.type !== 'group') {
// When properties change, clear stored height so the node auto-sizes to fit new content.
// A container-mode host (vm/lxc/docker_host) keeps a manually-set height: resetting it
// snaps the container back to auto-fit size and scrambles its nested children (#278).
// proxmox is always excluded (legacy behavior); the other container types are excluded
// only while actually in container mode.
const isContainerHost = CONTAINER_MODE_TYPES.has(n.data.type) && !!n.data.container_mode
if ('properties' in data && n.data.type !== 'proxmox' && n.data.type !== 'groupRect' && n.data.type !== 'group' && !isContainerHost) {
updated.height = undefined
}
if ('parent_id' in data) {
@@ -389,6 +530,18 @@ export const useCanvasStore = create<CanvasState>((set) => ({
return { nodes, edges, hasUnsavedChanges: true }
}),
setNodeStatus: (id, status) =>
set((state) => {
let changed = false
const nodes = state.nodes.map((n) => {
if (n.id !== id) return n
changed = true
return { ...n, data: { ...n.data, ...status } }
})
// No hasUnsavedChanges: live status is monitoring data, not a user edit.
return changed ? { nodes } : {}
}),
deleteNode: (id) =>
set((state) => {
const idsToRemove = new Set<string>()
@@ -448,7 +601,14 @@ export const useCanvasStore = create<CanvasState>((set) => ({
: { ...withMode, width: undefined, height: undefined }
}
if (n.data.parent_id === proxmoxId) {
// Idempotency guard: only convert a child's position when its nesting
// state actually changes. A child that already matches the target mode
// keeps its position untouched -- re-running the absolute<->relative
// conversion on an already-relative child corrupts it (children pile
// into a corner). See handleUpdateNode in App.tsx.
const alreadyNested = n.parentId === proxmoxId && n.extent === 'parent'
if (enabled && parentNode) {
if (alreadyNested) return n
return {
...n,
parentId: proxmoxId,
@@ -460,6 +620,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
}
}
if (!enabled && parentNode) {
if (!n.parentId) return n
return {
...n,
parentId: undefined,
@@ -787,6 +948,22 @@ export const useCanvasStore = create<CanvasState>((set) => ({
set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], fitViewPending: true })
},
applyLayout: (nodes, edges) =>
set((state) => {
// React Flow requires parents before children in the array
const parents = nodes.filter((n) => !n.parentId)
const children = nodes.filter((n) => !!n.parentId)
return {
nodes: [...parents, ...children],
edges,
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
future: [],
hasUnsavedChanges: true,
selectedNodeId: null,
fitViewPending: true,
}
}),
clearFitViewPending: () => set({ fitViewPending: false }),
applyTypeNodeStyle: (nodeType, style) =>
@@ -874,4 +1051,5 @@ export const useCanvasStore = create<CanvasState>((set) => ({
})
return { nodes, edges, hasUnsavedChanges: true }
}),
}))
}
})
+48
View File
@@ -0,0 +1,48 @@
import { create } from 'zustand'
import { markWalkthroughSeen } from '@/utils/walkthrough'
/**
* Runtime state of an active Getting Started tour. Persistence of the "already
* seen" flag lives in utils/walkthrough.ts; this store only tracks the live run.
*
* `total` is set by the overlay from the mode-filtered step list, so next()/goTo
* can clamp and finish() fires when the user advances past the last step.
*/
interface WalkthroughStore {
isActive: boolean
stepIndex: number
total: number
setTotal: (n: number) => void
start: () => void
next: () => void
prev: () => void
goTo: (n: number) => void
skip: () => void
finish: () => void
}
export const useWalkthroughStore = create<WalkthroughStore>((set, get) => ({
isActive: false,
stepIndex: 0,
total: 0,
setTotal: (n) => set({ total: n }),
start: () => set({ isActive: true, stepIndex: 0 }),
next: () => {
const { stepIndex, total } = get()
if (stepIndex >= total - 1) {
get().finish()
return
}
set({ stepIndex: stepIndex + 1 })
},
prev: () => set((s) => ({ stepIndex: Math.max(0, s.stepIndex - 1) })),
goTo: (n) => set((s) => ({ stepIndex: Math.max(0, Math.min(n, Math.max(0, s.total - 1))) })),
skip: () => {
markWalkthroughSeen('skipped')
set({ isActive: false })
},
finish: () => {
markWalkthroughSeen('completed')
set({ isActive: false })
},
}))
+3
View File
@@ -34,4 +34,7 @@ export interface YamlNode {
bottomHandles?: number
leftHandles?: number
rightHandles?: number
// Whether port-number labels are shown next to connection points. Only written
// when enabled so the toggle round-trips through export/import (issue #272).
showPortNumbers?: boolean
}
@@ -0,0 +1,63 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
DEFAULT_AUTOSAVE_SETTINGS,
readAutosaveSettings,
writeAutosaveSettings,
subscribeAutosaveSettings,
} from '../autosaveSettings'
describe('autosaveSettings', () => {
beforeEach(() => {
localStorage.clear()
})
it('defaults to disabled with a 5s delay', () => {
expect(DEFAULT_AUTOSAVE_SETTINGS).toEqual({ enabled: false, delay: 5 })
})
it('returns defaults when nothing stored', () => {
expect(readAutosaveSettings()).toEqual(DEFAULT_AUTOSAVE_SETTINGS)
})
it('roundtrips through localStorage', () => {
writeAutosaveSettings({ enabled: true, delay: 30 })
expect(readAutosaveSettings()).toEqual({ enabled: true, delay: 30 })
})
it('falls back to defaults when stored value is corrupted', () => {
localStorage.setItem('homelable.autosave', '{not json')
expect(readAutosaveSettings()).toEqual(DEFAULT_AUTOSAVE_SETTINGS)
})
it('fills missing fields from defaults', () => {
localStorage.setItem('homelable.autosave', JSON.stringify({ enabled: true }))
expect(readAutosaveSettings()).toEqual({ enabled: true, delay: DEFAULT_AUTOSAVE_SETTINGS.delay })
})
it('rejects a non-positive delay and falls back to default', () => {
localStorage.setItem('homelable.autosave', JSON.stringify({ enabled: true, delay: 0 }))
expect(readAutosaveSettings().delay).toBe(DEFAULT_AUTOSAVE_SETTINGS.delay)
localStorage.setItem('homelable.autosave', JSON.stringify({ enabled: true, delay: -10 }))
expect(readAutosaveSettings().delay).toBe(DEFAULT_AUTOSAVE_SETTINGS.delay)
})
it('rejects a non-numeric delay and falls back to default', () => {
localStorage.setItem('homelable.autosave', JSON.stringify({ enabled: true, delay: 'soon' }))
expect(readAutosaveSettings().delay).toBe(DEFAULT_AUTOSAVE_SETTINGS.delay)
})
it('rejects a non-boolean enabled and falls back to default', () => {
localStorage.setItem('homelable.autosave', JSON.stringify({ enabled: 'yes', delay: 10 }))
expect(readAutosaveSettings().enabled).toBe(DEFAULT_AUTOSAVE_SETTINGS.enabled)
})
it('notifies subscribers on write and stops after unsubscribe', () => {
const listener = vi.fn()
const unsubscribe = subscribeAutosaveSettings(listener)
writeAutosaveSettings({ enabled: true, delay: 10 })
expect(listener).toHaveBeenCalledWith({ enabled: true, delay: 10 })
unsubscribe()
writeAutosaveSettings({ enabled: false, delay: 3 })
expect(listener).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest'
import { decideCanvasLoad, isNewUserCanvas } from '../canvasLoadDecision'
describe('decideCanvasLoad', () => {
it('renders real nodes when the canvas has any', () => {
expect(decideCanvasLoad(true, true)).toBe('real')
// hasNodes wins even if the initialized flag is somehow false
expect(decideCanvasLoad(true, false)).toBe('real')
})
it('keeps an initialized-but-empty canvas empty (user cleared it)', () => {
expect(decideCanvasLoad(false, true)).toBe('empty')
})
it('shows the demo only for a brand-new, uninitialized canvas', () => {
expect(decideCanvasLoad(false, false)).toBe('demo')
})
})
describe('isNewUserCanvas', () => {
it('is true only for the demo mode', () => {
expect(isNewUserCanvas('demo')).toBe(true)
expect(isNewUserCanvas('empty')).toBe(false)
expect(isNewUserCanvas('real')).toBe(false)
})
})
@@ -208,4 +208,12 @@ describe('exportCanvasToYaml', () => {
expect(entry).not.toHaveProperty('topHandles')
expect(entry).not.toHaveProperty('leftHandles')
})
it('exports showPortNumbers only when enabled (issue #272)', () => {
const on = (yaml.load(exportCanvasToYaml([makeNode({ label: 'On', type: 'server', show_port_numbers: true })], [])) as Record<string, unknown>[])[0]
expect(on.showPortNumbers).toBe(true)
const off = (yaml.load(exportCanvasToYaml([makeNode({ label: 'Off', type: 'server', show_port_numbers: false })], [])) as Record<string, unknown>[])[0]
expect(off).not.toHaveProperty('showPortNumbers')
})
})
@@ -368,4 +368,30 @@ describe('parseYamlToCanvas', () => {
expect(edges[0].targetHandle).toBe('top-t')
expect(nodes.find((n) => n.data.label === 'Switch')!.data.bottom_handles).toBe(3)
})
// Regression for issue #272.
it('restores the show-port-numbers toggle from YAML', () => {
const yaml = `
- nodeType: server
label: "Server"
showPortNumbers: true
`
const { nodes } = parseYamlToCanvas(yaml, empty, emptyEdges)
expect(nodes.find((n) => n.data.label === 'Server')!.data.show_port_numbers).toBe(true)
})
it('round-trips show-port-numbers through export → import (issue #272)', () => {
const on: Node<NodeData> = {
id: 'on', type: 'server', position: { x: 0, y: 0 },
data: { label: 'On', type: 'server', status: 'online', services: [], show_port_numbers: true },
}
const off: Node<NodeData> = {
id: 'off', type: 'server', position: { x: 0, y: 0 },
data: { label: 'Off', type: 'server', status: 'online', services: [] },
}
const yamlStr = exportCanvasToYaml([on, off], [])
const { nodes } = parseYamlToCanvas(yamlStr, empty, emptyEdges)
expect(nodes.find((n) => n.data.label === 'On')!.data.show_port_numbers).toBe(true)
expect(nodes.find((n) => n.data.label === 'Off')!.data.show_port_numbers).toBeUndefined()
})
})
@@ -0,0 +1,66 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
WALKTHROUGH_VERSION,
readWalkthrough,
writeWalkthrough,
subscribeWalkthrough,
shouldOfferWalkthrough,
markWalkthroughSeen,
resetWalkthrough,
} from '../walkthrough'
beforeEach(() => localStorage.clear())
describe('readWalkthrough', () => {
it('defaults to pending / unseen when nothing is stored', () => {
expect(readWalkthrough()).toEqual({ seenVersion: null, status: 'pending' })
})
it('round-trips written state', () => {
writeWalkthrough({ seenVersion: 3, status: 'completed' })
expect(readWalkthrough()).toEqual({ seenVersion: 3, status: 'completed' })
})
it('falls back to defaults on malformed json', () => {
localStorage.setItem('homelable.walkthrough', '{not json')
expect(readWalkthrough()).toEqual({ seenVersion: null, status: 'pending' })
})
})
describe('shouldOfferWalkthrough', () => {
it('offers a brand-new user (seenVersion null)', () => {
expect(shouldOfferWalkthrough()).toBe(true)
})
it('offers an upgrader whose seen version is behind', () => {
writeWalkthrough({ seenVersion: WALKTHROUGH_VERSION - 1, status: 'completed' })
expect(shouldOfferWalkthrough()).toBe(true)
})
it('stops offering once the current version is stamped', () => {
markWalkthroughSeen('completed')
expect(shouldOfferWalkthrough()).toBe(false)
expect(readWalkthrough().seenVersion).toBe(WALKTHROUGH_VERSION)
})
})
describe('resetWalkthrough', () => {
it('re-offers after a reset', () => {
markWalkthroughSeen('skipped')
expect(shouldOfferWalkthrough()).toBe(false)
resetWalkthrough()
expect(shouldOfferWalkthrough()).toBe(true)
})
})
describe('subscribeWalkthrough', () => {
it('notifies listeners on write and unsubscribes cleanly', () => {
const spy = vi.fn()
const unsub = subscribeWalkthrough(spy)
writeWalkthrough({ seenVersion: 1, status: 'completed' })
expect(spy).toHaveBeenCalledWith({ seenVersion: 1, status: 'completed' })
unsub()
writeWalkthrough({ seenVersion: 2, status: 'skipped' })
expect(spy).toHaveBeenCalledTimes(1)
})
})
+43
View File
@@ -0,0 +1,43 @@
// Persisted client-side autosave preference.
// Kept in localStorage (per-user UI preference, not canvas data).
// Same-tab updates propagate via a CustomEvent so App.tsx and SettingsModal
// can stay in sync without a global store.
export interface AutosaveSettings {
enabled: boolean
delay: number // seconds of inactivity before auto-saving
}
export const DEFAULT_AUTOSAVE_SETTINGS: AutosaveSettings = { enabled: false, delay: 5 }
const KEY = 'homelable.autosave'
const EVENT = 'homelable:autosave-settings-changed'
export function readAutosaveSettings(): AutosaveSettings {
try {
const raw = localStorage.getItem(KEY)
if (!raw) return DEFAULT_AUTOSAVE_SETTINGS
const parsed = JSON.parse(raw) as Partial<AutosaveSettings>
return {
enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : DEFAULT_AUTOSAVE_SETTINGS.enabled,
delay: typeof parsed.delay === 'number' && parsed.delay > 0 ? parsed.delay : DEFAULT_AUTOSAVE_SETTINGS.delay,
}
} catch {
return DEFAULT_AUTOSAVE_SETTINGS
}
}
export function writeAutosaveSettings(s: AutosaveSettings): void {
try {
localStorage.setItem(KEY, JSON.stringify(s))
window.dispatchEvent(new CustomEvent<AutosaveSettings>(EVENT, { detail: s }))
} catch {
/* quota / SSR */
}
}
export function subscribeAutosaveSettings(listener: (s: AutosaveSettings) => void): () => void {
const handler = (e: Event) => listener((e as CustomEvent<AutosaveSettings>).detail)
window.addEventListener(EVENT, handler)
return () => window.removeEventListener(EVENT, handler)
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Decides what to render when a design's canvas loads.
*
* Three cases must stay distinct (issue: clearing a canvas re-showed the demo):
* - `real` the canvas has saved nodes; render them.
* - `empty` the canvas is initialized (ever saved / explicitly created) but
* has no nodes; the user intentionally cleared it keep it empty.
* - `demo` brand-new, never-initialized canvas; seed the demo so first-time
* users see an example (and, later, the Getting Started walkthrough).
*
* Backend errors are handled by the caller, NOT here a failed load must show an
* error and must never fall back to `demo` (that would hide the real failure).
*/
export type CanvasLoadMode = 'real' | 'empty' | 'demo'
export function decideCanvasLoad(hasNodes: boolean, initialized: boolean): CanvasLoadMode {
if (hasNodes) return 'real'
if (initialized) return 'empty'
return 'demo'
}
/** A new user is one who lands on the demo canvas — the Getting Started hook. */
export function isNewUserCanvas(mode: CanvasLoadMode): boolean {
return mode === 'demo'
}
+3
View File
@@ -92,6 +92,9 @@ export function exportCanvasToYaml(nodes: Node<NodeData>[], edges: Edge<EdgeData
// Custom connection-point counts, so imported edges land on real slots.
attachHandleCounts(entry, d)
// Preserve the port-number toggle so it survives a round-trip (issue #272).
if (d.show_port_numbers) entry.showPortNumbers = true
// Parent relationship: if this node has a parentId in React Flow,
// encode it as a 'parent' connection using any virtual edge between them.
if (node.parentId) {
+2
View File
@@ -79,6 +79,8 @@ export function parseYamlToCanvas(
...(yn.bottomHandles ? { bottom_handles: yn.bottomHandles } : {}),
...(yn.leftHandles ? { left_handles: yn.leftHandles } : {}),
...(yn.rightHandles ? { right_handles: yn.rightHandles } : {}),
// Restore the port-number toggle (issue #272).
...(yn.showPortNumbers ? { show_port_numbers: true } : {}),
}
newNodes.push({
+1 -1
View File
@@ -2,7 +2,7 @@ import type { NodeData } from '@/types'
export type NodeType = NodeData['type']
const CONTAINER_MODE_TYPES = new Set<NodeType>(['proxmox', 'vm', 'lxc', 'docker_host'])
export const CONTAINER_MODE_TYPES = new Set<NodeType>(['proxmox', 'vm', 'lxc', 'docker_host'])
const DOCKER_CONTAINER_PARENT_TYPES = new Set<NodeType>(['docker_host', 'lxc', 'vm', 'proxmox'])
export interface VirtualEdgeEndpoint {
+73
View File
@@ -0,0 +1,73 @@
/**
* Getting Started walkthrough persisted invite state (localStorage).
*
* Mirrors the autosaveSettings.ts pattern: a `homelable.<name>` key plus a
* `window` CustomEvent for same-tab sync, so App and SettingsModal stay aligned
* without a global store.
*
* `seenVersion` is stamped with WALKTHROUGH_VERSION when the user finishes or
* dismisses the tour. Bump WALKTHROUGH_VERSION whenever new steps ship so
* existing users are re-offered the tour exactly once (they may have missed the
* new features). A brand-new user starts at seenVersion=null and is offered too.
*/
export const WALKTHROUGH_VERSION = 1
export type WalkthroughStatus = 'pending' | 'skipped' | 'completed'
export interface WalkthroughState {
seenVersion: number | null
status: WalkthroughStatus
}
const KEY = 'homelable.walkthrough'
const EVENT = 'homelable:walkthrough-changed'
export const DEFAULT_WALKTHROUGH_STATE: WalkthroughState = { seenVersion: null, status: 'pending' }
function isStatus(v: unknown): v is WalkthroughStatus {
return v === 'pending' || v === 'skipped' || v === 'completed'
}
export function readWalkthrough(): WalkthroughState {
try {
const raw = localStorage.getItem(KEY)
if (!raw) return { ...DEFAULT_WALKTHROUGH_STATE }
const parsed = JSON.parse(raw) as Partial<WalkthroughState>
return {
seenVersion: typeof parsed.seenVersion === 'number' ? parsed.seenVersion : null,
status: isStatus(parsed.status) ? parsed.status : 'pending',
}
} catch {
return { ...DEFAULT_WALKTHROUGH_STATE }
}
}
export function writeWalkthrough(state: WalkthroughState): void {
try {
localStorage.setItem(KEY, JSON.stringify(state))
window.dispatchEvent(new CustomEvent(EVENT, { detail: state }))
} catch {
// ignore quota / SSR
}
}
export function subscribeWalkthrough(listener: (s: WalkthroughState) => void): () => void {
const handler = (e: Event) => listener((e as CustomEvent<WalkthroughState>).detail)
window.addEventListener(EVENT, handler)
return () => window.removeEventListener(EVENT, handler)
}
/** True when the invite should be offered (new user, or upgraded past the last seen version). */
export function shouldOfferWalkthrough(state: WalkthroughState = readWalkthrough()): boolean {
return state.seenVersion !== WALKTHROUGH_VERSION
}
/** Stamp the current version so the invite stops auto-showing until the next bump. */
export function markWalkthroughSeen(status: Exclude<WalkthroughStatus, 'pending'>): void {
writeWalkthrough({ seenVersion: WALKTHROUGH_VERSION, status })
}
/** Clear the stamp so the tour is offered again (used by "Restart" in Settings). */
export function resetWalkthrough(): void {
writeWalkthrough({ ...DEFAULT_WALKTHROUGH_STATE })
}
@@ -0,0 +1,72 @@
import { useEffect, useState } from 'react'
import { Compass, X } from 'lucide-react'
import './walkthrough.css'
import { Button } from '@/components/ui/button'
import { useWalkthroughStore } from '@/stores/walkthroughStore'
import {
readWalkthrough,
subscribeWalkthrough,
shouldOfferWalkthrough,
markWalkthroughSeen,
type WalkthroughState,
} from '@/utils/walkthrough'
/**
* First-run invite bubble (bottom-right, above the toaster).
*
* - Not now hide for this session only (no write), reappears next load
* - Don't show again persist as skipped, stops offering until the next version
* - Getting started launch the tour
*/
export function WalkthroughInvite() {
const [state, setState] = useState<WalkthroughState>(readWalkthrough)
useEffect(() => subscribeWalkthrough(setState), [])
// "Not now" dismissal — session-local, not persisted.
const [dismissedThisSession, setDismissedThisSession] = useState(false)
const isActive = useWalkthroughStore((s) => s.isActive)
const start = useWalkthroughStore((s) => s.start)
if (isActive || dismissedThisSession || !shouldOfferWalkthrough(state)) return null
return (
<div className="fixed bottom-20 right-4 z-[90] w-80 max-w-[calc(100vw-2rem)] rounded-xl border border-[#30363d] bg-[#161b22] p-4 shadow-2xl walkthrough-invite-enter">
<button
onClick={() => setDismissedThisSession(true)}
aria-label="Not now"
className="absolute top-2 right-2 text-muted-foreground hover:text-foreground p-1 rounded transition-colors"
>
<X size={14} />
</button>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[#00d4ff]/10 text-[#00d4ff]">
<Compass size={18} />
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-foreground">New here? Take the tour</h3>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
A 2-minute walkthrough of scanning, devices, and building your canvas.
</p>
</div>
</div>
<div className="mt-3 flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={() => markWalkthroughSeen('skipped')}
>
Don't show again
</Button>
<Button
size="sm"
className="h-7 px-3 text-xs bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
onClick={start}
>
Getting started
</Button>
</div>
</div>
)
}
@@ -0,0 +1,248 @@
import { useEffect, useMemo, useState, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { ArrowLeft, ArrowRight, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useWalkthroughStore } from '@/stores/walkthroughStore'
import { useWalkthroughActions } from './actions'
import { getSteps, type StepPlacement } from './steps'
import './walkthrough.css'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const CARD_W = 320
const CARD_H_EST = 210
const GAP = 16
const PAD = 6 // spotlight padding around the target
/** Coach-card position from the target rect + requested placement. */
function cardPosition(rect: DOMRect | null, placement: StepPlacement): { left: number; top: number } {
const vw = window.innerWidth
const vh = window.innerHeight
const clampL = (l: number) => Math.max(8, Math.min(l, vw - CARD_W - 8))
const clampT = (t: number) => Math.max(8, Math.min(t, vh - CARD_H_EST - 8))
if (!rect) return { left: clampL((vw - CARD_W) / 2), top: clampT((vh - CARD_H_EST) / 2) }
let side = placement
if (side === 'auto') {
side = rect.right + GAP + CARD_W < vw ? 'right' : rect.left - GAP - CARD_W > 0 ? 'left' : 'bottom'
}
switch (side) {
case 'right': return { left: clampL(rect.right + GAP), top: clampT(rect.top) }
case 'left': return { left: clampL(rect.left - GAP - CARD_W), top: clampT(rect.top) }
case 'top': return { left: clampL(rect.left), top: clampT(rect.top - GAP - CARD_H_EST) }
case 'bottom': return { left: clampL(rect.left), top: clampT(rect.bottom + GAP) }
case 'center':
default: return { left: clampL((vw - CARD_W) / 2), top: clampT((vh - CARD_H_EST) / 2) }
}
}
/**
* Position the card relative to an OPEN MODAL (the step's real subject).
* Large modals (inventory, scan history at ~90vw) leave no room beside them, so
* the card drops into the empty bottom-right corner instead of covering content.
* Small modals get the card beside them, on whichever side has room.
*/
function dialogCardPosition(rect: DOMRect): { left: number; top: number } {
const vw = window.innerWidth
const vh = window.innerHeight
const clampT = (t: number) => Math.max(8, Math.min(t, vh - CARD_H_EST - 8))
const isLarge = rect.width > vw * 0.6 || rect.height > vh * 0.7
if (isLarge) return { left: vw - CARD_W - 16, top: vh - CARD_H_EST - 16 }
if (rect.left >= CARD_W + GAP * 2) return { left: rect.left - GAP - CARD_W, top: clampT(rect.top) }
if (vw - rect.right >= CARD_W + GAP * 2) return { left: rect.right + GAP, top: clampT(rect.top) }
return { left: Math.max(8, Math.min(rect.left, vw - CARD_W - 8)), top: clampT(rect.bottom + GAP) }
}
export function WalkthroughOverlay() {
const { isActive, stepIndex, total, setTotal, next, prev, skip } = useWalkthroughStore()
const actions = useWalkthroughActions()
const steps = useMemo(() => getSteps(STANDALONE), [])
const step = steps[stepIndex]
// anchorRect: the element the step rings (sidebar/toolbar button). dialogRect:
// any open app modal, always kept lit so it stays readable during modal steps.
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null)
const [dialogRect, setDialogRect] = useState<DOMRect | null>(null)
// Keep the store's total in sync with the mode-filtered step list.
useEffect(() => {
if (isActive) setTotal(steps.length)
}, [isActive, steps.length, setTotal])
// Run the step's action on enter (close leftovers, open the right modal / inject
// demo data). Close everything when the tour ends.
useEffect(() => {
if (!actions) return
if (!isActive) { actions.closeAll(); return }
actions.closeAll()
const key = step?.action as keyof typeof actions | undefined
if (key && typeof actions[key] === 'function') (actions[key] as () => void)()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isActive, stepIndex, actions])
// Track target rects. Polls each frame because modals mount asynchronously after
// the step action fires, and layout shifts as they animate in.
useEffect(() => {
if (!isActive) return
let raf = 0
const update = () => {
const anchorEl = step?.anchor ? document.querySelector(step.anchor) : null
setAnchorRect(anchorEl ? anchorEl.getBoundingClientRect() : null)
// Any open app modal, excluding our own coach card (inside the overlay).
const dialogEl = Array.from(document.querySelectorAll('[role="dialog"]'))
.find((el) => !el.closest('[data-walkthrough-overlay]'))
setDialogRect(dialogEl ? dialogEl.getBoundingClientRect() : null)
raf = window.requestAnimationFrame(update)
}
update()
window.addEventListener('resize', update)
window.addEventListener('scroll', update, true)
return () => {
window.cancelAnimationFrame(raf)
window.removeEventListener('resize', update)
window.removeEventListener('scroll', update, true)
}
}, [isActive, stepIndex, step?.anchor])
const handleKey = useCallback((e: KeyboardEvent) => {
if (e.key === 'ArrowRight' || e.key === 'Enter') { e.preventDefault(); next() }
else if (e.key === 'ArrowLeft') { e.preventDefault(); prev() }
else if (e.key === 'Escape') { e.preventDefault(); skip() }
}, [next, prev, skip])
useEffect(() => {
if (!isActive) return
window.addEventListener('keydown', handleKey)
return () => window.removeEventListener('keydown', handleKey)
}, [isActive, handleKey])
// Flag the tour so app-modal backdrops drop their own dim/blur (see
// walkthrough.css) — the spotlight then dims consistently everywhere.
useEffect(() => {
if (!isActive) return
document.body.classList.add('walkthrough-running')
return () => document.body.classList.remove('walkthrough-running')
}, [isActive])
if (!isActive || !step) return null
const isLast = stepIndex >= total - 1
// Ring the step's anchor (sidebar/toolbar button) if it has one, else the modal.
const ringRect = anchorRect ?? dialogRect
// The card follows the OPEN MODAL when there is one (that's what the user reads),
// otherwise the step's anchor. Keeps the card off the modal's content.
const pos = dialogRect
? dialogCardPosition(dialogRect)
: cardPosition(anchorRect, step.placement ?? 'auto')
// Cut-outs keep both the ringed control and any open modal lit.
const holes = [anchorRect, dialogRect].filter((r): r is DOMRect => r !== null)
return createPortal(
<div className="fixed inset-0 z-[100]" data-walkthrough-overlay>
{/* Backdrop: dim everything except the cut-out holes (multi-hole SVG mask so
a ringed button AND an open modal can both stay readable). */}
<svg className="pointer-events-none absolute inset-0 h-full w-full">
<defs>
<mask id="wt-spotlight-mask">
<rect x="0" y="0" width="100%" height="100%" fill="white" />
{holes.map((r, i) => (
<rect
key={i}
x={r.left - PAD}
y={r.top - PAD}
width={r.width + PAD * 2}
height={r.height + PAD * 2}
rx="10"
fill="black"
/>
))}
</mask>
</defs>
<rect x="0" y="0" width="100%" height="100%" fill="#010409" fillOpacity="0.72" mask="url(#wt-spotlight-mask)" />
</svg>
{/* Pulsing ring on the primary target */}
{ringRect && (
<div
className="walkthrough-spotlight--pulse pointer-events-none absolute rounded-lg border-2 border-[#00d4ff]"
style={{
left: ringRect.left - PAD,
top: ringRect.top - PAD,
width: ringRect.width + PAD * 2,
height: ringRect.height + PAD * 2,
transition: 'left 0.25s, top 0.25s, width 0.25s, height 0.25s',
}}
/>
)}
{/* Coach card */}
<div
role="dialog"
aria-modal="false"
aria-label="Getting started walkthrough"
className="walkthrough-card-enter absolute pointer-events-auto rounded-xl border border-[#30363d] bg-[#161b22] p-4 shadow-2xl"
style={{ left: pos.left, top: pos.top, width: CARD_W, transition: 'left 0.25s, top 0.25s' }}
>
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold text-foreground">{step.title}</h3>
<button
onClick={skip}
aria-label="Skip walkthrough"
className="shrink-0 text-muted-foreground hover:text-foreground p-0.5 rounded transition-colors"
>
<X size={14} />
</button>
</div>
<p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">{step.body}</p>
{step.link && (
<a
href={step.link.href}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-[#00d4ff] hover:underline"
>
{step.link.label} <ArrowRight size={12} />
</a>
)}
<div className="mt-3 flex items-center justify-between gap-2">
{/* Progress dots */}
<div className="flex items-center gap-1.5">
{steps.map((s, i) => (
<span
key={s.id}
className={`h-1.5 rounded-full transition-all ${
i === stepIndex ? 'w-4 bg-[#00d4ff]' : 'w-1.5 bg-[#30363d]'
}`}
/>
))}
</div>
<div className="flex items-center gap-1.5">
<span className="mr-1 text-[10px] font-mono text-muted-foreground">
{stepIndex + 1}/{steps.length}
</span>
<Button
variant="ghost"
size="sm"
aria-label="Previous step"
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground disabled:opacity-40"
onClick={prev}
disabled={stepIndex === 0}
>
<ArrowLeft size={13} />
</Button>
<Button
size="sm"
className="h-7 px-3 text-xs bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
onClick={next}
>
{isLast ? 'Finish' : <>Next <ArrowRight size={13} className="ml-1" /></>}
</Button>
</div>
</div>
</div>
</div>,
document.body,
)
}
@@ -0,0 +1,46 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { renderWithProviders, screen, fireEvent } from '@/test/render'
import { WalkthroughInvite } from '../WalkthroughInvite'
import { useWalkthroughStore } from '@/stores/walkthroughStore'
import { readWalkthrough } from '@/utils/walkthrough'
beforeEach(() => {
localStorage.clear()
useWalkthroughStore.setState({ isActive: false, stepIndex: 0, total: 0 })
})
describe('WalkthroughInvite', () => {
it('offers the tour to a new user', () => {
renderWithProviders(<WalkthroughInvite />)
expect(screen.getByText('New here? Take the tour')).toBeInTheDocument()
})
it('is hidden once already seen', () => {
localStorage.setItem('homelable.walkthrough', JSON.stringify({ seenVersion: 1, status: 'completed' }))
renderWithProviders(<WalkthroughInvite />)
expect(screen.queryByText('New here? Take the tour')).not.toBeInTheDocument()
})
it('"Getting started" launches the tour', () => {
renderWithProviders(<WalkthroughInvite />)
fireEvent.click(screen.getByText('Getting started'))
expect(useWalkthroughStore.getState().isActive).toBe(true)
// Invite hides while the tour is active.
expect(screen.queryByText('New here? Take the tour')).not.toBeInTheDocument()
})
it('"Don\'t show again" persists and hides', () => {
renderWithProviders(<WalkthroughInvite />)
fireEvent.click(screen.getByText("Don't show again"))
expect(readWalkthrough().status).toBe('skipped')
expect(screen.queryByText('New here? Take the tour')).not.toBeInTheDocument()
})
it('"Not now" hides without persisting', () => {
renderWithProviders(<WalkthroughInvite />)
fireEvent.click(screen.getByLabelText('Not now'))
expect(screen.queryByText('New here? Take the tour')).not.toBeInTheDocument()
// Not persisted → would be offered again on next load.
expect(readWalkthrough().seenVersion).toBeNull()
})
})
@@ -0,0 +1,46 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { renderWithProviders, screen, fireEvent, cleanup, act } from '@/test/render'
import { WalkthroughOverlay } from '../WalkthroughOverlay'
import { getSteps } from '../steps'
import { useWalkthroughStore } from '@/stores/walkthroughStore'
const TOTAL = getSteps(false).length
const startTour = () => act(() => { useWalkthroughStore.getState().start() })
beforeEach(() => {
localStorage.clear()
useWalkthroughStore.setState({ isActive: false, stepIndex: 0, total: 0 })
})
afterEach(() => cleanup())
describe('WalkthroughOverlay', () => {
it('renders nothing while inactive', () => {
renderWithProviders(<WalkthroughOverlay />)
expect(screen.queryByText('Welcome to Homelable')).not.toBeInTheDocument()
})
it('shows the first step and progress when active', () => {
renderWithProviders(<WalkthroughOverlay />)
startTour()
expect(screen.getByText('Welcome to Homelable')).toBeInTheDocument()
expect(screen.getByText(`1/${TOTAL}`)).toBeInTheDocument()
})
it('advances and rewinds between steps', () => {
renderWithProviders(<WalkthroughOverlay />)
startTour()
fireEvent.click(screen.getByText('Next'))
expect(screen.getByText('Scan your network')).toBeInTheDocument()
fireEvent.click(screen.getByLabelText('Previous step'))
expect(screen.getByText('Welcome to Homelable')).toBeInTheDocument()
})
it('skip closes the overlay', () => {
renderWithProviders(<WalkthroughOverlay />)
startTour()
fireEvent.click(screen.getByLabelText('Skip walkthrough'))
expect(useWalkthroughStore.getState().isActive).toBe(false)
expect(screen.queryByText('Welcome to Homelable')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { getSteps, STEPS } from '../steps'
describe('getSteps', () => {
it('returns every step in full mode', () => {
expect(getSteps(false)).toHaveLength(STEPS.length)
})
it('drops backend-only (mode:full) steps in standalone', () => {
const standalone = getSteps(true)
expect(standalone.every((s) => s.mode !== 'full')).toBe(true)
expect(standalone.length).toBeLessThan(STEPS.length)
const ids = standalone.map((s) => s.id)
// Canvas-only steps survive; backend-only steps are filtered out.
expect(ids).toEqual(expect.arrayContaining(['welcome', 'nodes', 'grouping', 'style', 'end']))
expect(ids).not.toContain('scan')
expect(ids).not.toContain('scan-history')
expect(ids).not.toContain('inventory')
expect(ids).not.toContain('imports')
})
it('ends on a step with a GitHub link (the full-mode recap in standalone)', () => {
const last = getSteps(true).at(-1)
expect(last?.id).toBe('end')
expect(last?.link?.href).toContain('github.com')
})
})
+30
View File
@@ -0,0 +1,30 @@
import { createContext, useContext } from 'react'
/**
* Bridge between tour steps and the App's modal/canvas controls. App builds the
* concrete implementation from its useState setters and provides it here; the
* overlay resolves each step's `action` string to a function on this object.
*
* `closeAll` is called before every step's action so leftover modals from the
* previous step are dismissed.
*
* Grows as steps land (openInventoryDemo, editDemoNode, openStyle, ).
*/
export interface WalkthroughActionApi {
closeAll: () => void
openScanConfig: () => void
openScanHistoryDemo: () => void
openInventoryDemo: () => void
editFirstNode: () => void
selectTwoNodes: () => void
openStyle: () => void
openZigbeeImport: () => void
}
const WalkthroughActionsContext = createContext<WalkthroughActionApi | null>(null)
export const WalkthroughActionsProvider = WalkthroughActionsContext.Provider
export function useWalkthroughActions(): WalkthroughActionApi | null {
return useContext(WalkthroughActionsContext)
}
+65
View File
@@ -0,0 +1,65 @@
import type { ScanRun } from '@/components/modals/ScanHistoryModal'
import type { PendingDevice } from '@/components/modals/PendingDeviceModal'
/**
* Canned data injected into the real modals during the tour, so the demo steps
* look alive on a fresh install without hitting the backend. Never persisted.
*/
export const DEMO_SCAN_RUNS: ScanRun[] = [
{
id: 'tour-scan-running',
status: 'running',
kind: 'ip',
ranges: ['192.168.1.0/24'],
devices_found: 12,
// 8s ago so the live "Duration" reads a realistic elapsed time.
started_at: new Date(Date.now() - 8000).toISOString(),
finished_at: null,
error: null,
},
{
id: 'tour-scan-done',
status: 'done',
kind: 'ip',
ranges: ['192.168.1.0/24'],
devices_found: 9,
started_at: new Date(Date.now() - 3600_000).toISOString(),
finished_at: new Date(Date.now() - 3560_000).toISOString(),
error: null,
},
]
export const DEMO_PENDING_DEVICES: PendingDevice[] = [
{
id: 'tour-dev-1',
ip: '192.168.1.20',
mac: 'AA:BB:CC:DD:EE:20',
hostname: 'synology-nas',
os: 'DSM 7.2',
services: [{ port: 5000, protocol: 'tcp', service_name: 'Synology DSM', category: 'nas' }],
suggested_type: 'nas',
status: 'pending',
discovery_source: 'arp',
friendly_name: 'Synology NAS',
vendor: 'Synology',
model: 'DS920+',
discovered_at: new Date(Date.now() - 120_000).toISOString(),
canvas_count: 0,
},
{
id: 'tour-dev-2',
ip: '192.168.1.30',
mac: 'AA:BB:CC:DD:EE:30',
hostname: 'pi-hole',
os: null,
services: [{ port: 80, protocol: 'tcp', service_name: 'Pi-hole', category: 'network' }],
suggested_type: 'generic',
status: 'pending',
discovery_source: 'arp',
friendly_name: 'Pi-hole',
vendor: 'Raspberry Pi',
model: null,
discovered_at: new Date(Date.now() - 90_000).toISOString(),
canvas_count: 0,
},
]
+121
View File
@@ -0,0 +1,121 @@
/**
* Declarative Getting Started steps.
*
* `anchor` is a CSS selector for the element to ring/spotlight (undefined the
* overlay lights any open modal, or centers the card). `action` is a key resolved
* against the walkthrough actions context on step enter (opens a modal, injects
* demo data, selects nodes, ). `mode: 'full'` marks a backend-only step that is
* filtered out of the standalone/public build. `link` renders a link in the card
* (used for the closing "questions?" step).
*/
export type StepPlacement = 'center' | 'top' | 'bottom' | 'left' | 'right' | 'auto'
export interface TourStep {
id: string
title: string
body: string
anchor?: string
placement?: StepPlacement
action?: string
mode?: 'full' | 'all'
link?: { label: string; href: string }
}
export const STEPS: TourStep[] = [
{
id: 'welcome',
title: 'Welcome to Homelable',
body: 'Take a quick tour of the main features : scanning your network, managing devices, and building your canvas. You can skip anytime and restart later from Settings.',
placement: 'center',
mode: 'all',
},
{
id: 'scan',
title: 'Scan your network',
body: 'Start here: run an IP scan to auto-discover devices on your network. Pick the ranges and ports, then launch, discovered devices land in your inventory for review.',
anchor: '[data-tour="scan-network"]',
placement: 'right',
action: 'openScanConfig',
mode: 'full',
},
{
id: 'scan-history',
title: 'Follow scan progress',
body: 'Scan History shows every run live. A running scan updates in place with elapsed time and device count. You can stop it here too. (This is example data.)',
placement: 'left',
action: 'openScanHistoryDemo',
mode: 'full',
},
{
id: 'inventory',
title: 'Review discovered devices',
body: 'Discovered devices wait in your inventory. For each one you can approve it onto the canvas, hide it, or delete it, with detected services and vendor shown. (Example devices.)',
placement: 'left',
action: 'openInventoryDemo',
mode: 'full',
},
{
id: 'nodes',
title: 'Your devices on the canvas',
body: 'Approved devices become nodes on the canvas. Each shows live status, IP, hostname and running services. Drag to arrange them and draw links between them.',
anchor: '.react-flow__node',
placement: 'auto',
mode: 'all',
},
{
id: 'node-edit',
title: 'Edit a device',
body: 'Double-click any node to edit it: name, type, IP, the status-check method, appearance, and its parent (e.g. a VM inside a Proxmox host). Changes are yours until you Save.',
placement: 'left',
action: 'editFirstNode',
mode: 'all',
},
{
id: 'text-zone',
title: 'Annotate with text & zones',
body: 'Add free Text labels and Zones (colored rectangles) to document your layout to group a rack, mark a VLAN, or leave a note. Both live under the canvas actions.',
anchor: '[data-tour="add-zone"]',
placement: 'right',
mode: 'all',
},
{
id: 'grouping',
title: 'Group related devices',
body: 'Select multiple nodes, then Create Group to box them together and drag them as one. Perfect for a rack, a site, or a subnet.',
anchor: '[data-tour="create-group"]',
placement: 'left',
action: 'selectTwoNodes',
mode: 'all',
},
{
id: 'style',
title: 'Make it yours',
body: 'Switch the canvas theme from Style, or fine-tune colors, borders and fonts per node type. Your homelab, your look.',
anchor: '[data-tour="style"]',
placement: 'bottom',
action: 'openStyle',
mode: 'all',
},
{
id: 'imports',
title: 'Import from your stack',
body: 'Already running Zigbee2MQTT, Z-Wave JS, or Proxmox? Import devices directly from them instead of scanning. One click brings the whole topology in.',
anchor: '[data-tour="zigbee-import"]',
placement: 'right',
action: 'openZigbeeImport',
mode: 'full',
},
{
id: 'end',
title: "You're all set",
body: "That's the tour! Build your map, save it, and share a read-only view. In full mode you also get network scanning, a device inventory, and Zigbee / Z-Wave / Proxmox import. Have a question or an idea?",
placement: 'center',
mode: 'all',
link: { label: 'Ask on GitHub', href: 'https://github.com/Pouzor/homelable/issues' },
},
]
/** Steps for the current build — backend-only steps are dropped in standalone. */
export function getSteps(standalone: boolean): TourStep[] {
return STEPS.filter((s) => s.mode !== 'full' || !standalone)
}
+39
View File
@@ -0,0 +1,39 @@
/* Getting Started walkthrough animations. Kept in a shared stylesheet (imported
by both the invite bubble and the overlay) so keyframes exist regardless of
which component is mounted. */
@keyframes wt-invite-enter {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: none; }
}
.walkthrough-invite-enter { animation: wt-invite-enter 0.25s ease-out; }
@keyframes wt-card-enter {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}
.walkthrough-card-enter { animation: wt-card-enter 0.2s ease-out; }
/* The dim surround is drawn by the overlay's SVG mask; this is just the pulsing
cyan ring around the primary target (outline so it sits outside the border). */
.walkthrough-spotlight--pulse { animation: wt-pulse 1.8s ease-in-out infinite; }
@keyframes wt-pulse {
0%, 100% { outline: 0px solid rgba(0, 212, 255, 0.55); }
50% { outline: 7px solid rgba(0, 212, 255, 0); }
}
/* While the tour runs, app modals must not add their own dim/blur the overlay's
SVG spotlight is the single source of dimming, so a ringed sidebar/toolbar
control behind an open modal stays crisp instead of being double-dimmed and
blurred by the modal's own backdrop. */
body.walkthrough-running [data-slot="dialog-overlay"] {
background: transparent !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
@media (prefers-reduced-motion: reduce) {
.walkthrough-invite-enter,
.walkthrough-card-enter,
.walkthrough-spotlight--pulse { animation: none; }
}
+47 -1
View File
@@ -115,6 +115,7 @@ def _build_tools() -> list[Tool]:
"id": {"type": "string"},
"type": {"type": "string", "enum": NODE_TYPES, "default": "generic"},
"label": {"type": "string"},
**_DESIGN_ID_FIELD,
},
}),
Tool(name="hide_device", description="Hide a pending discovered device", inputSchema={
@@ -134,6 +135,21 @@ def _build_tools() -> list[Tool]:
"type": "object",
"properties": {},
}),
Tool(name="list_inventory", description="List the full device inventory (everything scanned except user-hidden devices): both pending devices awaiting triage and already-approved devices. Each row carries a 'status' field. Use the optional 'status' filter to narrow the result.", inputSchema={
"type": "object",
"properties": {
"status": {"type": "string", "enum": ["all", "pending", "approved"], "default": "all", "description": "Filter inventory by status. 'all' returns pending + approved."},
},
}),
Tool(name="list_hidden_devices", description="List devices the user has hidden from the inventory. Hidden devices are excluded from list_pending_devices and list_inventory; use restore_device to bring one back to pending.", inputSchema={
"type": "object",
"properties": {},
}),
Tool(name="restore_device", description="Restore (un-hide) a previously hidden device, returning it to pending status so it reappears in the triage list. Use this to undo a hide_device action.", inputSchema={
"type": "object",
"required": ["id"],
"properties": {"id": {"type": "string"}},
}),
Tool(name="list_designs", description="List all designs (canvases) with their IDs and node/group/text counts", inputSchema={
"type": "object",
"properties": {},
@@ -147,6 +163,13 @@ def _build_tools() -> list[Tool]:
"design_type": {"type": "string", "description": "Design type (default: network)."},
},
}),
Tool(name="delete_design", description="Delete a design (canvas) and all its nodes and edges. The last remaining design cannot be deleted.", inputSchema={
"type": "object",
"required": ["design_id"],
"properties": {
"design_id": {"type": "string", "description": "ID of the design to delete. Call list_designs to discover IDs."},
},
}),
]
@@ -228,7 +251,27 @@ async def _dispatch(name: str, args: dict) -> dict:
return await backend.get("/api/v1/nodes")
if name == "list_pending_devices":
return await backend.get("/api/v1/scan/pending")
# Backend /scan/pending returns the whole inventory: approved rows stay
# listed so the frontend can show a canvas-presence badge. This tool
# promises only devices "not yet approved or hidden", so filter to
# actual pending rows (legacy rows without a status count as pending).
devices = await backend.get("/api/v1/scan/pending")
return [d for d in devices if d.get("status", "pending") == "pending"]
if name == "list_inventory":
# /scan/pending returns the whole inventory minus hidden rows (pending +
# approved). Legacy rows without a status field count as pending.
devices = await backend.get("/api/v1/scan/pending")
wanted = args.get("status", "all")
if wanted == "all":
return devices
return [d for d in devices if d.get("status", "pending") == wanted]
if name == "list_hidden_devices":
return await backend.get("/api/v1/scan/hidden")
if name == "restore_device":
return await backend.post(f"/api/v1/scan/pending/{args['id']}/restore", {})
if name == "list_designs":
return await backend.get("/api/v1/designs")
@@ -236,4 +279,7 @@ async def _dispatch(name: str, args: dict) -> dict:
if name == "create_design":
return await backend.post("/api/v1/designs", args)
if name == "delete_design":
return await backend.delete(f"/api/v1/designs/{args['design_id']}")
raise ValueError(f"Unknown tool: {name}")
+71
View File
@@ -262,7 +262,78 @@ def test_create_design_schema_requires_name():
assert tool.inputSchema["required"] == ["name"]
@pytest.mark.anyio
async def test_delete_design(mock_backend):
await _dispatch("delete_design", {"design_id": "d-old"})
mock_backend.delete.assert_called_once_with("/api/v1/designs/d-old")
def test_delete_design_schema_requires_design_id():
tool = next(t for t in TOOLS if t.name == "delete_design")
assert tool.inputSchema["required"] == ["design_id"]
assert "design_id" in tool.inputSchema["properties"]
@pytest.mark.anyio
async def test_unknown_tool():
with pytest.raises(ValueError, match="Unknown tool"):
await _dispatch("nonexistent", {})
@pytest.mark.anyio
async def test_list_pending_devices_filters_non_pending(mock_backend):
"""The tool promises devices *not yet approved or hidden*; the backend
endpoint returns the whole inventory including approved rows (they carry
the canvas-presence badge). The tool must filter to status == "pending"
and keep legacy rows that lack the field."""
mock_backend.get = AsyncMock(return_value=[
{"id": "p1", "ip": "192.168.1.50", "status": "pending"},
{"id": "a1", "ip": "192.168.1.60", "status": "approved"},
{"id": "h1", "ip": "192.168.1.70", "status": "hidden"},
{"id": "legacy", "ip": "192.168.1.80"},
])
result = await _dispatch("list_pending_devices", {})
assert [d["id"] for d in result] == ["p1", "legacy"]
_INVENTORY = [
{"id": "p1", "ip": "192.168.1.50", "status": "pending"},
{"id": "a1", "ip": "192.168.1.60", "status": "approved"},
{"id": "legacy", "ip": "192.168.1.80"},
]
@pytest.mark.anyio
async def test_list_inventory_default_returns_all(mock_backend):
"""No status filter returns the whole non-hidden inventory verbatim."""
mock_backend.get = AsyncMock(return_value=list(_INVENTORY))
result = await _dispatch("list_inventory", {})
mock_backend.get.assert_called_once_with("/api/v1/scan/pending")
assert [d["id"] for d in result] == ["p1", "a1", "legacy"]
@pytest.mark.anyio
async def test_list_inventory_filters_approved(mock_backend):
mock_backend.get = AsyncMock(return_value=list(_INVENTORY))
result = await _dispatch("list_inventory", {"status": "approved"})
assert [d["id"] for d in result] == ["a1"]
@pytest.mark.anyio
async def test_list_inventory_pending_keeps_legacy(mock_backend):
"""Legacy rows without a status field count as pending."""
mock_backend.get = AsyncMock(return_value=list(_INVENTORY))
result = await _dispatch("list_inventory", {"status": "pending"})
assert [d["id"] for d in result] == ["p1", "legacy"]
@pytest.mark.anyio
async def test_list_hidden_devices(mock_backend):
await _dispatch("list_hidden_devices", {})
mock_backend.get.assert_called_once_with("/api/v1/scan/hidden")
@pytest.mark.anyio
async def test_restore_device(mock_backend):
await _dispatch("restore_device", {"id": "5"})
mock_backend.post.assert_called_once_with("/api/v1/scan/pending/5/restore", {})