Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9f3b4c18a | |||
| 80ebbca804 | |||
| 959cd4a3ae | |||
| 084a8e23e7 | |||
| f5dd46a729 | |||
| 121873f41c | |||
| 16d6348cec | |||
| 28fc4d8a7f | |||
| 5726289b18 | |||
| e85dab8cfc | |||
| deccff15cf | |||
| a1bf54c417 | |||
| 8c56921375 | |||
| bf8551015c | |||
| 3f030959be | |||
| 0b8b9760bc | |||
| f1fe82646d | |||
| b7e03142e9 | |||
| af3dc018c7 | |||
| 79bd0d4efb | |||
| a364a95cb5 | |||
| 5b0c064e68 | |||
| 6f01d5550d | |||
| 9000f82213 | |||
| 587f7f3e61 | |||
| 2f91ca3dfd | |||
| 93e9b68821 | |||
| d63c9e6451 | |||
| 71ab3bf391 | |||
| 9c0ed32485 | |||
| 8d752e0bd8 | |||
| 310b9cb3fd | |||
| fceefe6d4d | |||
| ba4f8b0be4 | |||
| a7b7327ce5 | |||
| 6ae8b91934 | |||
| 3cab115a80 | |||
| 9e088ca7ac | |||
| 6cc8399dca | |||
| 4956e8ab90 | |||
| d5cbdce870 | |||
| 15b7ce4481 | |||
| 8df7f682e0 | |||
| 30619caf0f | |||
| 47e144ac1f | |||
| 5fada99e25 | |||
| 2e4b353fcd | |||
| d0e5c105ec | |||
| 9a03097f80 |
@@ -5,6 +5,45 @@ All notable changes to **Homelable** are documented here.
|
|||||||
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/),
|
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/).
|
and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
|
## [3.1.1] - 2026-07-19
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Drag to reorder services in the node detail panel. Thanks @Isilla. (#302)
|
||||||
|
- Getting Started walkthrough tour with first-run canvas detection. (#300)
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- Round-trip Show Port Numbers on YAML export/import. (#301)
|
||||||
|
- Render error toasts on a red surface. (#300)
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
- Rework README header with logo, badges, and nav links. (#300)
|
||||||
|
|
||||||
|
## [3.1.0] - 2026-07-18
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Drag to reorder node properties. (#296)
|
||||||
|
- Search for nodes and properties. Thanks @Floyddotnet. (#294)
|
||||||
|
- Widen zone modal into two columns. (#293)
|
||||||
|
- Autosave canvas after a configurable inactivity delay (opt-in). Thanks @nicolabottini. (#289)
|
||||||
|
- MCP: `design_id` on approve_device + `delete_design` tool. Thanks @nicolabottini. (#281)
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- Scanner: decouple port discovery from version detection. (#295, #277)
|
||||||
|
- Allow undo after Auto Layout and YAML import. (#290)
|
||||||
|
- Move edge waypoints when the connected node or its container is dragged. (#288)
|
||||||
|
- Keep container host height when editing an unrelated field. (#287)
|
||||||
|
- Keep container children in place when editing a host node. Thanks @nicolabottini. (#286)
|
||||||
|
- `list_pending_devices` MCP tool leaked approved/hidden inventory rows. Thanks @MikeSviblov. (#273)
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
- Fix MCP client transport to http (was sse). (#275)
|
||||||
|
|
||||||
## [3.0.0] - 2026-07-10
|
## [3.0.0] - 2026-07-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,4 +1,34 @@
|
|||||||
# Homelable
|
<h1 align="center"><img src="docs/logo/icon-inline.svg" alt="Homelable" width="58" align="middle" /> Homelable</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<strong>Self-hosted homelab infrastructure visualization, scanning & live monitoring</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/Pouzor/homelable/releases/latest"><img src="https://img.shields.io/github/v/release/Pouzor/homelable" alt="Latest release" /></a>
|
||||||
|
<a href="https://github.com/Pouzor/homelable/actions/workflows/docker-ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/Pouzor/homelable/docker-ci.yml?branch=main&label=build" alt="Build status" /></a>
|
||||||
|
<a href="https://github.com/Pouzor/homelable/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" /></a>
|
||||||
|
<a href="https://github.com/Pouzor/homelable/issues"><img src="https://img.shields.io/github/issues/Pouzor/homelable" alt="Issues" /></a>
|
||||||
|
<a href="https://github.com/Pouzor/homelable/stargazers"><img src="https://img.shields.io/github/stars/Pouzor/homelable?style=social" alt="Stars" /></a>
|
||||||
|
<a href="https://github.com/Pouzor/homelable/network/members"><img src="https://img.shields.io/github/forks/Pouzor/homelable?style=social" alt="Forks" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://trendshift.io/repositories/24461?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-24461" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/24461/daily?language=TypeScript" alt="Pouzor%2Fhomelable | Trendshift" width="130" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="#screenshots">Screenshots</a> ·
|
||||||
|
<a href="#features">Features</a> ·
|
||||||
|
<a href="#installation">Installation</a> ·
|
||||||
|
<a href="#network-scanner">Network Scanner</a> ·
|
||||||
|
<a href="#zigbee2mqtt-import">Zigbee / Z-Wave</a> ·
|
||||||
|
<a href="#proxmox-ve-import">Proxmox</a> ·
|
||||||
|
<a href="#live-view-read-only-public-canvas">Live View</a> ·
|
||||||
|
<a href="#mcp-server-ai-integration-optional">MCP Server</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## About
|
||||||
|
|
||||||
Homelable is a self-hosted infrastructure visualization solution. It provides a network/zigbee scanning feature to accelerate the identification of machines, devices and services deployed on your local infrastructure.
|
Homelable is a self-hosted infrastructure visualization solution. It provides a network/zigbee scanning feature to accelerate the identification of machines, devices and services deployed on your local infrastructure.
|
||||||
|
|
||||||
@@ -10,7 +40,6 @@ If you just like the design, you can only run the frontend and export your desig
|
|||||||
|
|
||||||
If you are running <img width="22" height="22" align="top" alt="New_Home_Assistant_logo" src="https://github.com/user-attachments/assets/3bb17686-c706-40ce-a2d3-57e02378f37c" /> Homeassistant, check the [Homelable HA version](https://github.com/Pouzor/homelable-hacs) (via HACS)
|
If you are running <img width="22" height="22" align="top" alt="New_Home_Assistant_logo" src="https://github.com/user-attachments/assets/3bb17686-c706-40ce-a2d3-57e02378f37c" /> Homeassistant, check the [Homelable HA version](https://github.com/Pouzor/homelable-hacs) (via HACS)
|
||||||
|
|
||||||
<a href="https://trendshift.io/repositories/24461?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-24461" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/24461/daily?language=TypeScript" alt="Pouzor%2Fhomelable | Trendshift" width="250" height="55"/></a>
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
@@ -317,7 +346,7 @@ docker compose up -d mcp
|
|||||||
|
|
||||||
**Claude Code** — run this command in your terminal:
|
**Claude Code** — run this command in your terminal:
|
||||||
```bash
|
```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"
|
--header "X-API-Key: mcp_sk_yourkey"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -326,8 +355,8 @@ Or add it manually to `~/.claude.json`:
|
|||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"homelable": {
|
"homelable": {
|
||||||
"type": "sse",
|
"type": "http",
|
||||||
"url": "http://<your-homelab-ip>:8001/mcp",
|
"url": "http://<your-homelab-ip>:8001/mcp/",
|
||||||
"headers": {
|
"headers": {
|
||||||
"X-API-Key": "mcp_sk_yourkey"
|
"X-API-Key": "mcp_sk_yourkey"
|
||||||
}
|
}
|
||||||
@@ -341,8 +370,8 @@ Or add it manually to `~/.claude.json`:
|
|||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"homelable": {
|
"homelable": {
|
||||||
"type": "sse",
|
"type": "http",
|
||||||
"url": "http://<your-homelab-ip>:8001/mcp",
|
"url": "http://<your-homelab-ip>:8001/mcp/",
|
||||||
"headers": {
|
"headers": {
|
||||||
"X-API-Key": "mcp_sk_yourkey"
|
"X-API-Key": "mcp_sk_yourkey"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ async def load_canvas(
|
|||||||
edges=[EdgeResponse.model_validate(e) for e in edges],
|
edges=[EdgeResponse.model_validate(e) for e in edges],
|
||||||
viewport=viewport,
|
viewport=viewport,
|
||||||
custom_style=state.custom_style if state else None,
|
custom_style=state.custom_style if state else None,
|
||||||
|
# A CanvasState row exists only after a save (or explicit design create),
|
||||||
|
# so its presence marks an intentional canvas vs. a never-touched one.
|
||||||
|
initialized=state is not None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ class Settings(BaseSettings):
|
|||||||
# Scanner
|
# Scanner
|
||||||
scanner_ranges: list[str] = ["192.168.1.0/24"]
|
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).
|
# 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.
|
# http_ranges: extra nmap port ranges, opt-in, no default. Probe + TLS off by default.
|
||||||
scanner_http_ranges: list[str] = []
|
scanner_http_ranges: list[str] = []
|
||||||
|
|||||||
@@ -84,3 +84,8 @@ class CanvasStateResponse(BaseModel):
|
|||||||
edges: list[EdgeResponse]
|
edges: list[EdgeResponse]
|
||||||
viewport: dict[str, Any]
|
viewport: dict[str, Any]
|
||||||
custom_style: dict[str, Any] | None = None
|
custom_style: dict[str, Any] | None = None
|
||||||
|
# True once this design's canvas has ever been persisted (a CanvasState row
|
||||||
|
# exists). Lets the frontend tell a brand-new user (show demo) apart from one
|
||||||
|
# who intentionally cleared their canvas (keep it empty). False also for a
|
||||||
|
# missing/uninitialized design.
|
||||||
|
initialized: bool = False
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from typing import Any
|
|||||||
from sqlalchemy import or_, select
|
from sqlalchemy import or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.db.models import Node, PendingDevice, ScanRun
|
from app.db.models import Node, PendingDevice, ScanRun
|
||||||
from app.services.discovery_sources import add_source
|
from app.services.discovery_sources import add_source
|
||||||
from app.services.fingerprint import fingerprint_ports, suggest_node_type
|
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.
|
Phase 2 — single-IP port scan with service detection.
|
||||||
Runs in a thread (blocking). Returns the host dict enriched with open_ports.
|
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"]
|
ip = host_dict["ip"]
|
||||||
logger.info("[Phase 2] Scanning %s ...", 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)
|
logger.warning("[Phase 2] nmap not available, skipping %s", ip)
|
||||||
return host_dict
|
return host_dict
|
||||||
|
|
||||||
is_root = os.geteuid() == 0
|
# -sS (SYN) needs root; -sT (connect) works without it. nmap auto-selects
|
||||||
if is_root:
|
# -sT without root but being explicit avoids edge cases.
|
||||||
# SYN scan + version detection (fastest, most accurate)
|
scan_type = "-sS" if os.geteuid() == 0 else "-sT"
|
||||||
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}"
|
|
||||||
|
|
||||||
logger.debug("[Phase 2] %s args: %s", ip, scan_args)
|
# --- Pass A: port discovery (no -sV, no host-timeout) ---
|
||||||
nm = nmap.PortScanner()
|
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:
|
try:
|
||||||
nm.scan(hosts=ip, arguments=scan_args)
|
nm_disc.scan(hosts=ip, arguments=discovery_args)
|
||||||
except Exception as exc:
|
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
|
return host_dict
|
||||||
|
|
||||||
all_scanned = nm.all_hosts()
|
if ip not in nm_disc.all_hosts():
|
||||||
logger.debug("[Phase 2] %s — nmap returned %d host(s) in results", ip, len(all_scanned))
|
|
||||||
if ip not in all_scanned:
|
|
||||||
logger.info("[Phase 2] %s — no open ports found (all closed/filtered or nmap had no results)", ip)
|
logger.info("[Phase 2] %s — no open ports found (all closed/filtered or nmap had no results)", ip)
|
||||||
return host_dict
|
return host_dict
|
||||||
|
|
||||||
open_ports = []
|
open_ports = []
|
||||||
for proto in nm[ip].all_protocols():
|
for proto in nm_disc[ip].all_protocols():
|
||||||
for port, info in nm[ip][proto].items():
|
for port, info in nm_disc[ip][proto].items():
|
||||||
if info["state"] == "open":
|
if info["state"] == "open":
|
||||||
banner = (info.get("product", "") + " " + info.get("version", "")).strip()
|
open_ports.append({"port": port, "protocol": proto, "banner": ""})
|
||||||
open_ports.append({"port": port, "protocol": proto, "banner": banner})
|
|
||||||
|
|
||||||
if open_ports:
|
if not open_ports:
|
||||||
port_summary = ", ".join(
|
|
||||||
f"{p['port']}/{p['protocol']} ({p['banner'] or 'unknown'})" for p in open_ports
|
|
||||||
)
|
|
||||||
logger.info("[Phase 2] %s — %d open port(s): %s", ip, len(open_ports), port_summary)
|
|
||||||
else:
|
|
||||||
logger.info("[Phase 2] %s — 0 open ports detected", ip)
|
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
|
host_dict["open_ports"] = open_ports
|
||||||
if not host_dict["mac"]:
|
if not host_dict["mac"]:
|
||||||
host_dict["mac"] = nm[ip].get("addresses", {}).get("mac")
|
host_dict["mac"] = ver_mac or nm_disc[ip].get("addresses", {}).get("mac")
|
||||||
host_dict["os"] = _extract_os(nm, ip)
|
host_dict["os"] = ver_os
|
||||||
return host_dict
|
return host_dict
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,23 @@ async def test_load_canvas_empty(client: AsyncClient, headers: dict):
|
|||||||
assert data["viewport"] == {"x": 0, "y": 0, "zoom": 1}
|
assert data["viewport"] == {"x": 0, "y": 0, "zoom": 1}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_load_canvas_uninitialized_reports_initialized_false(client: AsyncClient, headers: dict):
|
||||||
|
# A never-saved canvas has no CanvasState row → initialized False. The frontend
|
||||||
|
# uses this to show the demo canvas only to genuinely new users.
|
||||||
|
data = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert data["initialized"] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_load_canvas_initialized_true_after_save(client: AsyncClient, headers: dict):
|
||||||
|
# Saving an EMPTY canvas still creates a CanvasState row, so a subsequently
|
||||||
|
# loaded empty canvas is reported initialized — the user cleared it on purpose
|
||||||
|
# and must not get the demo re-seeded.
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
data = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert data["nodes"] == []
|
||||||
|
assert data["initialized"] is True
|
||||||
|
|
||||||
|
|
||||||
async def test_load_canvas_requires_auth(client: AsyncClient):
|
async def test_load_canvas_requires_auth(client: AsyncClient):
|
||||||
res = await client.get("/api/v1/canvas")
|
res = await client.get("/api/v1/canvas")
|
||||||
assert res.status_code == 401
|
assert res.status_code == 401
|
||||||
|
|||||||
@@ -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
|
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
|
# _nmap_scan
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 82" width="64" height="82" fill="none">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||||
|
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||||
|
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="node-glow">
|
||||||
|
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||||
|
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||||
|
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||||
|
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||||
|
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||||
|
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||||
|
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||||
|
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||||
|
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "3.0.0",
|
"version": "3.1.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "3.0.0",
|
"version": "3.1.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.2.0",
|
"@base-ui/react": "^1.2.0",
|
||||||
"@dagrejs/dagre": "^2.0.4",
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "3.0.0",
|
"version": "3.1.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+207
-42
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useCallback, useRef, useState } from 'react'
|
import { useEffect, useCallback, useMemo, useRef, useState } from 'react'
|
||||||
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
|
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
|
||||||
import { type Node } from '@xyflow/react'
|
import { type Node } from '@xyflow/react'
|
||||||
import { applyDagreLayout } from '@/utils/layout'
|
import { applyDagreLayout } from '@/utils/layout'
|
||||||
@@ -37,12 +37,19 @@ import { ScanHistoryModal } from '@/components/modals/ScanHistoryModal'
|
|||||||
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
||||||
import { ConfirmAddToGroupModal } from '@/components/modals/ConfirmAddToGroupModal'
|
import { ConfirmAddToGroupModal } from '@/components/modals/ConfirmAddToGroupModal'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { readAutosaveSettings, subscribeAutosaveSettings, type AutosaveSettings } from '@/utils/autosaveSettings'
|
||||||
|
import { useAutosave } from '@/hooks/useAutosave'
|
||||||
import { useDesignStore } from '@/stores/designStore'
|
import { useDesignStore } from '@/stores/designStore'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { canvasApi, designsApi, liveviewApi } from '@/api/client'
|
import { canvasApi, designsApi, liveviewApi } from '@/api/client'
|
||||||
import * as standaloneStorage from '@/utils/standaloneStorage'
|
import * as standaloneStorage from '@/utils/standaloneStorage'
|
||||||
import { demoNodes, demoEdges } from '@/utils/demoData'
|
import { demoNodes, demoEdges } from '@/utils/demoData'
|
||||||
|
import { decideCanvasLoad, isNewUserCanvas } from '@/utils/canvasLoadDecision'
|
||||||
|
import { WalkthroughActionsProvider, type WalkthroughActionApi } from '@/walkthrough/actions'
|
||||||
|
import { WalkthroughInvite } from '@/walkthrough/WalkthroughInvite'
|
||||||
|
import { WalkthroughOverlay } from '@/walkthrough/WalkthroughOverlay'
|
||||||
|
import { DEMO_SCAN_RUNS, DEMO_PENDING_DEVICES } from '@/walkthrough/demoTourData'
|
||||||
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
||||||
import type { NodeData, EdgeData, CustomStyleDef, FloorMapConfig, NodeType } from '@/types'
|
import type { NodeData, EdgeData, CustomStyleDef, FloorMapConfig, NodeType } from '@/types'
|
||||||
import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
|
import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
|
||||||
@@ -53,7 +60,7 @@ import { buildProxmoxClusterEdges } from '@/components/proxmox/clusterEdges'
|
|||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, 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 canvasRef = useRef<HTMLDivElement>(null)
|
||||||
const { isAuthenticated } = useAuthStore()
|
const { isAuthenticated } = useAuthStore()
|
||||||
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
||||||
@@ -61,6 +68,29 @@ export default function App() {
|
|||||||
|
|
||||||
useStatusPolling()
|
useStatusPolling()
|
||||||
|
|
||||||
|
const [autosave, setAutosave] = useState<AutosaveSettings>(readAutosaveSettings)
|
||||||
|
useEffect(() => subscribeAutosaveSettings(setAutosave), [])
|
||||||
|
|
||||||
|
// Provenance: which design the in-memory canvas was loaded as. Differs from
|
||||||
|
// activeDesignId (the selection) during a switch, so autosave gates on this to
|
||||||
|
// avoid writing one design's canvas under another's id. Ref mirror for the
|
||||||
|
// fire-time guard, which must read the live value without re-arming the timer.
|
||||||
|
const [loadedDesignId, setLoadedDesignId] = useState<string | null>(null)
|
||||||
|
const loadedDesignIdRef = useRef<string | null>(loadedDesignId)
|
||||||
|
useEffect(() => { loadedDesignIdRef.current = loadedDesignId }, [loadedDesignId])
|
||||||
|
|
||||||
|
// True while the last canvas load failed (backend down/error). Drives the error
|
||||||
|
// banner and keeps us from masking a real failure with the demo canvas.
|
||||||
|
const [loadError, setLoadError] = useState(false)
|
||||||
|
// True when the loaded canvas is the demo (a brand-new user). Reserved as the
|
||||||
|
// entry signal for the upcoming Getting Started walkthrough.
|
||||||
|
const [isNewUser, setIsNewUser] = useState(false)
|
||||||
|
|
||||||
|
// Getting Started tour: when true, the corresponding modal renders injected demo
|
||||||
|
// data instead of hitting the backend.
|
||||||
|
const [tourScanHistoryDemo, setTourScanHistoryDemo] = useState(false)
|
||||||
|
const [tourInventoryDemo, setTourInventoryDemo] = useState(false)
|
||||||
|
|
||||||
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
||||||
const [styleEditorType, setStyleEditorType] = useState<NodeType | null>(null)
|
const [styleEditorType, setStyleEditorType] = useState<NodeType | null>(null)
|
||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
@@ -93,7 +123,7 @@ export default function App() {
|
|||||||
// Declare handleSave before the Ctrl+S effect so it is in scope.
|
// Declare handleSave before the Ctrl+S effect so it is in scope.
|
||||||
// Returns true on success, false on failure — the design-switch effect relies
|
// 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.
|
// 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 {
|
try {
|
||||||
const saveDesignId = designIdOverride ?? activeDesignId
|
const saveDesignId = designIdOverride ?? activeDesignId
|
||||||
if (STANDALONE) {
|
if (STANDALONE) {
|
||||||
@@ -101,7 +131,7 @@ export default function App() {
|
|||||||
// Floor plans are backend-only (upload/serve), so standalone never persists one.
|
// Floor plans are backend-only (upload/serve), so standalone never persists one.
|
||||||
standaloneStorage.saveCanvas(saveDesignId, { nodes, edges, theme_id: activeTheme, custom_style: customStyle })
|
standaloneStorage.saveCanvas(saveDesignId, { nodes, edges, theme_id: activeTheme, custom_style: customStyle })
|
||||||
markSaved()
|
markSaved()
|
||||||
toast.success('Canvas saved')
|
if (!options?.silent) toast.success('Canvas saved')
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
const nodesToSave = nodes.map(serializeNode)
|
const nodesToSave = nodes.map(serializeNode)
|
||||||
@@ -110,7 +140,7 @@ export default function App() {
|
|||||||
if (floorMap) viewport.floor_map = floorMap
|
if (floorMap) viewport.floor_map = floorMap
|
||||||
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport, custom_style: customStyle, design_id: saveDesignId })
|
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport, custom_style: customStyle, design_id: saveDesignId })
|
||||||
markSaved()
|
markSaved()
|
||||||
toast.success('Canvas saved')
|
if (!options?.silent) toast.success('Canvas saved')
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Save failed')
|
toast.error('Save failed')
|
||||||
@@ -122,53 +152,101 @@ export default function App() {
|
|||||||
const handleSaveRef = useRef(handleSave)
|
const handleSaveRef = useRef(handleSave)
|
||||||
useEffect(() => { handleSaveRef.current = handleSave }, [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) => {
|
const loadCanvasFromApi = useCallback(async (designId?: string) => {
|
||||||
|
let res
|
||||||
try {
|
try {
|
||||||
const res = await canvasApi.load(designId)
|
res = await canvasApi.load(designId)
|
||||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
|
||||||
if (apiNodes.length > 0) {
|
|
||||||
const proxmoxContainerMap = new Map<string, boolean>(
|
|
||||||
(apiNodes as ApiNode[])
|
|
||||||
.filter((n) => n.type === 'group' || n.container_mode === true)
|
|
||||||
.map((n) => [n.id, true])
|
|
||||||
)
|
|
||||||
const { nodes: rfNodes, edges: rfEdges } = migrateClusterHandles(
|
|
||||||
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)),
|
|
||||||
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
|
|
||||||
)
|
|
||||||
const savedTheme = res.data.viewport?.theme_id
|
|
||||||
if (savedTheme) setTheme(savedTheme)
|
|
||||||
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
|
||||||
const savedFloorMap = res.data.viewport?.floor_map as FloorMapConfig | undefined
|
|
||||||
// Clear when the target design has no floor plan, so it doesn't bleed
|
|
||||||
// across canvases when switching designs.
|
|
||||||
setFloorMap(savedFloorMap ?? null)
|
|
||||||
loadCanvas(rfNodes, rfEdges)
|
|
||||||
} else {
|
|
||||||
setFloorMap(null)
|
|
||||||
loadCanvas(demoNodes, demoEdges)
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
|
// Backend down / errored. Surface it and STOP — never fall back to the demo
|
||||||
|
// canvas, which would hide the real error and look like a fresh account.
|
||||||
|
// Leave the on-screen canvas and provenance untouched so an autosave can't
|
||||||
|
// clobber real data with an empty canvas.
|
||||||
|
setLoadError(true)
|
||||||
|
toast.error('Could not load canvas — backend not responding')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||||
|
const mode = decideCanvasLoad(apiNodes.length > 0, res.data.initialized === true)
|
||||||
|
if (mode === 'real') {
|
||||||
|
const proxmoxContainerMap = new Map<string, boolean>(
|
||||||
|
(apiNodes as ApiNode[])
|
||||||
|
.filter((n) => n.type === 'group' || n.container_mode === true)
|
||||||
|
.map((n) => [n.id, true])
|
||||||
|
)
|
||||||
|
const { nodes: rfNodes, edges: rfEdges } = migrateClusterHandles(
|
||||||
|
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)),
|
||||||
|
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
|
||||||
|
)
|
||||||
|
const savedTheme = res.data.viewport?.theme_id
|
||||||
|
if (savedTheme) setTheme(savedTheme)
|
||||||
|
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
||||||
|
const savedFloorMap = res.data.viewport?.floor_map as FloorMapConfig | undefined
|
||||||
|
// Clear when the target design has no floor plan, so it doesn't bleed
|
||||||
|
// across canvases when switching designs.
|
||||||
|
setFloorMap(savedFloorMap ?? null)
|
||||||
|
loadCanvas(rfNodes, rfEdges)
|
||||||
|
} else if (mode === 'empty') {
|
||||||
|
// Initialized but no nodes: the user cleared this canvas on purpose — respect
|
||||||
|
// it and keep it empty instead of re-seeding the demo.
|
||||||
|
const savedTheme = res.data.viewport?.theme_id
|
||||||
|
if (savedTheme) setTheme(savedTheme)
|
||||||
|
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
||||||
|
setFloorMap(null)
|
||||||
|
loadCanvas([], [])
|
||||||
|
} else {
|
||||||
|
// Brand-new, never-saved canvas: seed the demo.
|
||||||
setFloorMap(null)
|
setFloorMap(null)
|
||||||
loadCanvas(demoNodes, demoEdges)
|
loadCanvas(demoNodes, demoEdges)
|
||||||
}
|
}
|
||||||
|
setLoadError(false)
|
||||||
|
setIsNewUser(isNewUserCanvas(mode))
|
||||||
|
// Record provenance so autosave writes back under the design just loaded.
|
||||||
|
setLoadedDesignId(designId ?? null)
|
||||||
}, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
|
}, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
|
||||||
|
|
||||||
// Standalone counterpart of loadCanvasFromApi — reads a design's canvas from
|
// Standalone counterpart of loadCanvasFromApi — reads a design's canvas from
|
||||||
// localStorage, falling back to the demo canvas when it has never been saved.
|
// localStorage, falling back to the demo canvas when it has never been saved.
|
||||||
const loadStandaloneCanvas = useCallback((designId: string) => {
|
const loadStandaloneCanvas = useCallback((designId: string) => {
|
||||||
const saved = standaloneStorage.loadCanvas(designId)
|
const saved = standaloneStorage.loadCanvas(designId)
|
||||||
if (saved && saved.nodes.length > 0) {
|
// A stored entry (even with zero nodes) means the user has saved this canvas,
|
||||||
|
// so treat it as initialized and don't re-seed the demo on top of a canvas
|
||||||
|
// they deliberately cleared.
|
||||||
|
const mode = decideCanvasLoad((saved?.nodes.length ?? 0) > 0, saved !== null)
|
||||||
|
if (mode === 'real' && saved) {
|
||||||
if (saved.theme_id) setTheme(saved.theme_id)
|
if (saved.theme_id) setTheme(saved.theme_id)
|
||||||
if (saved.custom_style) setCustomStyle(saved.custom_style)
|
if (saved.custom_style) setCustomStyle(saved.custom_style)
|
||||||
// Floor plans are backend-only; keep the store clear in standalone mode.
|
// Floor plans are backend-only; keep the store clear in standalone mode.
|
||||||
setFloorMap(null)
|
setFloorMap(null)
|
||||||
const migrated = migrateClusterHandles(saved.nodes, saved.edges)
|
const migrated = migrateClusterHandles(saved.nodes, saved.edges)
|
||||||
loadCanvas(migrated.nodes, migrated.edges)
|
loadCanvas(migrated.nodes, migrated.edges)
|
||||||
|
} else if (mode === 'empty' && saved) {
|
||||||
|
if (saved.theme_id) setTheme(saved.theme_id)
|
||||||
|
if (saved.custom_style) setCustomStyle(saved.custom_style)
|
||||||
|
setFloorMap(null)
|
||||||
|
loadCanvas([], [])
|
||||||
} else {
|
} else {
|
||||||
setFloorMap(null)
|
setFloorMap(null)
|
||||||
loadCanvas(demoNodes, demoEdges)
|
loadCanvas(demoNodes, demoEdges)
|
||||||
}
|
}
|
||||||
|
setLoadError(false)
|
||||||
|
setIsNewUser(isNewUserCanvas(mode))
|
||||||
|
// Record provenance so autosave writes back under the design just loaded.
|
||||||
|
setLoadedDesignId(designId)
|
||||||
}, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
|
}, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
|
||||||
|
|
||||||
const loadDesignsAndCanvas = useCallback(async () => {
|
const loadDesignsAndCanvas = useCallback(async () => {
|
||||||
@@ -198,16 +276,73 @@ export default function App() {
|
|||||||
await loadCanvasFromApi(targetId)
|
await loadCanvasFromApi(targetId)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// If API fails (e.g. fresh DB with no designs), fall back to demo data
|
// Backend unreachable/errored — surface it. Do NOT seed the demo: that would
|
||||||
loadCanvas(demoNodes, demoEdges)
|
// hide a real outage behind a fake "new account" canvas.
|
||||||
|
setLoadError(true)
|
||||||
|
toast.error('Could not reach backend — check the server and retry')
|
||||||
}
|
}
|
||||||
}, [setDesigns, setActiveDesign, loadCanvasFromApi, loadStandaloneCanvas, activeDesignId, loadCanvas])
|
}, [setDesigns, setActiveDesign, loadCanvasFromApi, loadStandaloneCanvas, activeDesignId])
|
||||||
|
|
||||||
// Keep a ref so the auth effect can call the latest loader without listing it
|
// Keep a ref so the auth effect can call the latest loader without listing it
|
||||||
// as a dependency (which would re-fire on every design switch).
|
// as a dependency (which would re-fire on every design switch).
|
||||||
const loadDesignsAndCanvasRef = useRef(loadDesignsAndCanvas)
|
const loadDesignsAndCanvasRef = useRef(loadDesignsAndCanvas)
|
||||||
useEffect(() => { loadDesignsAndCanvasRef.current = loadDesignsAndCanvas }, [loadDesignsAndCanvas])
|
useEffect(() => { loadDesignsAndCanvasRef.current = loadDesignsAndCanvas }, [loadDesignsAndCanvas])
|
||||||
|
|
||||||
|
// Retry a failed load from the error banner.
|
||||||
|
const handleRetryLoad = useCallback(() => {
|
||||||
|
setLoadError(false)
|
||||||
|
void loadDesignsAndCanvasRef.current()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Bridge the Getting Started tour steps to the App's modal controls. The overlay
|
||||||
|
// calls closeAll() before each step, then the step's action to open the target.
|
||||||
|
const walkthroughActions = useMemo<WalkthroughActionApi>(() => ({
|
||||||
|
closeAll: () => {
|
||||||
|
setScanConfigOpen(false)
|
||||||
|
setScanHistoryOpen(false)
|
||||||
|
setTourScanHistoryDemo(false)
|
||||||
|
setPendingModalOpen(false)
|
||||||
|
setTourInventoryDemo(false)
|
||||||
|
setEditNodeId(null)
|
||||||
|
setThemeModalOpen(false)
|
||||||
|
setZigbeeImportOpen(false)
|
||||||
|
// Clear any tour-driven multi-selection so the DetailPanel closes.
|
||||||
|
useCanvasStore.setState((s) => ({
|
||||||
|
nodes: s.nodes.map((n) => (n.selected ? { ...n, selected: false } : n)),
|
||||||
|
selectedNodeIds: [],
|
||||||
|
selectedNodeId: null,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
openScanConfig: () => setScanConfigOpen(true),
|
||||||
|
openScanHistoryDemo: () => {
|
||||||
|
setTourScanHistoryDemo(true)
|
||||||
|
setScanHistoryOpen(true)
|
||||||
|
},
|
||||||
|
openInventoryDemo: () => {
|
||||||
|
setTourInventoryDemo(true)
|
||||||
|
openPendingModal(undefined, 'pending')
|
||||||
|
},
|
||||||
|
editFirstNode: () => {
|
||||||
|
const first = useCanvasStore.getState().nodes.find((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
|
||||||
|
if (first) setEditNodeId(first.id)
|
||||||
|
},
|
||||||
|
selectTwoNodes: () => {
|
||||||
|
const ids = useCanvasStore.getState().nodes
|
||||||
|
.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((n) => n.id)
|
||||||
|
if (ids.length < 2) return
|
||||||
|
const set = new Set(ids)
|
||||||
|
useCanvasStore.setState((s) => ({
|
||||||
|
nodes: s.nodes.map((n) => ({ ...n, selected: set.has(n.id) })),
|
||||||
|
selectedNodeIds: ids,
|
||||||
|
selectedNodeId: null,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
openStyle: () => setThemeModalOpen(true),
|
||||||
|
openZigbeeImport: () => setZigbeeImportOpen(true),
|
||||||
|
}), [openPendingModal])
|
||||||
|
|
||||||
// Load designs + canvas on auth (or immediately in standalone mode, which has
|
// Load designs + canvas on auth (or immediately in standalone mode, which has
|
||||||
// no auth gate).
|
// no auth gate).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -459,8 +594,13 @@ export default function App() {
|
|||||||
snapshotHistory()
|
snapshotHistory()
|
||||||
const existingNode = nodes.find((n) => n.id === editNodeId)
|
const existingNode = nodes.find((n) => n.id === editNodeId)
|
||||||
updateNode(editNodeId, data)
|
updateNode(editNodeId, data)
|
||||||
// If container_mode changed, apply structural changes (children parentId, node dimensions)
|
// Only run the structural container transition when container_mode ACTUALLY
|
||||||
if (typeof data.container_mode === 'boolean') {
|
// 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)
|
setProxmoxContainerMode(editNodeId, data.container_mode)
|
||||||
}
|
}
|
||||||
// Sync virtual edge when parent_id changes on an LXC/VM node
|
// Sync virtual edge when parent_id changes on an LXC/VM node
|
||||||
@@ -493,9 +633,11 @@ export default function App() {
|
|||||||
|
|
||||||
const handleAutoLayout = useCallback(() => {
|
const handleAutoLayout = useCallback(() => {
|
||||||
const laid = applyDagreLayout(nodes, edges)
|
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')
|
toast.success('Canvas auto-arranged')
|
||||||
}, [nodes, edges, loadCanvas])
|
}, [nodes, edges, applyLayout])
|
||||||
|
|
||||||
const handleExportMd = useCallback(async () => {
|
const handleExportMd = useCallback(async () => {
|
||||||
const md = generateMarkdownTable(nodes)
|
const md = generateMarkdownTable(nodes)
|
||||||
@@ -517,14 +659,14 @@ export default function App() {
|
|||||||
const handleImportYaml = useCallback((content: string) => {
|
const handleImportYaml = useCallback((content: string) => {
|
||||||
try {
|
try {
|
||||||
const { nodes: merged, edges: mergedEdges, imported } = parseYamlToCanvas(content, nodes, edges)
|
const { nodes: merged, edges: mergedEdges, imported } = parseYamlToCanvas(content, nodes, edges)
|
||||||
snapshotHistory()
|
// applyLayout keeps undo history so an import can be reverted; loadCanvas
|
||||||
loadCanvas(merged, mergedEdges)
|
// would wipe it (#280).
|
||||||
markUnsaved()
|
applyLayout(merged, mergedEdges)
|
||||||
toast.success(`Imported ${imported} node${imported !== 1 ? 's' : ''}`)
|
toast.success(`Imported ${imported} node${imported !== 1 ? 's' : ''}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(`Import failed: ${err instanceof Error ? err.message : String(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.
|
// 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.
|
// Standalone has no backend/key — it reads localStorage, so just open /view.
|
||||||
@@ -783,9 +925,12 @@ export default function App() {
|
|||||||
if (!STANDALONE && !isAuthenticated) return <LoginPage />
|
if (!STANDALONE && !isAuthenticated) return <LoginPage />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<WalkthroughActionsProvider value={walkthroughActions}>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<ReactFlowProvider>
|
<ReactFlowProvider>
|
||||||
<div className="flex h-screen w-screen overflow-hidden bg-[#0d1117]">
|
{/* data-new-user marks a first-time (demo) canvas — the hook the upcoming
|
||||||
|
Getting Started walkthrough keys off. */}
|
||||||
|
<div className="flex h-screen w-screen overflow-hidden bg-[#0d1117]" data-new-user={isNewUser}>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
onAddNode={() => setAddNodeOpen(true)}
|
onAddNode={() => setAddNodeOpen(true)}
|
||||||
onAddGroupRect={() => setAddGroupRectOpen(true)}
|
onAddGroupRect={() => setAddGroupRectOpen(true)}
|
||||||
@@ -800,6 +945,21 @@ export default function App() {
|
|||||||
onOpenPending={openPendingModal}
|
onOpenPending={openPendingModal}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
|
{loadError && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="flex items-center justify-between gap-3 bg-[#3d1418] border-b border-[#f85149] px-4 py-2 text-sm text-[#ffa198]"
|
||||||
|
>
|
||||||
|
<span>Backend not responding — the canvas could not be loaded.</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRetryLoad}
|
||||||
|
className="rounded border border-[#f85149] px-2 py-0.5 text-[#ffa198] hover:bg-[#f85149]/20"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Toolbar
|
<Toolbar
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onAutoLayout={handleAutoLayout}
|
onAutoLayout={handleAutoLayout}
|
||||||
@@ -935,6 +1095,7 @@ export default function App() {
|
|||||||
<ScanHistoryModal
|
<ScanHistoryModal
|
||||||
open={scanHistoryOpen}
|
open={scanHistoryOpen}
|
||||||
onClose={() => setScanHistoryOpen(false)}
|
onClose={() => setScanHistoryOpen(false)}
|
||||||
|
demoRuns={tourScanHistoryDemo ? DEMO_SCAN_RUNS : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1059,6 +1220,7 @@ export default function App() {
|
|||||||
onClose={() => setPendingModalOpen(false)}
|
onClose={() => setPendingModalOpen(false)}
|
||||||
highlightId={pendingHighlightId}
|
highlightId={pendingHighlightId}
|
||||||
initialStatus={pendingModalStatus}
|
initialStatus={pendingModalStatus}
|
||||||
|
demoDevices={tourInventoryDemo ? DEMO_PENDING_DEVICES : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ExportModal
|
<ExportModal
|
||||||
@@ -1068,7 +1230,10 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Toaster theme="dark" position="bottom-right" />
|
<Toaster theme="dark" position="bottom-right" />
|
||||||
|
<WalkthroughInvite />
|
||||||
|
<WalkthroughOverlay />
|
||||||
</ReactFlowProvider>
|
</ReactFlowProvider>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
</WalkthroughActionsProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,15 +43,45 @@ export function SearchBar({ onOpenPending }: SearchBarProps) {
|
|||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const q = query.toLowerCase().trim()
|
const q = query.toLowerCase().trim()
|
||||||
|
|
||||||
const nodeResults = q
|
const nodeResults = q
|
||||||
? nodes.filter((n) => {
|
? nodes.flatMap((n) => {
|
||||||
if (n.data.type === 'groupRect') return false
|
if (n.data.type === 'groupRect') return []
|
||||||
return (
|
|
||||||
n.data.label?.toLowerCase().includes(q) ||
|
const contains = (v?: string | null) => v?.toLowerCase().includes(q)
|
||||||
n.data.ip?.toLowerCase().includes(q) ||
|
|
||||||
n.data.hostname?.toLowerCase().includes(q) ||
|
const matches: Array<{ key: string; display_value: string }> = []
|
||||||
(n.data.services ?? []).some((s) => s.service_name?.toLowerCase().includes(q))
|
|
||||||
)
|
// 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 && (
|
{totalResults > 0 && (
|
||||||
<div style={{ borderTop: '1px solid #30363d', maxHeight: 260, overflowY: 'auto' }}>
|
<div style={{ borderTop: '1px solid #30363d', maxHeight: 260, overflowY: 'auto' }}>
|
||||||
{nodeResults.map((n) => (
|
{nodeResults.map((n, idx) => (
|
||||||
<button
|
<button
|
||||||
key={n.id}
|
key={`${n.node.id}-${idx}`}
|
||||||
onClick={() => goToNode(n.id)}
|
onClick={() => goToNode(n.node.id)}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -161,15 +191,19 @@ export function SearchBar({ onOpenPending }: SearchBarProps) {
|
|||||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#e6edf3', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<span style={{ fontSize: 12, fontWeight: 600, color: '#e6edf3', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
{n.data.label}
|
{n.node.data.label}
|
||||||
</span>
|
</span>
|
||||||
{n.data.ip && (
|
{n.matches.length > 0 && (
|
||||||
<span style={{ fontSize: 11, color: '#8b949e', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>
|
<span style={{ maxWidth: "50%", display: 'flex', flexDirection: 'column', gap: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
{n.data.ip}
|
{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>
|
||||||
)}
|
)}
|
||||||
<span style={{ fontSize: 10, color: '#6e7681', flexShrink: 0 }}>
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -103,6 +103,50 @@ describe('SearchBar', () => {
|
|||||||
expect(screen.queryByText('DB Server')).toBeNull()
|
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', () => {
|
it('excludes groupRect nodes from results', () => {
|
||||||
setupStore([
|
setupStore([
|
||||||
makeNode('gr1', { data: { label: 'DMZ Zone', type: 'groupRect', status: 'unknown', services: [], ip: null, hostname: null } }),
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
<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>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
|
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
|
<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 */}
|
{/* Label */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Label</Label>
|
<Label className="text-xs text-muted-foreground">Label</Label>
|
||||||
@@ -209,6 +214,38 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Colors */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Colors</Label>
|
<Label className="text-xs text-muted-foreground">Colors</Label>
|
||||||
@@ -245,33 +282,6 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Border style */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Border Style</Label>
|
<Label className="text-xs text-muted-foreground">Border Style</Label>
|
||||||
@@ -342,6 +352,8 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>{/* ── end RIGHT column ── */}
|
||||||
|
</div>{/* ── end 2-column grid ── */}
|
||||||
|
|
||||||
<div className="flex justify-between gap-2 pt-1">
|
<div className="flex justify-between gap-2 pt-1">
|
||||||
{onDelete && (
|
{onDelete && (
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ interface PendingDevicesModalProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
highlightId?: string
|
highlightId?: string
|
||||||
initialStatus?: 'pending' | 'hidden'
|
initialStatus?: 'pending' | 'hidden'
|
||||||
|
/** Getting Started tour: render these canned devices and skip the backend fetch. */
|
||||||
|
demoDevices?: PendingDevice[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const PORT_COLORS: Record<number, string> = {
|
const PORT_COLORS: Record<number, string> = {
|
||||||
@@ -113,7 +115,7 @@ function injectAutoEdges(edges: AutoEdge[] | undefined) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PendingDevicesModal({ open, onClose, highlightId, initialStatus = 'pending' }: PendingDevicesModalProps) {
|
export function PendingDevicesModal({ open, onClose, highlightId, initialStatus = 'pending', demoDevices }: PendingDevicesModalProps) {
|
||||||
const [devices, setDevices] = useState<PendingDevice[]>([])
|
const [devices, setDevices] = useState<PendingDevice[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [selected, setSelected] = useState<PendingDevice | null>(null)
|
const [selected, setSelected] = useState<PendingDevice | null>(null)
|
||||||
@@ -136,6 +138,8 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
const [dupPrompt, setDupPrompt] = useState<{ device: PendingDevice; conflict: DuplicateNodeConflict } | null>(null)
|
const [dupPrompt, setDupPrompt] = useState<{ device: PendingDevice; conflict: DuplicateNodeConflict } | null>(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
|
// Tour/demo mode: show injected devices, never touch the backend.
|
||||||
|
if (demoDevices) { setDevices(statusFilter === 'pending' ? demoDevices : []); return }
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = statusFilter === 'pending' ? await scanApi.pending() : await scanApi.hidden()
|
const res = statusFilter === 'pending' ? await scanApi.pending() : await scanApi.hidden()
|
||||||
@@ -145,7 +149,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [statusFilter])
|
}, [statusFilter, demoDevices])
|
||||||
|
|
||||||
useEffect(() => { if (open) load() }, [open, load])
|
useEffect(() => { if (open) load() }, [open, load])
|
||||||
useEffect(() => { if (open && scanEventTs > 0) load() }, [scanEventTs, open, load])
|
useEffect(() => { if (open && scanEventTs > 0) load() }, [scanEventTs, open, load])
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface ScanRun {
|
|||||||
interface ScanHistoryModalProps {
|
interface ScanHistoryModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
/** Getting Started tour: render these canned runs and skip the backend fetch. */
|
||||||
|
demoRuns?: ScanRun[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type KindFilter = 'all' | 'ip' | 'zigbee' | 'zwave' | 'proxmox'
|
type KindFilter = 'all' | 'ip' | 'zigbee' | 'zwave' | 'proxmox'
|
||||||
@@ -85,7 +87,7 @@ function runDuration(r: ScanRun, now: number): string {
|
|||||||
return formatDuration(end - start)
|
return formatDuration(end - start)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
export function ScanHistoryModal({ open, onClose, demoRuns }: ScanHistoryModalProps) {
|
||||||
const [runs, setRuns] = useState<ScanRun[]>([])
|
const [runs, setRuns] = useState<ScanRun[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [stopping, setStopping] = useState<string | null>(null)
|
const [stopping, setStopping] = useState<string | null>(null)
|
||||||
@@ -95,6 +97,8 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
|||||||
const prevRunsRef = useRef<ScanRun[]>([])
|
const prevRunsRef = useRef<ScanRun[]>([])
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
|
// Tour/demo mode: show injected runs, never touch the backend.
|
||||||
|
if (demoRuns) { setRuns(demoRuns); prevRunsRef.current = demoRuns; return }
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await scanApi.runs()
|
const res = await scanApi.runs()
|
||||||
@@ -127,7 +131,7 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [demoRuns])
|
||||||
|
|
||||||
// Load when opened; reset prior-state tracker so we don't replay old transitions
|
// Load when opened; reset prior-state tracker so we don't replay old transitions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -155,6 +159,7 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
|||||||
}, [open, runs])
|
}, [open, runs])
|
||||||
|
|
||||||
const handleStop = async (runId: string) => {
|
const handleStop = async (runId: string) => {
|
||||||
|
if (demoRuns) return // tour mode — no backend
|
||||||
setStopping(runId)
|
setStopping(runId)
|
||||||
try {
|
try {
|
||||||
await scanApi.stop(runId)
|
await scanApi.stop(runId)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type ZwaveConfigData,
|
type ZwaveConfigData,
|
||||||
} from '@/api/client'
|
} from '@/api/client'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { useWalkthroughStore } from '@/stores/walkthroughStore'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
type AlignmentSettings,
|
type AlignmentSettings,
|
||||||
@@ -18,6 +19,12 @@ import {
|
|||||||
writeAlignmentSettings,
|
writeAlignmentSettings,
|
||||||
subscribeAlignmentSettings,
|
subscribeAlignmentSettings,
|
||||||
} from '@/utils/alignmentSettings'
|
} from '@/utils/alignmentSettings'
|
||||||
|
import {
|
||||||
|
type AutosaveSettings,
|
||||||
|
readAutosaveSettings,
|
||||||
|
writeAutosaveSettings,
|
||||||
|
subscribeAutosaveSettings,
|
||||||
|
} from '@/utils/autosaveSettings'
|
||||||
|
|
||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
|
|
||||||
@@ -124,6 +131,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
const [zwInterval, setZwInterval] = useState(3600)
|
const [zwInterval, setZwInterval] = useState(3600)
|
||||||
const [zwSyncing, setZwSyncing] = useState(false)
|
const [zwSyncing, setZwSyncing] = useState(false)
|
||||||
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
|
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
|
||||||
|
const [autosave, setAutosave] = useState<AutosaveSettings>(readAutosaveSettings)
|
||||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||||
const setHideIp = useCanvasStore((s) => s.setHideIp)
|
const setHideIp = useCanvasStore((s) => s.setHideIp)
|
||||||
|
|
||||||
@@ -160,6 +168,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
useEffect(() => subscribeAlignmentSettings(setAlignment), [])
|
useEffect(() => subscribeAlignmentSettings(setAlignment), [])
|
||||||
|
useEffect(() => subscribeAutosaveSettings(setAutosave), [])
|
||||||
|
|
||||||
const updateAlignment = (patch: Partial<AlignmentSettings>) => {
|
const updateAlignment = (patch: Partial<AlignmentSettings>) => {
|
||||||
const next = { ...alignment, ...patch }
|
const next = { ...alignment, ...patch }
|
||||||
@@ -167,6 +176,12 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
writeAlignmentSettings(next)
|
writeAlignmentSettings(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateAutosave = (patch: Partial<AutosaveSettings>) => {
|
||||||
|
const next = { ...autosave, ...patch }
|
||||||
|
setAutosave(next)
|
||||||
|
writeAutosaveSettings(next)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSyncNow = async () => {
|
const handleSyncNow = async () => {
|
||||||
setPmSyncing(true)
|
setPmSyncing(true)
|
||||||
try {
|
try {
|
||||||
@@ -353,6 +368,52 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
Distance at which dragged nodes snap to neighbours. Hold Alt while dragging to disable.
|
Distance at which dragged nodes snap to neighbours. Hold Alt while dragging to disable.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center justify-between gap-2 cursor-pointer">
|
||||||
|
<span className="text-xs text-foreground">Autosave canvas</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autosave.enabled}
|
||||||
|
onChange={(e) => updateAutosave({ enabled: e.target.checked })}
|
||||||
|
className="cursor-pointer accent-[#00d4ff]"
|
||||||
|
aria-label="Toggle autosave"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className={autosave.enabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
|
||||||
|
<label className="text-xs text-muted-foreground">Save after</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={autosave.delay}
|
||||||
|
onChange={(e) => updateAutosave({ delay: Number(e.target.value) })}
|
||||||
|
disabled={!autosave.enabled}
|
||||||
|
className="px-2 py-1 rounded-md text-xs bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]"
|
||||||
|
aria-label="Autosave delay"
|
||||||
|
>
|
||||||
|
<option value={3}>3 s</option>
|
||||||
|
<option value={5}>5 s</option>
|
||||||
|
<option value={10}>10 s</option>
|
||||||
|
<option value={30}>30 s</option>
|
||||||
|
<option value={60}>60 s</option>
|
||||||
|
</select>
|
||||||
|
<span className="text-xs text-muted-foreground">of inactivity</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||||
|
Saves silently after this many seconds with no changes. Manual Ctrl+S still works.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs text-foreground">Getting started tour</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={() => { useWalkthroughStore.getState().start(); onClose() }}
|
||||||
|
>
|
||||||
|
Restart walkthrough
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ describe('GroupRectModal', () => {
|
|||||||
expect(screen.getByText('Z-Order (1 = furthest back)')).toBeDefined()
|
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', () => {
|
it('renders Edit Zone title when provided', () => {
|
||||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Zone" />)
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Zone" />)
|
||||||
expect(screen.getByText('Edit Zone')).toBeDefined()
|
expect(screen.getByText('Edit Zone')).toBeDefined()
|
||||||
|
|||||||
@@ -131,6 +131,9 @@ describe('SettingsModal', () => {
|
|||||||
vi.mocked(proxmoxApi.saveConfig).mockResolvedValue({ data: {} } as never)
|
vi.mocked(proxmoxApi.saveConfig).mockResolvedValue({ data: {} } as never)
|
||||||
render(<SettingsModal open onClose={vi.fn()} />)
|
render(<SettingsModal open onClose={vi.fn()} />)
|
||||||
await screen.findByDisplayValue('60')
|
await screen.findByDisplayValue('60')
|
||||||
|
// Wait for the async proxmox getConfig to hydrate the toggle before saving —
|
||||||
|
// otherwise Save can fire with the default sync_enabled=false under CI load.
|
||||||
|
await waitFor(() => expect(screen.getByLabelText('Toggle Proxmox auto-sync')).toBeChecked())
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(proxmoxApi.saveConfig).toHaveBeenCalledWith({ sync_enabled: true, sync_interval: 3600 })
|
expect(proxmoxApi.saveConfig).toHaveBeenCalledWith({ sync_enabled: true, sync_interval: 3600 })
|
||||||
@@ -174,6 +177,9 @@ describe('SettingsModal', () => {
|
|||||||
render(<SettingsModal open onClose={vi.fn()} />)
|
render(<SettingsModal open onClose={vi.fn()} />)
|
||||||
await screen.findByDisplayValue('60')
|
await screen.findByDisplayValue('60')
|
||||||
await screen.findByText('Zigbee auto-sync')
|
await screen.findByText('Zigbee auto-sync')
|
||||||
|
// Wait for the async zigbee getConfig to hydrate the toggle before saving —
|
||||||
|
// otherwise Save can fire with the default sync_enabled=false under CI load.
|
||||||
|
await waitFor(() => expect(screen.getByLabelText('Toggle Zigbee auto-sync')).toBeChecked())
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(zigbeeApi.saveConfig).toHaveBeenCalledWith({ sync_enabled: true, sync_interval: 1800 })
|
expect(zigbeeApi.saveConfig).toHaveBeenCalledWith({ sync_enabled: true, sync_interval: 1800 })
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createElement, useRef, useState } from 'react'
|
import { createElement, useRef, useState } from 'react'
|
||||||
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
|
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff, GripVertical } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
||||||
@@ -37,6 +37,10 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
const [newProp, setNewProp] = useState<PropForm>(EMPTY_PROP)
|
const [newProp, setNewProp] = useState<PropForm>(EMPTY_PROP)
|
||||||
const [editingPropIndex, setEditingPropIndex] = useState<number | null>(null)
|
const [editingPropIndex, setEditingPropIndex] = useState<number | null>(null)
|
||||||
const [editProp, setEditProp] = useState<PropForm>(EMPTY_PROP)
|
const [editProp, setEditProp] = useState<PropForm>(EMPTY_PROP)
|
||||||
|
const [dragPropIndex, setDragPropIndex] = useState<number | null>(null)
|
||||||
|
const [dragOverPropIndex, setDragOverPropIndex] = useState<number | null>(null)
|
||||||
|
const [dragSvcIndex, setDragSvcIndex] = useState<number | null>(null)
|
||||||
|
const [dragOverSvcIndex, setDragOverSvcIndex] = useState<number | null>(null)
|
||||||
|
|
||||||
// Multi-select panel
|
// Multi-select panel
|
||||||
const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id))
|
const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id))
|
||||||
@@ -156,6 +160,15 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
setEditingFor(null)
|
setEditingFor(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleReorderService = (from: number, to: number) => {
|
||||||
|
if (from === to || from < 0 || to < 0 || from >= services.length || to >= services.length) return
|
||||||
|
snapshotHistory()
|
||||||
|
const reordered = [...services]
|
||||||
|
const [moved] = reordered.splice(from, 1)
|
||||||
|
reordered.splice(to, 0, moved)
|
||||||
|
updateNode(node.id, { services: reordered })
|
||||||
|
}
|
||||||
|
|
||||||
// --- Property handlers ---
|
// --- Property handlers ---
|
||||||
const properties: NodeProperty[] = data.properties ?? []
|
const properties: NodeProperty[] = data.properties ?? []
|
||||||
|
|
||||||
@@ -202,6 +215,15 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
setEditingPropIndex(null)
|
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 (
|
return (
|
||||||
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
|
<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">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
@@ -302,6 +324,17 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
<PropertyBadge
|
<PropertyBadge
|
||||||
key={`${prop.key}-${i}`}
|
key={`${prop.key}-${i}`}
|
||||||
prop={prop}
|
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)}
|
onToggleVisible={() => handleTogglePropVisible(i)}
|
||||||
onEdit={() => handleStartEditProp(i)}
|
onEdit={() => handleStartEditProp(i)}
|
||||||
onRemove={() => handleRemoveProp(i)}
|
onRemove={() => handleRemoveProp(i)}
|
||||||
@@ -329,7 +362,25 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
editingIndex === i ? (
|
editingIndex === i ? (
|
||||||
<ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus />
|
<ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus />
|
||||||
) : (
|
) : (
|
||||||
<ServiceBadge key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`} svc={svc} host={host} status={serviceStatuses[serviceStatusKey(node.id, svc.port, svc.protocol)]} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
|
<ServiceBadge
|
||||||
|
key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`}
|
||||||
|
svc={svc}
|
||||||
|
host={host}
|
||||||
|
status={serviceStatuses[serviceStatusKey(node.id, svc.port, svc.protocol)]}
|
||||||
|
draggable={services.length > 1}
|
||||||
|
isDragging={dragSvcIndex === i}
|
||||||
|
isDragOver={dragOverSvcIndex === i && dragSvcIndex !== i}
|
||||||
|
onDragStart={() => setDragSvcIndex(i)}
|
||||||
|
onDragEnter={() => { if (dragSvcIndex !== null) setDragOverSvcIndex(i) }}
|
||||||
|
onDragEnd={() => { setDragSvcIndex(null); setDragOverSvcIndex(null) }}
|
||||||
|
onDrop={() => {
|
||||||
|
if (dragSvcIndex !== null) handleReorderService(dragSvcIndex, i)
|
||||||
|
setDragSvcIndex(null)
|
||||||
|
setDragOverSvcIndex(null)
|
||||||
|
}}
|
||||||
|
onEdit={() => handleStartEdit(i)}
|
||||||
|
onRemove={() => handleRemoveService(i)}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -494,6 +545,7 @@ function MultiSelectPanel({ nodeIds, nodes, groupName, setGroupName, creatingGro
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
data-tour="create-group"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full gap-2 bg-[#00d4ff]/10 text-[#00d4ff] border border-[#00d4ff]/30 hover:bg-[#00d4ff]/20"
|
className="w-full gap-2 bg-[#00d4ff]/10 text-[#00d4ff] border border-[#00d4ff]/30 hover:bg-[#00d4ff]/20"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -768,16 +820,41 @@ function PropertyForm({ form, onChange, onConfirm, onCancel, confirmLabel }: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PropertyBadge({ prop, onToggleVisible, onEdit, onRemove }: {
|
function PropertyBadge({ prop, draggable, isDragging, isDragOver, onDragStart, onDragEnter, onDragEnd, onDrop, onToggleVisible, onEdit, onRemove }: {
|
||||||
prop: NodeProperty
|
prop: NodeProperty
|
||||||
|
draggable: boolean
|
||||||
|
isDragging: boolean
|
||||||
|
isDragOver: boolean
|
||||||
|
onDragStart: () => void
|
||||||
|
onDragEnter: () => void
|
||||||
|
onDragEnd: () => void
|
||||||
|
onDrop: () => void
|
||||||
onToggleVisible: () => void
|
onToggleVisible: () => void
|
||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
onRemove: () => void
|
onRemove: () => void
|
||||||
}) {
|
}) {
|
||||||
const Icon = resolvePropertyIcon(prop.icon)
|
const Icon = resolvePropertyIcon(prop.icon)
|
||||||
return (
|
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">
|
<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' })}
|
{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="font-medium truncate text-foreground" title={prop.key}>{prop.key}</span>
|
||||||
<span className="text-muted-foreground truncate" title={prop.value}>· {prop.value}</span>
|
<span className="text-muted-foreground truncate" title={prop.value}>· {prop.value}</span>
|
||||||
@@ -805,7 +882,20 @@ const CATEGORY_COLORS: Record<string, string> = {
|
|||||||
web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
|
web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
|
||||||
}
|
}
|
||||||
|
|
||||||
function ServiceBadge({ svc, host, status, onEdit, onRemove }: { svc: ServiceInfo; host?: string; status?: ServiceStatus; onEdit: () => void; onRemove: () => void }) {
|
function ServiceBadge({ svc, host, status, draggable, isDragging, isDragOver, onDragStart, onDragEnter, onDragEnd, onDrop, onEdit, onRemove }: {
|
||||||
|
svc: ServiceInfo
|
||||||
|
host?: string
|
||||||
|
status?: ServiceStatus
|
||||||
|
draggable: boolean
|
||||||
|
isDragging: boolean
|
||||||
|
isDragOver: boolean
|
||||||
|
onDragStart: () => void
|
||||||
|
onDragEnter: () => void
|
||||||
|
onDragEnd: () => void
|
||||||
|
onDrop: () => void
|
||||||
|
onEdit: () => void
|
||||||
|
onRemove: () => void
|
||||||
|
}) {
|
||||||
const url = getServiceUrl(svc, host)
|
const url = getServiceUrl(svc, host)
|
||||||
// Manually-added services carry no category, so they fell back to grey even
|
// Manually-added services carry no category, so they fell back to grey even
|
||||||
// when they're reachable HTTP/HTTPS. Treat any resolvable web URL as `web`.
|
// when they're reachable HTTP/HTTPS. Treat any resolvable web URL as `web`.
|
||||||
@@ -816,10 +906,25 @@ function ServiceBadge({ svc, host, status, onEdit, onRemove }: { svc: ServiceInf
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
draggable={draggable}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragEnter={onDragEnter}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onDrop={(e) => { e.preventDefault(); onDrop() }}
|
||||||
className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors min-w-0"
|
className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors min-w-0"
|
||||||
style={{ background: '#21262d', borderColor: '#30363d' }}
|
style={{
|
||||||
|
background: '#21262d',
|
||||||
|
borderColor: isDragOver ? '#00d4ff' : '#30363d',
|
||||||
|
opacity: isDragging ? 0.4 : 1,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||||
|
{draggable && (
|
||||||
|
<span className="shrink-0 cursor-grab active:cursor-grabbing text-[#8b949e] hover:text-[#00d4ff]" title="Drag to reorder">
|
||||||
|
{createElement(GripVertical, { size: 11 })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
|
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
|
||||||
|
|
||||||
{url ? (
|
{url ? (
|
||||||
|
|||||||
@@ -268,13 +268,13 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex flex-col gap-0.5 p-2 border-t border-border">
|
<div className="flex flex-col gap-0.5 p-2 border-t border-border">
|
||||||
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
|
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} dataTour="add-node" />
|
||||||
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} />
|
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} dataTour="add-zone" />
|
||||||
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} />
|
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} dataTour="add-text" />
|
||||||
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} dataTour="scan-network" />}
|
||||||
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
|
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} dataTour="zigbee-import" />}
|
||||||
{!STANDALONE && <SidebarItem icon={RadioTower} label="Z-Wave Import" collapsed={collapsed} onClick={onZwaveImport} />}
|
{!STANDALONE && <SidebarItem icon={RadioTower} label="Z-Wave Import" collapsed={collapsed} onClick={onZwaveImport} dataTour="zwave-import" />}
|
||||||
{!STANDALONE && <SidebarItem icon={Server} label="Proxmox Import" collapsed={collapsed} onClick={onProxmoxImport} />}
|
{!STANDALONE && <SidebarItem icon={Server} label="Proxmox Import" collapsed={collapsed} onClick={onProxmoxImport} dataTour="proxmox-import" />}
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
icon={Save}
|
icon={Save}
|
||||||
label="Save Canvas"
|
label="Save Canvas"
|
||||||
@@ -360,12 +360,15 @@ interface SidebarItemProps {
|
|||||||
badge?: boolean
|
badge?: boolean
|
||||||
accent?: boolean
|
accent?: boolean
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
|
/** Stable anchor for the Getting Started walkthrough spotlight. */
|
||||||
|
dataTour?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarItem({ icon: Icon, label, collapsed, active, badge, accent, onClick }: SidebarItemProps) {
|
function SidebarItem({ icon: Icon, label, collapsed, active, badge, accent, onClick, dataTour }: SidebarItemProps) {
|
||||||
const btn = (
|
const btn = (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
data-tour={dataTour}
|
||||||
className={`relative flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm transition-colors cursor-pointer ${
|
className={`relative flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm transition-colors cursor-pointer ${
|
||||||
active
|
active
|
||||||
? 'bg-[#00d4ff]/10 text-[#00d4ff]'
|
? 'bg-[#00d4ff]/10 text-[#00d4ff]'
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
|||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onAutoLayout}>
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onAutoLayout}>
|
||||||
<LayoutDashboard size={14} /> Auto Layout
|
<LayoutDashboard size={14} /> Auto Layout
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onChangeStyle}>
|
<Button data-tour="style" size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onChangeStyle}>
|
||||||
<Palette size={14} /> Style
|
<Palette size={14} /> Style
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={() => fileInputRef.current?.click()} title="Import from YAML">
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={() => fileInputRef.current?.click()} title="Import from YAML">
|
||||||
|
|||||||
@@ -187,6 +187,44 @@ describe('DetailPanel', () => {
|
|||||||
expect(payload.properties).toHaveLength(0)
|
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', () => {
|
it('does not submit add form when key is empty', () => {
|
||||||
const updateNode = vi.fn()
|
const updateNode = vi.fn()
|
||||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||||
@@ -392,6 +430,44 @@ describe('DetailPanel', () => {
|
|||||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||||
expect(() => render(<DetailPanel onEdit={vi.fn()} />)).not.toThrow()
|
expect(() => render(<DetailPanel onEdit={vi.fn()} />)).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reorders services on drag and drop', () => {
|
||||||
|
const updateNode = vi.fn()
|
||||||
|
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||||
|
nodes: [makeNode({ services: [
|
||||||
|
{ port: 80, protocol: 'tcp', service_name: 'A' },
|
||||||
|
{ port: 81, protocol: 'tcp', service_name: 'B' },
|
||||||
|
{ port: 82, protocol: 'tcp', service_name: 'C' },
|
||||||
|
] })],
|
||||||
|
selectedNodeId: 'n1',
|
||||||
|
selectedNodeIds: [],
|
||||||
|
setSelectedNode: vi.fn(),
|
||||||
|
deleteNode: vi.fn(),
|
||||||
|
updateNode,
|
||||||
|
snapshotHistory: vi.fn(),
|
||||||
|
createGroup: vi.fn(),
|
||||||
|
ungroup: vi.fn(),
|
||||||
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||||
|
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
const rows = screen.getAllByTitle('Drag to reorder').map(
|
||||||
|
(g) => g.closest('[draggable="true"]') as HTMLElement,
|
||||||
|
)
|
||||||
|
// Drag last (C) onto first (A) position
|
||||||
|
fireEvent.dragStart(rows[2])
|
||||||
|
fireEvent.dragEnter(rows[0])
|
||||||
|
fireEvent.drop(rows[0])
|
||||||
|
|
||||||
|
expect(updateNode).toHaveBeenCalledOnce()
|
||||||
|
const [, payload] = updateNode.mock.calls[0]
|
||||||
|
expect(payload.services.map((s: { service_name: string }) => s.service_name)).toEqual(['C', 'A', 'B'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is not draggable with a single service', () => {
|
||||||
|
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'A' }] })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.queryByTitle('Drag to reorder')).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Services — edit', () => {
|
describe('Services — edit', () => {
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect, afterEach, beforeAll, vi } from 'vitest'
|
||||||
|
import { render, cleanup, waitFor } from '@testing-library/react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Toaster } from '../sonner'
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
// sonner reads matchMedia for theme detection; jsdom doesn't provide it.
|
||||||
|
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => cleanup())
|
||||||
|
|
||||||
|
describe('Toaster', () => {
|
||||||
|
it('renders error toasts on a red surface so failures are visible', async () => {
|
||||||
|
render(<Toaster />)
|
||||||
|
toast.error('Save failed')
|
||||||
|
// The style (with the CSS vars) lands on the toast list, mounted on first toast.
|
||||||
|
const list = await waitFor(() => {
|
||||||
|
const el = document.querySelector('[data-sonner-toaster]') as HTMLElement | null
|
||||||
|
expect(el).not.toBeNull()
|
||||||
|
return el!
|
||||||
|
})
|
||||||
|
const style = list.getAttribute('style') ?? ''
|
||||||
|
expect(style).toContain('--error-bg: #f85149')
|
||||||
|
expect(style).toContain('--error-text: #ffffff')
|
||||||
|
|
||||||
|
// richColors must be on for sonner to apply the per-type surface, and the
|
||||||
|
// toast itself must be tagged as an error so it picks up --error-bg.
|
||||||
|
const item = await waitFor(() => {
|
||||||
|
const el = document.querySelector('[data-sonner-toast]') as HTMLElement | null
|
||||||
|
expect(el).not.toBeNull()
|
||||||
|
return el!
|
||||||
|
})
|
||||||
|
expect(item.getAttribute('data-rich-colors')).toBe('true')
|
||||||
|
expect(item.getAttribute('data-type')).toBe('error')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -11,6 +11,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||||||
<Sonner
|
<Sonner
|
||||||
theme={theme as ToasterProps["theme"]}
|
theme={theme as ToasterProps["theme"]}
|
||||||
className="toaster group"
|
className="toaster group"
|
||||||
|
richColors
|
||||||
icons={{
|
icons={{
|
||||||
success: (
|
success: (
|
||||||
<CircleCheckIcon className="size-4" />
|
<CircleCheckIcon className="size-4" />
|
||||||
@@ -34,6 +35,23 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||||||
"--normal-text": "var(--popover-foreground)",
|
"--normal-text": "var(--popover-foreground)",
|
||||||
"--normal-border": "var(--border)",
|
"--normal-border": "var(--border)",
|
||||||
"--border-radius": "var(--radius)",
|
"--border-radius": "var(--radius)",
|
||||||
|
// richColors (below) is required for sonner to honor per-type surfaces.
|
||||||
|
// Errors get a red surface so failures (e.g. a save while the backend
|
||||||
|
// is down) stand out instead of reading like a normal toast...
|
||||||
|
"--error-bg": "#f85149",
|
||||||
|
"--error-text": "#ffffff",
|
||||||
|
"--error-border": "#da3633",
|
||||||
|
// ...while success/info/warning stay on the neutral popover surface so
|
||||||
|
// the rest of the toast UX is unchanged.
|
||||||
|
"--success-bg": "var(--popover)",
|
||||||
|
"--success-text": "var(--popover-foreground)",
|
||||||
|
"--success-border": "var(--border)",
|
||||||
|
"--info-bg": "var(--popover)",
|
||||||
|
"--info-text": "var(--popover-foreground)",
|
||||||
|
"--info-border": "var(--border)",
|
||||||
|
"--warning-bg": "var(--popover)",
|
||||||
|
"--warning-text": "var(--popover-foreground)",
|
||||||
|
"--warning-border": "var(--border)",
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { renderHook } from '@testing-library/react'
|
||||||
|
import { useAutosave } from '../useAutosave'
|
||||||
|
|
||||||
|
interface Args {
|
||||||
|
enabled?: boolean
|
||||||
|
delaySeconds?: number
|
||||||
|
hasUnsavedChanges?: boolean
|
||||||
|
designId?: string | null
|
||||||
|
changeSignals?: unknown[]
|
||||||
|
getLiveDesignId?: () => string | null
|
||||||
|
onSave?: (designId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function args(over: Args = {}) {
|
||||||
|
const designId = 'designId' in over ? (over.designId ?? null) : 'design-a'
|
||||||
|
return {
|
||||||
|
enabled: over.enabled ?? true,
|
||||||
|
delaySeconds: over.delaySeconds ?? 5,
|
||||||
|
hasUnsavedChanges: over.hasUnsavedChanges ?? true,
|
||||||
|
designId,
|
||||||
|
changeSignals: over.changeSignals ?? [[], []],
|
||||||
|
getLiveDesignId: over.getLiveDesignId ?? (() => designId),
|
||||||
|
onSave: over.onSave ?? vi.fn(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useAutosave', () => {
|
||||||
|
beforeEach(() => vi.useFakeTimers())
|
||||||
|
afterEach(() => vi.useRealTimers())
|
||||||
|
|
||||||
|
it('saves after the inactivity delay when enabled with unsaved changes', () => {
|
||||||
|
const onSave = vi.fn()
|
||||||
|
renderHook(() => useAutosave(args({ onSave, delaySeconds: 5 })))
|
||||||
|
expect(onSave).not.toHaveBeenCalled()
|
||||||
|
vi.advanceTimersByTime(4999)
|
||||||
|
expect(onSave).not.toHaveBeenCalled()
|
||||||
|
vi.advanceTimersByTime(1)
|
||||||
|
expect(onSave).toHaveBeenCalledTimes(1)
|
||||||
|
expect(onSave).toHaveBeenCalledWith('design-a')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does nothing when disabled', () => {
|
||||||
|
const onSave = vi.fn()
|
||||||
|
renderHook(() => useAutosave(args({ onSave, enabled: false })))
|
||||||
|
vi.advanceTimersByTime(60_000)
|
||||||
|
expect(onSave).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does nothing when there are no unsaved changes', () => {
|
||||||
|
const onSave = vi.fn()
|
||||||
|
renderHook(() => useAutosave(args({ onSave, hasUnsavedChanges: false })))
|
||||||
|
vi.advanceTimersByTime(60_000)
|
||||||
|
expect(onSave).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does nothing when the canvas has no known provenance', () => {
|
||||||
|
const onSave = vi.fn()
|
||||||
|
renderHook(() => useAutosave(args({ onSave, designId: null })))
|
||||||
|
vi.advanceTimersByTime(60_000)
|
||||||
|
expect(onSave).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('debounces: a change before the delay resets the timer', () => {
|
||||||
|
const onSave = vi.fn()
|
||||||
|
const { rerender } = renderHook((p: Args) => useAutosave(args(p)), {
|
||||||
|
initialProps: { onSave, changeSignals: [1] },
|
||||||
|
})
|
||||||
|
vi.advanceTimersByTime(4000)
|
||||||
|
rerender({ onSave, changeSignals: [2] }) // edit resets debounce
|
||||||
|
vi.advanceTimersByTime(4000)
|
||||||
|
expect(onSave).not.toHaveBeenCalled()
|
||||||
|
vi.advanceTimersByTime(1000)
|
||||||
|
expect(onSave).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips the save if a different canvas loaded while the timer was pending', () => {
|
||||||
|
const onSave = vi.fn()
|
||||||
|
// Pinned provenance at arm time = 'design-a', but a switch loaded 'design-b'
|
||||||
|
// before the timer fired — the on-screen canvas is no longer design-a's.
|
||||||
|
renderHook(() =>
|
||||||
|
useAutosave(args({ onSave, designId: 'design-a', getLiveDesignId: () => 'design-b' })),
|
||||||
|
)
|
||||||
|
vi.advanceTimersByTime(5000)
|
||||||
|
expect(onSave).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves under the pinned provenance id when it still matches at fire time', () => {
|
||||||
|
const onSave = vi.fn()
|
||||||
|
renderHook(() =>
|
||||||
|
useAutosave(args({ onSave, designId: 'design-a', getLiveDesignId: () => 'design-a' })),
|
||||||
|
)
|
||||||
|
vi.advanceTimersByTime(5000)
|
||||||
|
expect(onSave).toHaveBeenCalledTimes(1)
|
||||||
|
expect(onSave).toHaveBeenCalledWith('design-a')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,7 +7,7 @@ import { useAuthStore } from '@/stores/authStore'
|
|||||||
vi.mock('@/stores/canvasStore')
|
vi.mock('@/stores/canvasStore')
|
||||||
vi.mock('@/stores/authStore')
|
vi.mock('@/stores/authStore')
|
||||||
|
|
||||||
const mockUpdateNode = vi.fn()
|
const mockSetNodeStatus = vi.fn()
|
||||||
const mockNotifyScanDeviceFound = vi.fn()
|
const mockNotifyScanDeviceFound = vi.fn()
|
||||||
const mockSetServiceStatuses = vi.fn()
|
const mockSetServiceStatuses = vi.fn()
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ describe('useStatusPolling', () => {
|
|||||||
vi.stubGlobal('WebSocket', MockWebSocket)
|
vi.stubGlobal('WebSocket', MockWebSocket)
|
||||||
|
|
||||||
vi.mocked(useCanvasStore).mockReturnValue({
|
vi.mocked(useCanvasStore).mockReturnValue({
|
||||||
updateNode: mockUpdateNode,
|
setNodeStatus: mockSetNodeStatus,
|
||||||
notifyScanDeviceFound: mockNotifyScanDeviceFound,
|
notifyScanDeviceFound: mockNotifyScanDeviceFound,
|
||||||
setServiceStatuses: mockSetServiceStatuses,
|
setServiceStatuses: mockSetServiceStatuses,
|
||||||
} as ReturnType<typeof useCanvasStore>)
|
} as ReturnType<typeof useCanvasStore>)
|
||||||
@@ -50,7 +50,7 @@ describe('useStatusPolling', () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
mockUpdateNode.mockClear()
|
mockSetNodeStatus.mockClear()
|
||||||
mockNotifyScanDeviceFound.mockClear()
|
mockNotifyScanDeviceFound.mockClear()
|
||||||
mockSetServiceStatuses.mockClear()
|
mockSetServiceStatuses.mockClear()
|
||||||
})
|
})
|
||||||
@@ -95,7 +95,7 @@ describe('useStatusPolling', () => {
|
|||||||
expect(ws.send).toHaveBeenCalledWith(JSON.stringify({ token: 'test-token' }))
|
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())
|
renderHook(() => useStatusPolling())
|
||||||
const ws = MockWebSocket.instances[0]
|
const ws = MockWebSocket.instances[0]
|
||||||
ws.onmessage?.({
|
ws.onmessage?.({
|
||||||
@@ -106,7 +106,7 @@ describe('useStatusPolling', () => {
|
|||||||
response_time_ms: 42,
|
response_time_ms: 42,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
expect(mockUpdateNode).toHaveBeenCalledWith('node-1', {
|
expect(mockSetNodeStatus).toHaveBeenCalledWith('node-1', {
|
||||||
status: 'online',
|
status: 'online',
|
||||||
response_time_ms: 42,
|
response_time_ms: 42,
|
||||||
last_seen: '2024-01-01T12:00:00Z',
|
last_seen: '2024-01-01T12:00:00Z',
|
||||||
@@ -123,7 +123,7 @@ describe('useStatusPolling', () => {
|
|||||||
checked_at: '2024-01-01T12:00:00Z',
|
checked_at: '2024-01-01T12:00:00Z',
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
expect(mockUpdateNode).toHaveBeenCalledWith('node-1', {
|
expect(mockSetNodeStatus).toHaveBeenCalledWith('node-1', {
|
||||||
status: 'offline',
|
status: 'offline',
|
||||||
response_time_ms: undefined,
|
response_time_ms: undefined,
|
||||||
last_seen: undefined,
|
last_seen: undefined,
|
||||||
@@ -136,7 +136,7 @@ describe('useStatusPolling', () => {
|
|||||||
ws.onmessage?.({
|
ws.onmessage?.({
|
||||||
data: JSON.stringify({ node_id: 'node-1', status: 'online', response_time_ms: null }),
|
data: JSON.stringify({ node_id: 'node-1', status: 'online', response_time_ms: null }),
|
||||||
})
|
})
|
||||||
expect(mockUpdateNode).toHaveBeenCalledWith(
|
expect(mockSetNodeStatus).toHaveBeenCalledWith(
|
||||||
'node-1',
|
'node-1',
|
||||||
expect.objectContaining({ response_time_ms: undefined }),
|
expect.objectContaining({ response_time_ms: undefined }),
|
||||||
)
|
)
|
||||||
@@ -147,7 +147,7 @@ describe('useStatusPolling', () => {
|
|||||||
const ws = MockWebSocket.instances[0]
|
const ws = MockWebSocket.instances[0]
|
||||||
ws.onmessage?.({ data: JSON.stringify({ type: 'scan_device_found' }) })
|
ws.onmessage?.({ data: JSON.stringify({ type: 'scan_device_found' }) })
|
||||||
expect(mockNotifyScanDeviceFound).toHaveBeenCalledOnce()
|
expect(mockNotifyScanDeviceFound).toHaveBeenCalledOnce()
|
||||||
expect(mockUpdateNode).not.toHaveBeenCalled()
|
expect(mockSetNodeStatus).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('routes service_status messages to setServiceStatuses', () => {
|
it('routes service_status messages to setServiceStatuses', () => {
|
||||||
@@ -158,21 +158,21 @@ describe('useStatusPolling', () => {
|
|||||||
data: JSON.stringify({ type: 'service_status', node_id: 'node-9', services }),
|
data: JSON.stringify({ type: 'service_status', node_id: 'node-9', services }),
|
||||||
})
|
})
|
||||||
expect(mockSetServiceStatuses).toHaveBeenCalledWith('node-9', services)
|
expect(mockSetServiceStatuses).toHaveBeenCalledWith('node-9', services)
|
||||||
expect(mockUpdateNode).not.toHaveBeenCalled()
|
expect(mockSetNodeStatus).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ignores malformed JSON without throwing', () => {
|
it('ignores malformed JSON without throwing', () => {
|
||||||
renderHook(() => useStatusPolling())
|
renderHook(() => useStatusPolling())
|
||||||
const ws = MockWebSocket.instances[0]
|
const ws = MockWebSocket.instances[0]
|
||||||
expect(() => ws.onmessage?.({ data: 'not-valid-json{{' })).not.toThrow()
|
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', () => {
|
it('ignores messages with no node_id or status', () => {
|
||||||
renderHook(() => useStatusPolling())
|
renderHook(() => useStatusPolling())
|
||||||
const ws = MockWebSocket.instances[0]
|
const ws = MockWebSocket.instances[0]
|
||||||
ws.onmessage?.({ data: JSON.stringify({ some: 'unknown-field' }) })
|
ws.onmessage?.({ data: JSON.stringify({ some: 'unknown-field' }) })
|
||||||
expect(mockUpdateNode).not.toHaveBeenCalled()
|
expect(mockSetNodeStatus).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('closes WebSocket on unmount', () => {
|
it('closes WebSocket on unmount', () => {
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
|||||||
|
|
||||||
export function useStatusPolling() {
|
export function useStatusPolling() {
|
||||||
const wsRef = useRef<WebSocket | null>(null)
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
const { updateNode, notifyScanDeviceFound, setServiceStatuses } = useCanvasStore()
|
const { setNodeStatus, notifyScanDeviceFound, setServiceStatuses } = useCanvasStore()
|
||||||
const { isAuthenticated, token } = useAuthStore()
|
const { isAuthenticated, token } = useAuthStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -50,7 +50,9 @@ export function useStatusPolling() {
|
|||||||
} else if (msg.type === 'service_status' && msg.node_id && msg.services) {
|
} else if (msg.type === 'service_status' && msg.node_id && msg.services) {
|
||||||
setServiceStatuses(msg.node_id, msg.services)
|
setServiceStatuses(msg.node_id, msg.services)
|
||||||
} else if (msg.node_id && msg.status) {
|
} else if (msg.node_id && msg.status) {
|
||||||
updateNode(msg.node_id, {
|
// 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,
|
status: msg.status,
|
||||||
response_time_ms: msg.response_time_ms ?? undefined,
|
response_time_ms: msg.response_time_ms ?? undefined,
|
||||||
last_seen: msg.status === 'online' ? msg.checked_at : undefined,
|
last_seen: msg.status === 'online' ? msg.checked_at : undefined,
|
||||||
@@ -69,5 +71,5 @@ export function useStatusPolling() {
|
|||||||
ws.close()
|
ws.close()
|
||||||
wsRef.current = null
|
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()
|
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', () => {
|
it('loadCanvas sorts parents before children', () => {
|
||||||
const parent = makeNode('p1')
|
const parent = makeNode('p1')
|
||||||
const child: Node<NodeData> = { ...makeNode('c1', { parent_id: 'p1' }), parentId: 'p1', extent: 'parent' }
|
const child: Node<NodeData> = { ...makeNode('c1', { parent_id: 'p1' }), parentId: 'p1', extent: 'parent' }
|
||||||
|
|||||||
@@ -85,4 +85,31 @@ describe('canvasStore — history', () => {
|
|||||||
addNode(makeNode('n3'))
|
addNode(makeNode('n3'))
|
||||||
expect(useCanvasStore.getState().future).toHaveLength(0)
|
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: [],
|
nodes: [],
|
||||||
edges: [],
|
edges: [],
|
||||||
hasUnsavedChanges: false,
|
hasUnsavedChanges: false,
|
||||||
|
editSeq: 0,
|
||||||
selectedNodeId: null,
|
selectedNodeId: null,
|
||||||
selectedNodeIds: [],
|
selectedNodeIds: [],
|
||||||
editingGroupRectId: null,
|
editingGroupRectId: null,
|
||||||
@@ -52,6 +53,79 @@ describe('canvasStore — nodes', () => {
|
|||||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
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', () => {
|
it('setEditingTextId sets and clears editing text id', () => {
|
||||||
const { setEditingTextId } = useCanvasStore.getState()
|
const { setEditingTextId } = useCanvasStore.getState()
|
||||||
setEditingTextId('t1')
|
setEditingTextId('t1')
|
||||||
|
|||||||
@@ -118,4 +118,53 @@ describe('canvasStore — sizing & z-order', () => {
|
|||||||
useCanvasStore.getState().updateNode('r1', { properties: [] })
|
useCanvasStore.getState().updateNode('r1', { properties: [] })
|
||||||
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'r1')?.height).toBe(240)
|
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'r1')?.height).toBe(240)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Regression (#278): editing any field on a container-mode vm/lxc/docker_host host used to
|
||||||
|
// reset its manually-set height to undefined, snapping it back to auto-fit size and
|
||||||
|
// scrambling nested children. Only proxmox was excluded. Keep the manual height for every
|
||||||
|
// container-mode host type.
|
||||||
|
it.each(['vm', 'lxc', 'docker_host'] as const)(
|
||||||
|
'updateNode preserves manual height for a container-mode %s when properties change',
|
||||||
|
(type) => {
|
||||||
|
const host: Node<NodeData> = {
|
||||||
|
id: 'h1',
|
||||||
|
type,
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
width: 400,
|
||||||
|
height: 300,
|
||||||
|
data: { label: type, type, status: 'unknown', services: [], container_mode: true },
|
||||||
|
}
|
||||||
|
useCanvasStore.setState({ nodes: [host], edges: [] })
|
||||||
|
useCanvasStore.getState().updateNode('h1', { properties: [], label: 'renamed' })
|
||||||
|
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'h1')?.height).toBe(300)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
it('updateNode still resets height for a vm NOT in container mode when properties change', () => {
|
||||||
|
const leaf: Node<NodeData> = {
|
||||||
|
id: 'v1',
|
||||||
|
type: 'vm',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
width: 200,
|
||||||
|
height: 120,
|
||||||
|
data: { label: 'vm', type: 'vm', status: 'unknown', services: [], container_mode: false },
|
||||||
|
}
|
||||||
|
useCanvasStore.setState({ nodes: [leaf], edges: [] })
|
||||||
|
useCanvasStore.getState().updateNode('v1', { properties: [] })
|
||||||
|
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'v1')?.height).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updateNode preserves height for a proxmox host when properties change', () => {
|
||||||
|
const px: Node<NodeData> = {
|
||||||
|
id: 'px1',
|
||||||
|
type: 'proxmox',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
width: 500,
|
||||||
|
height: 350,
|
||||||
|
data: { label: 'px', type: 'proxmox', status: 'unknown', services: [], container_mode: true },
|
||||||
|
}
|
||||||
|
useCanvasStore.setState({ nodes: [px], edges: [] })
|
||||||
|
useCanvasStore.getState().updateNode('px1', { properties: [] })
|
||||||
|
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'px1')?.height).toBe(350)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import type { NodeChange, Node } from '@xyflow/react'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
import { makeNode, makeEdge } from '@/test/factories'
|
||||||
|
|
||||||
|
function resetStore() {
|
||||||
|
useCanvasStore.setState({
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
hasUnsavedChanges: false,
|
||||||
|
selectedNodeId: null,
|
||||||
|
selectedNodeIds: [],
|
||||||
|
editingGroupRectId: null,
|
||||||
|
editingTextId: null,
|
||||||
|
past: [],
|
||||||
|
future: [],
|
||||||
|
clipboard: { nodes: [], edges: [] },
|
||||||
|
serviceStatuses: {},
|
||||||
|
floorMap: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A `position` NodeChange as React Flow emits during/after a drag. */
|
||||||
|
function posChange(id: string, x: number, y: number): NodeChange<Node<NodeData>> {
|
||||||
|
return { type: 'position', id, position: { x, y }, dragging: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('canvasStore — edge waypoints follow dragged nodes (#279)', () => {
|
||||||
|
beforeEach(resetStore)
|
||||||
|
|
||||||
|
it('translates waypoints by the delta when a connected node is dragged', () => {
|
||||||
|
useCanvasStore.setState({
|
||||||
|
nodes: [makeNode('a', {}), makeNode('b', {})],
|
||||||
|
edges: [makeEdge('e1', 'a', 'b', { data: { type: 'ethernet', waypoints: [{ x: 100, y: 100 }, { x: 150, y: 120 }] } })],
|
||||||
|
})
|
||||||
|
// node 'a' starts at (0,0), dragged to (40,-10) → delta (40,-10)
|
||||||
|
useCanvasStore.getState().onNodesChange([posChange('a', 40, -10)])
|
||||||
|
|
||||||
|
const wps = useCanvasStore.getState().edges[0].data!.waypoints
|
||||||
|
expect(wps).toEqual([{ x: 140, y: 90 }, { x: 190, y: 110 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves waypoints untouched when an unrelated node is dragged', () => {
|
||||||
|
useCanvasStore.setState({
|
||||||
|
nodes: [makeNode('a', {}), makeNode('b', {}), makeNode('c', {})],
|
||||||
|
edges: [makeEdge('e1', 'a', 'b', { data: { type: 'ethernet', waypoints: [{ x: 100, y: 100 }] } })],
|
||||||
|
})
|
||||||
|
useCanvasStore.getState().onNodesChange([posChange('c', 40, 40)])
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().edges[0].data!.waypoints).toEqual([{ x: 100, y: 100 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('translates waypoints of edges between children when their container is dragged', () => {
|
||||||
|
// Children are parent-relative; their stored position does NOT change when
|
||||||
|
// the container moves, so the fix must propagate the container delta down.
|
||||||
|
useCanvasStore.setState({
|
||||||
|
nodes: [
|
||||||
|
makeNode({ id: 'box', type: 'group', position: { x: 0, y: 0 }, data: { label: 'box', type: 'lxc', container_mode: true } }),
|
||||||
|
makeNode({ id: 'a', position: { x: 20, y: 20 }, parentId: 'box' }),
|
||||||
|
makeNode({ id: 'b', position: { x: 80, y: 60 }, parentId: 'box' }),
|
||||||
|
],
|
||||||
|
edges: [makeEdge('e1', 'a', 'b', { data: { type: 'ethernet', waypoints: [{ x: 200, y: 200 }] } })],
|
||||||
|
})
|
||||||
|
// Drag the container from (0,0) to (30,15) → delta (30,15)
|
||||||
|
useCanvasStore.getState().onNodesChange([posChange('box', 30, 15)])
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().edges[0].data!.waypoints).toEqual([{ x: 230, y: 215 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('translates once (not doubled) when both endpoints move by the same delta', () => {
|
||||||
|
// A container drag reports the container moving; both children ride along.
|
||||||
|
useCanvasStore.setState({
|
||||||
|
nodes: [
|
||||||
|
makeNode({ id: 'box', type: 'group', position: { x: 0, y: 0 }, data: { label: 'box', type: 'lxc', container_mode: true } }),
|
||||||
|
makeNode({ id: 'a', position: { x: 20, y: 20 }, parentId: 'box' }),
|
||||||
|
makeNode({ id: 'b', position: { x: 80, y: 60 }, parentId: 'box' }),
|
||||||
|
],
|
||||||
|
edges: [makeEdge('e1', 'a', 'b', { data: { type: 'ethernet', waypoints: [{ x: 100, y: 100 }] } })],
|
||||||
|
})
|
||||||
|
useCanvasStore.getState().onNodesChange([posChange('box', 10, 10)])
|
||||||
|
|
||||||
|
// +10 once, not +20
|
||||||
|
expect(useCanvasStore.getState().edges[0].data!.waypoints).toEqual([{ x: 110, y: 110 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not alter edges without waypoints', () => {
|
||||||
|
useCanvasStore.setState({
|
||||||
|
nodes: [makeNode('a', {}), makeNode('b', {})],
|
||||||
|
edges: [makeEdge('e1', 'a', 'b')],
|
||||||
|
})
|
||||||
|
const before = useCanvasStore.getState().edges[0]
|
||||||
|
useCanvasStore.getState().onNodesChange([posChange('a', 40, 40)])
|
||||||
|
expect(useCanvasStore.getState().edges[0]).toBe(before)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores select-only changes (no movement)', () => {
|
||||||
|
useCanvasStore.setState({
|
||||||
|
nodes: [makeNode('a', {}), makeNode('b', {})],
|
||||||
|
edges: [makeEdge('e1', 'a', 'b', { data: { type: 'ethernet', waypoints: [{ x: 100, y: 100 }] } })],
|
||||||
|
})
|
||||||
|
useCanvasStore.getState().onNodesChange([{ type: 'select', id: 'a', selected: true }])
|
||||||
|
expect(useCanvasStore.getState().edges[0].data!.waypoints).toEqual([{ x: 100, y: 100 }])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { useWalkthroughStore } from '../walkthroughStore'
|
||||||
|
import { readWalkthrough, WALKTHROUGH_VERSION } from '@/utils/walkthrough'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
useWalkthroughStore.setState({ isActive: false, stepIndex: 0, total: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('walkthroughStore', () => {
|
||||||
|
it('start activates at step 0', () => {
|
||||||
|
useWalkthroughStore.getState().start()
|
||||||
|
const s = useWalkthroughStore.getState()
|
||||||
|
expect(s.isActive).toBe(true)
|
||||||
|
expect(s.stepIndex).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('next advances and finishes past the last step', () => {
|
||||||
|
const store = useWalkthroughStore.getState()
|
||||||
|
store.start()
|
||||||
|
store.setTotal(3)
|
||||||
|
store.next()
|
||||||
|
expect(useWalkthroughStore.getState().stepIndex).toBe(1)
|
||||||
|
store.next()
|
||||||
|
expect(useWalkthroughStore.getState().stepIndex).toBe(2)
|
||||||
|
// At the last step, next() finishes the tour and stamps it completed.
|
||||||
|
store.next()
|
||||||
|
expect(useWalkthroughStore.getState().isActive).toBe(false)
|
||||||
|
expect(readWalkthrough()).toEqual({ seenVersion: WALKTHROUGH_VERSION, status: 'completed' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prev clamps at 0', () => {
|
||||||
|
const store = useWalkthroughStore.getState()
|
||||||
|
store.start()
|
||||||
|
store.setTotal(3)
|
||||||
|
store.prev()
|
||||||
|
expect(useWalkthroughStore.getState().stepIndex).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('goTo clamps within [0, total-1]', () => {
|
||||||
|
const store = useWalkthroughStore.getState()
|
||||||
|
store.start()
|
||||||
|
store.setTotal(3)
|
||||||
|
store.goTo(9)
|
||||||
|
expect(useWalkthroughStore.getState().stepIndex).toBe(2)
|
||||||
|
store.goTo(-1)
|
||||||
|
expect(useWalkthroughStore.getState().stepIndex).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skip deactivates and stamps skipped', () => {
|
||||||
|
const store = useWalkthroughStore.getState()
|
||||||
|
store.start()
|
||||||
|
store.skip()
|
||||||
|
expect(useWalkthroughStore.getState().isActive).toBe(false)
|
||||||
|
expect(readWalkthrough().status).toBe('skipped')
|
||||||
|
expect(readWalkthrough().seenVersion).toBe(WALKTHROUGH_VERSION)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
type Node,
|
type Node,
|
||||||
type Edge,
|
type Edge,
|
||||||
type NodeChange,
|
type NodeChange,
|
||||||
|
type NodePositionChange,
|
||||||
type EdgeChange,
|
type EdgeChange,
|
||||||
type Connection,
|
type Connection,
|
||||||
applyNodeChanges,
|
applyNodeChanges,
|
||||||
@@ -13,6 +14,7 @@ import { generateUUID } from '@/utils/uuid'
|
|||||||
import { normalizeHandle, removedHandleIds, handleCountField, sideDefault, handleId, SIDES } from '@/utils/handleUtils'
|
import { normalizeHandle, removedHandleIds, handleCountField, sideDefault, handleId, SIDES } from '@/utils/handleUtils'
|
||||||
import { applyOpacity } from '@/utils/colorUtils'
|
import { applyOpacity } from '@/utils/colorUtils'
|
||||||
import { readHideIp, writeHideIp } from '@/utils/ipDisplay'
|
import { readHideIp, writeHideIp } from '@/utils/ipDisplay'
|
||||||
|
import { CONTAINER_MODE_TYPES } from '@/utils/virtualEdgeParent'
|
||||||
|
|
||||||
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
||||||
type Clipboard = { 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. */
|
/** 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
|
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. */
|
/** Key for the live per-service status overlay. */
|
||||||
export const serviceStatusKey = (nodeId: string, port?: number, protocol?: string): string =>
|
export const serviceStatusKey = (nodeId: string, port?: number, protocol?: string): string =>
|
||||||
`${nodeId}:${port ?? ''}/${protocol ?? ''}`
|
`${nodeId}:${port ?? ''}/${protocol ?? ''}`
|
||||||
@@ -28,6 +109,14 @@ interface CanvasState {
|
|||||||
nodes: Node<NodeData>[]
|
nodes: Node<NodeData>[]
|
||||||
edges: Edge<EdgeData>[]
|
edges: Edge<EdgeData>[]
|
||||||
hasUnsavedChanges: boolean
|
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
|
selectedNodeId: string | null
|
||||||
selectedNodeIds: string[]
|
selectedNodeIds: string[]
|
||||||
scanEventTs: number
|
scanEventTs: number
|
||||||
@@ -62,6 +151,14 @@ interface CanvasState {
|
|||||||
setSelectedNode: (id: string | null) => void
|
setSelectedNode: (id: string | null) => void
|
||||||
addNode: (node: Node<NodeData>) => void
|
addNode: (node: Node<NodeData>) => void
|
||||||
updateNode: (id: string, data: Partial<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
|
deleteNode: (id: string) => void
|
||||||
updateEdge: (id: string, data: Partial<EdgeData>) => void
|
updateEdge: (id: string, data: Partial<EdgeData>) => void
|
||||||
reconnectEdge: (id: string, connection: Connection) => void
|
reconnectEdge: (id: string, connection: Connection) => void
|
||||||
@@ -82,6 +179,10 @@ interface CanvasState {
|
|||||||
markSaved: () => void
|
markSaved: () => void
|
||||||
markUnsaved: () => void
|
markUnsaved: () => void
|
||||||
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => 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
|
fitViewPending: boolean
|
||||||
clearFitViewPending: () => void
|
clearFitViewPending: () => void
|
||||||
notifyScanDeviceFound: () => void
|
notifyScanDeviceFound: () => void
|
||||||
@@ -94,10 +195,33 @@ interface CanvasState {
|
|||||||
applyAllCustomStyles: (def: CustomStyleDef) => void
|
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: [],
|
nodes: [],
|
||||||
edges: [],
|
edges: [],
|
||||||
hasUnsavedChanges: false,
|
hasUnsavedChanges: false,
|
||||||
|
editSeq: 0,
|
||||||
selectedNodeId: null,
|
selectedNodeId: null,
|
||||||
selectedNodeIds: [],
|
selectedNodeIds: [],
|
||||||
editingGroupRectId: null,
|
editingGroupRectId: null,
|
||||||
@@ -250,18 +374,30 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
set((state) => {
|
set((state) => {
|
||||||
const nodes = applyNodeChanges(changes, state.nodes)
|
const nodes = applyNodeChanges(changes, state.nodes)
|
||||||
const selectedNodeIds = nodes.filter((n) => n.selected).map((n) => n.id)
|
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 {
|
return {
|
||||||
nodes,
|
nodes,
|
||||||
|
edges,
|
||||||
selectedNodeIds,
|
selectedNodeIds,
|
||||||
hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'),
|
...(edited ? { hasUnsavedChanges: true } : {}),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
onEdgesChange: (changes) =>
|
onEdgesChange: (changes) =>
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
edges: applyEdgeChanges(changes, state.edges),
|
const edited = changes.some((c) => c.type !== 'select')
|
||||||
hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'),
|
return {
|
||||||
})),
|
edges: applyEdgeChanges(changes, state.edges),
|
||||||
|
...(edited ? { hasUnsavedChanges: true } : {}),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
onConnect: (connection) =>
|
onConnect: (connection) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
@@ -325,8 +461,13 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
let nodes = state.nodes.map((n) => {
|
let nodes = state.nodes.map((n) => {
|
||||||
if (n.id !== id) return n
|
if (n.id !== id) return n
|
||||||
const updated: Node<NodeData> = { ...n, data: { ...n.data, ...data } }
|
const updated: Node<NodeData> = { ...n, data: { ...n.data, ...data } }
|
||||||
// When properties change, clear stored height so the node auto-sizes to fit new content
|
// 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') {
|
// 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
|
updated.height = undefined
|
||||||
}
|
}
|
||||||
if ('parent_id' in data) {
|
if ('parent_id' in data) {
|
||||||
@@ -389,6 +530,18 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
return { nodes, edges, hasUnsavedChanges: true }
|
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) =>
|
deleteNode: (id) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const idsToRemove = new Set<string>()
|
const idsToRemove = new Set<string>()
|
||||||
@@ -448,7 +601,14 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
: { ...withMode, width: undefined, height: undefined }
|
: { ...withMode, width: undefined, height: undefined }
|
||||||
}
|
}
|
||||||
if (n.data.parent_id === proxmoxId) {
|
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 (enabled && parentNode) {
|
||||||
|
if (alreadyNested) return n
|
||||||
return {
|
return {
|
||||||
...n,
|
...n,
|
||||||
parentId: proxmoxId,
|
parentId: proxmoxId,
|
||||||
@@ -460,6 +620,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!enabled && parentNode) {
|
if (!enabled && parentNode) {
|
||||||
|
if (!n.parentId) return n
|
||||||
return {
|
return {
|
||||||
...n,
|
...n,
|
||||||
parentId: undefined,
|
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 })
|
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 }),
|
clearFitViewPending: () => set({ fitViewPending: false }),
|
||||||
|
|
||||||
applyTypeNodeStyle: (nodeType, style) =>
|
applyTypeNodeStyle: (nodeType, style) =>
|
||||||
@@ -874,4 +1051,5 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
})
|
})
|
||||||
return { nodes, edges, hasUnsavedChanges: true }
|
return { nodes, edges, hasUnsavedChanges: true }
|
||||||
}),
|
}),
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { markWalkthroughSeen } from '@/utils/walkthrough'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime state of an active Getting Started tour. Persistence of the "already
|
||||||
|
* seen" flag lives in utils/walkthrough.ts; this store only tracks the live run.
|
||||||
|
*
|
||||||
|
* `total` is set by the overlay from the mode-filtered step list, so next()/goTo
|
||||||
|
* can clamp and finish() fires when the user advances past the last step.
|
||||||
|
*/
|
||||||
|
interface WalkthroughStore {
|
||||||
|
isActive: boolean
|
||||||
|
stepIndex: number
|
||||||
|
total: number
|
||||||
|
setTotal: (n: number) => void
|
||||||
|
start: () => void
|
||||||
|
next: () => void
|
||||||
|
prev: () => void
|
||||||
|
goTo: (n: number) => void
|
||||||
|
skip: () => void
|
||||||
|
finish: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWalkthroughStore = create<WalkthroughStore>((set, get) => ({
|
||||||
|
isActive: false,
|
||||||
|
stepIndex: 0,
|
||||||
|
total: 0,
|
||||||
|
setTotal: (n) => set({ total: n }),
|
||||||
|
start: () => set({ isActive: true, stepIndex: 0 }),
|
||||||
|
next: () => {
|
||||||
|
const { stepIndex, total } = get()
|
||||||
|
if (stepIndex >= total - 1) {
|
||||||
|
get().finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
set({ stepIndex: stepIndex + 1 })
|
||||||
|
},
|
||||||
|
prev: () => set((s) => ({ stepIndex: Math.max(0, s.stepIndex - 1) })),
|
||||||
|
goTo: (n) => set((s) => ({ stepIndex: Math.max(0, Math.min(n, Math.max(0, s.total - 1))) })),
|
||||||
|
skip: () => {
|
||||||
|
markWalkthroughSeen('skipped')
|
||||||
|
set({ isActive: false })
|
||||||
|
},
|
||||||
|
finish: () => {
|
||||||
|
markWalkthroughSeen('completed')
|
||||||
|
set({ isActive: false })
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -34,4 +34,7 @@ export interface YamlNode {
|
|||||||
bottomHandles?: number
|
bottomHandles?: number
|
||||||
leftHandles?: number
|
leftHandles?: number
|
||||||
rightHandles?: number
|
rightHandles?: number
|
||||||
|
// Whether port-number labels are shown next to connection points. Only written
|
||||||
|
// when enabled so the toggle round-trips through export/import (issue #272).
|
||||||
|
showPortNumbers?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
DEFAULT_AUTOSAVE_SETTINGS,
|
||||||
|
readAutosaveSettings,
|
||||||
|
writeAutosaveSettings,
|
||||||
|
subscribeAutosaveSettings,
|
||||||
|
} from '../autosaveSettings'
|
||||||
|
|
||||||
|
describe('autosaveSettings', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults to disabled with a 5s delay', () => {
|
||||||
|
expect(DEFAULT_AUTOSAVE_SETTINGS).toEqual({ enabled: false, delay: 5 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns defaults when nothing stored', () => {
|
||||||
|
expect(readAutosaveSettings()).toEqual(DEFAULT_AUTOSAVE_SETTINGS)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('roundtrips through localStorage', () => {
|
||||||
|
writeAutosaveSettings({ enabled: true, delay: 30 })
|
||||||
|
expect(readAutosaveSettings()).toEqual({ enabled: true, delay: 30 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to defaults when stored value is corrupted', () => {
|
||||||
|
localStorage.setItem('homelable.autosave', '{not json')
|
||||||
|
expect(readAutosaveSettings()).toEqual(DEFAULT_AUTOSAVE_SETTINGS)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fills missing fields from defaults', () => {
|
||||||
|
localStorage.setItem('homelable.autosave', JSON.stringify({ enabled: true }))
|
||||||
|
expect(readAutosaveSettings()).toEqual({ enabled: true, delay: DEFAULT_AUTOSAVE_SETTINGS.delay })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a non-positive delay and falls back to default', () => {
|
||||||
|
localStorage.setItem('homelable.autosave', JSON.stringify({ enabled: true, delay: 0 }))
|
||||||
|
expect(readAutosaveSettings().delay).toBe(DEFAULT_AUTOSAVE_SETTINGS.delay)
|
||||||
|
localStorage.setItem('homelable.autosave', JSON.stringify({ enabled: true, delay: -10 }))
|
||||||
|
expect(readAutosaveSettings().delay).toBe(DEFAULT_AUTOSAVE_SETTINGS.delay)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a non-numeric delay and falls back to default', () => {
|
||||||
|
localStorage.setItem('homelable.autosave', JSON.stringify({ enabled: true, delay: 'soon' }))
|
||||||
|
expect(readAutosaveSettings().delay).toBe(DEFAULT_AUTOSAVE_SETTINGS.delay)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a non-boolean enabled and falls back to default', () => {
|
||||||
|
localStorage.setItem('homelable.autosave', JSON.stringify({ enabled: 'yes', delay: 10 }))
|
||||||
|
expect(readAutosaveSettings().enabled).toBe(DEFAULT_AUTOSAVE_SETTINGS.enabled)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('notifies subscribers on write and stops after unsubscribe', () => {
|
||||||
|
const listener = vi.fn()
|
||||||
|
const unsubscribe = subscribeAutosaveSettings(listener)
|
||||||
|
writeAutosaveSettings({ enabled: true, delay: 10 })
|
||||||
|
expect(listener).toHaveBeenCalledWith({ enabled: true, delay: 10 })
|
||||||
|
unsubscribe()
|
||||||
|
writeAutosaveSettings({ enabled: false, delay: 3 })
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { decideCanvasLoad, isNewUserCanvas } from '../canvasLoadDecision'
|
||||||
|
|
||||||
|
describe('decideCanvasLoad', () => {
|
||||||
|
it('renders real nodes when the canvas has any', () => {
|
||||||
|
expect(decideCanvasLoad(true, true)).toBe('real')
|
||||||
|
// hasNodes wins even if the initialized flag is somehow false
|
||||||
|
expect(decideCanvasLoad(true, false)).toBe('real')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps an initialized-but-empty canvas empty (user cleared it)', () => {
|
||||||
|
expect(decideCanvasLoad(false, true)).toBe('empty')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the demo only for a brand-new, uninitialized canvas', () => {
|
||||||
|
expect(decideCanvasLoad(false, false)).toBe('demo')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isNewUserCanvas', () => {
|
||||||
|
it('is true only for the demo mode', () => {
|
||||||
|
expect(isNewUserCanvas('demo')).toBe(true)
|
||||||
|
expect(isNewUserCanvas('empty')).toBe(false)
|
||||||
|
expect(isNewUserCanvas('real')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -208,4 +208,12 @@ describe('exportCanvasToYaml', () => {
|
|||||||
expect(entry).not.toHaveProperty('topHandles')
|
expect(entry).not.toHaveProperty('topHandles')
|
||||||
expect(entry).not.toHaveProperty('leftHandles')
|
expect(entry).not.toHaveProperty('leftHandles')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('exports showPortNumbers only when enabled (issue #272)', () => {
|
||||||
|
const on = (yaml.load(exportCanvasToYaml([makeNode({ label: 'On', type: 'server', show_port_numbers: true })], [])) as Record<string, unknown>[])[0]
|
||||||
|
expect(on.showPortNumbers).toBe(true)
|
||||||
|
|
||||||
|
const off = (yaml.load(exportCanvasToYaml([makeNode({ label: 'Off', type: 'server', show_port_numbers: false })], [])) as Record<string, unknown>[])[0]
|
||||||
|
expect(off).not.toHaveProperty('showPortNumbers')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -368,4 +368,30 @@ describe('parseYamlToCanvas', () => {
|
|||||||
expect(edges[0].targetHandle).toBe('top-t')
|
expect(edges[0].targetHandle).toBe('top-t')
|
||||||
expect(nodes.find((n) => n.data.label === 'Switch')!.data.bottom_handles).toBe(3)
|
expect(nodes.find((n) => n.data.label === 'Switch')!.data.bottom_handles).toBe(3)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Regression for issue #272.
|
||||||
|
it('restores the show-port-numbers toggle from YAML', () => {
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: server
|
||||||
|
label: "Server"
|
||||||
|
showPortNumbers: true
|
||||||
|
`
|
||||||
|
const { nodes } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
expect(nodes.find((n) => n.data.label === 'Server')!.data.show_port_numbers).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('round-trips show-port-numbers through export → import (issue #272)', () => {
|
||||||
|
const on: Node<NodeData> = {
|
||||||
|
id: 'on', type: 'server', position: { x: 0, y: 0 },
|
||||||
|
data: { label: 'On', type: 'server', status: 'online', services: [], show_port_numbers: true },
|
||||||
|
}
|
||||||
|
const off: Node<NodeData> = {
|
||||||
|
id: 'off', type: 'server', position: { x: 0, y: 0 },
|
||||||
|
data: { label: 'Off', type: 'server', status: 'online', services: [] },
|
||||||
|
}
|
||||||
|
const yamlStr = exportCanvasToYaml([on, off], [])
|
||||||
|
const { nodes } = parseYamlToCanvas(yamlStr, empty, emptyEdges)
|
||||||
|
expect(nodes.find((n) => n.data.label === 'On')!.data.show_port_numbers).toBe(true)
|
||||||
|
expect(nodes.find((n) => n.data.label === 'Off')!.data.show_port_numbers).toBeUndefined()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
WALKTHROUGH_VERSION,
|
||||||
|
readWalkthrough,
|
||||||
|
writeWalkthrough,
|
||||||
|
subscribeWalkthrough,
|
||||||
|
shouldOfferWalkthrough,
|
||||||
|
markWalkthroughSeen,
|
||||||
|
resetWalkthrough,
|
||||||
|
} from '../walkthrough'
|
||||||
|
|
||||||
|
beforeEach(() => localStorage.clear())
|
||||||
|
|
||||||
|
describe('readWalkthrough', () => {
|
||||||
|
it('defaults to pending / unseen when nothing is stored', () => {
|
||||||
|
expect(readWalkthrough()).toEqual({ seenVersion: null, status: 'pending' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('round-trips written state', () => {
|
||||||
|
writeWalkthrough({ seenVersion: 3, status: 'completed' })
|
||||||
|
expect(readWalkthrough()).toEqual({ seenVersion: 3, status: 'completed' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to defaults on malformed json', () => {
|
||||||
|
localStorage.setItem('homelable.walkthrough', '{not json')
|
||||||
|
expect(readWalkthrough()).toEqual({ seenVersion: null, status: 'pending' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('shouldOfferWalkthrough', () => {
|
||||||
|
it('offers a brand-new user (seenVersion null)', () => {
|
||||||
|
expect(shouldOfferWalkthrough()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers an upgrader whose seen version is behind', () => {
|
||||||
|
writeWalkthrough({ seenVersion: WALKTHROUGH_VERSION - 1, status: 'completed' })
|
||||||
|
expect(shouldOfferWalkthrough()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops offering once the current version is stamped', () => {
|
||||||
|
markWalkthroughSeen('completed')
|
||||||
|
expect(shouldOfferWalkthrough()).toBe(false)
|
||||||
|
expect(readWalkthrough().seenVersion).toBe(WALKTHROUGH_VERSION)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resetWalkthrough', () => {
|
||||||
|
it('re-offers after a reset', () => {
|
||||||
|
markWalkthroughSeen('skipped')
|
||||||
|
expect(shouldOfferWalkthrough()).toBe(false)
|
||||||
|
resetWalkthrough()
|
||||||
|
expect(shouldOfferWalkthrough()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('subscribeWalkthrough', () => {
|
||||||
|
it('notifies listeners on write and unsubscribes cleanly', () => {
|
||||||
|
const spy = vi.fn()
|
||||||
|
const unsub = subscribeWalkthrough(spy)
|
||||||
|
writeWalkthrough({ seenVersion: 1, status: 'completed' })
|
||||||
|
expect(spy).toHaveBeenCalledWith({ seenVersion: 1, status: 'completed' })
|
||||||
|
unsub()
|
||||||
|
writeWalkthrough({ seenVersion: 2, status: 'skipped' })
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Decides what to render when a design's canvas loads.
|
||||||
|
*
|
||||||
|
* Three cases must stay distinct (issue: clearing a canvas re-showed the demo):
|
||||||
|
* - `real` → the canvas has saved nodes; render them.
|
||||||
|
* - `empty` → the canvas is initialized (ever saved / explicitly created) but
|
||||||
|
* has no nodes; the user intentionally cleared it — keep it empty.
|
||||||
|
* - `demo` → brand-new, never-initialized canvas; seed the demo so first-time
|
||||||
|
* users see an example (and, later, the Getting Started walkthrough).
|
||||||
|
*
|
||||||
|
* Backend errors are handled by the caller, NOT here — a failed load must show an
|
||||||
|
* error and must never fall back to `demo` (that would hide the real failure).
|
||||||
|
*/
|
||||||
|
export type CanvasLoadMode = 'real' | 'empty' | 'demo'
|
||||||
|
|
||||||
|
export function decideCanvasLoad(hasNodes: boolean, initialized: boolean): CanvasLoadMode {
|
||||||
|
if (hasNodes) return 'real'
|
||||||
|
if (initialized) return 'empty'
|
||||||
|
return 'demo'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A new user is one who lands on the demo canvas — the Getting Started hook. */
|
||||||
|
export function isNewUserCanvas(mode: CanvasLoadMode): boolean {
|
||||||
|
return mode === 'demo'
|
||||||
|
}
|
||||||
@@ -92,6 +92,9 @@ export function exportCanvasToYaml(nodes: Node<NodeData>[], edges: Edge<EdgeData
|
|||||||
// Custom connection-point counts, so imported edges land on real slots.
|
// Custom connection-point counts, so imported edges land on real slots.
|
||||||
attachHandleCounts(entry, d)
|
attachHandleCounts(entry, d)
|
||||||
|
|
||||||
|
// Preserve the port-number toggle so it survives a round-trip (issue #272).
|
||||||
|
if (d.show_port_numbers) entry.showPortNumbers = true
|
||||||
|
|
||||||
// Parent relationship: if this node has a parentId in React Flow,
|
// Parent relationship: if this node has a parentId in React Flow,
|
||||||
// encode it as a 'parent' connection using any virtual edge between them.
|
// encode it as a 'parent' connection using any virtual edge between them.
|
||||||
if (node.parentId) {
|
if (node.parentId) {
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ export function parseYamlToCanvas(
|
|||||||
...(yn.bottomHandles ? { bottom_handles: yn.bottomHandles } : {}),
|
...(yn.bottomHandles ? { bottom_handles: yn.bottomHandles } : {}),
|
||||||
...(yn.leftHandles ? { left_handles: yn.leftHandles } : {}),
|
...(yn.leftHandles ? { left_handles: yn.leftHandles } : {}),
|
||||||
...(yn.rightHandles ? { right_handles: yn.rightHandles } : {}),
|
...(yn.rightHandles ? { right_handles: yn.rightHandles } : {}),
|
||||||
|
// Restore the port-number toggle (issue #272).
|
||||||
|
...(yn.showPortNumbers ? { show_port_numbers: true } : {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
newNodes.push({
|
newNodes.push({
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { NodeData } from '@/types'
|
|||||||
|
|
||||||
export type NodeType = NodeData['type']
|
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'])
|
const DOCKER_CONTAINER_PARENT_TYPES = new Set<NodeType>(['docker_host', 'lxc', 'vm', 'proxmox'])
|
||||||
|
|
||||||
export interface VirtualEdgeEndpoint {
|
export interface VirtualEdgeEndpoint {
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Getting Started walkthrough — persisted invite state (localStorage).
|
||||||
|
*
|
||||||
|
* Mirrors the autosaveSettings.ts pattern: a `homelable.<name>` key plus a
|
||||||
|
* `window` CustomEvent for same-tab sync, so App and SettingsModal stay aligned
|
||||||
|
* without a global store.
|
||||||
|
*
|
||||||
|
* `seenVersion` is stamped with WALKTHROUGH_VERSION when the user finishes or
|
||||||
|
* dismisses the tour. Bump WALKTHROUGH_VERSION whenever new steps ship so
|
||||||
|
* existing users are re-offered the tour exactly once (they may have missed the
|
||||||
|
* new features). A brand-new user starts at seenVersion=null and is offered too.
|
||||||
|
*/
|
||||||
|
export const WALKTHROUGH_VERSION = 1
|
||||||
|
|
||||||
|
export type WalkthroughStatus = 'pending' | 'skipped' | 'completed'
|
||||||
|
|
||||||
|
export interface WalkthroughState {
|
||||||
|
seenVersion: number | null
|
||||||
|
status: WalkthroughStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY = 'homelable.walkthrough'
|
||||||
|
const EVENT = 'homelable:walkthrough-changed'
|
||||||
|
|
||||||
|
export const DEFAULT_WALKTHROUGH_STATE: WalkthroughState = { seenVersion: null, status: 'pending' }
|
||||||
|
|
||||||
|
function isStatus(v: unknown): v is WalkthroughStatus {
|
||||||
|
return v === 'pending' || v === 'skipped' || v === 'completed'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readWalkthrough(): WalkthroughState {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(KEY)
|
||||||
|
if (!raw) return { ...DEFAULT_WALKTHROUGH_STATE }
|
||||||
|
const parsed = JSON.parse(raw) as Partial<WalkthroughState>
|
||||||
|
return {
|
||||||
|
seenVersion: typeof parsed.seenVersion === 'number' ? parsed.seenVersion : null,
|
||||||
|
status: isStatus(parsed.status) ? parsed.status : 'pending',
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_WALKTHROUGH_STATE }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeWalkthrough(state: WalkthroughState): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(state))
|
||||||
|
window.dispatchEvent(new CustomEvent(EVENT, { detail: state }))
|
||||||
|
} catch {
|
||||||
|
// ignore quota / SSR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeWalkthrough(listener: (s: WalkthroughState) => void): () => void {
|
||||||
|
const handler = (e: Event) => listener((e as CustomEvent<WalkthroughState>).detail)
|
||||||
|
window.addEventListener(EVENT, handler)
|
||||||
|
return () => window.removeEventListener(EVENT, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the invite should be offered (new user, or upgraded past the last seen version). */
|
||||||
|
export function shouldOfferWalkthrough(state: WalkthroughState = readWalkthrough()): boolean {
|
||||||
|
return state.seenVersion !== WALKTHROUGH_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stamp the current version so the invite stops auto-showing until the next bump. */
|
||||||
|
export function markWalkthroughSeen(status: Exclude<WalkthroughStatus, 'pending'>): void {
|
||||||
|
writeWalkthrough({ seenVersion: WALKTHROUGH_VERSION, status })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the stamp so the tour is offered again (used by "Restart" in Settings). */
|
||||||
|
export function resetWalkthrough(): void {
|
||||||
|
writeWalkthrough({ ...DEFAULT_WALKTHROUGH_STATE })
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Compass, X } from 'lucide-react'
|
||||||
|
import './walkthrough.css'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useWalkthroughStore } from '@/stores/walkthroughStore'
|
||||||
|
import {
|
||||||
|
readWalkthrough,
|
||||||
|
subscribeWalkthrough,
|
||||||
|
shouldOfferWalkthrough,
|
||||||
|
markWalkthroughSeen,
|
||||||
|
type WalkthroughState,
|
||||||
|
} from '@/utils/walkthrough'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First-run invite bubble (bottom-right, above the toaster).
|
||||||
|
*
|
||||||
|
* - Not now → hide for this session only (no write), reappears next load
|
||||||
|
* - Don't show again → persist as skipped, stops offering until the next version
|
||||||
|
* - Getting started → launch the tour
|
||||||
|
*/
|
||||||
|
export function WalkthroughInvite() {
|
||||||
|
const [state, setState] = useState<WalkthroughState>(readWalkthrough)
|
||||||
|
useEffect(() => subscribeWalkthrough(setState), [])
|
||||||
|
|
||||||
|
// "Not now" dismissal — session-local, not persisted.
|
||||||
|
const [dismissedThisSession, setDismissedThisSession] = useState(false)
|
||||||
|
|
||||||
|
const isActive = useWalkthroughStore((s) => s.isActive)
|
||||||
|
const start = useWalkthroughStore((s) => s.start)
|
||||||
|
|
||||||
|
if (isActive || dismissedThisSession || !shouldOfferWalkthrough(state)) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-20 right-4 z-[90] w-80 max-w-[calc(100vw-2rem)] rounded-xl border border-[#30363d] bg-[#161b22] p-4 shadow-2xl walkthrough-invite-enter">
|
||||||
|
<button
|
||||||
|
onClick={() => setDismissedThisSession(true)}
|
||||||
|
aria-label="Not now"
|
||||||
|
className="absolute top-2 right-2 text-muted-foreground hover:text-foreground p-1 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[#00d4ff]/10 text-[#00d4ff]">
|
||||||
|
<Compass size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">New here? Take the tour</h3>
|
||||||
|
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||||
|
A 2-minute walkthrough of scanning, devices, and building your canvas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => markWalkthroughSeen('skipped')}
|
||||||
|
>
|
||||||
|
Don't show again
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-3 text-xs bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||||
|
onClick={start}
|
||||||
|
>
|
||||||
|
Getting started
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { useEffect, useMemo, useState, useCallback } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { ArrowLeft, ArrowRight, X } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useWalkthroughStore } from '@/stores/walkthroughStore'
|
||||||
|
import { useWalkthroughActions } from './actions'
|
||||||
|
import { getSteps, type StepPlacement } from './steps'
|
||||||
|
import './walkthrough.css'
|
||||||
|
|
||||||
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
|
|
||||||
|
const CARD_W = 320
|
||||||
|
const CARD_H_EST = 210
|
||||||
|
const GAP = 16
|
||||||
|
const PAD = 6 // spotlight padding around the target
|
||||||
|
|
||||||
|
/** Coach-card position from the target rect + requested placement. */
|
||||||
|
function cardPosition(rect: DOMRect | null, placement: StepPlacement): { left: number; top: number } {
|
||||||
|
const vw = window.innerWidth
|
||||||
|
const vh = window.innerHeight
|
||||||
|
const clampL = (l: number) => Math.max(8, Math.min(l, vw - CARD_W - 8))
|
||||||
|
const clampT = (t: number) => Math.max(8, Math.min(t, vh - CARD_H_EST - 8))
|
||||||
|
if (!rect) return { left: clampL((vw - CARD_W) / 2), top: clampT((vh - CARD_H_EST) / 2) }
|
||||||
|
|
||||||
|
let side = placement
|
||||||
|
if (side === 'auto') {
|
||||||
|
side = rect.right + GAP + CARD_W < vw ? 'right' : rect.left - GAP - CARD_W > 0 ? 'left' : 'bottom'
|
||||||
|
}
|
||||||
|
switch (side) {
|
||||||
|
case 'right': return { left: clampL(rect.right + GAP), top: clampT(rect.top) }
|
||||||
|
case 'left': return { left: clampL(rect.left - GAP - CARD_W), top: clampT(rect.top) }
|
||||||
|
case 'top': return { left: clampL(rect.left), top: clampT(rect.top - GAP - CARD_H_EST) }
|
||||||
|
case 'bottom': return { left: clampL(rect.left), top: clampT(rect.bottom + GAP) }
|
||||||
|
case 'center':
|
||||||
|
default: return { left: clampL((vw - CARD_W) / 2), top: clampT((vh - CARD_H_EST) / 2) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position the card relative to an OPEN MODAL (the step's real subject).
|
||||||
|
* Large modals (inventory, scan history at ~90vw) leave no room beside them, so
|
||||||
|
* the card drops into the empty bottom-right corner instead of covering content.
|
||||||
|
* Small modals get the card beside them, on whichever side has room.
|
||||||
|
*/
|
||||||
|
function dialogCardPosition(rect: DOMRect): { left: number; top: number } {
|
||||||
|
const vw = window.innerWidth
|
||||||
|
const vh = window.innerHeight
|
||||||
|
const clampT = (t: number) => Math.max(8, Math.min(t, vh - CARD_H_EST - 8))
|
||||||
|
const isLarge = rect.width > vw * 0.6 || rect.height > vh * 0.7
|
||||||
|
if (isLarge) return { left: vw - CARD_W - 16, top: vh - CARD_H_EST - 16 }
|
||||||
|
if (rect.left >= CARD_W + GAP * 2) return { left: rect.left - GAP - CARD_W, top: clampT(rect.top) }
|
||||||
|
if (vw - rect.right >= CARD_W + GAP * 2) return { left: rect.right + GAP, top: clampT(rect.top) }
|
||||||
|
return { left: Math.max(8, Math.min(rect.left, vw - CARD_W - 8)), top: clampT(rect.bottom + GAP) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WalkthroughOverlay() {
|
||||||
|
const { isActive, stepIndex, total, setTotal, next, prev, skip } = useWalkthroughStore()
|
||||||
|
const actions = useWalkthroughActions()
|
||||||
|
const steps = useMemo(() => getSteps(STANDALONE), [])
|
||||||
|
const step = steps[stepIndex]
|
||||||
|
|
||||||
|
// anchorRect: the element the step rings (sidebar/toolbar button). dialogRect:
|
||||||
|
// any open app modal, always kept lit so it stays readable during modal steps.
|
||||||
|
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null)
|
||||||
|
const [dialogRect, setDialogRect] = useState<DOMRect | null>(null)
|
||||||
|
|
||||||
|
// Keep the store's total in sync with the mode-filtered step list.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isActive) setTotal(steps.length)
|
||||||
|
}, [isActive, steps.length, setTotal])
|
||||||
|
|
||||||
|
// Run the step's action on enter (close leftovers, open the right modal / inject
|
||||||
|
// demo data). Close everything when the tour ends.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!actions) return
|
||||||
|
if (!isActive) { actions.closeAll(); return }
|
||||||
|
actions.closeAll()
|
||||||
|
const key = step?.action as keyof typeof actions | undefined
|
||||||
|
if (key && typeof actions[key] === 'function') (actions[key] as () => void)()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isActive, stepIndex, actions])
|
||||||
|
|
||||||
|
// Track target rects. Polls each frame because modals mount asynchronously after
|
||||||
|
// the step action fires, and layout shifts as they animate in.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive) return
|
||||||
|
let raf = 0
|
||||||
|
const update = () => {
|
||||||
|
const anchorEl = step?.anchor ? document.querySelector(step.anchor) : null
|
||||||
|
setAnchorRect(anchorEl ? anchorEl.getBoundingClientRect() : null)
|
||||||
|
// Any open app modal, excluding our own coach card (inside the overlay).
|
||||||
|
const dialogEl = Array.from(document.querySelectorAll('[role="dialog"]'))
|
||||||
|
.find((el) => !el.closest('[data-walkthrough-overlay]'))
|
||||||
|
setDialogRect(dialogEl ? dialogEl.getBoundingClientRect() : null)
|
||||||
|
raf = window.requestAnimationFrame(update)
|
||||||
|
}
|
||||||
|
update()
|
||||||
|
window.addEventListener('resize', update)
|
||||||
|
window.addEventListener('scroll', update, true)
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(raf)
|
||||||
|
window.removeEventListener('resize', update)
|
||||||
|
window.removeEventListener('scroll', update, true)
|
||||||
|
}
|
||||||
|
}, [isActive, stepIndex, step?.anchor])
|
||||||
|
|
||||||
|
const handleKey = useCallback((e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'ArrowRight' || e.key === 'Enter') { e.preventDefault(); next() }
|
||||||
|
else if (e.key === 'ArrowLeft') { e.preventDefault(); prev() }
|
||||||
|
else if (e.key === 'Escape') { e.preventDefault(); skip() }
|
||||||
|
}, [next, prev, skip])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive) return
|
||||||
|
window.addEventListener('keydown', handleKey)
|
||||||
|
return () => window.removeEventListener('keydown', handleKey)
|
||||||
|
}, [isActive, handleKey])
|
||||||
|
|
||||||
|
// Flag the tour so app-modal backdrops drop their own dim/blur (see
|
||||||
|
// walkthrough.css) — the spotlight then dims consistently everywhere.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive) return
|
||||||
|
document.body.classList.add('walkthrough-running')
|
||||||
|
return () => document.body.classList.remove('walkthrough-running')
|
||||||
|
}, [isActive])
|
||||||
|
|
||||||
|
if (!isActive || !step) return null
|
||||||
|
|
||||||
|
const isLast = stepIndex >= total - 1
|
||||||
|
// Ring the step's anchor (sidebar/toolbar button) if it has one, else the modal.
|
||||||
|
const ringRect = anchorRect ?? dialogRect
|
||||||
|
// The card follows the OPEN MODAL when there is one (that's what the user reads),
|
||||||
|
// otherwise the step's anchor. Keeps the card off the modal's content.
|
||||||
|
const pos = dialogRect
|
||||||
|
? dialogCardPosition(dialogRect)
|
||||||
|
: cardPosition(anchorRect, step.placement ?? 'auto')
|
||||||
|
// Cut-outs keep both the ringed control and any open modal lit.
|
||||||
|
const holes = [anchorRect, dialogRect].filter((r): r is DOMRect => r !== null)
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-[100]" data-walkthrough-overlay>
|
||||||
|
{/* Backdrop: dim everything except the cut-out holes (multi-hole SVG mask so
|
||||||
|
a ringed button AND an open modal can both stay readable). */}
|
||||||
|
<svg className="pointer-events-none absolute inset-0 h-full w-full">
|
||||||
|
<defs>
|
||||||
|
<mask id="wt-spotlight-mask">
|
||||||
|
<rect x="0" y="0" width="100%" height="100%" fill="white" />
|
||||||
|
{holes.map((r, i) => (
|
||||||
|
<rect
|
||||||
|
key={i}
|
||||||
|
x={r.left - PAD}
|
||||||
|
y={r.top - PAD}
|
||||||
|
width={r.width + PAD * 2}
|
||||||
|
height={r.height + PAD * 2}
|
||||||
|
rx="10"
|
||||||
|
fill="black"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width="100%" height="100%" fill="#010409" fillOpacity="0.72" mask="url(#wt-spotlight-mask)" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Pulsing ring on the primary target */}
|
||||||
|
{ringRect && (
|
||||||
|
<div
|
||||||
|
className="walkthrough-spotlight--pulse pointer-events-none absolute rounded-lg border-2 border-[#00d4ff]"
|
||||||
|
style={{
|
||||||
|
left: ringRect.left - PAD,
|
||||||
|
top: ringRect.top - PAD,
|
||||||
|
width: ringRect.width + PAD * 2,
|
||||||
|
height: ringRect.height + PAD * 2,
|
||||||
|
transition: 'left 0.25s, top 0.25s, width 0.25s, height 0.25s',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Coach card */}
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="false"
|
||||||
|
aria-label="Getting started walkthrough"
|
||||||
|
className="walkthrough-card-enter absolute pointer-events-auto rounded-xl border border-[#30363d] bg-[#161b22] p-4 shadow-2xl"
|
||||||
|
style={{ left: pos.left, top: pos.top, width: CARD_W, transition: 'left 0.25s, top 0.25s' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">{step.title}</h3>
|
||||||
|
<button
|
||||||
|
onClick={skip}
|
||||||
|
aria-label="Skip walkthrough"
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-foreground p-0.5 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">{step.body}</p>
|
||||||
|
|
||||||
|
{step.link && (
|
||||||
|
<a
|
||||||
|
href={step.link.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-[#00d4ff] hover:underline"
|
||||||
|
>
|
||||||
|
{step.link.label} <ArrowRight size={12} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between gap-2">
|
||||||
|
{/* Progress dots */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{steps.map((s, i) => (
|
||||||
|
<span
|
||||||
|
key={s.id}
|
||||||
|
className={`h-1.5 rounded-full transition-all ${
|
||||||
|
i === stepIndex ? 'w-4 bg-[#00d4ff]' : 'w-1.5 bg-[#30363d]'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="mr-1 text-[10px] font-mono text-muted-foreground">
|
||||||
|
{stepIndex + 1}/{steps.length}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label="Previous step"
|
||||||
|
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||||
|
onClick={prev}
|
||||||
|
disabled={stepIndex === 0}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={13} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-3 text-xs bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||||
|
onClick={next}
|
||||||
|
>
|
||||||
|
{isLast ? 'Finish' : <>Next <ArrowRight size={13} className="ml-1" /></>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { renderWithProviders, screen, fireEvent } from '@/test/render'
|
||||||
|
import { WalkthroughInvite } from '../WalkthroughInvite'
|
||||||
|
import { useWalkthroughStore } from '@/stores/walkthroughStore'
|
||||||
|
import { readWalkthrough } from '@/utils/walkthrough'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
useWalkthroughStore.setState({ isActive: false, stepIndex: 0, total: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('WalkthroughInvite', () => {
|
||||||
|
it('offers the tour to a new user', () => {
|
||||||
|
renderWithProviders(<WalkthroughInvite />)
|
||||||
|
expect(screen.getByText('New here? Take the tour')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is hidden once already seen', () => {
|
||||||
|
localStorage.setItem('homelable.walkthrough', JSON.stringify({ seenVersion: 1, status: 'completed' }))
|
||||||
|
renderWithProviders(<WalkthroughInvite />)
|
||||||
|
expect(screen.queryByText('New here? Take the tour')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('"Getting started" launches the tour', () => {
|
||||||
|
renderWithProviders(<WalkthroughInvite />)
|
||||||
|
fireEvent.click(screen.getByText('Getting started'))
|
||||||
|
expect(useWalkthroughStore.getState().isActive).toBe(true)
|
||||||
|
// Invite hides while the tour is active.
|
||||||
|
expect(screen.queryByText('New here? Take the tour')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('"Don\'t show again" persists and hides', () => {
|
||||||
|
renderWithProviders(<WalkthroughInvite />)
|
||||||
|
fireEvent.click(screen.getByText("Don't show again"))
|
||||||
|
expect(readWalkthrough().status).toBe('skipped')
|
||||||
|
expect(screen.queryByText('New here? Take the tour')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('"Not now" hides without persisting', () => {
|
||||||
|
renderWithProviders(<WalkthroughInvite />)
|
||||||
|
fireEvent.click(screen.getByLabelText('Not now'))
|
||||||
|
expect(screen.queryByText('New here? Take the tour')).not.toBeInTheDocument()
|
||||||
|
// Not persisted → would be offered again on next load.
|
||||||
|
expect(readWalkthrough().seenVersion).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { renderWithProviders, screen, fireEvent, cleanup, act } from '@/test/render'
|
||||||
|
import { WalkthroughOverlay } from '../WalkthroughOverlay'
|
||||||
|
import { getSteps } from '../steps'
|
||||||
|
import { useWalkthroughStore } from '@/stores/walkthroughStore'
|
||||||
|
|
||||||
|
const TOTAL = getSteps(false).length
|
||||||
|
|
||||||
|
const startTour = () => act(() => { useWalkthroughStore.getState().start() })
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
useWalkthroughStore.setState({ isActive: false, stepIndex: 0, total: 0 })
|
||||||
|
})
|
||||||
|
afterEach(() => cleanup())
|
||||||
|
|
||||||
|
describe('WalkthroughOverlay', () => {
|
||||||
|
it('renders nothing while inactive', () => {
|
||||||
|
renderWithProviders(<WalkthroughOverlay />)
|
||||||
|
expect(screen.queryByText('Welcome to Homelable')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the first step and progress when active', () => {
|
||||||
|
renderWithProviders(<WalkthroughOverlay />)
|
||||||
|
startTour()
|
||||||
|
expect(screen.getByText('Welcome to Homelable')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(`1/${TOTAL}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('advances and rewinds between steps', () => {
|
||||||
|
renderWithProviders(<WalkthroughOverlay />)
|
||||||
|
startTour()
|
||||||
|
fireEvent.click(screen.getByText('Next'))
|
||||||
|
expect(screen.getByText('Scan your network')).toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByLabelText('Previous step'))
|
||||||
|
expect(screen.getByText('Welcome to Homelable')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skip closes the overlay', () => {
|
||||||
|
renderWithProviders(<WalkthroughOverlay />)
|
||||||
|
startTour()
|
||||||
|
fireEvent.click(screen.getByLabelText('Skip walkthrough'))
|
||||||
|
expect(useWalkthroughStore.getState().isActive).toBe(false)
|
||||||
|
expect(screen.queryByText('Welcome to Homelable')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { getSteps, STEPS } from '../steps'
|
||||||
|
|
||||||
|
describe('getSteps', () => {
|
||||||
|
it('returns every step in full mode', () => {
|
||||||
|
expect(getSteps(false)).toHaveLength(STEPS.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops backend-only (mode:full) steps in standalone', () => {
|
||||||
|
const standalone = getSteps(true)
|
||||||
|
expect(standalone.every((s) => s.mode !== 'full')).toBe(true)
|
||||||
|
expect(standalone.length).toBeLessThan(STEPS.length)
|
||||||
|
const ids = standalone.map((s) => s.id)
|
||||||
|
// Canvas-only steps survive; backend-only steps are filtered out.
|
||||||
|
expect(ids).toEqual(expect.arrayContaining(['welcome', 'nodes', 'grouping', 'style', 'end']))
|
||||||
|
expect(ids).not.toContain('scan')
|
||||||
|
expect(ids).not.toContain('scan-history')
|
||||||
|
expect(ids).not.toContain('inventory')
|
||||||
|
expect(ids).not.toContain('imports')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ends on a step with a GitHub link (the full-mode recap in standalone)', () => {
|
||||||
|
const last = getSteps(true).at(-1)
|
||||||
|
expect(last?.id).toBe('end')
|
||||||
|
expect(last?.link?.href).toContain('github.com')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { createContext, useContext } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridge between tour steps and the App's modal/canvas controls. App builds the
|
||||||
|
* concrete implementation from its useState setters and provides it here; the
|
||||||
|
* overlay resolves each step's `action` string to a function on this object.
|
||||||
|
*
|
||||||
|
* `closeAll` is called before every step's action so leftover modals from the
|
||||||
|
* previous step are dismissed.
|
||||||
|
*
|
||||||
|
* Grows as steps land (openInventoryDemo, editDemoNode, openStyle, …).
|
||||||
|
*/
|
||||||
|
export interface WalkthroughActionApi {
|
||||||
|
closeAll: () => void
|
||||||
|
openScanConfig: () => void
|
||||||
|
openScanHistoryDemo: () => void
|
||||||
|
openInventoryDemo: () => void
|
||||||
|
editFirstNode: () => void
|
||||||
|
selectTwoNodes: () => void
|
||||||
|
openStyle: () => void
|
||||||
|
openZigbeeImport: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const WalkthroughActionsContext = createContext<WalkthroughActionApi | null>(null)
|
||||||
|
|
||||||
|
export const WalkthroughActionsProvider = WalkthroughActionsContext.Provider
|
||||||
|
|
||||||
|
export function useWalkthroughActions(): WalkthroughActionApi | null {
|
||||||
|
return useContext(WalkthroughActionsContext)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { ScanRun } from '@/components/modals/ScanHistoryModal'
|
||||||
|
import type { PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canned data injected into the real modals during the tour, so the demo steps
|
||||||
|
* look alive on a fresh install without hitting the backend. Never persisted.
|
||||||
|
*/
|
||||||
|
export const DEMO_SCAN_RUNS: ScanRun[] = [
|
||||||
|
{
|
||||||
|
id: 'tour-scan-running',
|
||||||
|
status: 'running',
|
||||||
|
kind: 'ip',
|
||||||
|
ranges: ['192.168.1.0/24'],
|
||||||
|
devices_found: 12,
|
||||||
|
// 8s ago so the live "Duration" reads a realistic elapsed time.
|
||||||
|
started_at: new Date(Date.now() - 8000).toISOString(),
|
||||||
|
finished_at: null,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tour-scan-done',
|
||||||
|
status: 'done',
|
||||||
|
kind: 'ip',
|
||||||
|
ranges: ['192.168.1.0/24'],
|
||||||
|
devices_found: 9,
|
||||||
|
started_at: new Date(Date.now() - 3600_000).toISOString(),
|
||||||
|
finished_at: new Date(Date.now() - 3560_000).toISOString(),
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DEMO_PENDING_DEVICES: PendingDevice[] = [
|
||||||
|
{
|
||||||
|
id: 'tour-dev-1',
|
||||||
|
ip: '192.168.1.20',
|
||||||
|
mac: 'AA:BB:CC:DD:EE:20',
|
||||||
|
hostname: 'synology-nas',
|
||||||
|
os: 'DSM 7.2',
|
||||||
|
services: [{ port: 5000, protocol: 'tcp', service_name: 'Synology DSM', category: 'nas' }],
|
||||||
|
suggested_type: 'nas',
|
||||||
|
status: 'pending',
|
||||||
|
discovery_source: 'arp',
|
||||||
|
friendly_name: 'Synology NAS',
|
||||||
|
vendor: 'Synology',
|
||||||
|
model: 'DS920+',
|
||||||
|
discovered_at: new Date(Date.now() - 120_000).toISOString(),
|
||||||
|
canvas_count: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tour-dev-2',
|
||||||
|
ip: '192.168.1.30',
|
||||||
|
mac: 'AA:BB:CC:DD:EE:30',
|
||||||
|
hostname: 'pi-hole',
|
||||||
|
os: null,
|
||||||
|
services: [{ port: 80, protocol: 'tcp', service_name: 'Pi-hole', category: 'network' }],
|
||||||
|
suggested_type: 'generic',
|
||||||
|
status: 'pending',
|
||||||
|
discovery_source: 'arp',
|
||||||
|
friendly_name: 'Pi-hole',
|
||||||
|
vendor: 'Raspberry Pi',
|
||||||
|
model: null,
|
||||||
|
discovered_at: new Date(Date.now() - 90_000).toISOString(),
|
||||||
|
canvas_count: 0,
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Declarative Getting Started steps.
|
||||||
|
*
|
||||||
|
* `anchor` is a CSS selector for the element to ring/spotlight (undefined → the
|
||||||
|
* overlay lights any open modal, or centers the card). `action` is a key resolved
|
||||||
|
* against the walkthrough actions context on step enter (opens a modal, injects
|
||||||
|
* demo data, selects nodes, …). `mode: 'full'` marks a backend-only step that is
|
||||||
|
* filtered out of the standalone/public build. `link` renders a link in the card
|
||||||
|
* (used for the closing "questions?" step).
|
||||||
|
*/
|
||||||
|
export type StepPlacement = 'center' | 'top' | 'bottom' | 'left' | 'right' | 'auto'
|
||||||
|
|
||||||
|
export interface TourStep {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
anchor?: string
|
||||||
|
placement?: StepPlacement
|
||||||
|
action?: string
|
||||||
|
mode?: 'full' | 'all'
|
||||||
|
link?: { label: string; href: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STEPS: TourStep[] = [
|
||||||
|
{
|
||||||
|
id: 'welcome',
|
||||||
|
title: 'Welcome to Homelable',
|
||||||
|
body: 'Take a quick tour of the main features : scanning your network, managing devices, and building your canvas. You can skip anytime and restart later from Settings.',
|
||||||
|
placement: 'center',
|
||||||
|
mode: 'all',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'scan',
|
||||||
|
title: 'Scan your network',
|
||||||
|
body: 'Start here: run an IP scan to auto-discover devices on your network. Pick the ranges and ports, then launch, discovered devices land in your inventory for review.',
|
||||||
|
anchor: '[data-tour="scan-network"]',
|
||||||
|
placement: 'right',
|
||||||
|
action: 'openScanConfig',
|
||||||
|
mode: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'scan-history',
|
||||||
|
title: 'Follow scan progress',
|
||||||
|
body: 'Scan History shows every run live. A running scan updates in place with elapsed time and device count. You can stop it here too. (This is example data.)',
|
||||||
|
placement: 'left',
|
||||||
|
action: 'openScanHistoryDemo',
|
||||||
|
mode: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'inventory',
|
||||||
|
title: 'Review discovered devices',
|
||||||
|
body: 'Discovered devices wait in your inventory. For each one you can approve it onto the canvas, hide it, or delete it, with detected services and vendor shown. (Example devices.)',
|
||||||
|
placement: 'left',
|
||||||
|
action: 'openInventoryDemo',
|
||||||
|
mode: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nodes',
|
||||||
|
title: 'Your devices on the canvas',
|
||||||
|
body: 'Approved devices become nodes on the canvas. Each shows live status, IP, hostname and running services. Drag to arrange them and draw links between them.',
|
||||||
|
anchor: '.react-flow__node',
|
||||||
|
placement: 'auto',
|
||||||
|
mode: 'all',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'node-edit',
|
||||||
|
title: 'Edit a device',
|
||||||
|
body: 'Double-click any node to edit it: name, type, IP, the status-check method, appearance, and its parent (e.g. a VM inside a Proxmox host). Changes are yours until you Save.',
|
||||||
|
placement: 'left',
|
||||||
|
action: 'editFirstNode',
|
||||||
|
mode: 'all',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'text-zone',
|
||||||
|
title: 'Annotate with text & zones',
|
||||||
|
body: 'Add free Text labels and Zones (colored rectangles) to document your layout to group a rack, mark a VLAN, or leave a note. Both live under the canvas actions.',
|
||||||
|
anchor: '[data-tour="add-zone"]',
|
||||||
|
placement: 'right',
|
||||||
|
mode: 'all',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'grouping',
|
||||||
|
title: 'Group related devices',
|
||||||
|
body: 'Select multiple nodes, then Create Group to box them together and drag them as one. Perfect for a rack, a site, or a subnet.',
|
||||||
|
anchor: '[data-tour="create-group"]',
|
||||||
|
placement: 'left',
|
||||||
|
action: 'selectTwoNodes',
|
||||||
|
mode: 'all',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'style',
|
||||||
|
title: 'Make it yours',
|
||||||
|
body: 'Switch the canvas theme from Style, or fine-tune colors, borders and fonts per node type. Your homelab, your look.',
|
||||||
|
anchor: '[data-tour="style"]',
|
||||||
|
placement: 'bottom',
|
||||||
|
action: 'openStyle',
|
||||||
|
mode: 'all',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'imports',
|
||||||
|
title: 'Import from your stack',
|
||||||
|
body: 'Already running Zigbee2MQTT, Z-Wave JS, or Proxmox? Import devices directly from them instead of scanning. One click brings the whole topology in.',
|
||||||
|
anchor: '[data-tour="zigbee-import"]',
|
||||||
|
placement: 'right',
|
||||||
|
action: 'openZigbeeImport',
|
||||||
|
mode: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'end',
|
||||||
|
title: "You're all set",
|
||||||
|
body: "That's the tour! Build your map, save it, and share a read-only view. In full mode you also get network scanning, a device inventory, and Zigbee / Z-Wave / Proxmox import. Have a question or an idea?",
|
||||||
|
placement: 'center',
|
||||||
|
mode: 'all',
|
||||||
|
link: { label: 'Ask on GitHub', href: 'https://github.com/Pouzor/homelable/issues' },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Steps for the current build — backend-only steps are dropped in standalone. */
|
||||||
|
export function getSteps(standalone: boolean): TourStep[] {
|
||||||
|
return STEPS.filter((s) => s.mode !== 'full' || !standalone)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/* Getting Started walkthrough animations. Kept in a shared stylesheet (imported
|
||||||
|
by both the invite bubble and the overlay) so keyframes exist regardless of
|
||||||
|
which component is mounted. */
|
||||||
|
|
||||||
|
@keyframes wt-invite-enter {
|
||||||
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
.walkthrough-invite-enter { animation: wt-invite-enter 0.25s ease-out; }
|
||||||
|
|
||||||
|
@keyframes wt-card-enter {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
.walkthrough-card-enter { animation: wt-card-enter 0.2s ease-out; }
|
||||||
|
|
||||||
|
/* The dim surround is drawn by the overlay's SVG mask; this is just the pulsing
|
||||||
|
cyan ring around the primary target (outline so it sits outside the border). */
|
||||||
|
.walkthrough-spotlight--pulse { animation: wt-pulse 1.8s ease-in-out infinite; }
|
||||||
|
@keyframes wt-pulse {
|
||||||
|
0%, 100% { outline: 0px solid rgba(0, 212, 255, 0.55); }
|
||||||
|
50% { outline: 7px solid rgba(0, 212, 255, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* While the tour runs, app modals must not add their own dim/blur — the overlay's
|
||||||
|
SVG spotlight is the single source of dimming, so a ringed sidebar/toolbar
|
||||||
|
control behind an open modal stays crisp instead of being double-dimmed and
|
||||||
|
blurred by the modal's own backdrop. */
|
||||||
|
body.walkthrough-running [data-slot="dialog-overlay"] {
|
||||||
|
background: transparent !important;
|
||||||
|
backdrop-filter: none !important;
|
||||||
|
-webkit-backdrop-filter: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.walkthrough-invite-enter,
|
||||||
|
.walkthrough-card-enter,
|
||||||
|
.walkthrough-spotlight--pulse { animation: none; }
|
||||||
|
}
|
||||||
+47
-1
@@ -115,6 +115,7 @@ def _build_tools() -> list[Tool]:
|
|||||||
"id": {"type": "string"},
|
"id": {"type": "string"},
|
||||||
"type": {"type": "string", "enum": NODE_TYPES, "default": "generic"},
|
"type": {"type": "string", "enum": NODE_TYPES, "default": "generic"},
|
||||||
"label": {"type": "string"},
|
"label": {"type": "string"},
|
||||||
|
**_DESIGN_ID_FIELD,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
Tool(name="hide_device", description="Hide a pending discovered device", inputSchema={
|
Tool(name="hide_device", description="Hide a pending discovered device", inputSchema={
|
||||||
@@ -134,6 +135,21 @@ def _build_tools() -> list[Tool]:
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {},
|
"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={
|
Tool(name="list_designs", description="List all designs (canvases) with their IDs and node/group/text counts", inputSchema={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {},
|
"properties": {},
|
||||||
@@ -147,6 +163,13 @@ def _build_tools() -> list[Tool]:
|
|||||||
"design_type": {"type": "string", "description": "Design type (default: network)."},
|
"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")
|
return await backend.get("/api/v1/nodes")
|
||||||
|
|
||||||
if name == "list_pending_devices":
|
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":
|
if name == "list_designs":
|
||||||
return await backend.get("/api/v1/designs")
|
return await backend.get("/api/v1/designs")
|
||||||
@@ -236,4 +279,7 @@ async def _dispatch(name: str, args: dict) -> dict:
|
|||||||
if name == "create_design":
|
if name == "create_design":
|
||||||
return await backend.post("/api/v1/designs", args)
|
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}")
|
raise ValueError(f"Unknown tool: {name}")
|
||||||
|
|||||||
@@ -262,7 +262,78 @@ def test_create_design_schema_requires_name():
|
|||||||
assert tool.inputSchema["required"] == ["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
|
@pytest.mark.anyio
|
||||||
async def test_unknown_tool():
|
async def test_unknown_tool():
|
||||||
with pytest.raises(ValueError, match="Unknown tool"):
|
with pytest.raises(ValueError, match="Unknown tool"):
|
||||||
await _dispatch("nonexistent", {})
|
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", {})
|
||||||
|
|||||||
Reference in New Issue
Block a user