Compare commits

...

34 Commits

Author SHA1 Message Date
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
31 changed files with 1420 additions and 120 deletions
+23
View File
@@ -5,6 +5,29 @@ 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.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
+5 -5
View File
@@ -317,7 +317,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 +326,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 +341,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.0
+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] = []
+64 -27
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 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:
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"]), "")
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)
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
+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
# ---------------------------------------------------------------------------
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "3.0.0",
"version": "3.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "3.0.0",
"version": "3.1.0",
"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.0",
"type": "module",
"scripts": {
"dev": "vite",
+52 -12
View File
@@ -37,6 +37,8 @@ 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'
@@ -53,7 +55,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 +63,17 @@ 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])
const [themeModalOpen, setThemeModalOpen] = useState(false)
const [styleEditorType, setStyleEditorType] = useState<NodeType | null>(null)
const [searchOpen, setSearchOpen] = useState(false)
@@ -93,7 +106,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 +114,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 +123,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,6 +135,21 @@ 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) => {
try {
const res = await canvasApi.load(designId)
@@ -151,6 +179,9 @@ export default function App() {
} catch {
setFloorMap(null)
loadCanvas(demoNodes, demoEdges)
} finally {
// Record provenance so autosave writes back under the design just loaded.
setLoadedDesignId(designId ?? null)
}
}, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
@@ -169,6 +200,8 @@ export default function App() {
setFloorMap(null)
loadCanvas(demoNodes, demoEdges)
}
// Record provenance so autosave writes back under the design just loaded.
setLoadedDesignId(designId)
}, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
const loadDesignsAndCanvas = useCallback(async () => {
@@ -459,8 +492,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 +531,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 +557,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.
+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 && (
@@ -18,6 +18,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 +130,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 +167,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 +175,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 +367,40 @@ 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>
</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()
+50 -3
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,8 @@ 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)
// Multi-select panel
const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id))
@@ -202,6 +204,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 +313,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)}
@@ -768,16 +790,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>
@@ -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({
@@ -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 }])
})
})
+187 -9
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) => ({
edges: applyEdgeChanges(changes, state.edges),
hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'),
})),
set((state) => {
const edited = changes.some((c) => c.type !== 'select')
return {
edges: applyEdgeChanges(changes, state.edges),
...(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 }
}),
}))
}
})
@@ -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)
})
})
+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)
}
+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 {
+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", {})