Compare commits

...

31 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #183

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

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

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

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

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

Off by default — no behaviour change until enabled.

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

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

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

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

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

ha-relevant: no
2026-06-09 16:29:52 +02:00
51 changed files with 2514 additions and 484 deletions
+3
View File
@@ -6,6 +6,9 @@ on:
pull_request: pull_request:
branches: [main] branches: [main]
permissions:
contents: read
jobs: jobs:
smoke-and-integration: smoke-and-integration:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+3
View File
@@ -6,6 +6,9 @@ on:
pull_request: pull_request:
branches: [main] branches: [main]
permissions:
contents: read
jobs: jobs:
lint-scripts: lint-scripts:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+3
View File
@@ -8,6 +8,9 @@ on:
schedule: schedule:
- cron: '0 9 * * 1' # Weekly on Monday - cron: '0 9 * * 1' # Weekly on Monday
permissions:
contents: read
jobs: jobs:
secrets-scan: secrets-scan:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+1
View File
@@ -46,6 +46,7 @@ htmlcov/
*.db-shm *.db-shm
*.db-wal *.db-wal
*.db.back *.db.back
*.db.back-*
# Docker # Docker
.docker/ .docker/
+1 -1
View File
@@ -1 +1 @@
2.4.0 2.5.1
+15 -2
View File
@@ -1,20 +1,27 @@
"""App-level settings (status checker interval, etc.).""" """App-level settings (status checker interval, etc.)."""
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from app.api.deps import get_current_user from app.api.deps import get_current_user
from app.core.config import settings from app.core.config import settings
from app.core.scheduler import reschedule_service_checks, set_service_checks_enabled
router = APIRouter() router = APIRouter()
class AppSettings(BaseModel): class AppSettings(BaseModel):
interval_seconds: int interval_seconds: int
service_check_enabled: bool = False
service_check_interval: int = Field(default=300, ge=30)
@router.get("", response_model=AppSettings) @router.get("", response_model=AppSettings)
async def get_settings(_: str = Depends(get_current_user)) -> AppSettings: async def get_settings(_: str = Depends(get_current_user)) -> AppSettings:
return AppSettings(interval_seconds=settings.status_checker_interval) return AppSettings(
interval_seconds=settings.status_checker_interval,
service_check_enabled=settings.service_check_enabled,
service_check_interval=settings.service_check_interval,
)
@router.post("", response_model=AppSettings) @router.post("", response_model=AppSettings)
@@ -23,7 +30,13 @@ async def update_settings(
) -> AppSettings: ) -> AppSettings:
try: try:
settings.status_checker_interval = payload.interval_seconds settings.status_checker_interval = payload.interval_seconds
settings.service_check_enabled = payload.service_check_enabled
settings.service_check_interval = payload.service_check_interval
settings.save_overrides() settings.save_overrides()
# Apply the service-check schedule live.
set_service_checks_enabled(payload.service_check_enabled)
if payload.service_check_enabled:
reschedule_service_checks(payload.service_check_interval)
return payload return payload
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc raise HTTPException(status_code=500, detail=str(exc)) from exc
+22 -2
View File
@@ -1,3 +1,4 @@
import contextlib
import json import json
from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi import APIRouter, WebSocket, WebSocketDisconnect
@@ -10,6 +11,12 @@ router = APIRouter()
_connections: list[WebSocket] = [] _connections: list[WebSocket] = []
def _drop(websocket: WebSocket) -> None:
"""Remove a connection if still present — idempotent, never raises."""
with contextlib.suppress(ValueError):
_connections.remove(websocket)
@router.websocket("/ws/status") @router.websocket("/ws/status")
async def ws_status(websocket: WebSocket) -> None: async def ws_status(websocket: WebSocket) -> None:
# Accept first so we can send a close frame with a reason code # Accept first so we can send a close frame with a reason code
@@ -33,7 +40,11 @@ async def ws_status(websocket: WebSocket) -> None:
while True: while True:
await websocket.receive_text() await websocket.receive_text()
except WebSocketDisconnect: except WebSocketDisconnect:
_connections.remove(websocket) pass
finally:
# Any error (disconnect or otherwise) must release the slot, else the
# dead socket lingers in the broadcast pool.
_drop(websocket)
async def _broadcast(payload: str) -> None: async def _broadcast(payload: str) -> None:
@@ -41,7 +52,7 @@ async def _broadcast(payload: str) -> None:
try: try:
await conn.send_text(payload) await conn.send_text(payload)
except Exception: except Exception:
_connections.remove(conn) _drop(conn)
async def broadcast_status(node_id: str, status: str, checked_at: str, response_time_ms: int | None = None) -> None: async def broadcast_status(node_id: str, status: str, checked_at: str, response_time_ms: int | None = None) -> None:
@@ -54,6 +65,15 @@ async def broadcast_status(node_id: str, status: str, checked_at: str, response_
})) }))
async def broadcast_service_status(node_id: str, services: list[dict[str, object]], checked_at: str) -> None:
await _broadcast(json.dumps({
"type": "service_status",
"node_id": node_id,
"services": services,
"checked_at": checked_at,
}))
async def broadcast_scan_update(run_id: str, devices_found: int) -> None: async def broadcast_scan_update(run_id: str, devices_found: int) -> None:
await _broadcast(json.dumps({ await _broadcast(json.dumps({
"type": "scan_device_found", "type": "scan_device_found",
+10
View File
@@ -51,6 +51,10 @@ class Settings(BaseSettings):
# Status checker # Status checker
status_checker_interval: int = 60 status_checker_interval: int = 60
# Per-service status checker (independent of node checks). Off by default.
service_check_enabled: bool = False
service_check_interval: int = 300
# MCP service key — set MCP_SERVICE_KEY in .env # MCP service key — set MCP_SERVICE_KEY in .env
# Used by the MCP server to authenticate against the backend without a user password. # Used by the MCP server to authenticate against the backend without a user password.
# Leave empty to disable MCP service key auth. # Leave empty to disable MCP service key auth.
@@ -77,6 +81,10 @@ class Settings(BaseSettings):
self.scanner_ranges = data["scanner_ranges"] self.scanner_ranges = data["scanner_ranges"]
if "status_checker_interval" in data: if "status_checker_interval" in data:
self.status_checker_interval = int(data["status_checker_interval"]) self.status_checker_interval = int(data["status_checker_interval"])
if "service_check_enabled" in data:
self.service_check_enabled = bool(data["service_check_enabled"])
if "service_check_interval" in data:
self.service_check_interval = int(data["service_check_interval"])
except Exception: except Exception:
pass pass
@@ -86,6 +94,8 @@ class Settings(BaseSettings):
self._override_path().write_text(json.dumps({ self._override_path().write_text(json.dumps({
"scanner_ranges": self.scanner_ranges, "scanner_ranges": self.scanner_ranges,
"status_checker_interval": self.status_checker_interval, "status_checker_interval": self.status_checker_interval,
"service_check_enabled": self.service_check_enabled,
"service_check_interval": self.service_check_interval,
})) }))
+72 -1
View File
@@ -9,7 +9,7 @@ from sqlalchemy import select
from app.core.config import settings from app.core.config import settings
from app.db.database import AsyncSessionLocal from app.db.database import AsyncSessionLocal
from app.db.models import Node from app.db.models import Node
from app.services.status_checker import check_node from app.services.status_checker import check_node, check_services
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -73,6 +73,50 @@ async def _run_status_checks() -> None:
]) ])
def _node_host(ip: str | None, hostname: str | None) -> str | None:
"""Pick the address to probe services on: first IP, else hostname."""
if ip:
first = ip.split(",")[0].strip()
if first:
return first
return hostname or None
async def _run_service_checks() -> None:
"""Check every service of every node and broadcast per-service results."""
if not settings.service_check_enabled:
return
from app.api.routes.status import broadcast_service_status # avoid circular import
async with AsyncSessionLocal() as db:
result = await db.execute(select(Node))
nodes = result.scalars().all()
checkable = [
(n.id, _node_host(n.ip, n.hostname), list(n.services or []))
for n in nodes
if n.services
]
now = datetime.now(timezone.utc).isoformat()
for node_id, host, services in checkable:
try:
statuses = await check_services(host, services)
await broadcast_service_status(node_id=node_id, services=statuses, checked_at=now)
except Exception as exc:
logger.error("Service checks failed for node %s: %s", node_id, exc)
def _add_service_check_job() -> None:
scheduler.add_job(
_run_service_checks,
"interval",
seconds=settings.service_check_interval,
id="service_checks",
max_instances=1,
coalesce=True,
)
def start_scheduler() -> None: def start_scheduler() -> None:
global scheduler global scheduler
if scheduler.running: if scheduler.running:
@@ -89,6 +133,8 @@ def start_scheduler() -> None:
max_instances=1, max_instances=1,
coalesce=True, coalesce=True,
) )
if settings.service_check_enabled:
_add_service_check_job()
scheduler.start() scheduler.start()
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval) logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
@@ -104,6 +150,31 @@ def reschedule_status_checks(interval_seconds: int) -> None:
logger.info("Status checks rescheduled to every %ds", interval_seconds) logger.info("Status checks rescheduled to every %ds", interval_seconds)
def reschedule_service_checks(interval_seconds: int) -> None:
"""Update the service-check interval on the running scheduler (if enabled)."""
if interval_seconds < 30:
raise ValueError(f"interval_seconds must be >= 30, got {interval_seconds}")
if not scheduler.running:
logger.warning("Scheduler not running, skipping reschedule")
return
if scheduler.get_job("service_checks"):
scheduler.reschedule_job("service_checks", trigger="interval", seconds=interval_seconds)
logger.info("Service checks rescheduled to every %ds", interval_seconds)
def set_service_checks_enabled(enabled: bool) -> None:
"""Add or remove the service-check job on the running scheduler."""
if not scheduler.running:
return
job = scheduler.get_job("service_checks")
if enabled and not job:
_add_service_check_job()
logger.info("Service checks enabled — every %ds", settings.service_check_interval)
elif not enabled and job:
scheduler.remove_job("service_checks")
logger.info("Service checks disabled")
def stop_scheduler() -> None: def stop_scheduler() -> None:
if scheduler.running: if scheduler.running:
scheduler.shutdown(wait=False) scheduler.shutdown(wait=False)
+97 -7
View File
@@ -64,17 +64,35 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
return {"status": "offline", "response_time_ms": None} return {"status": "offline", "response_time_ms": None}
def _is_ipv6(host: str) -> bool:
"""True if host is a literal IPv6 address (bracketed or bare)."""
try:
socket.inet_pton(socket.AF_INET6, host.strip("[]"))
return True
except OSError:
return False
async def _ping(host: str) -> bool: async def _ping(host: str) -> bool:
# ping(8) -W flag units differ by OS: # Send 2 probes with a ~2s timeout so a single dropped packet or a slow
# Linux: seconds (-W 1 = 1s) # device (ESPHome, IoT) doesn't flap a node offline. Success = any reply.
# macOS: milliseconds (-W 1 = 1ms — fails for any RTT >1ms) #
# Windows: -w in ms (-w 1000 = 1s) # -W flag units differ by OS:
# Linux: seconds (-W 2 = 2s)
# macOS: milliseconds (-W 2000 = 2s)
# Windows: -w in ms (-w 2000 = 2s)
#
# IPv6-only hosts (e.g. Alexa) never answer IPv4 ping, so target the right
# stack: macOS ships a separate ping6; Linux/Windows take a -6 flag.
ipv6 = _is_ipv6(host)
if sys.platform == "win32": if sys.platform == "win32":
args = ["ping", "-n", "1", "-w", "1000", host] family = ["-6"] if ipv6 else ["-4"]
args = ["ping", *family, "-n", "2", "-w", "2000", host]
elif sys.platform == "darwin": elif sys.platform == "darwin":
args = ["ping", "-c", "1", "-W", "1000", host] args = ["ping6", "-c", "2", host] if ipv6 else ["ping", "-c", "2", "-W", "2000", host]
else: else:
args = ["ping", "-c", "1", "-W", "1", host] family = ["-6"] if ipv6 else []
args = ["ping", *family, "-c", "2", "-W", "2", host]
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
*args, *args,
stdout=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.DEVNULL,
@@ -100,3 +118,75 @@ async def _tcp_connect(host: str, port: int) -> bool:
return True return True
except (TimeoutError, OSError, socket.gaierror): except (TimeoutError, OSError, socket.gaierror):
return False return False
# --- Per-service status checks ---
# Ports that are not HTTP/web. These get NO status check — a service here stays
# grey (unknown) rather than going red. An open TCP socket doesn't prove the
# service is healthy, and a closed one flaps red misleadingly (e.g. SSH on a
# box that simply firewalls 22). Only HTTP(S)-reachable services are checked.
_NON_HTTP_PORTS = frozenset({
22, 21, 23, 25, 465, 587, 53, 110, 143, 993, 995, 389, 636, 445, 514,
1433, 3306, 5432, 5672, 6379, 9092, 11211, 27017, 27018,
})
_HTTPS_PORTS = frozenset({443, 8443})
def _service_host(svc: dict[str, Any], host: str) -> str:
"""Bracket bare IPv6 literals for use in a URL."""
return f"[{host}]" if _is_ipv6(host) else host
async def check_service(svc: dict[str, Any], host: str | None) -> str:
"""Check a single service. Returns 'online' | 'offline' | 'unknown'.
Only HTTP(S)-reachable services get a real check (an HTTP GET). Everything
else — SSH, databases, mail, DNS, raw TCP, UDP, port-less — stays 'unknown'
so it keeps its category colour instead of flashing red. An open TCP socket
doesn't prove a non-web service is healthy, so we don't pretend it does.
"""
if not host or host.startswith("-"):
return "unknown"
if str(svc.get("protocol", "")).lower() == "udp":
return "unknown"
port = svc.get("port")
port = int(port) if isinstance(port, int) or (isinstance(port, str) and port.isdigit()) else None
# Non-HTTP ports (SSH 22, DB, mail, …) are never checked — keep them grey.
if port is not None and port in _NON_HTTP_PORTS:
return "unknown"
name = str(svc.get("service_name", "")).lower()
is_web = port is not None or "http" in name
if not is_web:
return "unknown"
try:
scheme = "https" if (
port in _HTTPS_PORTS or "https" in name or "ssl" in name or "tls" in name
) else "http"
url_host = _service_host(svc, host)
url = f"{scheme}://{url_host}" + (f":{port}" if port is not None else "")
return "online" if await _http_get(url, verify=False) else "offline"
except Exception as exc:
logger.debug("Service check failed for %s:%s (%s)", host, port, exc)
return "offline"
async def check_services(
host: str | None, services: list[dict[str, Any]], concurrency: int = 10
) -> list[dict[str, Any]]:
"""Check every service against host concurrently (bounded).
Returns a list of {port, protocol, status} dicts, one per input service.
"""
sem = asyncio.Semaphore(concurrency)
async def _one(svc: dict[str, Any]) -> dict[str, Any]:
async with sem:
status = await check_service(svc, host)
return {"port": svc.get("port"), "protocol": svc.get("protocol"), "status": status}
return await asyncio.gather(*[_one(s) for s in services]) if services else []
+2 -2
View File
@@ -8,14 +8,14 @@ pydantic==2.9.2
pydantic-settings==2.5.2 pydantic-settings==2.5.2
python-jose[cryptography]==3.5.0 python-jose[cryptography]==3.5.0
bcrypt==4.2.1 bcrypt==4.2.1
python-multipart==0.0.27 python-multipart==0.0.31
apscheduler==3.10.4 apscheduler==3.10.4
python-nmap==0.7.1 python-nmap==0.7.1
pyyaml==6.0.2 pyyaml==6.0.2
types-PyYAML==6.0.12.20240917 types-PyYAML==6.0.12.20240917
websockets==13.1 websockets==13.1
httpx==0.27.2 httpx==0.27.2
zeroconf==0.149.7 zeroconf==0.149.12
aiomqtt==2.3.0 aiomqtt==2.3.0
# Dev # Dev
+95 -1
View File
@@ -5,7 +5,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.scheduler import _run_status_checks, start_scheduler, stop_scheduler from app.core.scheduler import (
_run_service_checks,
_run_status_checks,
set_service_checks_enabled,
start_scheduler,
stop_scheduler,
)
from app.db.database import Base from app.db.database import Base
from app.db.models import Node from app.db.models import Node
@@ -141,6 +147,7 @@ def test_scheduler_uses_settings_interval():
with patch("app.core.scheduler.settings") as mock_settings, \ with patch("app.core.scheduler.settings") as mock_settings, \
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched): patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
mock_settings.status_checker_interval = 45 mock_settings.status_checker_interval = 45
mock_settings.service_check_enabled = False
start_scheduler() start_scheduler()
_, kwargs = mock_sched.add_job.call_args _, kwargs = mock_sched.add_job.call_args
assert kwargs["seconds"] == 45 assert kwargs["seconds"] == 45
@@ -155,3 +162,90 @@ def test_start_and_stop_scheduler():
mock_sched.add_job.assert_called_once() mock_sched.add_job.assert_called_once()
mock_sched.start.assert_called_once() mock_sched.start.assert_called_once()
mock_sched.shutdown.assert_called_once() mock_sched.shutdown.assert_called_once()
# ---------------------------------------------------------------------------
# Service checks
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_run_service_checks_disabled_does_nothing(mem_db):
async with mem_db() as session:
session.add(_make_node(services=[{"port": 80, "protocol": "tcp", "service_name": "http"}]))
await session.commit()
with patch("app.core.scheduler.settings") as mock_settings, \
patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
patch("app.services.status_checker.check_services", new_callable=AsyncMock) as mock_cs:
mock_settings.service_check_enabled = False
await _run_service_checks()
mock_cs.assert_not_called()
@pytest.mark.asyncio
async def test_run_service_checks_broadcasts_per_node(mem_db):
async with mem_db() as session:
node = _make_node(
ip="10.0.0.5",
services=[{"port": 80, "protocol": "tcp", "service_name": "http"}],
)
session.add(node)
await session.commit()
node_id = node.id
statuses = [{"port": 80, "protocol": "tcp", "status": "offline"}]
with patch("app.core.scheduler.settings") as mock_settings, \
patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
patch("app.core.scheduler.check_services", new_callable=AsyncMock, return_value=statuses), \
patch("app.api.routes.status.broadcast_service_status", new_callable=AsyncMock) as mock_bcast:
mock_settings.service_check_enabled = True
await _run_service_checks()
mock_bcast.assert_awaited_once()
_, kwargs = mock_bcast.call_args
assert kwargs["node_id"] == node_id
assert kwargs["services"] == statuses
@pytest.mark.asyncio
async def test_run_service_checks_skips_nodes_without_services(mem_db):
async with mem_db() as session:
session.add(_make_node(ip="10.0.0.6", services=[]))
await session.commit()
with patch("app.core.scheduler.settings") as mock_settings, \
patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
patch("app.core.scheduler.check_services", new_callable=AsyncMock) as mock_cs:
mock_settings.service_check_enabled = True
await _run_service_checks()
mock_cs.assert_not_called()
def test_set_service_checks_enabled_adds_and_removes_job():
mock_sched = MagicMock()
mock_sched.running = True
with patch("app.core.scheduler.scheduler", mock_sched), \
patch("app.core.scheduler.settings") as mock_settings:
mock_settings.service_check_interval = 300
# Enable: no existing job -> add
mock_sched.get_job.return_value = None
set_service_checks_enabled(True)
mock_sched.add_job.assert_called_once()
# Disable: existing job -> remove
mock_sched.get_job.return_value = MagicMock()
set_service_checks_enabled(False)
mock_sched.remove_job.assert_called_once_with("service_checks")
def test_start_scheduler_adds_service_job_when_enabled():
mock_sched = MagicMock()
with patch("app.core.scheduler.settings") as mock_settings, \
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
mock_settings.status_checker_interval = 60
mock_settings.service_check_enabled = True
mock_settings.service_check_interval = 300
start_scheduler()
job_ids = [kw.get("id") for _, kw in mock_sched.add_job.call_args_list]
assert "status_checks" in job_ids
assert "service_checks" in job_ids
+39
View File
@@ -45,3 +45,42 @@ async def test_update_settings_saves_interval(client: AsyncClient, headers):
async def test_update_settings_requires_auth(client: AsyncClient): async def test_update_settings_requires_auth(client: AsyncClient):
res = await client.post("/api/v1/settings", json={"interval_seconds": 30}) res = await client.post("/api/v1/settings", json={"interval_seconds": 30})
assert res.status_code == 401 assert res.status_code == 401
@pytest.mark.asyncio
async def test_get_settings_returns_service_check_fields(client: AsyncClient, headers):
res = await client.get("/api/v1/settings", headers=headers)
data = res.json()
assert "service_check_enabled" in data
assert "service_check_interval" in data
assert isinstance(data["service_check_enabled"], bool)
assert isinstance(data["service_check_interval"], int)
@pytest.mark.asyncio
async def test_update_settings_saves_service_check_fields(client: AsyncClient, headers):
with patch("app.api.routes.settings.settings") as mock_settings:
mock_settings.save_overrides = lambda: None
res = await client.post(
"/api/v1/settings",
json={
"interval_seconds": 60,
"service_check_enabled": True,
"service_check_interval": 600,
},
headers=headers,
)
assert res.status_code == 200
body = res.json()
assert body["service_check_enabled"] is True
assert body["service_check_interval"] == 600
@pytest.mark.asyncio
async def test_update_settings_rejects_too_short_service_interval(client: AsyncClient, headers):
res = await client.post(
"/api/v1/settings",
json={"interval_seconds": 60, "service_check_enabled": True, "service_check_interval": 5},
headers=headers,
)
assert res.status_code == 422
+67 -1
View File
@@ -5,7 +5,13 @@ import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect from starlette.websockets import WebSocketDisconnect
from app.api.routes.status import _connections, broadcast_scan_update, broadcast_status from app.api.routes.status import (
_connections,
_drop,
broadcast_scan_update,
broadcast_service_status,
broadcast_status,
)
from app.main import app from app.main import app
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -155,3 +161,63 @@ async def test_broadcast_no_connections():
assert len(_connections) == 0 assert len(_connections) == 0
await broadcast_status(node_id="n", status="online", checked_at="t") await broadcast_status(node_id="n", status="online", checked_at="t")
await broadcast_scan_update(run_id="r", devices_found=0) await broadcast_scan_update(run_id="r", devices_found=0)
# ---------------------------------------------------------------------------
# broadcast_service_status
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_broadcast_service_status_payload():
received: list[str] = []
class FakeWS:
async def send_text(self, text: str) -> None:
received.append(text)
fake = FakeWS()
_connections.append(fake)
try:
await broadcast_service_status(
node_id="node-7",
services=[{"port": 80, "protocol": "tcp", "status": "offline"}],
checked_at="2024-01-01T00:00:00",
)
finally:
_drop(fake)
msg = json.loads(received[0])
assert msg["type"] == "service_status"
assert msg["node_id"] == "node-7"
assert msg["services"] == [{"port": 80, "protocol": "tcp", "status": "offline"}]
# ---------------------------------------------------------------------------
# _drop — idempotent connection removal (regression for double-remove crash)
# ---------------------------------------------------------------------------
def test_drop_is_idempotent():
"""Dropping a connection twice must not raise (was a ValueError crash)."""
class FakeWS:
pass
fake = FakeWS()
_connections.append(fake)
_drop(fake)
_drop(fake) # second drop must be a no-op
assert fake not in _connections
@pytest.mark.asyncio
async def test_broadcast_dead_connection_dropped_once_safely():
"""A send failure removes the dead socket without a double-remove crash."""
class DeadWS:
async def send_text(self, _: str) -> None:
raise RuntimeError("disconnected")
dead = DeadWS()
_connections.append(dead)
await broadcast_status(node_id="n", status="online", checked_at="t")
# A second broadcast must not raise even though dead is already gone.
await broadcast_status(node_id="n", status="online", checked_at="t")
assert dead not in _connections
+198 -4
View File
@@ -3,7 +3,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from app.services.status_checker import _ping, _tcp_connect, check_node from app.services.status_checker import (
_ping,
_tcp_connect,
check_node,
check_service,
check_services,
)
# --- check_node dispatcher --- # --- check_node dispatcher ---
@@ -169,9 +175,14 @@ async def test_ping_uses_unix_args_on_non_windows():
assert "-c" in captured["args"] assert "-c" in captured["args"]
assert "-W" in captured["args"] assert "-W" in captured["args"]
assert "-n" not in captured["args"] assert "-n" not in captured["args"]
# Linux: -W is in seconds; 1s is the intended timeout # 2 probes so a single dropped packet doesn't flap the node offline
c_idx = captured["args"].index("-c")
assert captured["args"][c_idx + 1] == "2"
# Linux: -W is in seconds; 2s is the intended timeout
w_idx = captured["args"].index("-W") w_idx = captured["args"].index("-W")
assert captured["args"][w_idx + 1] == "1" assert captured["args"][w_idx + 1] == "2"
# IPv4 target → no -6 flag
assert "-6" not in captured["args"]
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -193,7 +204,7 @@ async def test_ping_uses_macos_millisecond_timeout():
assert "-c" in captured["args"] assert "-c" in captured["args"]
assert "-W" in captured["args"] assert "-W" in captured["args"]
w_idx = captured["args"].index("-W") w_idx = captured["args"].index("-W")
assert captured["args"][w_idx + 1] == "1000" assert captured["args"][w_idx + 1] == "2000"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -216,6 +227,75 @@ async def test_ping_uses_windows_args_on_win32():
assert "-c" not in captured["args"] assert "-c" not in captured["args"]
# --- _ping IPv6 support ---
@pytest.mark.asyncio
async def test_ping_ipv6_linux_uses_dash6():
"""IPv6-only devices (e.g. Alexa) need ping -6 on Linux."""
captured = {}
async def fake_exec(*args, **kwargs):
captured["args"] = args
proc = MagicMock()
proc.returncode = 0
proc.wait = AsyncMock()
return proc
with patch("app.services.status_checker.sys.platform", "linux"), \
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
await _ping("fe80::1")
assert "-6" in captured["args"]
assert captured["args"][-1] == "fe80::1"
@pytest.mark.asyncio
async def test_ping_ipv6_macos_uses_ping6():
"""macOS ships a separate ping6 binary for IPv6 targets."""
captured = {}
async def fake_exec(*args, **kwargs):
captured["args"] = args
proc = MagicMock()
proc.returncode = 0
proc.wait = AsyncMock()
return proc
with patch("app.services.status_checker.sys.platform", "darwin"), \
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
await _ping("2001:db8::1")
assert captured["args"][0] == "ping6"
@pytest.mark.asyncio
async def test_ping_ipv6_windows_uses_dash6():
captured = {}
async def fake_exec(*args, **kwargs):
captured["args"] = args
proc = MagicMock()
proc.returncode = 0
proc.wait = AsyncMock()
return proc
with patch("app.services.status_checker.sys.platform", "win32"), \
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
await _ping("2001:db8::1")
assert "-6" in captured["args"]
def test_is_ipv6_detection():
from app.services.status_checker import _is_ipv6
assert _is_ipv6("fe80::1") is True
assert _is_ipv6("2001:db8::1") is True
assert _is_ipv6("[2001:db8::1]") is True
assert _is_ipv6("192.168.1.1") is False
assert _is_ipv6("example.local") is False
# --- check_node target validation --- # --- check_node target validation ---
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -268,3 +348,117 @@ async def test_tcp_connect_os_error():
with patch("asyncio.open_connection", new_callable=AsyncMock, side_effect=OSError("refused")): with patch("asyncio.open_connection", new_callable=AsyncMock, side_effect=OSError("refused")):
result = await _tcp_connect("192.168.1.1", 9999) result = await _tcp_connect("192.168.1.1", 9999)
assert result is False assert result is False
# --- check_service ---
@pytest.mark.asyncio
async def test_check_service_no_host_is_unknown():
assert await check_service({"port": 80, "protocol": "tcp", "service_name": "http"}, None) == "unknown"
@pytest.mark.asyncio
async def test_check_service_flag_host_is_unknown():
assert await check_service({"port": 80, "protocol": "tcp", "service_name": "http"}, "-O") == "unknown"
@pytest.mark.asyncio
async def test_check_service_udp_is_unknown():
assert await check_service({"port": 53, "protocol": "udp", "service_name": "dns"}, "10.0.0.1") == "unknown"
@pytest.mark.asyncio
async def test_check_service_portless_non_web_is_unknown():
svc = {"protocol": "tcp", "service_name": "thing"}
assert await check_service(svc, "10.0.0.1") == "unknown"
@pytest.mark.asyncio
async def test_check_service_web_uses_http_get():
captured = {}
async def fake_http_get(url, verify=False):
captured["url"] = url
return True
svc = {"port": 8080, "protocol": "tcp", "service_name": "http"}
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
result = await check_service(svc, "10.0.0.1")
assert result == "online"
assert captured["url"] == "http://10.0.0.1:8080"
@pytest.mark.asyncio
async def test_check_service_https_port_uses_https_scheme():
captured = {}
async def fake_http_get(url, verify=False):
captured["url"] = url
return True
svc = {"port": 443, "protocol": "tcp", "service_name": "web"}
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
await check_service(svc, "10.0.0.1")
assert captured["url"].startswith("https://")
@pytest.mark.asyncio
async def test_check_service_web_offline_when_http_fails():
svc = {"port": 80, "protocol": "tcp", "service_name": "http"}
with patch("app.services.status_checker._http_get", new_callable=AsyncMock, return_value=False):
assert await check_service(svc, "10.0.0.1") == "offline"
@pytest.mark.asyncio
async def test_check_service_non_http_port_is_unknown():
"""Non-HTTP ports (DB, mail, …) stay grey — no TCP check, no red flap."""
svc = {"port": 5432, "protocol": "tcp", "service_name": "postgres"}
with patch("app.services.status_checker._tcp_connect", new_callable=AsyncMock) as mock_tcp, \
patch("app.services.status_checker._http_get", new_callable=AsyncMock) as mock_http:
result = await check_service(svc, "10.0.0.1")
assert result == "unknown"
mock_tcp.assert_not_called()
mock_http.assert_not_called()
@pytest.mark.asyncio
async def test_check_service_ssh_port_22_is_unknown():
"""SSH (port 22) is never checked — keep it grey, not red/green."""
svc = {"port": 22, "protocol": "tcp", "service_name": "ssh"}
with patch("app.services.status_checker._tcp_connect", new_callable=AsyncMock) as mock_tcp:
result = await check_service(svc, "10.0.0.1")
assert result == "unknown"
mock_tcp.assert_not_called()
@pytest.mark.asyncio
async def test_check_service_ipv6_brackets_url_host():
captured = {}
async def fake_http_get(url, verify=False):
captured["url"] = url
return True
svc = {"port": 80, "protocol": "tcp", "service_name": "http"}
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
await check_service(svc, "2001:db8::1")
assert captured["url"] == "http://[2001:db8::1]:80"
@pytest.mark.asyncio
async def test_check_services_returns_status_per_service():
services = [
{"port": 80, "protocol": "tcp", "service_name": "http"},
{"port": 5432, "protocol": "tcp", "service_name": "postgres"},
]
with patch("app.services.status_checker._http_get", new_callable=AsyncMock, return_value=True):
results = await check_services("10.0.0.1", services)
assert results == [
{"port": 80, "protocol": "tcp", "status": "online"},
{"port": 5432, "protocol": "tcp", "status": "unknown"},
]
@pytest.mark.asyncio
async def test_check_services_empty_list():
assert await check_services("10.0.0.1", []) == []
+124 -124
View File
@@ -1,12 +1,12 @@
{ {
"name": "frontend", "name": "frontend",
"version": "2.4.0", "version": "2.5.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "frontend", "name": "frontend",
"version": "2.4.0", "version": "2.5.1",
"dependencies": { "dependencies": {
"@base-ui/react": "^1.2.0", "@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4", "@dagrejs/dagre": "^2.0.4",
@@ -52,7 +52,7 @@
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.48.0", "typescript-eslint": "^8.48.0",
"vite": "^7.3.1", "vite": "^7.3.5",
"vitest": "^4.0.18" "vitest": "^4.0.18"
} }
}, },
@@ -964,9 +964,9 @@
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -981,9 +981,9 @@
} }
}, },
"node_modules/@esbuild/android-arm": { "node_modules/@esbuild/android-arm": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -998,9 +998,9 @@
} }
}, },
"node_modules/@esbuild/android-arm64": { "node_modules/@esbuild/android-arm64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1015,9 +1015,9 @@
} }
}, },
"node_modules/@esbuild/android-x64": { "node_modules/@esbuild/android-x64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1032,9 +1032,9 @@
} }
}, },
"node_modules/@esbuild/darwin-arm64": { "node_modules/@esbuild/darwin-arm64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1049,9 +1049,9 @@
} }
}, },
"node_modules/@esbuild/darwin-x64": { "node_modules/@esbuild/darwin-x64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1066,9 +1066,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-arm64": { "node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1083,9 +1083,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-x64": { "node_modules/@esbuild/freebsd-x64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1100,9 +1100,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm": { "node_modules/@esbuild/linux-arm": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1117,9 +1117,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm64": { "node_modules/@esbuild/linux-arm64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1134,9 +1134,9 @@
} }
}, },
"node_modules/@esbuild/linux-ia32": { "node_modules/@esbuild/linux-ia32": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -1151,9 +1151,9 @@
} }
}, },
"node_modules/@esbuild/linux-loong64": { "node_modules/@esbuild/linux-loong64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@@ -1168,9 +1168,9 @@
} }
}, },
"node_modules/@esbuild/linux-mips64el": { "node_modules/@esbuild/linux-mips64el": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
@@ -1185,9 +1185,9 @@
} }
}, },
"node_modules/@esbuild/linux-ppc64": { "node_modules/@esbuild/linux-ppc64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -1202,9 +1202,9 @@
} }
}, },
"node_modules/@esbuild/linux-riscv64": { "node_modules/@esbuild/linux-riscv64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -1219,9 +1219,9 @@
} }
}, },
"node_modules/@esbuild/linux-s390x": { "node_modules/@esbuild/linux-s390x": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -1236,9 +1236,9 @@
} }
}, },
"node_modules/@esbuild/linux-x64": { "node_modules/@esbuild/linux-x64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1253,9 +1253,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-arm64": { "node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1270,9 +1270,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-x64": { "node_modules/@esbuild/netbsd-x64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1287,9 +1287,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-arm64": { "node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1304,9 +1304,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-x64": { "node_modules/@esbuild/openbsd-x64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1321,9 +1321,9 @@
} }
}, },
"node_modules/@esbuild/openharmony-arm64": { "node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1338,9 +1338,9 @@
} }
}, },
"node_modules/@esbuild/sunos-x64": { "node_modules/@esbuild/sunos-x64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1355,9 +1355,9 @@
} }
}, },
"node_modules/@esbuild/win32-arm64": { "node_modules/@esbuild/win32-arm64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1372,9 +1372,9 @@
} }
}, },
"node_modules/@esbuild/win32-ia32": { "node_modules/@esbuild/win32-ia32": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -1389,9 +1389,9 @@
} }
}, },
"node_modules/@esbuild/win32-x64": { "node_modules/@esbuild/win32-x64": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -5216,9 +5216,9 @@
} }
}, },
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.27.7", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
@@ -5229,32 +5229,32 @@
"node": ">=18" "node": ">=18"
}, },
"optionalDependencies": { "optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.7", "@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.27.7", "@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.27.7", "@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.27.7", "@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.27.7", "@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.27.7", "@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.27.7", "@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.27.7", "@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.27.7", "@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.27.7", "@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.27.7", "@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.27.7" "@esbuild/win32-x64": "0.28.1"
} }
}, },
"node_modules/escalade": { "node_modules/escalade": {
@@ -5877,16 +5877,16 @@
} }
}, },
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.5", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.8", "combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0", "es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2", "hasown": "^2.0.4",
"mime-types": "^2.1.12" "mime-types": "^2.1.35"
}, },
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@@ -6161,9 +6161,9 @@
} }
}, },
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
@@ -6196,9 +6196,9 @@
} }
}, },
"node_modules/hono": { "node_modules/hono": {
"version": "4.12.18", "version": "4.12.25",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz",
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=16.9.0" "node": ">=16.9.0"
@@ -9267,9 +9267,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "7.3.2", "version": "7.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
+7 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "2.4.0", "version": "2.5.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -37,6 +37,11 @@
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"overrides": {
"hono": "^4.12.21",
"esbuild": "^0.28.1",
"form-data": "^4.0.6"
},
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
@@ -58,7 +63,7 @@
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.48.0", "typescript-eslint": "^8.48.0",
"vite": "^7.3.1", "vite": "^7.3.5",
"vitest": "^4.0.18" "vitest": "^4.0.18"
} }
} }
+52 -13
View File
@@ -27,7 +27,9 @@ import { TextModal, type TextFormData } from '@/components/modals/TextModal'
import { ThemeModal } from '@/components/modals/ThemeModal' import { ThemeModal } from '@/components/modals/ThemeModal'
import { SearchModal } from '@/components/modals/SearchModal' import { SearchModal } from '@/components/modals/SearchModal'
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal' import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
import { ScanHistoryModal } from '@/components/modals/ScanHistoryModal'
import { ShortcutsModal } from '@/components/modals/ShortcutsModal' import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
import { ConfirmAddToGroupModal } from '@/components/modals/ConfirmAddToGroupModal'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { useDesignStore } from '@/stores/designStore' import { useDesignStore } from '@/stores/designStore'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
@@ -42,7 +44,7 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const STANDALONE_STORAGE_KEY = 'homelable_canvas' const STANDALONE_STORAGE_KEY = 'homelable_canvas'
export default function App() { export default function App() {
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo } = useCanvasStore() const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer } = useCanvasStore()
const canvasRef = useRef<HTMLDivElement>(null) const canvasRef = useRef<HTMLDivElement>(null)
const { isAuthenticated } = useAuthStore() const { isAuthenticated } = useAuthStore()
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore() const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
@@ -52,7 +54,7 @@ export default function App() {
const [themeModalOpen, setThemeModalOpen] = useState(false) const [themeModalOpen, setThemeModalOpen] = useState(false)
const [searchOpen, setSearchOpen] = useState(false) const [searchOpen, setSearchOpen] = useState(false)
const [sidebarForceView, setSidebarForceView] = useState<'history' | undefined>(undefined) const [scanHistoryOpen, setScanHistoryOpen] = useState(false)
const [pendingModalOpen, setPendingModalOpen] = useState(false) const [pendingModalOpen, setPendingModalOpen] = useState(false)
const [pendingModalStatus, setPendingModalStatus] = useState<'pending' | 'hidden'>('pending') const [pendingModalStatus, setPendingModalStatus] = useState<'pending' | 'hidden'>('pending')
const [pendingHighlightId, setPendingHighlightId] = useState<string | undefined>(undefined) const [pendingHighlightId, setPendingHighlightId] = useState<string | undefined>(undefined)
@@ -68,6 +70,8 @@ export default function App() {
const [addTextOpen, setAddTextOpen] = useState(false) const [addTextOpen, setAddTextOpen] = useState(false)
const [editNodeId, setEditNodeId] = useState<string | null>(null) const [editNodeId, setEditNodeId] = useState<string | null>(null)
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null) const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
const [pendingGroupAdd, setPendingGroupAdd] = useState<{ nodeId: string; groupId: string } | null>(null)
const [pendingContainerAdd, setPendingContainerAdd] = useState<{ nodeId: string; containerId: string } | null>(null)
const [editEdgeId, setEditEdgeId] = useState<string | null>(null) const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
const [scanConfigOpen, setScanConfigOpen] = useState(false) const [scanConfigOpen, setScanConfigOpen] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false)
@@ -241,9 +245,16 @@ export default function App() {
const id = generateUUID() const id = generateUUID()
const isContainerNode = data.container_mode === true const isContainerNode = data.container_mode === true
const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null
// Children position is relative to parent; place near top-left with padding // Only nest when the parent is an actual container. For a non-container
const position = parentNode // parent the LXC/VM stays a free node (linked by a virtual edge) — setting
? { x: 20, y: 50 } // extent:'parent' on a non-container would trap it inside the parent's tiny
// bounding box with no way to drag it out (issue #205 follow-up).
const nestInParent = !!parentNode?.data.container_mode
// Seed an ABSOLUTE position near the container's top-left; addNode converts
// it to container-relative. addNode is the single authority for parentId /
// extent, so we don't set them here.
const position = nestInParent && parentNode
? { x: parentNode.position.x + 20, y: parentNode.position.y + 50 }
: { x: 300, y: 300 } : { x: 300, y: 300 }
const newNode: Node<NodeData> = { const newNode: Node<NodeData> = {
@@ -251,7 +262,6 @@ export default function App() {
type: data.type ?? 'generic', type: data.type ?? 'generic',
position, position,
data: { status: 'unknown', services: [], ...data } as NodeData, data: { status: 'unknown', services: [], ...data } as NodeData,
...(data.parent_id ? { parentId: data.parent_id, extent: 'parent' as const } : {}),
...(isContainerNode ? { width: 300, height: 200 } : {}), ...(isContainerNode ? { width: 300, height: 200 } : {}),
} }
addNode(newNode) addNode(newNode)
@@ -607,7 +617,7 @@ export default function App() {
onZigbeeImport={() => setZigbeeImportOpen(true)} onZigbeeImport={() => setZigbeeImportOpen(true)}
onSave={handleSave} onSave={handleSave}
onOpenSettings={() => setSettingsOpen(true)} onOpenSettings={() => setSettingsOpen(true)}
forceView={sidebarForceView} onOpenHistory={() => setScanHistoryOpen(true)}
onOpenPending={openPendingModal} onOpenPending={openPendingModal}
/> />
<div className="flex flex-col flex-1 min-w-0"> <div className="flex flex-col flex-1 min-w-0">
@@ -631,6 +641,8 @@ export default function App() {
onEdgeDoubleClick={handleEdgeDoubleClick} onEdgeDoubleClick={handleEdgeDoubleClick}
onNodeDoubleClick={handleNodeDoubleClick} onNodeDoubleClick={handleNodeDoubleClick}
onNodeDragStart={snapshotHistory} onNodeDragStart={snapshotHistory}
onRequestAddToGroup={setPendingGroupAdd}
onRequestAddToContainer={setPendingContainerAdd}
onOpenPending={(deviceId) => openPendingModal(deviceId)} onOpenPending={(deviceId) => openPendingModal(deviceId)}
/> />
</div> </div>
@@ -645,7 +657,7 @@ export default function App() {
onClose={() => setAddNodeOpen(false)} onClose={() => setAddNodeOpen(false)}
onSubmit={handleAddNode} onSubmit={handleAddNode}
title="Add Node" title="Add Node"
parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type }))} parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode }))}
/> />
{/* key forces re-mount when editing a different node, resetting form state */} {/* key forces re-mount when editing a different node, resetting form state */}
@@ -672,7 +684,7 @@ export default function App() {
} }
return nodes return nodes
.filter((n) => !descendants.has(n.id)) .filter((n) => !descendants.has(n.id))
.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type })) .map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode }))
})()} })()}
currentNodeId={editNodeId ?? undefined} currentNodeId={editNodeId ?? undefined}
/> />
@@ -706,8 +718,6 @@ export default function App() {
onClose={() => setScanConfigOpen(false)} onClose={() => setScanConfigOpen(false)}
onScanNow={() => { onScanNow={() => {
toast.success('Network scan started — check Scan History for results') toast.success('Network scan started — check Scan History for results')
setSidebarForceView(undefined)
setTimeout(() => setSidebarForceView('history'), 0)
}} }}
/> />
)} )}
@@ -718,12 +728,18 @@ export default function App() {
onClose={() => setZigbeeImportOpen(false)} onClose={() => setZigbeeImportOpen(false)}
onAddToCanvas={handleZigbeeAddToCanvas} onAddToCanvas={handleZigbeeAddToCanvas}
onPendingImported={() => { onPendingImported={() => {
setSidebarForceView(undefined) toast.success('Zigbee import started — check Scan History for results')
setTimeout(() => setSidebarForceView('history'), 0)
}} }}
/> />
)} )}
{!STANDALONE && (
<ScanHistoryModal
open={scanHistoryOpen}
onClose={() => setScanHistoryOpen(false)}
/>
)}
<GroupRectModal <GroupRectModal
open={addGroupRectOpen} open={addGroupRectOpen}
onClose={() => setAddGroupRectOpen(false)} onClose={() => setAddGroupRectOpen(false)}
@@ -804,6 +820,29 @@ export default function App() {
/> />
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} /> <ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
<ConfirmAddToGroupModal
open={!!pendingGroupAdd}
nodeLabel={pendingGroupAdd ? (nodes.find((n) => n.id === pendingGroupAdd.nodeId)?.data.label ?? '') : ''}
targetLabel={pendingGroupAdd ? (nodes.find((n) => n.id === pendingGroupAdd.groupId)?.data.label ?? '') : ''}
onConfirm={() => {
if (pendingGroupAdd) addToGroup(pendingGroupAdd.groupId, pendingGroupAdd.nodeId)
setPendingGroupAdd(null)
}}
onCancel={() => setPendingGroupAdd(null)}
/>
<ConfirmAddToGroupModal
open={!!pendingContainerAdd}
variant="container"
nodeLabel={pendingContainerAdd ? (nodes.find((n) => n.id === pendingContainerAdd.nodeId)?.data.label ?? '') : ''}
targetLabel={pendingContainerAdd ? (nodes.find((n) => n.id === pendingContainerAdd.containerId)?.data.label ?? '') : ''}
onConfirm={() => {
if (pendingContainerAdd) addToContainer(pendingContainerAdd.containerId, pendingContainerAdd.nodeId)
setPendingContainerAdd(null)
}}
onCancel={() => setPendingContainerAdd(null)}
/>
{!STANDALONE && ( {!STANDALONE && (
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} /> <SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
)} )}
+2 -2
View File
@@ -204,8 +204,8 @@ describe('api/client', () => {
it('settingsApi get/save', () => { it('settingsApi get/save', () => {
mod.settingsApi.get() mod.settingsApi.get()
expect(api.get).toHaveBeenCalledWith('/settings') expect(api.get).toHaveBeenCalledWith('/settings')
mod.settingsApi.save({ interval_seconds: 30 }) mod.settingsApi.save({ interval_seconds: 30, service_check_enabled: true, service_check_interval: 600 })
expect(api.post).toHaveBeenCalledWith('/settings', { interval_seconds: 30 }) expect(api.post).toHaveBeenCalledWith('/settings', { interval_seconds: 30, service_check_enabled: true, service_check_interval: 600 })
}) })
it('zigbeeApi.testConnection/importNetwork/importToPending', () => { it('zigbeeApi.testConnection/importNetwork/importToPending', () => {
+8 -2
View File
@@ -90,9 +90,15 @@ export const scanApi = {
saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data), saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
} }
export interface AppSettings {
interval_seconds: number
service_check_enabled: boolean
service_check_interval: number
}
export const settingsApi = { export const settingsApi = {
get: () => api.get<{ interval_seconds: number }>('/settings'), get: () => api.get<AppSettings>('/settings'),
save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data), save: (data: AppSettings) => api.post<AppSettings>('/settings', data),
} }
export const designsApi = { export const designsApi = {
@@ -30,10 +30,12 @@ interface CanvasContainerProps {
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
onNodeDoubleClick?: (node: Node<NodeData>) => void onNodeDoubleClick?: (node: Node<NodeData>) => void
onNodeDragStart?: () => void onNodeDragStart?: () => void
onRequestAddToGroup?: (payload: { nodeId: string; groupId: string }) => void
onRequestAddToContainer?: (payload: { nodeId: string; containerId: string }) => void
onOpenPending?: (deviceId: string) => void onOpenPending?: (deviceId: string) => void
} }
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onOpenPending }: CanvasContainerProps) { export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onRequestAddToGroup, onRequestAddToContainer, onOpenPending }: CanvasContainerProps) {
const [lassoMode, setLassoMode] = useState(true) const [lassoMode, setLassoMode] = useState(true)
const { const {
nodes, edges, nodes, edges,
@@ -42,7 +44,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
fitViewPending, clearFitViewPending, fitViewPending, clearFitViewPending,
copySelectedNodes, pasteNodes, copySelectedNodes, pasteNodes,
} = useCanvasStore() } = useCanvasStore()
const { fitView, screenToFlowPosition } = useReactFlow() const { fitView, screenToFlowPosition, getIntersectingNodes } = useReactFlow<Node<NodeData>>()
// Track the last cursor position over the canvas so paste lands under it. // Track the last cursor position over the canvas so paste lands under it.
const cursorRef = useRef<{ x: number; y: number } | null>(null) const cursorRef = useRef<{ x: number; y: number } | null>(null)
@@ -125,6 +127,24 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides() const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides()
// Drop a top-level node onto a group → ask App to confirm adding it. Runs
// before the alignment snap so detection uses the dropped position.
const handleNodeDragStop = useCallback<NonNullable<typeof onNodeDragStop>>((event, dragNode, dragNodes) => {
if (dragNode && !dragNode.parentId &&
dragNode.data.type !== 'group' && dragNode.data.type !== 'groupRect') {
const intersecting = getIntersectingNodes(dragNode)
const group = intersecting.find((n) => n.data.type === 'group')
if (group) {
onRequestAddToGroup?.({ nodeId: dragNode.id, groupId: group.id })
} else {
// Any node in container_mode (proxmox, docker_host, …) accepts children.
const container = intersecting.find((n) => n.id !== dragNode.id && n.data.container_mode === true)
if (container) onRequestAddToContainer?.({ nodeId: dragNode.id, containerId: container.id })
}
}
onNodeDragStop(event, dragNode, dragNodes)
}, [onRequestAddToGroup, onRequestAddToContainer, getIntersectingNodes, onNodeDragStop])
return ( return (
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }} onMouseMove={onMouseMove}> <div className="w-full h-full" style={{ background: theme.colors.canvasBackground }} onMouseMove={onMouseMove}>
<ReactFlow <ReactFlow
@@ -139,7 +159,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
onNodeDoubleClick={handleNodeDoubleClick} onNodeDoubleClick={handleNodeDoubleClick}
onNodeDragStart={onNodeDragStart} onNodeDragStart={onNodeDragStart}
onNodeDrag={onNodeDrag} onNodeDrag={onNodeDrag}
onNodeDragStop={onNodeDragStop} onNodeDragStop={handleNodeDragStop}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
edgeTypes={edgeTypes} edgeTypes={edgeTypes}
deleteKeyCode={['Backspace', 'Delete']} deleteKeyCode={['Backspace', 'Delete']}
@@ -20,7 +20,9 @@ vi.mock('@/stores/themeStore', () => ({
})) }))
vi.mock('@/stores/canvasStore', () => ({ vi.mock('@/stores/canvasStore', () => ({
useCanvasStore: (sel: (s: { hideIp: boolean }) => unknown) => sel({ hideIp: false }), useCanvasStore: (sel: (s: { hideIp: boolean; serviceStatuses: Record<string, string> }) => unknown) =>
sel({ hideIp: false, serviceStatuses: {} }),
serviceStatusKey: (nodeId: string, port?: number, protocol?: string) => `${nodeId}:${port ?? ''}/${protocol ?? ''}`,
})) }))
vi.mock('@/utils/themes', () => ({ vi.mock('@/utils/themes', () => ({
@@ -9,6 +9,9 @@ import type { NodeData, EdgeData } from '@/types'
// Capture props passed to ReactFlow so we can test the callbacks // Capture props passed to ReactFlow so we can test the callbacks
let rfProps: Record<string, unknown> = {} let rfProps: Record<string, unknown> = {}
// Hoisted holder so the mock factory can read the configurable intersection set.
const rf = vi.hoisted(() => ({ intersecting: [] as unknown[] }))
vi.mock('@xyflow/react', () => ({ vi.mock('@xyflow/react', () => ({
ReactFlow: (props: Record<string, unknown>) => { ReactFlow: (props: Record<string, unknown>) => {
rfProps = props rfProps = props
@@ -21,7 +24,13 @@ vi.mock('@xyflow/react', () => ({
ConnectionMode: { Loose: 'loose' }, ConnectionMode: { Loose: 'loose' },
SelectionMode: { Partial: 'partial' }, SelectionMode: { Partial: 'partial' },
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' }, Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
useReactFlow: () => ({ fitView: vi.fn() }), useReactFlow: () => ({
fitView: vi.fn(),
screenToFlowPosition: vi.fn(),
getIntersectingNodes: () => rf.intersecting,
setNodes: vi.fn(),
getNodes: () => [],
}),
})) }))
vi.mock('@xyflow/react/dist/style.css', () => ({})) vi.mock('@xyflow/react/dist/style.css', () => ({}))
@@ -42,6 +51,7 @@ function makeEdge(id: string): Edge<EdgeData> {
describe('CanvasContainer', () => { describe('CanvasContainer', () => {
beforeEach(() => { beforeEach(() => {
rfProps = {} rfProps = {}
rf.intersecting = []
useCanvasStore.setState({ nodes: [], edges: [], selectedNodeId: null }) useCanvasStore.setState({ nodes: [], edges: [], selectedNodeId: null })
useThemeStore.setState({ activeTheme: 'default' }) useThemeStore.setState({ activeTheme: 'default' })
}) })
@@ -154,6 +164,93 @@ describe('CanvasContainer', () => {
expect(rfProps.onNodeDragStart).toBe(onNodeDragStart) expect(rfProps.onNodeDragStart).toBe(onNodeDragStart)
}) })
// ── Drag onto group → onRequestAddToGroup ─────────────────────────────────
function groupNode(id: string): Node<NodeData> {
return { id, type: 'group', position: { x: 0, y: 0 }, data: { label: id, type: 'group', status: 'unknown', services: [] } }
}
it('fires onRequestAddToGroup when a node is dropped over a group', () => {
const onRequestAddToGroup = vi.fn()
const node = makeNode('n1')
const group = groupNode('g1')
rf.intersecting = [group]
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} />)
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
expect(onRequestAddToGroup).toHaveBeenCalledWith({ nodeId: 'n1', groupId: 'g1' })
})
it('does not fire onRequestAddToGroup when no group is under the node', () => {
const onRequestAddToGroup = vi.fn()
const node = makeNode('n1')
rf.intersecting = [makeNode('n2')]
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} />)
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
expect(onRequestAddToGroup).not.toHaveBeenCalled()
})
it('does not fire onRequestAddToGroup for an already-parented node', () => {
const onRequestAddToGroup = vi.fn()
const node = { ...makeNode('n1'), parentId: 'gOther' }
rf.intersecting = [groupNode('g1')]
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} />)
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
expect(onRequestAddToGroup).not.toHaveBeenCalled()
})
it('does not fire onRequestAddToGroup when the dragged node is itself a group', () => {
const onRequestAddToGroup = vi.fn()
const node = groupNode('g2')
rf.intersecting = [groupNode('g1')]
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} />)
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
expect(onRequestAddToGroup).not.toHaveBeenCalled()
})
// ── Drag onto container node → onRequestAddToContainer ────────────────────
function containerNode(id: string, type: NodeData['type'] = 'proxmox'): Node<NodeData> {
return { id, type, position: { x: 0, y: 0 }, data: { label: id, type, status: 'unknown', services: [], container_mode: true } }
}
it('fires onRequestAddToContainer when a node is dropped over a container_mode node', () => {
const onRequestAddToContainer = vi.fn()
const node = makeNode('n1')
rf.intersecting = [containerNode('px1')]
render(<CanvasContainer onRequestAddToContainer={onRequestAddToContainer} />)
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
expect(onRequestAddToContainer).toHaveBeenCalledWith({ nodeId: 'n1', containerId: 'px1' })
})
it('prefers a group over a container when both intersect', () => {
const onRequestAddToGroup = vi.fn()
const onRequestAddToContainer = vi.fn()
const node = makeNode('n1')
rf.intersecting = [containerNode('px1'), groupNode('g1')]
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} onRequestAddToContainer={onRequestAddToContainer} />)
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
expect(onRequestAddToGroup).toHaveBeenCalledWith({ nodeId: 'n1', groupId: 'g1' })
expect(onRequestAddToContainer).not.toHaveBeenCalled()
})
it('does not fire onRequestAddToContainer for an already-parented node', () => {
const onRequestAddToContainer = vi.fn()
const node = { ...makeNode('n1'), parentId: 'pxOther' }
rf.intersecting = [containerNode('px1')]
render(<CanvasContainer onRequestAddToContainer={onRequestAddToContainer} />)
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
expect(onRequestAddToContainer).not.toHaveBeenCalled()
})
it('does not fire onRequestAddToContainer when the target node is not in container_mode', () => {
const onRequestAddToContainer = vi.fn()
const node = makeNode('n1')
rf.intersecting = [makeNode('n2')]
render(<CanvasContainer onRequestAddToContainer={onRequestAddToContainer} />)
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
expect(onRequestAddToContainer).not.toHaveBeenCalled()
})
// ── Canvas settings ─────────────────────────────────────────────────────── // ── Canvas settings ───────────────────────────────────────────────────────
it('enables snapToGrid', () => { it('enables snapToGrid', () => {
@@ -0,0 +1,70 @@
import { describe, it, expect, vi } from 'vitest'
import { render } from '@testing-library/react'
import { ReactFlowProvider } from '@xyflow/react'
import type { EdgeProps, Edge } from '@xyflow/react'
import type { EdgeData } from '@/types'
/**
* Issue #183 — connection labels must support multiple lines.
*
* The label is a free-text string; newlines entered in the EdgeModal textarea
* are stored verbatim. The rendered label div must preserve those newlines
* (`whitespace-pre-line`) instead of collapsing them into a single line.
*
* <EdgeLabelRenderer> normally portals into a node that only exists inside a
* full <ReactFlow> host, so we stub it to a passthrough to render the label
* markup directly.
*/
vi.mock('@xyflow/react', async (importOriginal) => {
const actual = await importOriginal<typeof import('@xyflow/react')>()
return {
...actual,
EdgeLabelRenderer: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}
})
const { HomelableEdge } = await import('../index')
function renderEdge(data: Partial<EdgeData> = {}) {
const props = {
id: 'e1',
source: 'a',
target: 'b',
sourceX: 0,
sourceY: 0,
targetX: 100,
targetY: 100,
sourcePosition: 'bottom',
targetPosition: 'top',
data: { type: 'ethernet', ...data } as EdgeData,
selected: false,
} as unknown as EdgeProps<Edge<EdgeData>>
return render(
<ReactFlowProvider>
<svg>
<HomelableEdge {...props} />
</svg>
</ReactFlowProvider>,
)
}
describe('HomelableEdge label', () => {
it('renders the label text', () => {
const { getByText } = renderEdge({ label: 'uplink' })
expect(getByText('uplink')).toBeTruthy()
})
it('preserves newlines in the rendered label (issue #183)', () => {
const { container } = renderEdge({ label: 'line one\nline two' })
const label = Array.from(container.querySelectorAll('div.whitespace-pre-line')).find((d) =>
d.textContent === 'line one\nline two',
)
expect(label).toBeTruthy()
})
it('renders no label div when label is empty', () => {
const { container } = renderEdge({ label: undefined })
expect(container.querySelector('div.whitespace-pre-line')).toBeNull()
})
})
@@ -406,7 +406,7 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
<EdgeLabelRenderer> <EdgeLabelRenderer>
{data?.label && ( {data?.label && (
<div <div
className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded" className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded whitespace-pre-line text-center"
style={{ style={{
transform: `translate(-50%, -50%) translate(${labelPosition.x}px, ${labelPosition.y}px)`, transform: `translate(-50%, -50%) translate(${labelPosition.x}px, ${labelPosition.y}px)`,
background: theme.colors.edgeLabelBackground, background: theme.colors.edgeLabelBackground,
@@ -8,7 +8,7 @@ import { NodeIcon } from '@/components/ui/NodeIcon'
import { resolvePropertyIcon } from '@/utils/propertyIcons' import { resolvePropertyIcon } from '@/utils/propertyIcons'
import { useThemeStore } from '@/stores/themeStore' import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes' import { THEMES } from '@/utils/themes'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore, serviceStatusKey } from '@/stores/canvasStore'
import { maskIp, primaryIp, splitIps } from '@/utils/maskIp' import { maskIp, primaryIp, splitIps } from '@/utils/maskIp'
import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils' import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils'
import { getServiceUrl } from '@/utils/serviceUrl' import { getServiceUrl } from '@/utils/serviceUrl'
@@ -31,6 +31,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
const activeTheme = useThemeStore((s) => s.activeTheme) const activeTheme = useThemeStore((s) => s.activeTheme)
const hideIp = useCanvasStore((s) => s.hideIp) const hideIp = useCanvasStore((s) => s.hideIp)
const serviceStatuses = useCanvasStore((s) => s.serviceStatuses)
const theme = THEMES[activeTheme] const theme = THEMES[activeTheme]
const resolvedIcon = resolveNodeIcon(typeIcon, data.custom_icon) const resolvedIcon = resolveNodeIcon(typeIcon, data.custom_icon)
@@ -151,6 +152,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
<div className="flex flex-col gap-1 px-2.5 py-1.5 overflow-hidden"> <div className="flex flex-col gap-1 px-2.5 py-1.5 overflow-hidden">
{services.map((svc, idx) => { {services.map((svc, idx) => {
const url = getServiceUrl(svc, serviceHost) const url = getServiceUrl(svc, serviceHost)
const svcOffline = serviceStatuses[serviceStatusKey(id, svc.port, svc.protocol)] === 'offline'
const row = ( const row = (
<div <div
className="nodrag flex items-center justify-between gap-2 px-1.5 py-1 rounded text-[10px] min-w-0 overflow-hidden" className="nodrag flex items-center justify-between gap-2 px-1.5 py-1 rounded text-[10px] min-w-0 overflow-hidden"
@@ -164,7 +166,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
{/* LEFT: service name */} {/* LEFT: service name */}
<span <span
className="font-medium truncate" className="font-medium truncate"
style={{ minWidth: 0 }} style={{ minWidth: 0, color: svcOffline ? '#f85149' : undefined }}
title={svc.service_name} title={svc.service_name}
> >
{svc.service_name} {svc.service_name}
@@ -0,0 +1,59 @@
import { Layers } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
interface ConfirmAddToGroupModalProps {
open: boolean
nodeLabel: string
/** Label of the destination group/container. */
targetLabel: string
/** Destination kind — drives the wording. Defaults to 'group'. */
variant?: 'group' | 'container'
onConfirm: () => void
onCancel: () => void
}
export function ConfirmAddToGroupModal({
open,
nodeLabel,
targetLabel,
variant = 'group',
onConfirm,
onCancel,
}: ConfirmAddToGroupModalProps) {
const action = variant === 'container' ? 'Add to container' : 'Add to group'
const noun = variant === 'container' ? 'container' : 'group'
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onCancel() }}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Layers size={16} className="text-[#00d4ff]" />
{action}
</DialogTitle>
<DialogDescription>
Add <span className="font-medium text-foreground">{nodeLabel}</span> to the {noun}{' '}
<span className="font-medium text-foreground">{targetLabel}</span>?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={onCancel}>Cancel</Button>
<Button
size="sm"
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
onClick={onConfirm}
>
{action}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+7 -5
View File
@@ -4,6 +4,7 @@ import { RotateCcw } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { EDGE_TYPE_LABELS, type EdgeData, type EdgePathStyle, type EdgeType } from '@/types' import { EDGE_TYPE_LABELS, type EdgeData, type EdgePathStyle, type EdgeType } from '@/types'
@@ -44,7 +45,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
e.preventDefault() e.preventDefault()
onSubmit({ onSubmit({
type, type,
label: label || undefined, label: label.trim() || undefined,
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined, vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
custom_color: customColor, custom_color: customColor,
path_style: pathStyle, path_style: pathStyle,
@@ -96,12 +97,13 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
)} )}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Label <span className="text-muted-foreground/50">(optional)</span></Label> <Label className="text-xs text-muted-foreground">Label <span className="text-muted-foreground/50">(optional, multi-line)</span></Label>
<Input <Textarea
value={label} value={label}
onChange={(e) => setLabel(e.target.value)} onChange={(e) => setLabel(e.target.value)}
placeholder="e.g. 1G, trunk..." placeholder={'e.g. 1G, trunk...\nsecond line'}
className={`bg-[#21262d] border-[#30363d] text-sm h-8 ${modalStyles['modal-radius']}`} rows={2}
className={`bg-[#21262d] border-[#30363d] text-sm min-h-16 resize-none ${modalStyles['modal-radius']}`}
/> />
</div> </div>
+19 -7
View File
@@ -55,6 +55,8 @@ interface ParentCandidate {
id: string id: string
label: string label: string
type: NodeType type: NodeType
/** True when the node has container mode on, so any node can nest inside it. */
container_mode?: boolean
} }
interface NodeModalProps { interface NodeModalProps {
@@ -98,12 +100,14 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
const selectedType = (form.type ?? 'generic') as NodeType const selectedType = (form.type ?? 'generic') as NodeType
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType) const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
const validParentTypes = getValidParentTypes(selectedType) const validParentTypes = getValidParentTypes(selectedType)
// A parent is valid either by the type rules (lxc/vm/docker_container) or
// because the candidate is a container-mode node (any child can nest in it).
const isValidParent = (p: ParentCandidate) =>
validParentTypes.includes(p.type) || p.container_mode === true
let safeParentId = form.parent_id let safeParentId = form.parent_id
if (validParentTypes.length === 0) { if (safeParentId) {
safeParentId = undefined
} else if (safeParentId) {
const parent = parentCandidates.find((n) => n.id === safeParentId) const parent = parentCandidates.find((n) => n.id === safeParentId)
if (!parent || !validParentTypes.includes(parent.type)) safeParentId = undefined if (!parent || !isValidParent(parent)) safeParentId = undefined
} }
onSubmit({ onSubmit({
...form, ...form,
@@ -130,7 +134,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
setForm((f) => { setForm((f) => {
const next: Partial<NodeData> = { ...f, type: t } const next: Partial<NodeData> = { ...f, type: t }
if (ZIGBEE_TYPES.includes(t)) next.check_method = 'none' as CheckMethod if (ZIGBEE_TYPES.includes(t)) next.check_method = 'none' as CheckMethod
if (getValidParentTypes(t).length === 0) next.parent_id = undefined // Drop the parent only if it's no longer a valid target for the
// new type — keep container-mode parents (any node can nest).
const parent = parentCandidates.find((n) => n.id === f.parent_id)
if (f.parent_id && !(parent && (getValidParentTypes(t).includes(parent.type) || parent.container_mode === true))) {
next.parent_id = undefined
}
return next return next
}) })
}}> }}>
@@ -349,9 +358,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
{(() => { {(() => {
const childType = (form.type ?? 'generic') as NodeType const childType = (form.type ?? 'generic') as NodeType
const validParentTypes = getValidParentTypes(childType) const validParentTypes = getValidParentTypes(childType)
if (validParentTypes.length === 0) return null // Candidates: type-based parents (lxc/vm/docker_container) plus any
// container-mode node. The current parent is always kept so an
// already-nested node can be re-targeted or detached here.
const validParents = parentCandidates.filter( const validParents = parentCandidates.filter(
(n) => n.id !== currentNodeId && validParentTypes.includes(n.type), (n) => n.id !== currentNodeId &&
(validParentTypes.includes(n.type) || n.container_mode === true || n.id === form.parent_id),
) )
if (validParents.length === 0) return null if (validParents.length === 0) return null
return ( return (
@@ -0,0 +1,311 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { RefreshCw, X, Loader2, StopCircle, Clock, ScanLine, Network, Inbox } from 'lucide-react'
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { scanApi } from '@/api/client'
import { useCanvasStore } from '@/stores/canvasStore'
import { toast } from 'sonner'
export interface ScanRun {
id: string
status: string
kind?: string
ranges: string[]
devices_found: number
started_at: string
finished_at: string | null
error: string | null
}
interface ScanHistoryModalProps {
open: boolean
onClose: () => void
}
type KindFilter = 'all' | 'ip' | 'zigbee'
type StatusFilter = 'all' | 'running' | 'done' | 'error' | 'cancelled'
const STATUS_FILTERS: { key: StatusFilter; label: string }[] = [
{ key: 'all', label: 'All' },
{ key: 'running', label: 'Running' },
{ key: 'done', label: 'Done' },
{ key: 'error', label: 'Error' },
{ key: 'cancelled', label: 'Cancelled' },
]
const KIND_FILTERS: { key: KindFilter; label: string }[] = [
{ key: 'all', label: 'All' },
{ key: 'ip', label: 'IP' },
{ key: 'zigbee', label: 'Zigbee' },
]
function statusColor(s: string): string {
return s === 'done' ? '#39d353'
: s === 'running' ? '#e3b341'
: s === 'error' ? '#f85149'
: s === 'cancelled' ? '#8b949e'
: '#8b949e'
}
function parseUtc(ts: string): number {
return new Date(ts.endsWith('Z') ? ts : ts + 'Z').getTime()
}
function formatDuration(ms: number): string {
if (ms < 0) ms = 0
const s = Math.floor(ms / 1000)
if (s < 60) return `${s}s`
const m = Math.floor(s / 60)
const rem = s % 60
if (m < 60) return rem ? `${m}m ${rem}s` : `${m}m`
const h = Math.floor(m / 60)
return `${h}h ${m % 60}m`
}
function runDuration(r: ScanRun, now: number): string {
const start = parseUtc(r.started_at)
const end = r.finished_at ? parseUtc(r.finished_at) : now
return formatDuration(end - start)
}
export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
const [runs, setRuns] = useState<ScanRun[]>([])
const [loading, setLoading] = useState(false)
const [stopping, setStopping] = useState<string | null>(null)
const [kindFilter, setKindFilter] = useState<KindFilter>('all')
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const [now, setNow] = useState(() => Date.now())
const prevRunsRef = useRef<ScanRun[]>([])
const load = useCallback(async () => {
setLoading(true)
try {
const res = await scanApi.runs()
const next: ScanRun[] = res.data
// Surface transitions and refresh dependent UI
for (const run of next) {
const prev = prevRunsRef.current.find((r) => r.id === run.id)
if (prev?.status === 'running' && run.status === 'error') {
toast.error(`Scan failed: ${run.error ?? 'unknown error'}`)
}
if (prev?.status === 'running' && run.status === 'done') {
if (run.kind === 'zigbee') {
toast.success(`Zigbee import done — ${run.devices_found} device${run.devices_found !== 1 ? 's' : ''}`)
}
useCanvasStore.getState().notifyScanDeviceFound()
}
}
prevRunsRef.current = next
setRuns(next)
} catch {
toast.error('Failed to load scan history')
} finally {
setLoading(false)
}
}, [])
// Load when opened; reset prior-state tracker so we don't replay old transitions
useEffect(() => {
if (!open) return
prevRunsRef.current = []
load()
}, [open, load])
// Auto-refresh every 3s while any run is still running (only when open)
useEffect(() => {
if (!open) return
const hasRunning = runs.some((r) => r.status === 'running')
if (!hasRunning) return
const id = setInterval(load, 3000)
return () => clearInterval(id)
}, [open, runs, load])
// Tick the clock every second while a scan is running (for live elapsed duration)
useEffect(() => {
if (!open) return
const hasRunning = runs.some((r) => r.status === 'running')
if (!hasRunning) return
const id = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(id)
}, [open, runs])
const handleStop = async (runId: string) => {
setStopping(runId)
try {
await scanApi.stop(runId)
toast.success('Scan stop requested')
} catch {
toast.error('Failed to stop scan')
} finally {
setStopping(null)
}
}
const filtered = runs.filter((r) => {
const k = r.kind === 'zigbee' ? 'zigbee' : 'ip'
if (kindFilter !== 'all' && k !== kindFilter) return false
if (statusFilter !== 'all' && r.status !== statusFilter) return false
return true
})
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<DialogContent
showCloseButton={false}
className="!max-w-none w-[90vw] max-w-2xl h-[80vh] p-0 flex flex-col gap-0 bg-[#0d1117] border-border"
>
<DialogHeader className="px-4 py-3 border-b border-border shrink-0">
<div className="flex items-center justify-between gap-3">
<DialogTitle className="text-base font-semibold flex items-center gap-2">
<Clock size={16} className="text-[#00d4ff]" />
Scan History
<span className="text-muted-foreground font-normal text-xs">
({filtered.length}{filtered.length !== runs.length && ` of ${runs.length}`})
</span>
</DialogTitle>
<div className="flex items-center gap-1">
<button onClick={load} className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors" title="Refresh">
<RefreshCw size={14} className={loading ? 'animate-spin' : undefined} />
</button>
<DialogClose
render={
<button
className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors"
aria-label="Close"
/>
}
>
<X size={14} />
</DialogClose>
</div>
</div>
</DialogHeader>
{/* Filters */}
<div className="px-4 py-2 border-b border-border bg-[#161b22] shrink-0 flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Type</span>
{KIND_FILTERS.map((f) => (
<FilterChip key={f.key} active={kindFilter === f.key} onClick={() => setKindFilter(f.key)}>
{f.label}
</FilterChip>
))}
</div>
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Status</span>
{STATUS_FILTERS.map((f) => (
<FilterChip key={f.key} active={statusFilter === f.key} onClick={() => setStatusFilter(f.key)}>
{f.label}
</FilterChip>
))}
</div>
</div>
{/* List */}
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-2">
{loading && runs.length === 0 && (
<div className="flex justify-center py-10">
<Loader2 size={18} className="animate-spin text-muted-foreground" />
</div>
)}
{!loading && filtered.length === 0 && (
<div className="flex flex-col items-center gap-2 py-12 text-muted-foreground">
<Inbox size={28} className="opacity-50" />
<p className="text-sm">{runs.length === 0 ? 'No scans yet' : 'No scans match the filters'}</p>
</div>
)}
{filtered.map((r) => {
const isZigbee = r.kind === 'zigbee'
return (
<div key={r.id} className="rounded-lg border border-border bg-[#161b22] p-3">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: statusColor(r.status) }} />
<span className="font-mono text-sm text-foreground capitalize">{r.status}</span>
{r.status === 'running' && <Loader2 size={12} className="animate-spin text-[#e3b341]" />}
<span
className="inline-flex items-center gap-1 text-[10px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
style={isZigbee
? { background: '#00d4ff22', color: '#00d4ff' }
: { background: '#a855f722', color: '#a855f7' }}
>
{isZigbee ? <Network size={10} /> : <ScanLine size={10} />}
{isZigbee ? 'Zigbee' : 'IP'}
</span>
<span className="ml-auto text-xs text-muted-foreground font-mono">
{r.devices_found} found
</span>
{r.status === 'running' && (
<Tooltip>
<TooltipTrigger>
<button
aria-label="Stop scan"
onClick={() => handleStop(r.id)}
disabled={stopping === r.id}
className="p-1 text-[#f85149] hover:bg-[#f85149]/10 rounded transition-colors disabled:opacity-50"
>
{stopping === r.id
? <Loader2 size={13} className="animate-spin" />
: <StopCircle size={13} />}
</button>
</TooltipTrigger>
<TooltipContent side="left">Stop scan</TooltipContent>
</Tooltip>
)}
</div>
{/* Meta grid */}
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-[11px]">
<Meta label="Started" value={new Date(parseUtc(r.started_at)).toLocaleString()} />
<Meta
label="Finished"
value={r.finished_at ? new Date(parseUtc(r.finished_at)).toLocaleString() : '—'}
/>
<Meta label="Duration" value={runDuration(r, now)} mono />
<Meta label="Devices" value={`${r.devices_found}`} mono />
</div>
{r.ranges.length > 0 && (
<div className="mt-2 text-[11px]">
<span className="text-muted-foreground">Ranges: </span>
<span className="text-[#8b949e] font-mono break-all">{r.ranges.join(', ')}</span>
</div>
)}
{r.error && (
<div className="mt-2 text-[11px] text-[#f85149] leading-tight whitespace-pre-wrap break-words rounded bg-[#f85149]/10 px-2 py-1.5">
{r.error}
</div>
)}
</div>
)
})}
</div>
</DialogContent>
</Dialog>
)
}
function FilterChip({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
return (
<button
onClick={onClick}
className={`text-[11px] px-2 py-0.5 rounded-full border transition-colors cursor-pointer ${
active
? 'bg-[#00d4ff]/10 border-[#00d4ff]/40 text-[#00d4ff]'
: 'border-border text-muted-foreground hover:text-foreground hover:border-[#30363d]'
}`}
>
{children}
</button>
)
}
function Meta({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex flex-col">
<span className="text-muted-foreground text-[10px] uppercase tracking-wider">{label}</span>
<span className={`text-foreground ${mono ? 'font-mono' : ''}`}>{value}</span>
</div>
)
}
@@ -20,6 +20,8 @@ interface SettingsModalProps {
export function SettingsModal({ open, onClose }: SettingsModalProps) { export function SettingsModal({ open, onClose }: SettingsModalProps) {
const [interval, setIntervalValue] = useState(60) const [interval, setIntervalValue] = useState(60)
const [serviceCheckEnabled, setServiceCheckEnabled] = useState(false)
const [serviceInterval, setServiceInterval] = useState(300)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings) const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
const hideIp = useCanvasStore((s) => s.hideIp) const hideIp = useCanvasStore((s) => s.hideIp)
@@ -28,7 +30,11 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
useEffect(() => { useEffect(() => {
if (!open || STANDALONE) return if (!open || STANDALONE) return
settingsApi.get() settingsApi.get()
.then((res) => setIntervalValue(res.data.interval_seconds)) .then((res) => {
setIntervalValue(res.data.interval_seconds)
setServiceCheckEnabled(res.data.service_check_enabled)
setServiceInterval(res.data.service_check_interval)
})
.catch(() => {/* use default */}) .catch(() => {/* use default */})
}, [open]) }, [open])
@@ -49,7 +55,11 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
} }
setSaving(true) setSaving(true)
try { try {
await settingsApi.save({ interval_seconds: interval }) await settingsApi.save({
interval_seconds: interval,
service_check_enabled: serviceCheckEnabled,
service_check_interval: serviceInterval,
})
toast.success('Settings saved') toast.success('Settings saved')
onClose() onClose()
} catch { } catch {
@@ -85,6 +95,36 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
<p className="text-[10px] text-muted-foreground leading-tight"> <p className="text-[10px] text-muted-foreground leading-tight">
How often node health is polled (ping, HTTP, SSH) How often node health is polled (ping, HTTP, SSH)
</p> </p>
<label className="flex items-center justify-between gap-2 cursor-pointer pt-2">
<span className="text-xs text-foreground">Check services individually</span>
<input
type="checkbox"
checked={serviceCheckEnabled}
onChange={(e) => setServiceCheckEnabled(e.target.checked)}
className="cursor-pointer accent-[#00d4ff]"
aria-label="Toggle per-service status checks"
/>
</label>
<div className={serviceCheckEnabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
<label className="text-xs text-muted-foreground">Service check interval (s)</label>
<div className="flex items-center gap-2">
<input
type="number"
min={30}
max={3600}
value={serviceInterval}
onChange={(e) => { const v = Number(e.target.value); if (!isNaN(v)) setServiceInterval(v) }}
className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]"
aria-label="Service check interval"
/>
<span className="text-xs text-muted-foreground">seconds</span>
</div>
<p className="text-[10px] text-muted-foreground leading-tight">
Probes each service port. Offline services turn red. Default 300s (5 min).
</p>
</div>
</div> </div>
)} )}
@@ -0,0 +1,46 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { ConfirmAddToGroupModal } from '../ConfirmAddToGroupModal'
describe('ConfirmAddToGroupModal', () => {
it('renders nothing when closed', () => {
render(
<ConfirmAddToGroupModal open={false} nodeLabel="Router" targetLabel="DMZ" onConfirm={vi.fn()} onCancel={vi.fn()} />,
)
expect(screen.queryByText('Add to group')).toBeNull()
})
it('shows node and group labels when open', () => {
render(
<ConfirmAddToGroupModal open nodeLabel="Router" targetLabel="DMZ" onConfirm={vi.fn()} onCancel={vi.fn()} />,
)
expect(screen.getByText('Router')).toBeDefined()
expect(screen.getByText('DMZ')).toBeDefined()
})
it('calls onConfirm when the confirm button is clicked', () => {
const onConfirm = vi.fn()
render(
<ConfirmAddToGroupModal open nodeLabel="Router" targetLabel="DMZ" onConfirm={onConfirm} onCancel={vi.fn()} />,
)
fireEvent.click(screen.getByRole('button', { name: /add to group/i }))
expect(onConfirm).toHaveBeenCalledOnce()
})
it('calls onCancel when the cancel button is clicked', () => {
const onCancel = vi.fn()
render(
<ConfirmAddToGroupModal open nodeLabel="Router" targetLabel="DMZ" onConfirm={vi.fn()} onCancel={onCancel} />,
)
fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
expect(onCancel).toHaveBeenCalledOnce()
})
it('uses container wording when variant is container', () => {
render(
<ConfirmAddToGroupModal open variant="container" nodeLabel="VM" targetLabel="Proxmox" onConfirm={vi.fn()} onCancel={vi.fn()} />,
)
expect(screen.getByRole('button', { name: /add to container/i })).toBeDefined()
expect(screen.queryByText('Add to group')).toBeNull()
})
})
@@ -47,11 +47,32 @@ describe('EdgeModal', () => {
it('calls onSubmit with label when filled', () => { it('calls onSubmit with label when filled', () => {
const onSubmit = vi.fn() const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />) render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.change(screen.getByPlaceholderText('e.g. 1G, trunk...'), { target: { value: 'uplink' } }) fireEvent.change(screen.getByPlaceholderText(/e\.g\. 1G, trunk/), { target: { value: 'uplink' } })
fireEvent.click(screen.getByRole('button', { name: 'Connect' })) fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].label).toBe('uplink') expect(onSubmit.mock.calls[0][0].label).toBe('uplink')
}) })
it('preserves newlines in label so it can span multiple lines (issue #183)', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.change(screen.getByPlaceholderText(/e\.g\. 1G, trunk/), { target: { value: 'line one\nline two' } })
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].label).toBe('line one\nline two')
})
it('trims surrounding whitespace/blank lines from label on submit', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.change(screen.getByPlaceholderText(/e\.g\. 1G, trunk/), { target: { value: ' a\nb\n\n' } })
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].label).toBe('a\nb')
})
it('renders the label field as a textarea (multi-line input)', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByPlaceholderText(/e\.g\. 1G, trunk/).tagName).toBe('TEXTAREA')
})
it('omits label from payload when empty', () => { it('omits label from payload when empty', () => {
const onSubmit = vi.fn() const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />) render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
@@ -171,7 +192,7 @@ describe('EdgeModal', () => {
it('pre-fills label from initial prop', () => { it('pre-fills label from initial prop', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ label: 'trunk' }} />) render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ label: 'trunk' }} />)
const input = screen.getByPlaceholderText('e.g. 1G, trunk...') as HTMLInputElement const input = screen.getByPlaceholderText(/e\.g\. 1G, trunk/) as HTMLTextAreaElement
expect(input.value).toBe('trunk') expect(input.value).toBe('trunk')
}) })
@@ -353,6 +353,48 @@ describe('NodeModal', () => {
expect(screen.getByText('Parent Container')).toBeDefined() expect(screen.getByText('Parent Container')).toBeDefined()
}) })
it('renders Parent Container for a plain node when a container-mode candidate exists', () => {
renderModal({
initial: { ...BASE, type: 'server' },
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: true }],
})
expect(screen.getByText('Parent Container')).toBeDefined()
})
it('still hides Parent Container for a plain node when the candidate is not in container mode', () => {
renderModal({
initial: { ...BASE, type: 'server' },
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: false }],
})
expect(screen.queryByText('Parent Container')).toBeNull()
})
it('renders Parent Container for an already-nested plain node so it can be detached', () => {
renderModal({
initial: { ...BASE, type: 'server', parent_id: 'px1' },
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: true }],
})
expect(screen.getByText('Parent Container')).toBeDefined()
})
it('keeps a container-mode parent_id on submit for a plain node', () => {
const { onSubmit } = renderModal({
initial: { ...BASE, type: 'server', parent_id: 'px1' },
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: true }],
})
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).parent_id).toBe('px1')
})
it('drops a parent_id that is not a valid container on submit', () => {
const { onSubmit } = renderModal({
initial: { ...BASE, type: 'server', parent_id: 'px1' },
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: false }],
})
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).parent_id).toBeUndefined()
})
// ── Appearance ──────────────────────────────────────────────────────── // ── Appearance ────────────────────────────────────────────────────────
it('renders 3 color swatch labels (border, background, icon)', () => { it('renders 3 color swatch labels (border, background, icon)', () => {
@@ -1,21 +1,17 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { Sidebar } from '../Sidebar' import { ScanHistoryModal } from '../ScanHistoryModal'
import * as canvasStore from '@/stores/canvasStore'
import { TooltipProvider } from '@/components/ui/tooltip' import { TooltipProvider } from '@/components/ui/tooltip'
vi.mock('@/stores/canvasStore')
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('@/stores/canvasStore', () => ({
useCanvasStore: { getState: () => ({ notifyScanDeviceFound: vi.fn() }) },
}))
vi.mock('@/api/client', () => ({ vi.mock('@/api/client', () => ({
scanApi: { scanApi: {
trigger: vi.fn(),
pending: vi.fn().mockResolvedValue({ data: [] }),
hidden: vi.fn().mockResolvedValue({ data: [] }),
runs: vi.fn().mockResolvedValue({ data: [] }), runs: vi.fn().mockResolvedValue({ data: [] }),
stop: vi.fn(), stop: vi.fn(),
getConfig: vi.fn().mockResolvedValue({ data: { ranges: [] } }),
}, },
settingsApi: { get: vi.fn(), save: vi.fn() },
})) }))
import { scanApi } from '@/api/client' import { scanApi } from '@/api/client'
@@ -24,9 +20,10 @@ import { toast } from 'sonner'
const RUNNING_RUN = { const RUNNING_RUN = {
id: 'run-1', id: 'run-1',
status: 'running', status: 'running',
kind: 'ip',
ranges: ['192.168.1.0/24'], ranges: ['192.168.1.0/24'],
devices_found: 2, devices_found: 2,
started_at: new Date().toISOString(), started_at: new Date(Date.now() - 5000).toISOString(),
finished_at: null, finished_at: null,
error: null, error: null,
} }
@@ -34,16 +31,18 @@ const RUNNING_RUN = {
const DONE_RUN = { const DONE_RUN = {
id: 'run-2', id: 'run-2',
status: 'done', status: 'done',
kind: 'ip',
ranges: ['192.168.1.0/24'], ranges: ['192.168.1.0/24'],
devices_found: 3, devices_found: 3,
started_at: new Date().toISOString(), started_at: new Date(Date.now() - 60000).toISOString(),
finished_at: new Date().toISOString(), finished_at: new Date(Date.now() - 30000).toISOString(),
error: null, error: null,
} }
const CANCELLED_RUN = { const CANCELLED_RUN = {
id: 'run-3', id: 'run-3',
status: 'cancelled', status: 'cancelled',
kind: 'ip',
ranges: ['192.168.1.0/24'], ranges: ['192.168.1.0/24'],
devices_found: 1, devices_found: 1,
started_at: new Date().toISOString(), started_at: new Date().toISOString(),
@@ -51,36 +50,26 @@ const CANCELLED_RUN = {
error: null, error: null,
} }
function renderSidebar() { const ZIGBEE_RUN = {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ id: 'run-4',
nodes: [], status: 'done',
hasUnsavedChanges: false, kind: 'zigbee',
hideIp: false, ranges: [],
toggleHideIp: vi.fn(), devices_found: 7,
addNode: vi.fn(), started_at: new Date().toISOString(),
scanEventTs: 0, finished_at: new Date().toISOString(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>) error: null,
}
function renderModal() {
return render( return render(
<TooltipProvider> <TooltipProvider>
<Sidebar <ScanHistoryModal open onClose={vi.fn()} />
onAddNode={vi.fn()}
onAddGroupRect={vi.fn()}
onScan={vi.fn()}
onSave={vi.fn()}
onNodeApproved={vi.fn()}
/>
</TooltipProvider> </TooltipProvider>
) )
} }
async function openHistory() { describe('ScanHistoryModal', () => {
fireEvent.click(screen.getByRole('button', { name: 'Scan History' }))
// Wait for runs to load
await waitFor(() => expect(scanApi.runs).toHaveBeenCalled())
}
describe('ScanHistoryPanel — stop scan', () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(toast.success).mockReset() vi.mocked(toast.success).mockReset()
vi.mocked(toast.error).mockReset() vi.mocked(toast.error).mockReset()
@@ -88,68 +77,82 @@ describe('ScanHistoryPanel — stop scan', () => {
vi.mocked(scanApi.runs).mockResolvedValue({ data: [] } as never) vi.mocked(scanApi.runs).mockResolvedValue({ data: [] } as never)
}) })
it('shows stop button only for running scans', async () => { it('loads runs when opened', async () => {
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN, DONE_RUN] } as never) vi.mocked(scanApi.runs).mockResolvedValue({ data: [DONE_RUN] } as never)
renderSidebar() renderModal()
await openHistory() await waitFor(() => expect(scanApi.runs).toHaveBeenCalled())
expect(await screen.findByText('done')).toBeDefined()
await waitFor(() => expect(screen.getByText('running')).toBeDefined())
// Exactly one stop button rendered (for the running scan only)
const stopButtons = screen.getAllByRole('button', { name: 'Stop scan' })
expect(stopButtons).toHaveLength(1)
}) })
it('calls scanApi.stop with the correct run ID on click', async () => { it('shows empty state when no scans', async () => {
renderModal()
expect(await screen.findByText('No scans yet')).toBeDefined()
})
it('shows stop button only for running scans', async () => {
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN, DONE_RUN] } as never)
renderModal()
await waitFor(() => expect(screen.getByText('running')).toBeDefined())
expect(screen.getAllByRole('button', { name: 'Stop scan' })).toHaveLength(1)
})
it('calls scanApi.stop with the correct run ID', async () => {
vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never) vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never)
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never) vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
renderSidebar() renderModal()
await openHistory()
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' }) const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
fireEvent.click(stopBtn) fireEvent.click(stopBtn)
await waitFor(() => expect(scanApi.stop).toHaveBeenCalledWith('run-1'))
await waitFor(() => {
expect(scanApi.stop).toHaveBeenCalledWith('run-1')
})
}) })
it('shows success toast when stop succeeds', async () => { it('shows success toast when stop succeeds', async () => {
vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never) vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never)
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never) vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
renderSidebar() renderModal()
await openHistory()
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' }) const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
fireEvent.click(stopBtn) fireEvent.click(stopBtn)
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Scan stop requested'))
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith('Scan stop requested')
})
}) })
it('shows error toast when stop fails', async () => { it('shows error toast when stop fails', async () => {
vi.mocked(scanApi.stop).mockRejectedValue(new Error('network')) vi.mocked(scanApi.stop).mockRejectedValue(new Error('network'))
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never) vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
renderSidebar() renderModal()
await openHistory()
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' }) const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
fireEvent.click(stopBtn) fireEvent.click(stopBtn)
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Failed to stop scan'))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Failed to stop scan')
})
}) })
it('renders cancelled status without stop button or spinner', async () => { it('renders cancelled status without a stop button', async () => {
vi.mocked(scanApi.runs).mockResolvedValue({ data: [CANCELLED_RUN] } as never) vi.mocked(scanApi.runs).mockResolvedValue({ data: [CANCELLED_RUN] } as never)
renderSidebar() renderModal()
await openHistory()
await waitFor(() => expect(screen.getByText('cancelled')).toBeDefined()) await waitFor(() => expect(screen.getByText('cancelled')).toBeDefined())
// No stop button
expect(screen.queryByRole('button', { name: 'Stop scan' })).toBeNull() expect(screen.queryByRole('button', { name: 'Stop scan' })).toBeNull()
}) })
it('shows duration for a finished run', async () => {
vi.mocked(scanApi.runs).mockResolvedValue({ data: [DONE_RUN] } as never)
renderModal()
// DONE_RUN ran 30s
expect(await screen.findByText('30s')).toBeDefined()
})
it('filters by status', async () => {
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN, DONE_RUN] } as never)
renderModal()
await waitFor(() => expect(screen.getByText('done')).toBeDefined())
fireEvent.click(screen.getByRole('button', { name: 'Running' }))
expect(screen.queryByText('done')).toBeNull()
expect(screen.getByText('running')).toBeDefined()
})
it('filters by kind', async () => {
vi.mocked(scanApi.runs).mockResolvedValue({ data: [DONE_RUN, ZIGBEE_RUN] } as never)
renderModal()
await waitFor(() => expect(screen.getAllByText('done').length).toBe(2))
fireEvent.click(screen.getByRole('button', { name: 'Zigbee' }))
// Only the zigbee run (7 found) remains
expect(screen.getByText('7 found')).toBeDefined()
expect(screen.queryByText('3 found')).toBeNull()
})
}) })
@@ -17,8 +17,8 @@ import { useCanvasStore } from '@/stores/canvasStore'
describe('SettingsModal', () => { describe('SettingsModal', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60 } } as never) vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60, service_check_enabled: false, service_check_interval: 300 } } as never)
vi.mocked(settingsApi.save).mockResolvedValue({ data: { interval_seconds: 60 } } as never) vi.mocked(settingsApi.save).mockResolvedValue({ data: { interval_seconds: 60, service_check_enabled: false, service_check_interval: 300 } } as never)
vi.mocked(toast.success).mockReset() vi.mocked(toast.success).mockReset()
vi.mocked(toast.error).mockReset() vi.mocked(toast.error).mockReset()
}) })
@@ -47,7 +47,7 @@ describe('SettingsModal', () => {
fireEvent.change(input, { target: { value: '180' } }) fireEvent.change(input, { target: { value: '180' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' })) fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => { await waitFor(() => {
expect(settingsApi.save).toHaveBeenCalledWith({ interval_seconds: 180 }) expect(settingsApi.save).toHaveBeenCalledWith({ interval_seconds: 180, service_check_enabled: false, service_check_interval: 300 })
expect(toast.success).toHaveBeenCalledWith('Settings saved') expect(toast.success).toHaveBeenCalledWith('Settings saved')
expect(onClose).toHaveBeenCalled() expect(onClose).toHaveBeenCalled()
}) })
@@ -76,6 +76,22 @@ describe('SettingsModal', () => {
expect(localStorage.getItem('homelable.hideIp')).toBe('true') expect(localStorage.getItem('homelable.hideIp')).toBe('true')
}) })
it('loads and toggles the per-service check setting, saving its interval', async () => {
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60, service_check_enabled: true, service_check_interval: 600 } } as never)
render(<SettingsModal open onClose={vi.fn()} />)
const toggle = await screen.findByLabelText('Toggle per-service status checks') as HTMLInputElement
// The toggle label renders immediately; its checked state only flips once
// settingsApi.get() resolves, so wait for that before asserting.
await waitFor(() => expect(toggle.checked).toBe(true))
expect(await screen.findByDisplayValue('600')).toBeDefined()
fireEvent.click(toggle) // disable
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(settingsApi.save).toHaveBeenCalledWith({ interval_seconds: 60, service_check_enabled: false, service_check_interval: 600 })
})
})
it('calls onClose on Cancel', async () => { it('calls onClose on Cancel', async () => {
const onClose = vi.fn() const onClose = vi.fn()
render(<SettingsModal open onClose={onClose} />) render(<SettingsModal open onClose={onClose} />)
+147 -15
View File
@@ -1,10 +1,10 @@
import { createElement, useState } from 'react' import { createElement, useRef, useState } from 'react'
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react' import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore, serviceStatusKey } from '@/stores/canvasStore'
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types' import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type ServiceStatus, type NodeData, type NodeProperty } from '@/types'
import { getServiceUrl } from '@/utils/serviceUrl' import { getServiceUrl } from '@/utils/serviceUrl'
import { splitIps } from '@/utils/maskIp' import { splitIps } from '@/utils/maskIp'
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons' import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
@@ -21,7 +21,8 @@ type PropForm = { key: string; value: string; icon: string | null; visible: bool
const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true } const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true }
export function DetailPanel({ onEdit }: DetailPanelProps) { export function DetailPanel({ onEdit }: DetailPanelProps) {
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup } = useCanvasStore() const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup, removeFromGroup, setNodeSize } = useCanvasStore()
const serviceStatuses = useCanvasStore((s) => s.serviceStatuses)
const [addingForNode, setAddingForNode] = useState<string | null>(null) const [addingForNode, setAddingForNode] = useState<string | null>(null)
const [newSvc, setNewSvc] = useState<SvcForm>(EMPTY_FORM) const [newSvc, setNewSvc] = useState<SvcForm>(EMPTY_FORM)
@@ -64,6 +65,9 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
node={node} node={node}
nodes={nodes} nodes={nodes}
onUngroup={() => { ungroup(node.id) }} onUngroup={() => { ungroup(node.id) }}
onRemoveChild={(id) => { snapshotHistory(); removeFromGroup(node.id, id) }}
onChangeDescription={(value) => updateNode(node.id, { notes: value })}
onSnapshotBeforeEdit={snapshotHistory}
onToggleBorder={() => { onToggleBorder={() => {
snapshotHistory() snapshotHistory()
updateNode(node.id, { updateNode(node.id, {
@@ -251,6 +255,13 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
{data.last_seen && <DetailRow label="Last Seen" value={new Date(/[Zz]|[+-]\d{2}:?\d{2}$/.test(data.last_seen) ? data.last_seen : data.last_seen + 'Z').toLocaleString()} />} {data.last_seen && <DetailRow label="Last Seen" value={new Date(/[Zz]|[+-]\d{2}:?\d{2}$/.test(data.last_seen) ? data.last_seen : data.last_seen + 'Z').toLocaleString()} />}
</div> </div>
{/* Size section — manual width/height entry for pixel-exact sizing */}
<SizeFields
key={node.id}
node={node}
onCommit={(size) => { snapshotHistory(); setNodeSize(node.id, size) }}
/>
{/* Properties section */} {/* Properties section */}
<div className="px-4 py-3 border-t border-border"> <div className="px-4 py-3 border-t border-border">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
@@ -314,7 +325,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
editingIndex === i ? ( editingIndex === i ? (
<ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus /> <ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus />
) : ( ) : (
<ServiceBadge key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`} svc={svc} host={host} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} /> <ServiceBadge key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`} svc={svc} host={host} status={serviceStatuses[serviceStatusKey(node.id, svc.port, svc.protocol)]} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
) )
)} )}
</div> </div>
@@ -341,6 +352,79 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
) )
} }
// --- Size fields (manual width/height entry) ---
const round = (v: number | undefined): string => (v != null ? String(Math.round(v)) : '')
function SizeFields({ node, onCommit }: { node: Node<NodeData>; onCommit: (size: { width?: number; height?: number }) => void }) {
// Live size from the explicit dimension, falling back to the DOM-measured
// size so the field shows the node's current footprint even before resize.
const liveWidth = round(node.width ?? node.measured?.width)
const liveHeight = round(node.height ?? node.measured?.height)
const [width, setWidth] = useState(liveWidth)
const [height, setHeight] = useState(liveHeight)
// Resync the fields when the node is resized by dragging its corner handle
// (which mutates node.width/height in the store). Adjusting state during
// render — the React-blessed alternative to an effect — keeps it in step
// without cascading renders. The field the user is actively editing is left
// alone so we don't clobber their keystrokes mid-type.
const [editing, setEditing] = useState<'width' | 'height' | null>(null)
const [prev, setPrev] = useState({ w: liveWidth, h: liveHeight })
if (prev.w !== liveWidth || prev.h !== liveHeight) {
setPrev({ w: liveWidth, h: liveHeight })
if (editing !== 'width') setWidth(liveWidth)
if (editing !== 'height') setHeight(liveHeight)
}
const commit = (raw: string, axis: 'width' | 'height') => {
const n = Number(raw)
if (!raw.trim() || Number.isNaN(n) || n <= 0) {
// Reset the field to the live value on invalid input — no mutation.
if (axis === 'width') setWidth(round(node.width ?? node.measured?.width))
else setHeight(round(node.height ?? node.measured?.height))
return
}
onCommit({ [axis]: n })
}
return (
<div className="px-4 py-3 border-t border-border">
<span className="text-xs text-muted-foreground">Size</span>
<div className="mt-2 flex items-center gap-2">
<label className="flex flex-1 items-center gap-1.5">
<span className="text-[10px] text-muted-foreground/70 w-3">W</span>
<Input
type="number"
min={140}
aria-label="Width"
value={width}
onFocus={() => setEditing('width')}
onChange={(e) => setWidth(e.target.value)}
onBlur={() => { setEditing(null); commit(width, 'width') }}
onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur() }}
className="h-7 text-xs font-mono"
/>
</label>
<label className="flex flex-1 items-center gap-1.5">
<span className="text-[10px] text-muted-foreground/70 w-3">H</span>
<Input
type="number"
min={50}
aria-label="Height"
value={height}
onFocus={() => setEditing('height')}
onChange={(e) => setHeight(e.target.value)}
onBlur={() => { setEditing(null); commit(height, 'height') }}
onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur() }}
className="h-7 text-xs font-mono"
/>
</label>
</div>
</div>
)
}
// --- Multi-select panel --- // --- Multi-select panel ---
interface MultiSelectPanelProps { interface MultiSelectPanelProps {
@@ -425,17 +509,33 @@ interface GroupDetailPanelProps {
node: Node<NodeData> node: Node<NodeData>
nodes: Node<NodeData>[] nodes: Node<NodeData>[]
onUngroup: () => void onUngroup: () => void
onRemoveChild: (id: string) => void
onChangeDescription: (value: string) => void
onSnapshotBeforeEdit: () => void
onToggleBorder: () => void onToggleBorder: () => void
onClose: () => void onClose: () => void
onSelectChild: (id: string) => void onSelectChild: (id: string) => void
} }
function GroupDetailPanel({ node, nodes, onUngroup, onToggleBorder, onClose, onSelectChild }: GroupDetailPanelProps) { function GroupDetailPanel({ node, nodes, onUngroup, onRemoveChild, onChangeDescription, onSnapshotBeforeEdit, onToggleBorder, onClose, onSelectChild }: GroupDetailPanelProps) {
const children = nodes.filter((n) => n.parentId === node.id) const children = nodes.filter((n) => n.parentId === node.id)
const onlineCount = children.filter((n) => n.data.status === 'online').length const onlineCount = children.filter((n) => n.data.status === 'online').length
const offlineCount = children.filter((n) => n.data.status === 'offline').length const offlineCount = children.filter((n) => n.data.status === 'offline').length
const showBorder = node.data.custom_colors?.show_border !== false const showBorder = node.data.custom_colors?.show_border !== false
// Description reuses data.notes, which already round-trips to the backend.
// Controlled + committed on every keystroke so ANY save path (incl. Ctrl+S,
// which never blurs the field) captures it. History is snapshotted once at the
// start of an edit session so the whole edit is a single undo step.
const snappedRef = useRef(false)
const handleDescriptionChange = (value: string) => {
if (!snappedRef.current) {
onSnapshotBeforeEdit()
snappedRef.current = true
}
onChangeDescription(value)
}
const handleUngroup = () => { const handleUngroup = () => {
if (confirm(`Ungroup "${node.data.label}"? Nodes will be released to the canvas.`)) { if (confirm(`Ungroup "${node.data.label}"? Nodes will be released to the canvas.`)) {
onUngroup() onUngroup()
@@ -461,20 +561,48 @@ function GroupDetailPanel({ node, nodes, onUngroup, onToggleBorder, onClose, onS
{offlineCount > 0 && <span style={{ color: STATUS_COLORS.offline }}> {offlineCount} offline</span>} {offlineCount > 0 && <span style={{ color: STATUS_COLORS.offline }}> {offlineCount} offline</span>}
</div> </div>
{/* Description */}
<div className="px-4 py-3 border-b border-border">
<label htmlFor="group-description" className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">
Description
</label>
<textarea
id="group-description"
value={node.data.notes ?? ''}
onFocus={() => { snappedRef.current = false }}
onChange={(e) => handleDescriptionChange(e.target.value)}
placeholder="Add a description for this group…"
rows={3}
className="mt-1.5 w-full resize-y rounded-md bg-[#21262d] border border-[#30363d] px-2 py-1.5 text-xs text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:border-[#00d4ff]/50"
/>
</div>
{/* Children list */} {/* Children list */}
<div className="flex-1 px-4 py-3 space-y-1.5 overflow-y-auto"> <div className="flex-1 px-4 py-3 space-y-1.5 overflow-y-auto">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Members</span> <span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Members</span>
{children.length === 0 && <p className="text-xs text-muted-foreground/50">No nodes in this group.</p>} {children.length === 0 && <p className="text-xs text-muted-foreground/50">No nodes in this group.</p>}
{children.map((child) => ( {children.map((child) => (
<button <div
key={child.id} key={child.id}
onClick={() => onSelectChild(child.id)} className="group/member w-full flex items-center gap-2 px-2 py-1.5 rounded-md bg-[#21262d] text-xs hover:bg-[#30363d] transition-colors"
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md bg-[#21262d] text-xs hover:bg-[#30363d] transition-colors text-left"
> >
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: STATUS_COLORS[child.data.status] }} /> <button
<span className="truncate text-foreground font-medium">{child.data.label}</span> onClick={() => onSelectChild(child.id)}
<span className="ml-auto text-muted-foreground shrink-0">{NODE_TYPE_LABELS[child.data.type] ?? child.data.type}</span> className="flex items-center gap-2 min-w-0 flex-1 text-left cursor-pointer"
</button> >
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: STATUS_COLORS[child.data.status] }} />
<span className="truncate text-foreground font-medium">{child.data.label}</span>
<span className="ml-auto text-muted-foreground shrink-0">{NODE_TYPE_LABELS[child.data.type] ?? child.data.type}</span>
</button>
<button
onClick={() => onRemoveChild(child.id)}
aria-label={`Remove ${child.data.label} from group`}
title="Remove from group"
className="shrink-0 opacity-0 group-hover/member:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] cursor-pointer"
>
<X size={12} />
</button>
</div>
))} ))}
</div> </div>
@@ -673,9 +801,13 @@ const CATEGORY_COLORS: Record<string, string> = {
web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e', web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
} }
function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) { function ServiceBadge({ svc, host, status, onEdit, onRemove }: { svc: ServiceInfo; host?: string; status?: ServiceStatus; onEdit: () => void; onRemove: () => void }) {
const url = getServiceUrl(svc, host) const url = getServiceUrl(svc, host)
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e' // Manually-added services carry no category, so they fell back to grey even
// when they're reachable HTTP/HTTPS. Treat any resolvable web URL as `web`.
const categoryColor = CATEGORY_COLORS[svc.category ?? ''] ?? (url ? CATEGORY_COLORS.web : '#8b949e')
// A live offline service overrides the category colour with red.
const color = status === 'offline' ? '#f85149' : categoryColor
const pathLabel = svc.path?.trim() ? svc.path.trim() : '' const pathLabel = svc.path?.trim() ? svc.path.trim() : ''
return ( return (
+8 -174
View File
@@ -1,11 +1,11 @@
import { useState, useCallback, useEffect, useRef } from 'react' import { useState, useCallback } from 'react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Settings, StopCircle, LogOut, Network, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react' import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
import { Logo } from '@/components/ui/Logo' import { Logo } from '@/components/ui/Logo'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { useDesignStore } from '@/stores/designStore' import { useDesignStore } from '@/stores/designStore'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
import { designsApi, scanApi } from '@/api/client' import { designsApi } from '@/api/client'
import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons' import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal' import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal'
import type { Design } from '@/types' import type { Design } from '@/types'
@@ -14,24 +14,11 @@ import { useLatestRelease } from '@/hooks/useLatestRelease'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
type SidebarView = 'canvas' | 'history'
const PENDING_TRIGGERS: { kind: 'pending' | 'hidden'; icon: typeof ScanLine; label: string }[] = [ const PENDING_TRIGGERS: { kind: 'pending' | 'hidden'; icon: typeof ScanLine; label: string }[] = [
{ kind: 'pending', icon: ScanLine, label: 'Pending Devices' }, { kind: 'pending', icon: ScanLine, label: 'Pending Devices' },
{ kind: 'hidden', icon: EyeOff, label: 'Hidden Devices' }, { kind: 'hidden', icon: EyeOff, label: 'Hidden Devices' },
] ]
interface ScanRun {
id: string
status: string
kind?: string
ranges: string[]
devices_found: number
started_at: string
finished_at: string | null
error: string | null
}
interface SidebarProps { interface SidebarProps {
onAddNode: () => void onAddNode: () => void
onAddGroupRect: () => void onAddGroupRect: () => void
@@ -40,14 +27,12 @@ interface SidebarProps {
onZigbeeImport: () => void onZigbeeImport: () => void
onSave: () => void onSave: () => void
onOpenSettings: () => void onOpenSettings: () => void
forceView?: SidebarView onOpenHistory: () => void
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
} }
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onSave, onOpenSettings, forceView, onOpenPending }: SidebarProps) { export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onSave, onOpenSettings, onOpenHistory, onOpenPending }: SidebarProps) {
const [collapsed, setCollapsed] = useState(false) const [collapsed, setCollapsed] = useState(false)
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
const [prevForceView, setPrevForceView] = useState(forceView)
const logout = useAuthStore((s) => s.logout) const logout = useAuthStore((s) => s.logout)
const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore() const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore()
const [designSwitcherOpen, setDesignSwitcherOpen] = useState(false) const [designSwitcherOpen, setDesignSwitcherOpen] = useState(false)
@@ -81,15 +66,6 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
} }
}, [designs.length, removeDesign]) }, [designs.length, removeDesign])
// forceView acts as a one-shot trigger from parent; user clicks afterwards still control view.
if (forceView !== prevForceView) {
setPrevForceView(forceView)
if (forceView) {
setActiveView(forceView)
setCollapsed(false)
}
}
const { nodes, hasUnsavedChanges } = useCanvasStore() const { nodes, hasUnsavedChanges } = useCanvasStore()
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text') const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
@@ -193,8 +169,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
icon={LayoutDashboard} icon={LayoutDashboard}
label="Canvas" label="Canvas"
collapsed={collapsed} collapsed={collapsed}
active={activeView === 'canvas'} active
onClick={() => setActiveView('canvas')}
/> />
{!STANDALONE && PENDING_TRIGGERS.map((t) => ( {!STANDALONE && PENDING_TRIGGERS.map((t) => (
<SidebarItem <SidebarItem
@@ -210,23 +185,12 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
icon={Clock} icon={Clock}
label="Scan History" label="Scan History"
collapsed={collapsed} collapsed={collapsed}
active={activeView === 'history'} onClick={onOpenHistory}
onClick={() => setActiveView('history')}
/> />
)} )}
</nav> </nav>
{/* View content (only when expanded) */} {!collapsed && <div className="flex-1" />}
{!collapsed && activeView !== 'canvas' && (
<div className="flex-1 min-h-0 overflow-y-auto border-t border-border">
{activeView === 'history' && <ScanHistoryPanel />}
</div>
)}
{/* Stats (only on canvas view) */}
{!collapsed && activeView === 'canvas' && (
<div className="flex-1" />
)}
{/* Stats footer */} {/* Stats footer */}
{!collapsed && ( {!collapsed && (
@@ -294,136 +258,6 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
) )
} }
function ScanHistoryPanel() {
const [runs, setRuns] = useState<ScanRun[]>([])
const [loading, setLoading] = useState(false)
const prevRunsRef = useRef<ScanRun[]>([])
const load = useCallback(async () => {
setLoading(true)
try {
const res = await scanApi.runs()
const next: ScanRun[] = res.data
// Surface transitions and refresh dependent UI
for (const run of next) {
const prev = prevRunsRef.current.find((r) => r.id === run.id)
if (prev?.status === 'running' && run.status === 'error') {
toast.error(`Scan failed: ${run.error ?? 'unknown error'}`)
}
if (prev?.status === 'running' && run.status === 'done') {
if (run.kind === 'zigbee') {
toast.success(`Zigbee import done — ${run.devices_found} device${run.devices_found !== 1 ? 's' : ''}`)
}
// Notify pending modal/canvas to refresh
useCanvasStore.getState().notifyScanDeviceFound()
}
}
prevRunsRef.current = next
setRuns(next)
} catch {
toast.error('Failed to load scan history')
} finally {
setLoading(false)
}
}, [])
// Initial load
useEffect(() => { load() }, [load])
// Auto-refresh every 3s while any run is still running
useEffect(() => {
const hasRunning = runs.some((r) => r.status === 'running')
if (!hasRunning) return
const id = setInterval(load, 3000)
return () => clearInterval(id)
}, [runs, load])
const [stopping, setStopping] = useState<string | null>(null)
const handleStop = async (runId: string) => {
setStopping(runId)
try {
await scanApi.stop(runId)
toast.success('Scan stop requested')
} catch {
toast.error('Failed to stop scan')
} finally {
setStopping(null)
}
}
const statusColor = (s: string) =>
s === 'done' ? '#39d353'
: s === 'running' ? '#e3b341'
: s === 'error' ? '#f85149'
: s === 'cancelled' ? '#8b949e'
: '#8b949e'
return (
<div className="p-2">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">History</span>
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5">
<RefreshCw size={12} />
</button>
</div>
{loading && runs.length === 0 && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
{!loading && runs.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-4">No scans yet</p>
)}
{runs.map((r) => (
<div key={r.id} className="mb-2 p-2 rounded-md bg-[#21262d] text-xs">
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: statusColor(r.status) }} />
<span className="font-mono text-foreground capitalize">{r.status}</span>
{r.status === 'running' && <Loader2 size={10} className="animate-spin text-[#e3b341]" />}
<span
className="text-[9px] font-mono px-1 py-0.5 rounded uppercase tracking-wider"
style={r.kind === 'zigbee'
? { background: '#00d4ff22', color: '#00d4ff' }
: { background: '#a855f722', color: '#a855f7' }}
>
{r.kind === 'zigbee' ? 'ZIG' : 'IP'}
</span>
<span className="ml-auto text-muted-foreground font-mono">{r.devices_found} found</span>
{r.status === 'running' && (
<Tooltip>
<TooltipTrigger>
<button
aria-label="Stop scan"
onClick={() => handleStop(r.id)}
disabled={stopping === r.id}
className="p-0.5 text-[#f85149] hover:bg-[#f85149]/10 rounded transition-colors disabled:opacity-50"
>
{stopping === r.id
? <Loader2 size={11} className="animate-spin" />
: <StopCircle size={11} />
}
</button>
</TooltipTrigger>
<TooltipContent side="left">Stop scan</TooltipContent>
</Tooltip>
)}
</div>
<div className="text-muted-foreground text-[10px] mt-0.5">
{new Date(r.started_at.endsWith('Z') ? r.started_at : r.started_at + 'Z').toLocaleString()}
</div>
{r.ranges.length > 0 && (
<div className="text-[#8b949e] text-[10px] font-mono truncate">{r.ranges.join(', ')}</div>
)}
{r.error && (
<div className="text-[#f85149] text-[10px] mt-1 leading-tight wrap-break-word whitespace-pre-wrap">
{r.error}
</div>
)}
</div>
))}
</div>
)
}
function VersionBadge() { function VersionBadge() {
const current = __APP_VERSION__ const current = __APP_VERSION__
const { latest, hasUpdate } = useLatestRelease(current) const { latest, hasUpdate } = useLatestRelease(current)
@@ -5,7 +5,10 @@ import * as canvasStore from '@/stores/canvasStore'
import type { NodeData } from '@/types' import type { NodeData } from '@/types'
import type { Node } from '@xyflow/react' import type { Node } from '@xyflow/react'
vi.mock('@/stores/canvasStore') vi.mock('@/stores/canvasStore', async (importActual) => ({
...(await importActual<typeof canvasStore>()),
useCanvasStore: vi.fn(),
}))
function makeNode(data: Partial<NodeData>): Node<NodeData> { function makeNode(data: Partial<NodeData>): Node<NodeData> {
return { return {
@@ -22,8 +25,8 @@ function makeNode(data: Partial<NodeData>): Node<NodeData> {
} }
} }
function setupStore(nodeData: Partial<NodeData> = {}) { function setupStore(nodeData: Partial<NodeData> = {}, serviceStatuses: Record<string, string> = {}) {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ const state = {
nodes: [makeNode(nodeData)], nodes: [makeNode(nodeData)],
selectedNodeId: 'n1', selectedNodeId: 'n1',
selectedNodeIds: [], selectedNodeIds: [],
@@ -33,7 +36,13 @@ function setupStore(nodeData: Partial<NodeData> = {}) {
snapshotHistory: vi.fn(), snapshotHistory: vi.fn(),
createGroup: vi.fn(), createGroup: vi.fn(),
ungroup: vi.fn(), ungroup: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>) setNodeSize: vi.fn(),
serviceStatuses,
}
// Support both the bare destructure call and the selector-based call.
vi.mocked(canvasStore.useCanvasStore).mockImplementation(
((sel?: (s: typeof state) => unknown) => (sel ? sel(state) : state)) as unknown as typeof canvasStore.useCanvasStore,
)
} }
describe('DetailPanel', () => { describe('DetailPanel', () => {
@@ -502,6 +511,43 @@ describe('DetailPanel', () => {
render(<DetailPanel onEdit={vi.fn()} />) render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('health').tagName).not.toBe('A') expect(screen.getByText('health').tagName).not.toBe('A')
}) })
it('colors a categoryless but reachable web service blue, not grey', () => {
setupStore({ ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] })
render(<DetailPanel onEdit={vi.fn()} />)
const link = screen.getByRole('link', { name: 'nginx' })
expect(link.style.color).toBe('rgb(0, 212, 255)') // #00d4ff (web)
})
it('keeps a categoryless unreachable service grey', () => {
setupStore({ ip: undefined, services: [{ protocol: 'tcp', service_name: 'health', path: '' }] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('health').style.color).toBe('rgb(139, 148, 158)') // #8b949e
})
it('respects an explicit category over the url fallback', () => {
setupStore({ ip: '192.168.1.10', services: [{ port: 5432, protocol: 'tcp', service_name: 'pg', category: 'database', path: '' }] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('pg').style.color).toBe('rgb(168, 85, 247)') // #a855f7 (database)
})
it('paints a service red when its live status is offline', () => {
setupStore(
{ ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] },
{ 'n1:8080/tcp': 'offline' },
)
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByRole('link', { name: 'nginx' }).style.color).toBe('rgb(248, 81, 73)') // #f85149
})
it('keeps the category colour when the live status is online', () => {
setupStore(
{ ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] },
{ 'n1:8080/tcp': 'online' },
)
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByRole('link', { name: 'nginx' }).style.color).toBe('rgb(0, 212, 255)') // #00d4ff (web)
})
}) })
describe('Last Seen formatting', () => { describe('Last Seen formatting', () => {
@@ -526,4 +572,69 @@ describe('DetailPanel', () => {
expect(row?.textContent).not.toMatch(/Invalid Date/) expect(row?.textContent).not.toMatch(/Invalid Date/)
}) })
}) })
describe('Size section', () => {
function setupSized(node: Partial<Node<NodeData>>, setNodeSize = vi.fn()) {
const state = {
nodes: [{ ...makeNode({}), ...node }],
selectedNodeId: 'n1',
selectedNodeIds: [],
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode: vi.fn(),
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
setNodeSize,
serviceStatuses: {},
}
vi.mocked(canvasStore.useCanvasStore).mockImplementation(
((sel?: (s: typeof state) => unknown) => (sel ? sel(state) : state)) as unknown as typeof canvasStore.useCanvasStore,
)
return setNodeSize
}
it('shows the current width/height, preferring explicit over measured', () => {
setupSized({ width: 220, height: 130, measured: { width: 999, height: 999 } })
render(<DetailPanel onEdit={vi.fn()} />)
expect((screen.getByLabelText('Width') as HTMLInputElement).value).toBe('220')
expect((screen.getByLabelText('Height') as HTMLInputElement).value).toBe('130')
})
it('falls back to the measured size before a resize', () => {
setupSized({ measured: { width: 187, height: 73 } })
render(<DetailPanel onEdit={vi.fn()} />)
expect((screen.getByLabelText('Width') as HTMLInputElement).value).toBe('187')
expect((screen.getByLabelText('Height') as HTMLInputElement).value).toBe('73')
})
it('commits a manual width on blur', () => {
const setNodeSize = setupSized({ width: 200, height: 100 })
render(<DetailPanel onEdit={vi.fn()} />)
const w = screen.getByLabelText('Width')
fireEvent.change(w, { target: { value: '260' } })
fireEvent.blur(w)
expect(setNodeSize).toHaveBeenCalledWith('n1', { width: 260 })
})
it('reverts invalid input without committing', () => {
const setNodeSize = setupSized({ width: 200, height: 100 })
render(<DetailPanel onEdit={vi.fn()} />)
const w = screen.getByLabelText('Width') as HTMLInputElement
fireEvent.change(w, { target: { value: 'abc' } })
fireEvent.blur(w)
expect(setNodeSize).not.toHaveBeenCalled()
expect(w.value).toBe('200')
})
it('resyncs the field when the node is resized by corner drag', () => {
setupSized({ width: 200, height: 100 })
const { rerender } = render(<DetailPanel onEdit={vi.fn()} />)
expect((screen.getByLabelText('Width') as HTMLInputElement).value).toBe('200')
// Simulate a corner-drag resize updating the store dimension.
setupSized({ width: 340, height: 100 })
rerender(<DetailPanel onEdit={vi.fn()} />)
expect((screen.getByLabelText('Width') as HTMLInputElement).value).toBe('340')
})
})
}) })
@@ -42,6 +42,7 @@ const mockStore = {
snapshotHistory: vi.fn(), snapshotHistory: vi.fn(),
createGroup: vi.fn(), createGroup: vi.fn(),
ungroup: vi.fn(), ungroup: vi.fn(),
removeFromGroup: vi.fn(),
} }
function setupStore(overrides = {}) { function setupStore(overrides = {}) {
@@ -230,4 +231,46 @@ describe('GroupDetailPanel', () => {
fireEvent.click(screen.getByText('Child Node Alpha')) fireEvent.click(screen.getByText('Child Node Alpha'))
expect(setSelectedNode).toHaveBeenCalledWith('c1') expect(setSelectedNode).toHaveBeenCalledWith('c1')
}) })
it('removes a child from the group via the remove button', () => {
const removeFromGroup = vi.fn()
const snapshotHistory = vi.fn()
const group = makeGroupNode()
const child = makeNode('c1', { parentId: 'g1', data: { label: 'Router', type: 'router', status: 'online', services: [] } })
setupStore({ nodes: [group, child], selectedNodeId: 'g1', selectedNodeIds: ['g1'], removeFromGroup, snapshotHistory })
renderPanel()
fireEvent.click(screen.getByRole('button', { name: /remove router from group/i }))
expect(removeFromGroup).toHaveBeenCalledWith('g1', 'c1')
expect(snapshotHistory).toHaveBeenCalled()
})
it('renders the existing group description from notes', () => {
const group = makeGroupNode()
group.data = { ...group.data, notes: 'Critical DMZ hosts' } as typeof group.data
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
renderPanel()
expect((screen.getByLabelText('Description') as HTMLTextAreaElement).value).toBe('Critical DMZ hosts')
})
it('commits the description to the store on each change (so Ctrl+S captures it)', () => {
const updateNode = vi.fn()
const group = makeGroupNode()
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], updateNode })
renderPanel()
fireEvent.change(screen.getByLabelText('Description'), { target: { value: 'New notes' } })
expect(updateNode).toHaveBeenCalledWith('g1', { notes: 'New notes' })
})
it('snapshots history once at the start of an edit, not on every keystroke', () => {
const updateNode = vi.fn()
const snapshotHistory = vi.fn()
const group = makeGroupNode()
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], updateNode, snapshotHistory })
renderPanel()
const textarea = screen.getByLabelText('Description')
fireEvent.change(textarea, { target: { value: 'a' } })
fireEvent.change(textarea, { target: { value: 'ab' } })
fireEvent.change(textarea, { target: { value: 'abc' } })
expect(snapshotHistory).toHaveBeenCalledTimes(1)
})
}) })
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest' import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { render, screen, fireEvent } from '@testing-library/react'
import { Sidebar } from '../Sidebar' import { Sidebar } from '../Sidebar'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
@@ -67,10 +67,12 @@ function mockAuth() {
const defaultProps = { const defaultProps = {
onAddNode: vi.fn(), onAddNode: vi.fn(),
onAddGroupRect: vi.fn(), onAddGroupRect: vi.fn(),
onAddText: vi.fn(),
onScan: vi.fn(), onScan: vi.fn(),
onZigbeeImport: vi.fn(), onZigbeeImport: vi.fn(),
onSave: vi.fn(), onSave: vi.fn(),
onOpenSettings: vi.fn(), onOpenSettings: vi.fn(),
onOpenHistory: vi.fn(),
onOpenPending: vi.fn(), onOpenPending: vi.fn(),
} }
@@ -243,19 +245,10 @@ describe('Sidebar', () => {
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'hidden') expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'hidden')
}) })
it('shows History panel when Scan History nav item is clicked', async () => { it('calls onOpenHistory when Scan History nav item is clicked', () => {
render(<Sidebar {...defaultProps} />) render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Scan History')) fireEvent.click(screen.getByText('Scan History'))
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument()) expect(defaultProps.onOpenHistory).toHaveBeenCalledOnce()
})
// Regression: forceView must not freeze local state across rerenders.
it('allows switching views after forceView is set by parent', async () => {
const { rerender } = render(<Sidebar {...defaultProps} forceView="history" />)
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
rerender(<Sidebar {...defaultProps} forceView="history" />)
fireEvent.click(screen.getByText('Canvas'))
await waitFor(() => expect(screen.queryByText('No scans yet')).not.toBeInTheDocument())
}) })
it('calls onOpenSettings when Settings is clicked', () => { it('calls onOpenSettings when Settings is clicked', () => {
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"min-h-16 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1.5 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
@@ -9,6 +9,7 @@ vi.mock('@/stores/authStore')
const mockUpdateNode = vi.fn() const mockUpdateNode = vi.fn()
const mockNotifyScanDeviceFound = vi.fn() const mockNotifyScanDeviceFound = vi.fn()
const mockSetServiceStatuses = vi.fn()
class MockWebSocket { class MockWebSocket {
static instances: MockWebSocket[] = [] static instances: MockWebSocket[] = []
@@ -33,6 +34,7 @@ describe('useStatusPolling', () => {
vi.mocked(useCanvasStore).mockReturnValue({ vi.mocked(useCanvasStore).mockReturnValue({
updateNode: mockUpdateNode, updateNode: mockUpdateNode,
notifyScanDeviceFound: mockNotifyScanDeviceFound, notifyScanDeviceFound: mockNotifyScanDeviceFound,
setServiceStatuses: mockSetServiceStatuses,
} as ReturnType<typeof useCanvasStore>) } as ReturnType<typeof useCanvasStore>)
vi.mocked(useAuthStore).mockReturnValue({ vi.mocked(useAuthStore).mockReturnValue({
@@ -50,6 +52,7 @@ describe('useStatusPolling', () => {
vi.restoreAllMocks() vi.restoreAllMocks()
mockUpdateNode.mockClear() mockUpdateNode.mockClear()
mockNotifyScanDeviceFound.mockClear() mockNotifyScanDeviceFound.mockClear()
mockSetServiceStatuses.mockClear()
}) })
it('does not open WebSocket when not authenticated', () => { it('does not open WebSocket when not authenticated', () => {
@@ -147,6 +150,17 @@ describe('useStatusPolling', () => {
expect(mockUpdateNode).not.toHaveBeenCalled() expect(mockUpdateNode).not.toHaveBeenCalled()
}) })
it('routes service_status messages to setServiceStatuses', () => {
renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
const services = [{ port: 80, protocol: 'tcp', status: 'offline' }]
ws.onmessage?.({
data: JSON.stringify({ type: 'service_status', node_id: 'node-9', services }),
})
expect(mockSetServiceStatuses).toHaveBeenCalledWith('node-9', services)
expect(mockUpdateNode).not.toHaveBeenCalled()
})
it('ignores malformed JSON without throwing', () => { it('ignores malformed JSON without throwing', () => {
renderHook(() => useStatusPolling()) renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0] const ws = MockWebSocket.instances[0]
+12 -2
View File
@@ -1,6 +1,13 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
import type { ServiceStatus } from '@/types'
interface ServiceStatusEntry {
port?: number
protocol?: string
status: ServiceStatus
}
interface StatusMessage { interface StatusMessage {
type?: string type?: string
@@ -10,13 +17,14 @@ interface StatusMessage {
response_time_ms?: number | null response_time_ms?: number | null
run_id?: string run_id?: string
devices_found?: number devices_found?: number
services?: ServiceStatusEntry[]
} }
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
export function useStatusPolling() { export function useStatusPolling() {
const wsRef = useRef<WebSocket | null>(null) const wsRef = useRef<WebSocket | null>(null)
const { updateNode, notifyScanDeviceFound } = useCanvasStore() const { updateNode, notifyScanDeviceFound, setServiceStatuses } = useCanvasStore()
const { isAuthenticated, token } = useAuthStore() const { isAuthenticated, token } = useAuthStore()
useEffect(() => { useEffect(() => {
@@ -39,6 +47,8 @@ export function useStatusPolling() {
const msg: StatusMessage = JSON.parse(event.data) const msg: StatusMessage = JSON.parse(event.data)
if (msg.type === 'scan_device_found') { if (msg.type === 'scan_device_found') {
notifyScanDeviceFound() notifyScanDeviceFound()
} else if (msg.type === 'service_status' && msg.node_id && msg.services) {
setServiceStatuses(msg.node_id, msg.services)
} else if (msg.node_id && msg.status) { } else if (msg.node_id && msg.status) {
updateNode(msg.node_id, { updateNode(msg.node_id, {
status: msg.status, status: msg.status,
@@ -59,5 +69,5 @@ export function useStatusPolling() {
ws.close() ws.close()
wsRef.current = null wsRef.current = null
} }
}, [isAuthenticated, token, updateNode, notifyScanDeviceFound]) }, [isAuthenticated, token, updateNode, notifyScanDeviceFound, setServiceStatuses])
} }
@@ -31,9 +31,36 @@ describe('canvasStore', () => {
past: [], past: [],
future: [], future: [],
clipboard: { nodes: [], edges: [] }, clipboard: { nodes: [], edges: [] },
serviceStatuses: {},
}) })
}) })
it('setServiceStatuses stores live status keyed by node/port/protocol', () => {
const { setServiceStatuses } = useCanvasStore.getState()
setServiceStatuses('node-1', [
{ port: 80, protocol: 'tcp', status: 'offline' },
{ port: 443, protocol: 'tcp', status: 'online' },
])
const { serviceStatuses } = useCanvasStore.getState()
expect(serviceStatuses['node-1:80/tcp']).toBe('offline')
expect(serviceStatuses['node-1:443/tcp']).toBe('online')
})
it('setServiceStatuses merges without dropping other nodes', () => {
const { setServiceStatuses } = useCanvasStore.getState()
setServiceStatuses('node-1', [{ port: 80, protocol: 'tcp', status: 'online' }])
setServiceStatuses('node-2', [{ port: 22, protocol: 'tcp', status: 'offline' }])
const { serviceStatuses } = useCanvasStore.getState()
expect(serviceStatuses['node-1:80/tcp']).toBe('online')
expect(serviceStatuses['node-2:22/tcp']).toBe('offline')
})
it('does not mark canvas unsaved on a service status update', () => {
useCanvasStore.setState({ hasUnsavedChanges: false })
useCanvasStore.getState().setServiceStatuses('n', [{ port: 80, protocol: 'tcp', status: 'offline' }])
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
})
it('setEditingTextId sets and clears editing text id', () => { it('setEditingTextId sets and clears editing text id', () => {
const { setEditingTextId } = useCanvasStore.getState() const { setEditingTextId } = useCanvasStore.getState()
setEditingTextId('t1') setEditingTextId('t1')
@@ -123,6 +150,23 @@ describe('canvasStore', () => {
expect(nested?.extent).toBe('parent') expect(nested?.extent).toBe('parent')
}) })
it('addNode strips parentId/extent when the parent is not a container', () => {
// Regression: a stray extent:'parent' on a non-container parent traps the
// node in the parent's tiny box with no way to drag it out (issue #205).
const parent = { ...makeNode('p1', { container_mode: false }), position: { x: 100, y: 100 } }
useCanvasStore.getState().addNode(parent)
const trapped: Node<NodeData> = {
...makeNode('c1', { parent_id: 'p1' }),
position: { x: 150, y: 180 },
parentId: 'p1',
extent: 'parent',
}
useCanvasStore.getState().addNode(trapped)
const child = useCanvasStore.getState().nodes.find((n) => n.id === 'c1')
expect(child?.parentId).toBeUndefined()
expect(child?.extent).toBeUndefined()
})
it('docker_container nests under docker_host with container_mode on', () => { it('docker_container nests under docker_host with container_mode on', () => {
const host = { ...makeNode('dh1', { type: 'docker_host', container_mode: true }), position: { x: 100, y: 100 } } const host = { ...makeNode('dh1', { type: 'docker_host', container_mode: true }), position: { x: 100, y: 100 } }
const container = { ...makeNode('dc1', { type: 'docker_container' }), position: { x: 160, y: 180 } } const container = { ...makeNode('dc1', { type: 'docker_container' }), position: { x: 160, y: 180 } }
@@ -487,6 +531,186 @@ describe('canvasStore', () => {
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
}) })
// ── addToGroup ──────────────────────────────────────────────────────────────
it('addToGroup nests a top-level node with parent-relative position', () => {
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 76, y: 52 }, width: 448, height: 252 }
const child = { ...makeNode('n1'), position: { x: 300, y: 200 } }
useCanvasStore.setState({ nodes: [group, child] })
useCanvasStore.getState().addToGroup('g1', 'n1')
const moved = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
expect(moved?.parentId).toBe('g1')
expect(moved?.extent).toBe('parent')
expect(moved?.data.parent_id).toBe('g1')
// 300-76=224, 200-52=148
expect(moved?.position).toEqual({ x: 224, y: 148 })
})
it('addToGroup places the group before the child in the array', () => {
const child = { ...makeNode('n1'), position: { x: 300, y: 200 } }
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
// child first to prove reordering
useCanvasStore.setState({ nodes: [child, group] })
useCanvasStore.getState().addToGroup('g1', 'n1')
const { nodes } = useCanvasStore.getState()
expect(nodes.findIndex((n) => n.id === 'g1')).toBeLessThan(nodes.findIndex((n) => n.id === 'n1'))
})
it('addToGroup is a no-op when target is not a group', () => {
const notGroup = { ...makeNode('s1'), position: { x: 0, y: 0 } }
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
useCanvasStore.setState({ nodes: [notGroup, child] })
useCanvasStore.getState().markSaved()
useCanvasStore.getState().addToGroup('s1', 'n1')
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBeUndefined()
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
})
it('addToGroup is a no-op when child already belongs to the group', () => {
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
const child = { ...makeNode('n1'), position: { x: 50, y: 50 }, parentId: 'g1', extent: 'parent' as const }
useCanvasStore.setState({ nodes: [group, child] })
useCanvasStore.getState().markSaved()
useCanvasStore.getState().addToGroup('g1', 'n1')
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
})
it('addToGroup snapshots history and marks unsaved', () => {
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
useCanvasStore.setState({ nodes: [group, child] })
useCanvasStore.getState().markSaved()
useCanvasStore.getState().addToGroup('g1', 'n1')
expect(useCanvasStore.getState().past).toHaveLength(1)
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
// ── addToContainer ──────────────────────────────────────────────────────────
it('addToContainer nests a top-level node under a container_mode node', () => {
const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true, label: 'PX' }), position: { x: 76, y: 52 }, width: 448, height: 252 }
const child = { ...makeNode('n1'), position: { x: 300, y: 200 } }
useCanvasStore.setState({ nodes: [container, child] })
useCanvasStore.getState().addToContainer('px1', 'n1')
const moved = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
expect(moved?.parentId).toBe('px1')
expect(moved?.extent).toBe('parent')
expect(moved?.data.parent_id).toBe('px1')
// 300-76=224, 200-52=148
expect(moved?.position).toEqual({ x: 224, y: 148 })
})
it('addToContainer works for any container_mode type (docker_host)', () => {
const host = { ...makeNode('dh1', { type: 'docker_host', container_mode: true }), position: { x: 0, y: 0 } }
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
useCanvasStore.setState({ nodes: [host, child] })
useCanvasStore.getState().addToContainer('dh1', 'n1')
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBe('dh1')
})
it('addToContainer places the container before the child in the array', () => {
const child = { ...makeNode('n1'), position: { x: 300, y: 200 } }
const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } }
useCanvasStore.setState({ nodes: [child, container] })
useCanvasStore.getState().addToContainer('px1', 'n1')
const { nodes } = useCanvasStore.getState()
expect(nodes.findIndex((n) => n.id === 'px1')).toBeLessThan(nodes.findIndex((n) => n.id === 'n1'))
})
it('addToContainer is a no-op when target is not in container_mode', () => {
const notContainer = { ...makeNode('px1', { type: 'proxmox', container_mode: false }), position: { x: 0, y: 0 } }
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
useCanvasStore.setState({ nodes: [notContainer, child] })
useCanvasStore.getState().markSaved()
useCanvasStore.getState().addToContainer('px1', 'n1')
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBeUndefined()
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
})
it('addToContainer is a no-op when child already belongs to the container', () => {
const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } }
const child = { ...makeNode('n1'), position: { x: 50, y: 50 }, parentId: 'px1', extent: 'parent' as const }
useCanvasStore.setState({ nodes: [container, child] })
useCanvasStore.getState().markSaved()
useCanvasStore.getState().addToContainer('px1', 'n1')
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
})
it('addToContainer snapshots history and marks unsaved', () => {
const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } }
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
useCanvasStore.setState({ nodes: [container, child] })
useCanvasStore.getState().markSaved()
useCanvasStore.getState().addToContainer('px1', 'n1')
expect(useCanvasStore.getState().past).toHaveLength(1)
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
// ── removeFromGroup ─────────────────────────────────────────────────────────
it('removeFromGroup releases the child to absolute coords and keeps the group', () => {
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 76, y: 52 } }
const child = { ...makeNode('n1'), position: { x: 224, y: 148 }, parentId: 'g1', extent: 'parent' as const }
useCanvasStore.setState({ nodes: [group, child] })
useCanvasStore.getState().removeFromGroup('g1', 'n1')
const { nodes } = useCanvasStore.getState()
const released = nodes.find((n) => n.id === 'n1')
expect(released?.parentId).toBeUndefined()
expect(released?.extent).toBeUndefined()
expect(released?.data.parent_id).toBeUndefined()
// 224+76=300, 148+52=200
expect(released?.position).toEqual({ x: 300, y: 200 })
// group survives
expect(nodes.find((n) => n.id === 'g1')).toBeDefined()
})
it('removeFromGroup is a no-op when child is not in the group', () => {
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
useCanvasStore.setState({ nodes: [group, child] })
useCanvasStore.getState().markSaved()
useCanvasStore.getState().removeFromGroup('g1', 'n1')
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
})
it('removeFromGroup snapshots history and marks unsaved', () => {
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
const child = { ...makeNode('n1'), position: { x: 50, y: 50 }, parentId: 'g1', extent: 'parent' as const }
useCanvasStore.setState({ nodes: [group, child] })
useCanvasStore.getState().markSaved()
useCanvasStore.getState().removeFromGroup('g1', 'n1')
expect(useCanvasStore.getState().past).toHaveLength(1)
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
it('updateEdge updates edge data and marks unsaved', () => { it('updateEdge updates edge data and marks unsaved', () => {
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] })) useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] }))
useCanvasStore.getState().markSaved() useCanvasStore.getState().markSaved()
@@ -1056,4 +1280,29 @@ describe('canvasStore — custom style apply', () => {
expect(e.data?.custom_color).toBe('#aabbcc') expect(e.data?.custom_color).toBe('#aabbcc')
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
}) })
it('setNodeSize sets explicit width/height and marks unsaved', () => {
useCanvasStore.setState({ nodes: [makeNode('n1')], hasUnsavedChanges: false })
useCanvasStore.getState().setNodeSize('n1', { width: 220, height: 130 })
const n = useCanvasStore.getState().nodes.find((x) => x.id === 'n1')!
expect(n.width).toBe(220)
expect(n.height).toBe(130)
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
it('setNodeSize clamps below the minimum box', () => {
useCanvasStore.setState({ nodes: [makeNode('n1')] })
useCanvasStore.getState().setNodeSize('n1', { width: 10, height: 10 })
const n = useCanvasStore.getState().nodes.find((x) => x.id === 'n1')!
expect(n.width).toBe(140)
expect(n.height).toBe(50)
})
it('setNodeSize updates only the provided axis', () => {
useCanvasStore.setState({ nodes: [{ ...makeNode('n1'), width: 200, height: 100 }] })
useCanvasStore.getState().setNodeSize('n1', { width: 300 })
const n = useCanvasStore.getState().nodes.find((x) => x.id === 'n1')!
expect(n.width).toBe(300)
expect(n.height).toBe(100)
})
}) })
+157 -2
View File
@@ -9,7 +9,7 @@ import {
applyEdgeChanges, applyEdgeChanges,
addEdge, addEdge,
} from '@xyflow/react' } from '@xyflow/react'
import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef } from '@/types' import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, ServiceStatus } from '@/types'
import { generateUUID } from '@/utils/uuid' import { generateUUID } from '@/utils/uuid'
import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils' import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
import { applyOpacity } from '@/utils/colorUtils' import { applyOpacity } from '@/utils/colorUtils'
@@ -21,6 +21,10 @@ 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
/** Key for the live per-service status overlay. */
export const serviceStatusKey = (nodeId: string, port?: number, protocol?: string): string =>
`${nodeId}:${port ?? ''}/${protocol ?? ''}`
interface CanvasState { interface CanvasState {
nodes: Node<NodeData>[] nodes: Node<NodeData>[]
edges: Edge<EdgeData>[] edges: Edge<EdgeData>[]
@@ -28,6 +32,8 @@ interface CanvasState {
selectedNodeId: string | null selectedNodeId: string | null
selectedNodeIds: string[] selectedNodeIds: string[]
scanEventTs: number scanEventTs: number
// Live per-service status overlay (not persisted), keyed via serviceStatusKey.
serviceStatuses: Record<string, ServiceStatus>
// History // History
past: HistoryEntry[] past: HistoryEntry[]
@@ -55,6 +61,7 @@ interface CanvasState {
deleteEdge: (id: string) => void deleteEdge: (id: string) => void
setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void
setNodeZIndex: (id: string, zIndex: number) => void setNodeZIndex: (id: string, zIndex: number) => void
setNodeSize: (id: string, size: { width?: number; height?: number }) => void
editingGroupRectId: string | null editingGroupRectId: string | null
setEditingGroupRectId: (id: string | null) => void setEditingGroupRectId: (id: string | null) => void
editingTextId: string | null editingTextId: string | null
@@ -62,12 +69,16 @@ interface CanvasState {
toggleNodeCollapsed: (id: string) => void toggleNodeCollapsed: (id: string) => void
createGroup: (nodeIds: string[], name: string) => void createGroup: (nodeIds: string[], name: string) => void
ungroup: (groupId: string) => void ungroup: (groupId: string) => void
addToGroup: (groupId: string, childId: string) => void
addToContainer: (containerId: string, childId: string) => void
removeFromGroup: (groupId: string, childId: string) => void
markSaved: () => void markSaved: () => void
markUnsaved: () => void markUnsaved: () => void
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
fitViewPending: boolean fitViewPending: boolean
clearFitViewPending: () => void clearFitViewPending: () => void
notifyScanDeviceFound: () => void notifyScanDeviceFound: () => void
setServiceStatuses: (nodeId: string, statuses: { port?: number; protocol?: string; status: ServiceStatus }[]) => void
hideIp: boolean hideIp: boolean
toggleHideIp: () => void toggleHideIp: () => void
setHideIp: (value: boolean) => void setHideIp: (value: boolean) => void
@@ -86,6 +97,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
editingTextId: null, editingTextId: null,
hideIp: readHideIp(), hideIp: readHideIp(),
scanEventTs: 0, scanEventTs: 0,
serviceStatuses: {},
fitViewPending: false, fitViewPending: false,
past: [], past: [],
@@ -277,7 +289,9 @@ export const useCanvasStore = create<CanvasState>((set) => ({
y: Math.max(10, node.position.y - parent.position.y), y: Math.max(10, node.position.y - parent.position.y),
}, },
} }
: node // Not nesting: strip any parentId/extent a caller may have set so a
// non-container parent can't trap the node in its bounding box.
: { ...node, parentId: undefined, extent: undefined }
// Parents must come before children in the array (React Flow requirement) // Parents must come before children in the array (React Flow requirement)
const withoutNew = state.nodes.filter((n) => n.id !== node.id) const withoutNew = state.nodes.filter((n) => n.id !== node.id)
if (enriched.parentId) { if (enriched.parentId) {
@@ -455,6 +469,22 @@ export const useCanvasStore = create<CanvasState>((set) => ({
hasUnsavedChanges: true, hasUnsavedChanges: true,
})), })),
// Manual width/height entry. Lets the user type an exact size instead of
// dragging the resize handle (which lands on fractional content-fit pixels).
// A clamp matches the NodeResizer minimums so the box can't collapse.
setNodeSize: (id, size) =>
set((state) => ({
nodes: state.nodes.map((n) => {
if (n.id !== id) return n
return {
...n,
...(size.width != null ? { width: Math.max(140, size.width) } : {}),
...(size.height != null ? { height: Math.max(50, size.height) } : {}),
}
}),
hasUnsavedChanges: true,
})),
setEditingGroupRectId: (id) => set({ editingGroupRectId: id }), setEditingGroupRectId: (id) => set({ editingGroupRectId: id }),
setEditingTextId: (id) => set({ editingTextId: id }), setEditingTextId: (id) => set({ editingTextId: id }),
@@ -575,12 +605,137 @@ export const useCanvasStore = create<CanvasState>((set) => ({
} }
}), }),
// Nest an existing top-level node inside a group. Inverse of removeFromGroup.
addToGroup: (groupId, childId) =>
set((state) => {
const group = state.nodes.find((n) => n.id === groupId)
const child = state.nodes.find((n) => n.id === childId)
if (!group || !child || group.data.type !== 'group') return state
if (child.id === groupId || child.parentId === groupId) return state
const updatedNodes = state.nodes.map((n) => {
if (n.id !== childId) return n
return {
...n,
parentId: groupId,
extent: 'parent' as const,
// Absolute → group-relative. Clamp so the node stays inside the box.
position: {
x: Math.max(8, n.position.x - group.position.x),
y: Math.max(8, n.position.y - group.position.y),
},
selected: false,
data: { ...n.data, parent_id: groupId },
}
})
// React Flow requires the parent to precede its children in the array.
const others = updatedNodes.filter((n) => n.id !== childId)
const movedChild = updatedNodes.find((n) => n.id === childId)!
const groupIdx = others.findIndex((n) => n.id === groupId)
const nodes = [
...others.slice(0, groupIdx + 1),
movedChild,
...others.slice(groupIdx + 1),
]
return {
nodes,
hasUnsavedChanges: true,
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
future: [],
}
}),
// Nest an existing top-level node inside a container node (proxmox /
// docker_host / … in container_mode). Mirrors addToGroup but the target is
// any node with data.container_mode === true rather than a group.
addToContainer: (containerId, childId) =>
set((state) => {
const container = state.nodes.find((n) => n.id === containerId)
const child = state.nodes.find((n) => n.id === childId)
if (!container || !child || container.data.container_mode !== true) return state
if (child.id === containerId || child.parentId === containerId) return state
const updatedNodes = state.nodes.map((n) => {
if (n.id !== childId) return n
return {
...n,
parentId: containerId,
extent: 'parent' as const,
// Absolute → container-relative. Clamp so the node stays inside.
position: {
x: Math.max(8, n.position.x - container.position.x),
y: Math.max(8, n.position.y - container.position.y),
},
selected: false,
data: { ...n.data, parent_id: containerId },
}
})
// React Flow requires the parent to precede its children in the array.
const others = updatedNodes.filter((n) => n.id !== childId)
const movedChild = updatedNodes.find((n) => n.id === childId)!
const containerIdx = others.findIndex((n) => n.id === containerId)
const nodes = [
...others.slice(0, containerIdx + 1),
movedChild,
...others.slice(containerIdx + 1),
]
return {
nodes,
hasUnsavedChanges: true,
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
future: [],
}
}),
// Release a single child from a group back to the canvas. Group stays.
removeFromGroup: (groupId, childId) =>
set((state) => {
const group = state.nodes.find((n) => n.id === groupId)
const child = state.nodes.find((n) => n.id === childId)
if (!group || !child || child.parentId !== groupId) return state
const nodes = state.nodes.map((n) => {
if (n.id !== childId) return n
return {
...n,
parentId: undefined,
extent: undefined,
position: {
x: n.position.x + group.position.x,
y: n.position.y + group.position.y,
},
data: { ...n.data, parent_id: undefined },
}
})
return {
nodes,
hasUnsavedChanges: true,
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
future: [],
}
}),
markSaved: () => set({ hasUnsavedChanges: false }), markSaved: () => set({ hasUnsavedChanges: false }),
markUnsaved: () => set({ hasUnsavedChanges: true }), markUnsaved: () => set({ hasUnsavedChanges: true }),
notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }), notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }),
setServiceStatuses: (nodeId, statuses) =>
set((state) => {
// Live overlay only — never touches node data, so it stays out of saves.
const next = { ...state.serviceStatuses }
for (const s of statuses) {
next[serviceStatusKey(nodeId, s.port, s.protocol)] = s.status
}
return { serviceStatuses: next }
}),
toggleHideIp: () => set((s) => { toggleHideIp: () => set((s) => {
const hideIp = !s.hideIp const hideIp = !s.hideIp
writeHideIp(hideIp) writeHideIp(hideIp)
+2
View File
@@ -78,6 +78,8 @@ export interface ServiceInfo {
category?: string category?: string
} }
export type ServiceStatus = 'online' | 'offline' | 'unknown'
export interface NodeProperty { export interface NodeProperty {
key: string key: string
value: string value: string
@@ -102,6 +102,20 @@ describe('serializeNode — regular node', () => {
expect(result.height).toBeNull() expect(result.height).toBeNull()
}) })
it('prefers explicit width/height over the measured (content-fit) value', () => {
const node: Node<NodeData> = { ...makeRfNode({ width: 200, height: 100 }), measured: { width: 187, height: 73 } }
const result = serializeNode(node)
expect(result.width).toBe(200)
expect(result.height).toBe(100)
})
it('falls back to measured when no explicit dimension is set', () => {
const node: Node<NodeData> = { ...makeRfNode(), measured: { width: 187, height: 73 } }
const result = serializeNode(node)
expect(result.width).toBe(187)
expect(result.height).toBe(73)
})
it('serializes hardware fields', () => { it('serializes hardware fields', () => {
const node = makeRfNode({ const node = makeRfNode({
data: { data: {
@@ -162,13 +176,14 @@ describe('serializeNode — groupRect', () => {
expect((result.custom_colors as Record<string, unknown>).height).toBe(250) expect((result.custom_colors as Record<string, unknown>).height).toBe(250)
}) })
it('falls back to measured dimensions over explicit width/height', () => { it('prefers explicit width/height over measured, falling back to measured per-axis', () => {
const node: Node<NodeData> = { const node: Node<NodeData> = {
...makeRfNode({ type: 'groupRect', data: { label: 'Z', type: 'groupRect', status: 'unknown', services: [] }, width: 400 }), ...makeRfNode({ type: 'groupRect', data: { label: 'Z', type: 'groupRect', status: 'unknown', services: [] }, width: 400 }),
measured: { width: 420, height: 260 }, measured: { width: 420, height: 260 },
} }
const result = serializeNode(node) const result = serializeNode(node)
expect((result.custom_colors as Record<string, unknown>).width).toBe(420) // Explicit width wins; height has no explicit value so it uses measured.
expect((result.custom_colors as Record<string, unknown>).width).toBe(400)
expect((result.custom_colors as Record<string, unknown>).height).toBe(260) expect((result.custom_colors as Record<string, unknown>).height).toBe(260)
}) })
@@ -323,6 +338,21 @@ describe('deserializeApiNode — regular node', () => {
expect(result.width).toBeUndefined() expect(result.width).toBeUndefined()
expect(result.height).toBeUndefined() expect(result.height).toBeUndefined()
}) })
// Regression: issue #205 — a leaf vm/lxc nested in a container would lose its
// saved size on reload because the restore branch excluded those types.
it.each(['vm', 'lxc', 'docker_host'] as const)(
'restores saved width/height for a leaf %s node (container_mode false)',
(type) => {
const map = new Map([['px1', true]])
const result = deserializeApiNode(
makeApiNode({ type, container_mode: false, parent_id: 'px1', width: 240, height: 90 }),
map,
)
expect(result.width).toBe(240)
expect(result.height).toBe(90)
},
)
}) })
// ── deserializeApiNode — groupRect ──────────────────────────────────────────── // ── deserializeApiNode — groupRect ────────────────────────────────────────────
@@ -53,6 +53,33 @@ describe('generateMarkdownTable', () => {
expect(md).toContain('A\\|B') expect(md).toContain('A\\|B')
}) })
it('escapes backslashes before pipes so the escape char is not ambiguous', () => {
const nodes = [makeNode({ label: 'A\\|B' })]
const md = generateMarkdownTable(nodes)
// backslash doubled, then the literal pipe escaped
expect(md).toContain('A\\\\\\|B')
})
it('collapses newlines in cell values so they do not break the table', () => {
const nodes = [makeNode({ label: 'line1\nline2', hostname: 'a\r\nb' })]
const lines = generateMarkdownTable(nodes).split('\n')
// header + separator + exactly one data row (no extra line from the value)
expect(lines).toHaveLength(3)
expect(lines[2]).toContain('line1 line2')
expect(lines[2]).toContain('a b')
})
it('escapes pipe characters inside service names', () => {
const nodes = [makeNode({
label: 'Server',
services: [{ port: 80, protocol: 'tcp', service_name: 'web|proxy' }],
})]
const lines = generateMarkdownTable(nodes).split('\n')
// header + separator + exactly one data row — the pipe must not add a column
expect(lines).toHaveLength(3)
expect(lines[2]).toContain('web\\|proxy')
})
it('generates one row per non-groupRect node', () => { it('generates one row per non-groupRect node', () => {
const nodes = [ const nodes = [
makeNode({ type: 'server', label: 'A' }, '1'), makeNode({ type: 'server', label: 'A' }, '1'),
+16 -7
View File
@@ -75,8 +75,8 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
pos_y: n.position.y, pos_y: n.position.y,
custom_colors: { custom_colors: {
...n.data.custom_colors, ...n.data.custom_colors,
width: n.measured?.width ?? n.width ?? 360, width: n.width ?? n.measured?.width ?? 360,
height: n.measured?.height ?? n.height ?? 240, height: n.height ?? n.measured?.height ?? 240,
// Stash collapse state inside custom_colors so the API/YAML blob does // Stash collapse state inside custom_colors so the API/YAML blob does
// not need a new column. Hoisted back to `data.collapsed` on load. // not need a new column. Hoisted back to `data.collapsed` on load.
collapsed: n.data.collapsed ?? false, collapsed: n.data.collapsed ?? false,
@@ -112,8 +112,11 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
disk_gb: n.data.disk_gb ?? null, disk_gb: n.data.disk_gb ?? null,
show_hardware: n.data.show_hardware ?? false, show_hardware: n.data.show_hardware ?? false,
properties: n.data.properties ?? [], properties: n.data.properties ?? [],
width: n.measured?.width ?? n.width ?? null, // Prefer the explicit (resized) dimension over the DOM-measured one so a
height: n.measured?.height ?? n.height ?? null, // manual resize persists its exact target instead of drifting to the
// fractional content-fit value.
width: n.width ?? n.measured?.width ?? null,
height: n.height ?? n.measured?.height ?? null,
bottom_handles: clampBottomHandles(n.data.bottom_handles ?? 1), bottom_handles: clampBottomHandles(n.data.bottom_handles ?? 1),
show_port_numbers: n.data.show_port_numbers ?? false, show_port_numbers: n.data.show_port_numbers ?? false,
pos_x: n.position.x, pos_x: n.position.x,
@@ -179,11 +182,17 @@ export function deserializeApiNode(
collapsed: Boolean(n.custom_colors?.collapsed), collapsed: Boolean(n.custom_colors?.collapsed),
} as unknown as NodeData, } as unknown as NodeData,
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}), ...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
// Container hosts (Proxmox/VM/LXC/docker in container_mode) get a default
// box if none was saved. Every other node — including LEAF vm/lxc/docker
// nodes nested inside a container — restores its own saved width/height.
// Gating on container_mode (not type) is what keeps a resized nested node
// from snapping back to content-fit on reload.
...(['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) && n.container_mode !== false ...(['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) && n.container_mode !== false
? { width: n.width ?? 300, height: n.height ?? 200 } ? { width: n.width ?? 300, height: n.height ?? 200 }
: {}), : {
...(n.width && !['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) ? { width: n.width } : {}), ...(n.width ? { width: n.width } : {}),
...(n.height && !['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) ? { height: n.height } : {}), ...(n.height ? { height: n.height } : {}),
}),
} }
} }
+6 -3
View File
@@ -5,8 +5,11 @@ const EMPTY = '—'
function cell(v: string | null | undefined): string { function cell(v: string | null | undefined): string {
if (!v) return EMPTY if (!v) return EMPTY
// Escape pipe chars so they don't break the table // Escape backslashes first, then pipes, and collapse newlines so they don't break the table
return v.replace(/\|/g, '\\|') return v
.replace(/\\/g, '\\\\')
.replace(/\|/g, '\\|')
.replace(/\r?\n/g, ' ')
} }
export function generateMarkdownTable(nodes: Node<NodeData>[]): string { export function generateMarkdownTable(nodes: Node<NodeData>[]): string {
@@ -27,7 +30,7 @@ export function generateMarkdownTable(nodes: Node<NodeData>[]): string {
cell(d.ip), cell(d.ip),
cell(d.hostname), cell(d.hostname),
cell(d.status), cell(d.status),
services, cell(services),
] ]
}) })