Compare commits

...

9 Commits

Author SHA1 Message Date
Pouzor 0e59f15608 chore: bump version to 1.8.1 2026-04-05 10:32:37 +02:00
Pouzor bd22891fab fix: correct UTC timestamp display and pre-existing code quality issues 2026-04-05 03:06:29 +02:00
Pouzor d96b502524 fix: correct UTC timestamp display in scan history, pending devices, and detail panel 2026-04-05 02:50:23 +02:00
Pouzor 9cb9d02459 feat: extend search (Ctrl+F and Ctrl+K) to include pending devices
Both SearchBar and SearchModal now fetch and search pending devices by
IP, hostname, and service name. Selecting a pending result opens the
sidebar to the Pending tab and highlights the matching device.
2026-04-05 02:40:12 +02:00
Pouzor 08a3a689c5 chore: bump version to 1.8.0 2026-04-04 23:29:20 +02:00
Remy 034ba599f4 Merge pull request #40 from Pouzor/feat/bottom-handles
feat: configurable bottom handles, scanner rewrite, UI polish
2026-04-04 23:23:20 +02:00
Pouzor 38c5bcb606 feat: configurable bottom handles, scanner rewrite, UI polish
## New features
- Configurable bottom connection points per node (1–4 handles)
- Fit view on load
- LiveView improvements
- Node modal: inline Type/Icon picker, default icon in trigger
- Remove redundant Save button from ScanConfigModal

## Scanner fixes
- Phase 1: replace nmap ARP sweep with concurrent asyncio ping sweep
  (50 parallel pings, 1s timeout). Zero false positives, works in any
  Docker network mode. Supplements with /proc/net/arp for ICMP-blocked devices.
- Phase 2: explicit -sS (root) / -sT (non-root) scan type; bump
  host-timeout to 60s; gather(return_exceptions=True) so one failing
  host doesn't abort the batch
- Fix 404 on missing device in hide/ignore
- Validate CIDR ranges to prevent nmap injection
- Thread-safe cancel set, pre-fetch canvas/hidden IPs (no N+1 queries)
- Logging: attach StreamHandler to root logger so app.* logs are visible

## Tests
- 21 backend scanner tests (ping sweep, ARP cache, Phase 2 tolerance)
- Full NodeModal coverage (53 tests)
- LiveView, store, edge label tests
2026-04-04 23:15:47 +02:00
Pouzor 0a72f9be67 test: add tests for useStatusPolling hook and Sidebar component
- 12 tests for useStatusPolling: WebSocket lifecycle, auth guards,
  message routing (status/scan_device_found), error resilience, cleanup
- 21 tests for Sidebar: render, collapse/expand, node stats, action
  callbacks, view navigation, scan trigger, settings panel toggle
2026-04-03 22:55:56 +02:00
Pouzor 041583c9b4 fix: set correct edge handles on YAML import
Edges created by parseYamlToCanvas had no sourceHandle/targetHandle,
causing React Flow to connect everything at the top of nodes.

- Regular links: bottom → top-t
- Parent edges: bottom → top-t
- clusterR/clusterL: cluster-right → cluster-left
2026-04-03 00:50:34 +02:00
41 changed files with 2178 additions and 539 deletions
+40 -8
View File
@@ -1,8 +1,10 @@
import ipaddress
import logging import logging
import uuid
from typing import Any from typing import Any
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, field_validator
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -18,6 +20,16 @@ from app.services.scanner import request_cancel, run_scan
class ScanConfig(BaseModel): class ScanConfig(BaseModel):
ranges: list[str] ranges: list[str]
@field_validator("ranges")
@classmethod
def validate_cidr(cls, v: list[str]) -> list[str]:
for r in v:
try:
ipaddress.ip_network(r, strict=False)
except ValueError as exc:
raise ValueError(f"Invalid CIDR range: {r!r}") from exc
return v
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -49,6 +61,10 @@ async def stop_scan(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user), _: str = Depends(get_current_user),
) -> dict[str, bool]: ) -> dict[str, bool]:
try:
uuid.UUID(run_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid run_id format") from None
run = await db.get(ScanRun, run_id) run = await db.get(ScanRun, run_id)
if not run: if not run:
raise HTTPException(status_code=404, detail="Scan run not found") raise HTTPException(status_code=404, detail="Scan run not found")
@@ -64,6 +80,19 @@ async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_
return list(result.scalars().all()) return list(result.scalars().all())
@router.delete("/pending", response_model=dict)
async def clear_pending(
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, int]:
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending"))
devices = result.scalars().all()
for device in devices:
await db.delete(device)
await db.commit()
return {"deleted": len(devices)}
@router.get("/hidden", response_model=list[PendingDeviceResponse]) @router.get("/hidden", response_model=list[PendingDeviceResponse])
async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]: async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "hidden")) result = await db.execute(select(PendingDevice).where(PendingDevice.status == "hidden"))
@@ -92,9 +121,10 @@ async def hide_device(
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user) device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
) -> dict[str, bool]: ) -> dict[str, bool]:
device = await db.get(PendingDevice, device_id) device = await db.get(PendingDevice, device_id)
if device: if not device:
device.status = "hidden" raise HTTPException(status_code=404, detail="Device not found")
await db.commit() device.status = "hidden"
await db.commit()
return {"hidden": True} return {"hidden": True}
@@ -103,9 +133,10 @@ async def ignore_device(
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user) device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
) -> dict[str, bool]: ) -> dict[str, bool]:
device = await db.get(PendingDevice, device_id) device = await db.get(PendingDevice, device_id)
if device: if not device:
await db.delete(device) raise HTTPException(status_code=404, detail="Device not found")
await db.commit() await db.delete(device)
await db.commit()
return {"ignored": True} return {"ignored": True}
@@ -127,4 +158,5 @@ async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_u
settings.save_overrides() settings.save_overrides()
return payload return payload
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc logger.error("Failed to save scan config: %s", exc)
raise HTTPException(status_code=500, detail="Failed to save scan config") from exc
+4
View File
@@ -57,6 +57,10 @@ async def init_db() -> None:
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN width REAL") await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN width REAL")
with suppress(OperationalError): with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN height REAL") await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN height REAL")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN bottom_handles INTEGER NOT NULL DEFAULT 1")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_source TEXT")
# Migrate animated column from boolean (0/1) to string ('none'/'snake') # Migrate animated column from boolean (0/1) to string ('none'/'snake')
with suppress(OperationalError): with suppress(OperationalError):
await conn.exec_driver_sql("UPDATE edges SET animated = 'snake' WHERE animated = '1' OR animated = 1") await conn.exec_driver_sql("UPDATE edges SET animated = 'snake' WHERE animated = '1' OR animated = 1")
+2
View File
@@ -44,6 +44,7 @@ class Node(Base):
show_hardware: Mapped[bool] = mapped_column(Boolean, default=False) show_hardware: Mapped[bool] = mapped_column(Boolean, default=False)
width: Mapped[float | None] = mapped_column(Float, nullable=True) width: Mapped[float | None] = mapped_column(Float, nullable=True)
height: Mapped[float | None] = mapped_column(Float, nullable=True) height: Mapped[float | None] = mapped_column(Float, nullable=True)
bottom_handles: Mapped[int] = mapped_column(Integer, default=1)
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
response_time_ms: Mapped[int | None] = mapped_column(Integer) response_time_ms: Mapped[int | None] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
@@ -90,6 +91,7 @@ class PendingDevice(Base):
services: Mapped[list[Any]] = mapped_column(JSON, default=list) services: Mapped[list[Any]] = mapped_column(JSON, default=list)
suggested_type: Mapped[str | None] = mapped_column(String) suggested_type: Mapped[str | None] = mapped_column(String)
status: Mapped[str] = mapped_column(String, default="pending") status: Mapped[str] = mapped_column(String, default="pending")
discovery_source: Mapped[str | None] = mapped_column(String)
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+13 -1
View File
@@ -1,3 +1,5 @@
import logging
import logging.config
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Any from typing import Any
@@ -14,6 +16,16 @@ from app.db.database import init_db
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# Ensure app logs are visible: attach a handler to the root logger if none
# exists (uvicorn only installs handlers on its own loggers, not the root).
root_logger = logging.getLogger()
if not root_logger.handlers:
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("%(levelname)s:%(name)s:%(message)s"))
root_logger.addHandler(handler)
root_logger.setLevel(logging.INFO)
logging.getLogger("app").setLevel(logging.INFO)
logging.getLogger("app.services.scanner").setLevel(logging.INFO)
await init_db() await init_db()
settings.load_overrides() settings.load_overrides()
start_scheduler() start_scheduler()
@@ -23,7 +35,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
app = FastAPI( app = FastAPI(
title="Homelable API", title="Homelable API",
version="1.4.0", version="1.8.1",
lifespan=lifespan, lifespan=lifespan,
) )
+1
View File
@@ -31,6 +31,7 @@ class NodeSave(BaseModel):
show_hardware: bool = False show_hardware: bool = False
width: float | None = None width: float | None = None
height: float | None = None height: float | None = None
bottom_handles: int = 1
pos_x: float = 0 pos_x: float = 0
pos_y: float = 0 pos_y: float = 0
+2
View File
@@ -29,6 +29,7 @@ class NodeBase(BaseModel):
show_hardware: bool = False show_hardware: bool = False
width: float | None = None width: float | None = None
height: float | None = None height: float | None = None
bottom_handles: int = 1
class NodeCreate(NodeBase): class NodeCreate(NodeBase):
@@ -60,6 +61,7 @@ class NodeUpdate(BaseModel):
show_hardware: bool | None = None show_hardware: bool | None = None
width: float | None = None width: float | None = None
height: float | None = None height: float | None = None
bottom_handles: int | None = None
class NodeResponse(NodeBase): class NodeResponse(NodeBase):
+1
View File
@@ -13,6 +13,7 @@ class PendingDeviceResponse(BaseModel):
services: list[Any] services: list[Any]
suggested_type: str | None suggested_type: str | None
status: str status: str
discovery_source: str | None
discovered_at: datetime discovered_at: datetime
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
+239 -90
View File
@@ -1,7 +1,12 @@
"""Network scanner: ARP sweep + nmap service detection + mDNS discovery.""" """Network scanner: ARP sweep + nmap service detection + mDNS discovery."""
import asyncio import asyncio
import ipaddress
import logging import logging
import os
import re
import socket import socket
import subprocess
import threading
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
@@ -13,8 +18,9 @@ from app.services.fingerprint import fingerprint_ports, suggest_node_type
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Run IDs that have been requested to cancel # Run IDs that have been requested to cancel (thread-safe via lock)
_cancelled_runs: set[str] = set() _cancelled_runs: set[str] = set()
_cancelled_lock = threading.Lock()
# Port list for service detection (Phase 2) # Port list for service detection (Phase 2)
_EXTRA_PORTS = ( _EXTRA_PORTS = (
@@ -55,11 +61,13 @@ except ImportError:
def request_cancel(run_id: str) -> None: def request_cancel(run_id: str) -> None:
"""Signal a running scan to stop early.""" """Signal a running scan to stop early."""
_cancelled_runs.add(run_id) with _cancelled_lock:
_cancelled_runs.add(run_id)
def _is_cancelled(run_id: str) -> bool: def _is_cancelled(run_id: str) -> bool:
return run_id in _cancelled_runs with _cancelled_lock:
return run_id in _cancelled_runs
def _resolve_hostname(ip: str) -> str | None: def _resolve_hostname(ip: str) -> str | None:
@@ -79,80 +87,216 @@ def _extract_os(nm: object, host: str) -> str | None:
return None return None
def _nmap_arp_sweep(target: str) -> dict[str, dict[str, Any]]: def _arp_table_hosts(network: str) -> dict[str, dict[str, Any]]:
""" """
Phase 1: ARP ping sweep — finds ALL alive hosts regardless of open ports. Read the OS ARP cache for recently-seen hosts in the target network.
Returns {ip: host_dict} for every host that responds. Works without root on both Linux (/proc/net/arp) and macOS (arp -a).
Supplements nmap discovery — catches IoT and devices with all ports filtered.
""" """
nm = nmap.PortScanner() try:
nm.scan(hosts=target, arguments="-sn -PR -PA80,443 --host-timeout 10s") net = ipaddress.ip_network(network, strict=False)
found: dict[str, dict[str, Any]] = {}
# Linux: parse /proc/net/arp — present on any Linux kernel (including Docker)
proc_arp = "/proc/net/arp"
try:
with open(proc_arp) as f:
for line in f.readlines()[1:]: # skip header row
parts = line.split()
if len(parts) >= 4:
ip, mac = parts[0], parts[3]
if mac == "00:00:00:00:00:00":
continue
try:
if ipaddress.ip_address(ip) in net:
found[ip] = {
"ip": ip, "mac": mac,
"hostname": _resolve_hostname(ip),
"os": None, "open_ports": [],
}
except ValueError:
pass
# /proc/net/arp opened successfully — return whatever we found (may be empty)
# Don't fall through to `arp -a` since we're on Linux
return found
except FileNotFoundError:
pass # Not Linux — fall through to macOS `arp -a`
# macOS: parse `arp -a` output
result = subprocess.run(["arp", "-a"], capture_output=True, text=True, timeout=5)
for line in result.stdout.splitlines():
m = re.search(r"\((\d+\.\d+\.\d+\.\d+)\)\s+at\s+([0-9a-f:]+)", line)
if not m:
continue
ip, mac = m.group(1), m.group(2)
if mac in ("(incomplete)", "ff:ff:ff:ff:ff:ff"):
continue
try:
if ipaddress.ip_address(ip) in net:
found[ip] = {"ip": ip, "mac": mac, "hostname": _resolve_hostname(ip), "os": None, "open_ports": []}
except ValueError:
pass
return found
except Exception as exc:
logger.warning("[Phase 1] ARP cache lookup failed: %s", exc)
return {}
async def _ping_sweep(target: str) -> dict[str, dict[str, Any]]:
"""
Phase 1: Concurrent ICMP ping sweep + ARP cache.
Pings all IPs in the CIDR in parallel (up to 50 at once, 1s timeout each).
Supplements with the OS ARP cache to catch devices that block ICMP.
Works in Docker with CAP_NET_RAW — no nmap, no false positives.
"""
net = ipaddress.ip_network(target, strict=False)
all_ips = [str(ip) for ip in net.hosts()]
logger.info("[Phase 1] Pinging %d hosts in %s ...", len(all_ips), target)
sem = asyncio.Semaphore(50)
async def _ping(ip: str) -> str | None:
async with sem:
try:
proc = await asyncio.create_subprocess_exec(
"ping", "-c", "1", "-W", "1", ip,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
return ip if proc.returncode == 0 else None
except Exception:
return None
ping_results = await asyncio.gather(*[_ping(ip) for ip in all_ips])
alive_ips: set[str] = {ip for ip in ping_results if ip is not None}
logger.info("[Phase 1] %d/%d hosts responded to ping", len(alive_ips), len(all_ips))
# ARP cache: catch devices that block ICMP but were recently active,
# and enrich ping-alive hosts with their MAC addresses.
arp_cache = await asyncio.to_thread(_arp_table_hosts, target)
alive: dict[str, dict[str, Any]] = {} alive: dict[str, dict[str, Any]] = {}
for host in nm.all_hosts():
if nm[host].state() == "up": for ip in alive_ips:
alive[host] = { mac = arp_cache.get(ip, {}).get("mac")
"ip": host, hostname = await asyncio.to_thread(_resolve_hostname, ip)
"hostname": _resolve_hostname(host), logger.info("[Phase 1] %s mac=%s hostname=%s (ping)", ip, mac or "n/a", hostname or "n/a")
"mac": nm[host].get("addresses", {}).get("mac"), alive[ip] = {"ip": ip, "mac": mac, "hostname": hostname, "os": None, "open_ports": []}
"os": None,
"open_ports": [], for ip, host in arp_cache.items():
} if ip not in alive:
logger.info(
"[Phase 1] %s mac=%s hostname=%s (ARP cache only)",
ip, host.get("mac") or "n/a", host.get("hostname") or "n/a",
)
alive[ip] = host
return alive return alive
def _nmap_port_scan(alive: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: def _nmap_scan_single(host_dict: dict[str, Any]) -> dict[str, Any]:
""" """
Phase 2: Service detection on the alive host set from Phase 1. Phase 2 — single-IP port scan with service detection.
Mutates alive in-place with open_ports/os; returns all hosts including Runs in a thread (blocking). Returns the host dict enriched with open_ports.
those with zero open ports (IoT devices often have none). """
ip = host_dict["ip"]
logger.info("[Phase 2] Scanning %s ...", ip)
if not _NMAP_AVAILABLE:
logger.warning("[Phase 2] nmap not available, skipping %s", ip)
return host_dict
is_root = os.geteuid() == 0
if is_root:
# SYN scan + version detection (fastest, most accurate)
scan_args = f"-sS -sV --open -T4 -Pn --host-timeout 60s -p {_EXTRA_PORTS}"
else:
# TCP connect scan (-sT) — no raw sockets needed, works without root.
# nmap auto-selects -sT without root but being explicit avoids edge cases.
scan_args = f"-sT -sV --open -T4 -Pn --host-timeout 60s -p {_EXTRA_PORTS}"
logger.debug("[Phase 2] %s args: %s", ip, scan_args)
nm = nmap.PortScanner()
try:
nm.scan(hosts=ip, arguments=scan_args)
except Exception as exc:
logger.warning("[Phase 2] nmap FAILED for %s (%s: %s) — skipping port scan", ip, type(exc).__name__, exc)
return host_dict
all_scanned = nm.all_hosts()
logger.debug("[Phase 2] %s — nmap returned %d host(s) in results", ip, len(all_scanned))
if ip not in all_scanned:
logger.info("[Phase 2] %s — no open ports found (all closed/filtered or nmap had no results)", ip)
return host_dict
open_ports = []
for proto in nm[ip].all_protocols():
for port, info in nm[ip][proto].items():
if info["state"] == "open":
banner = (info.get("product", "") + " " + info.get("version", "")).strip()
open_ports.append({"port": port, "protocol": proto, "banner": banner})
if open_ports:
port_summary = ", ".join(
f"{p['port']}/{p['protocol']} ({p['banner'] or 'unknown'})" for p in open_ports
)
logger.info("[Phase 2] %s%d open port(s): %s", ip, len(open_ports), port_summary)
else:
logger.info("[Phase 2] %s — 0 open ports detected", ip)
host_dict["open_ports"] = open_ports
if not host_dict["mac"]:
host_dict["mac"] = nm[ip].get("addresses", {}).get("mac")
host_dict["os"] = _extract_os(nm, ip)
return host_dict
async def _nmap_port_scan(alive: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
"""
Phase 2: Per-IP service detection with bounded concurrency.
Each host is scanned independently in a thread — no inter-host timeout interference.
Up to 10 hosts scanned concurrently.
""" """
if not alive: if not alive:
return [] return []
nm = nmap.PortScanner()
try:
nm.scan(
hosts=" ".join(alive.keys()),
arguments=f"-sV --open -T4 --host-timeout 30s -p {_EXTRA_PORTS}",
)
except Exception as exc:
logger.warning("Port scan failed, returning ARP-only results: %s", exc)
return list(alive.values())
for host in nm.all_hosts(): logger.info("[Phase 2] Starting per-IP port scan for %d host(s)", len(alive))
if host not in alive: semaphore = asyncio.Semaphore(10)
continue
open_ports = []
for proto in nm[host].all_protocols():
for port, info in nm[host][proto].items():
if info["state"] == "open":
open_ports.append({
"port": port,
"protocol": proto,
"banner": (
info.get("product", "") + " " + info.get("version", "")
).strip(),
})
alive[host]["open_ports"] = open_ports
if not alive[host]["mac"]:
alive[host]["mac"] = nm[host].get("addresses", {}).get("mac")
alive[host]["os"] = _extract_os(nm, host)
return list(alive.values()) async def _scan_with_sem(host_dict: dict[str, Any]) -> dict[str, Any]:
async with semaphore:
return await asyncio.to_thread(_nmap_scan_single, host_dict)
raw = await asyncio.gather(*[_scan_with_sem(h) for h in alive.values()], return_exceptions=True)
results = []
for item in raw:
if isinstance(item, BaseException):
logger.warning("[Phase 2] Unexpected error in gather: %s", item)
else:
results.append(item)
logger.info("[Phase 2] Completed — %d/%d host(s) scanned", len(results), len(alive))
return results
def _nmap_scan(target: str) -> list[dict[str, Any]]: async def _nmap_scan(target: str) -> list[dict[str, Any]]:
""" """
Full two-phase scan for a CIDR range. Two-phase scan for a CIDR range.
Phase 1: ARP sweep to find alive hosts (catches IoT with no open ports). Phase 1: Concurrent ping sweep to find alive hosts (fast, no false positives).
Phase 2: Service detection on alive hosts only. Phase 2: Per-IP nmap port scan with service detection (bounded concurrency, 10 at a time).
""" """
logger.info("[Scan] Starting scan for %s — nmap available: %s", target, _NMAP_AVAILABLE)
if not _NMAP_AVAILABLE: if not _NMAP_AVAILABLE:
logger.warning("[Scan] nmap not available — returning mock data")
return _mock_scan(target) return _mock_scan(target)
try: try:
alive = _nmap_arp_sweep(target) alive = await _ping_sweep(target)
logger.info("[Phase 1] Found %d alive host(s) in %s: %s",
len(alive), target, ", ".join(sorted(alive.keys())))
except Exception as exc: except Exception as exc:
logger.error("nmap ARP sweep failed: %s", exc) logger.error("Phase 1 ping sweep failed: %s", exc)
raise RuntimeError(str(exc)) from exc raise RuntimeError(str(exc)) from exc
return _nmap_port_scan(alive) return await _nmap_port_scan(alive)
async def _mdns_discover(timeout: float = 4.0) -> list[dict[str, Any]]: async def _mdns_discover(timeout: float = 4.0) -> list[dict[str, Any]]:
@@ -236,45 +380,50 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
from app.api.routes.status import broadcast_scan_update from app.api.routes.status import broadcast_scan_update
devices_found = 0 devices_found = 0
mdns_task: asyncio.Task[list[dict[str, Any]]] | None = None
try: try:
# Clean up stale pending devices whose IPs are already in the canvas # Validate all ranges are valid CIDRs before passing anything to nmap
for r in ranges:
try:
ipaddress.ip_network(r, strict=False)
except ValueError:
raise ValueError(f"Invalid CIDR range: {r!r}") from None
# Pre-fetch canvas IPs and hidden IPs once — avoids N+1 queries per host
canvas_ips_result = await db.execute(select(Node.ip).where(Node.ip.isnot(None))) canvas_ips_result = await db.execute(select(Node.ip).where(Node.ip.isnot(None)))
canvas_ips: set[str] = {row[0] for row in canvas_ips_result.fetchall()} canvas_ips: set[str] = {row[0] for row in canvas_ips_result.fetchall()}
hidden_ips_result = await db.execute(
select(PendingDevice.ip).where(PendingDevice.status == "hidden")
)
hidden_ips: set[str] = {row[0] for row in hidden_ips_result.fetchall()}
# Clean up stale pending devices whose IPs are already in the canvas
if canvas_ips: if canvas_ips:
stale_result = await db.execute( from sqlalchemy import delete as sa_delete
select(PendingDevice).where( await db.execute(
sa_delete(PendingDevice).where(
PendingDevice.status == "pending", PendingDevice.status == "pending",
PendingDevice.ip.in_(canvas_ips), PendingDevice.ip.in_(canvas_ips),
) )
) )
for stale in stale_result.scalars().all():
await db.delete(stale)
await db.commit() await db.commit()
# Start mDNS discovery in the background while nmap scans run # Start mDNS discovery in the background while nmap scans run
mdns_task: asyncio.Task[list[dict[str, Any]]] = asyncio.create_task( mdns_task = asyncio.create_task(_mdns_discover())
_mdns_discover()
)
# Track IPs found by nmap so mDNS doesn't duplicate them # Track IPs found by nmap so mDNS doesn't duplicate them
nmap_ips: set[str] = set() nmap_ips: set[str] = set()
async def _process_host(host: dict[str, Any]) -> None: async def _process_host(host: dict[str, Any], discovery_source: str = "arp") -> None:
nonlocal devices_found nonlocal devices_found
ip = host["ip"] ip = host["ip"]
# Skip canvas nodes and user-hidden devices # Skip canvas nodes and user-hidden devices (sets pre-fetched before loop)
canvas_result = await db.execute(select(Node).where(Node.ip == ip)) if ip in canvas_ips:
if canvas_result.scalar_one_or_none() is not None:
logger.debug("Skipping %s — already in canvas", ip) logger.debug("Skipping %s — already in canvas", ip)
return return
hidden_result = await db.execute( if ip in hidden_ips:
select(PendingDevice).where(
PendingDevice.ip == ip,
PendingDevice.status == "hidden",
)
)
if hidden_result.scalar_one_or_none() is not None:
logger.debug("Skipping %s — hidden by user", ip) logger.debug("Skipping %s — hidden by user", ip)
return return
@@ -303,43 +452,40 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
services=services, services=services,
suggested_type=suggested_type, suggested_type=suggested_type,
status="pending", status="pending",
discovery_source=discovery_source,
)) ))
devices_found += 1 devices_found += 1
await db.commit() await db.commit()
run = await db.get(ScanRun, run_id)
if run:
run.devices_found = devices_found
await db.commit()
await broadcast_scan_update(run_id=run_id, devices_found=devices_found) await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
# nmap scan per CIDR — results stream in progressively # nmap scan per CIDR — results stream in progressively
for cidr in ranges: for cidr in ranges:
if _is_cancelled(run_id): if _is_cancelled(run_id):
break break
hosts = await asyncio.to_thread(_nmap_scan, cidr) hosts = await _nmap_scan(cidr)
for host in hosts: for host in hosts:
if _is_cancelled(run_id): if _is_cancelled(run_id):
break break
nmap_ips.add(host["ip"]) nmap_ips.add(host["ip"])
await _process_host(host) await _process_host(host)
# Collect mDNS results; add devices not already found by nmap # Update ScanRun count once after all CIDR ranges
run = await db.get(ScanRun, run_id)
if run:
run.devices_found = devices_found
await db.commit()
# Collect mDNS results — task already has its own 4s internal timeout
if not _is_cancelled(run_id): if not _is_cancelled(run_id):
try: mdns_hosts = await mdns_task
mdns_hosts = await asyncio.wait_for(mdns_task, timeout=1.0)
except asyncio.TimeoutError:
mdns_task.cancel()
mdns_hosts = []
for host in mdns_hosts: for host in mdns_hosts:
if _is_cancelled(run_id): if _is_cancelled(run_id):
break break
if host["ip"] in nmap_ips: if host["ip"] in nmap_ips:
continue # already processed with richer nmap data continue # already processed with richer nmap data
await _process_host(host) await _process_host(host, discovery_source="mdns")
else: else:
mdns_task.cancel() mdns_task.cancel()
@@ -353,6 +499,8 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
except Exception as exc: except Exception as exc:
logger.error("Scan failed: %s", exc) logger.error("Scan failed: %s", exc)
if mdns_task is not None and not mdns_task.done():
mdns_task.cancel()
run = await db.get(ScanRun, run_id) run = await db.get(ScanRun, run_id)
if run: if run:
run.status = "error" run.status = "error"
@@ -360,4 +508,5 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
run.finished_at = datetime.now(timezone.utc) run.finished_at = datetime.now(timezone.utc)
await db.commit() await db.commit()
finally: finally:
_cancelled_runs.discard(run_id) with _cancelled_lock:
_cancelled_runs.discard(run_id)
+2 -1
View File
@@ -322,7 +322,8 @@ async def test_stop_scan_requires_auth(client: AsyncClient):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stop_scan_not_found(client: AsyncClient, headers): async def test_stop_scan_not_found(client: AsyncClient, headers):
res = await client.post("/api/v1/scan/nonexistent-id/stop", headers=headers) import uuid as _uuid
res = await client.post(f"/api/v1/scan/{_uuid.uuid4()}/stop", headers=headers)
assert res.status_code == 404 assert res.status_code == 404
+316 -87
View File
@@ -3,10 +3,12 @@ import uuid
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from sqlalchemy import select as sa_select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from app.db.database import Base from app.db.database import Base
from app.db.models import PendingDevice, ScanRun from app.db.models import Node, PendingDevice, ScanRun
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
@@ -18,7 +20,11 @@ def _make_run_id() -> str:
@pytest.fixture @pytest.fixture
async def mem_db(): async def mem_db():
engine = create_async_engine("sqlite+aiosqlite:///:memory:") engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
@@ -31,140 +37,246 @@ def _make_scan_run(run_id: str) -> ScanRun:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _nmap_arp_sweep # _ping_sweep
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_nmap_arp_sweep_returns_alive_hosts(): @pytest.mark.asyncio
from app.services.scanner import _nmap_arp_sweep async def test_ping_sweep_returns_alive_hosts():
from app.services.scanner import _ping_sweep
mock_nm = MagicMock() async def fake_ping(ip: str) -> str | None:
mock_nm.all_hosts.return_value = ["192.168.1.1", "192.168.1.2"] return ip if ip in {"192.168.1.1", "192.168.1.2"} else None
mock_nm.__getitem__ = lambda self, host: MagicMock(
state=lambda: "up",
get=lambda key, default=None: {"mac": "aa:bb:cc:dd:ee:ff"} if key == "addresses" else default,
)
with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm), \ with patch("app.services.scanner._ping_sweep", wraps=None):
pass # just ensure import is fine
# Patch asyncio.create_subprocess_exec to simulate ping responses
responding = {"192.168.1.1", "192.168.1.2"}
async def mock_subprocess(*args, **kwargs):
ip = args[-1]
proc = MagicMock()
proc.returncode = 0 if ip in responding else 1
proc.wait = AsyncMock(return_value=proc.returncode)
return proc
with patch("asyncio.create_subprocess_exec", side_effect=mock_subprocess), \
patch("app.services.scanner._arp_table_hosts", return_value={}), \
patch("app.services.scanner._resolve_hostname", return_value=None): patch("app.services.scanner._resolve_hostname", return_value=None):
result = _nmap_arp_sweep("192.168.1.0/24") result = await _ping_sweep("192.168.1.0/30") # .1 .2 only in /30
assert set(result.keys()) == {"192.168.1.1", "192.168.1.2"} assert "192.168.1.1" in result
assert "192.168.1.2" in result
for host in result.values(): for host in result.values():
assert host["open_ports"] == [] # empty until phase 2 assert host["open_ports"] == []
def test_nmap_arp_sweep_skips_down_hosts(): @pytest.mark.asyncio
from app.services.scanner import _nmap_arp_sweep async def test_ping_sweep_excludes_non_responding():
from app.services.scanner import _ping_sweep
states = {"192.168.1.1": "up", "192.168.1.2": "down"} async def mock_subprocess(*args, **kwargs):
ip = args[-1]
proc = MagicMock()
proc.returncode = 0 if ip == "192.168.1.1" else 1
proc.wait = AsyncMock(return_value=proc.returncode)
return proc
mock_nm = MagicMock() with patch("asyncio.create_subprocess_exec", side_effect=mock_subprocess), \
mock_nm.all_hosts.return_value = list(states.keys()) patch("app.services.scanner._arp_table_hosts", return_value={}), \
def getitem(host):
m = MagicMock()
m.state.return_value = states[host]
m.get.return_value = {}
return m
mock_nm.__getitem__ = lambda self, host: getitem(host)
with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm), \
patch("app.services.scanner._resolve_hostname", return_value=None): patch("app.services.scanner._resolve_hostname", return_value=None):
result = _nmap_arp_sweep("192.168.1.0/24") result = await _ping_sweep("192.168.1.0/30")
assert "192.168.1.1" in result assert "192.168.1.1" in result
assert "192.168.1.2" not in result assert "192.168.1.2" not in result
# --------------------------------------------------------------------------- @pytest.mark.asyncio
# _nmap_port_scan async def test_ping_sweep_supplements_with_arp_cache():
# --------------------------------------------------------------------------- """Devices that block ICMP but appear in ARP cache should still be discovered."""
from app.services.scanner import _ping_sweep
def test_nmap_port_scan_merges_ports(): async def mock_subprocess(*args, **kwargs):
from app.services.scanner import _nmap_port_scan proc = MagicMock()
proc.returncode = 1 # all pings fail
proc.wait = AsyncMock(return_value=1)
return proc
alive = { arp_extra = {
"192.168.1.10": {"ip": "192.168.1.10", "hostname": None, "mac": None, "os": None, "open_ports": []}, "192.168.1.10": {"ip": "192.168.1.10", "mac": "aa:bb:cc:dd:ee:10", "hostname": None, "os": None, "open_ports": []},
} }
with patch("asyncio.create_subprocess_exec", side_effect=mock_subprocess), \
patch("app.services.scanner._arp_table_hosts", return_value=arp_extra), \
patch("app.services.scanner._resolve_hostname", return_value=None):
result = await _ping_sweep("192.168.1.0/24")
assert "192.168.1.10" in result
assert result["192.168.1.10"]["mac"] == "aa:bb:cc:dd:ee:10"
@pytest.mark.asyncio
async def test_ping_sweep_enriches_mac_from_arp_cache():
"""Ping-alive hosts with no ARP entry get their MAC from the ARP cache."""
from app.services.scanner import _ping_sweep
async def mock_subprocess(*args, **kwargs):
ip = args[-1]
proc = MagicMock()
proc.returncode = 0 if ip == "192.168.1.1" else 1
proc.wait = AsyncMock(return_value=proc.returncode)
return proc
arp_extra = {
"192.168.1.1": {"ip": "192.168.1.1", "mac": "de:ad:be:ef:00:01", "hostname": None, "os": None, "open_ports": []},
}
with patch("asyncio.create_subprocess_exec", side_effect=mock_subprocess), \
patch("app.services.scanner._arp_table_hosts", return_value=arp_extra), \
patch("app.services.scanner._resolve_hostname", return_value=None):
result = await _ping_sweep("192.168.1.0/30")
assert result["192.168.1.1"]["mac"] == "de:ad:be:ef:00:01"
# ---------------------------------------------------------------------------
# _arp_table_hosts
# ---------------------------------------------------------------------------
def test_arp_table_hosts_parses_proc_net_arp():
import io # noqa: PLC0415
from app.services.scanner import _arp_table_hosts
arp_content = (
"IP address HW type Flags HW address Mask Device\n"
"192.168.1.1 0x1 0x2 aa:bb:cc:dd:ee:01 * eth0\n"
"192.168.1.50 0x1 0x2 aa:bb:cc:dd:ee:02 * eth0\n"
"10.0.0.1 0x1 0x2 aa:bb:cc:dd:ee:03 * eth0\n" # outside subnet
"192.168.1.99 0x1 0x2 00:00:00:00:00:00 * eth0\n" # incomplete
)
mock_file = MagicMock()
mock_file.__enter__ = MagicMock(return_value=io.StringIO(arp_content))
mock_file.__exit__ = MagicMock(return_value=False)
with patch("builtins.open", return_value=mock_file), \
patch("app.services.scanner._resolve_hostname", return_value=None):
result = _arp_table_hosts("192.168.1.0/24")
assert "192.168.1.1" in result
assert "192.168.1.50" in result
assert "10.0.0.1" not in result # outside target subnet
assert "192.168.1.99" not in result # zero MAC skipped
def test_arp_table_hosts_parses_macos_arp_output():
from app.services.scanner import _arp_table_hosts
arp_output = (
"router.lan (192.168.1.1) at aa:bb:cc:dd:ee:01 on en0 ifscope [ethernet]\n"
"device.lan (192.168.1.20) at aa:bb:cc:dd:ee:02 on en0 ifscope [ethernet]\n"
"? (192.168.1.99) at (incomplete) on en0 ifscope [ethernet]\n"
"? (10.0.0.1) at aa:bb:cc:dd:ee:04 on en0 ifscope [ethernet]\n" # outside subnet
)
mock_result = MagicMock()
mock_result.stdout = arp_output
with patch("builtins.open", side_effect=FileNotFoundError), \
patch("subprocess.run", return_value=mock_result), \
patch("app.services.scanner._resolve_hostname", return_value=None):
result = _arp_table_hosts("192.168.1.0/24")
assert "192.168.1.1" in result
assert "192.168.1.20" in result
assert "192.168.1.99" not in result # incomplete MAC
assert "10.0.0.1" not in result # outside subnet
# ---------------------------------------------------------------------------
# _nmap_scan_single (Phase 2 per-IP worker)
# ---------------------------------------------------------------------------
def test_nmap_scan_single_detects_open_ports():
from app.services.scanner import _nmap_scan_single
host = {"ip": "192.168.1.10", "hostname": None, "mac": None, "os": None, "open_ports": []}
# Build a realistic host entry: protocols → ports → port info
port_info = {80: {"state": "open", "product": "nginx", "version": "1.24"}}
mock_host = MagicMock()
mock_host.all_protocols.return_value = ["tcp"]
mock_host.__getitem__ = MagicMock(return_value=port_info)
mock_host.get.return_value = {}
mock_nm = MagicMock() mock_nm = MagicMock()
mock_nm.all_hosts.return_value = ["192.168.1.10"] mock_nm.all_hosts.return_value = ["192.168.1.10"]
mock_nm.__getitem__ = lambda self, host: MagicMock( mock_nm.__getitem__ = MagicMock(return_value=mock_host)
all_protocols=lambda: ["tcp"],
**{"__getitem__": lambda self2, proto: {
80: {"state": "open", "product": "nginx", "version": "1.24"},
}},
get=lambda key, default=None: default,
)
with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm), \ with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm), \
patch("app.services.scanner._extract_os", return_value=None): patch("app.services.scanner._extract_os", return_value=None):
result = _nmap_port_scan(alive) result = _nmap_scan_single(host)
assert len(result) == 1 assert len(result["open_ports"]) == 1
assert result[0]["open_ports"][0]["port"] == 80 assert result["open_ports"][0]["port"] == 80
assert result["open_ports"][0]["banner"] == "nginx 1.24"
def test_nmap_port_scan_returns_arp_only_on_failure(): def test_nmap_scan_single_returns_host_unchanged_on_error():
from app.services.scanner import _nmap_port_scan from app.services.scanner import _nmap_scan_single
alive = {
"192.168.1.20": {"ip": "192.168.1.20", "hostname": None, "mac": None, "os": None, "open_ports": []},
}
host = {"ip": "192.168.1.20", "hostname": None, "mac": None, "os": None, "open_ports": []}
mock_nm = MagicMock() mock_nm = MagicMock()
mock_nm.scan.side_effect = Exception("nmap error") mock_nm.scan.side_effect = Exception("nmap error")
with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm): with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm):
result = _nmap_port_scan(alive) result = _nmap_scan_single(host)
# Should return the ARP-found host even though port scan failed assert result["ip"] == "192.168.1.20"
assert len(result) == 1 assert result["open_ports"] == []
assert result[0]["ip"] == "192.168.1.20"
assert result[0]["open_ports"] == []
def test_nmap_port_scan_includes_hosts_with_no_open_ports(): def test_nmap_scan_single_returns_host_unchanged_when_no_results():
"""IoT devices found by ARP but with no open TCP ports must still be returned.""" """Host confirmed alive in Phase 1 but all ports filtered — keep it with empty ports."""
from app.services.scanner import _nmap_port_scan from app.services.scanner import _nmap_scan_single
alive = { host = {"ip": "192.168.1.30", "hostname": "shelly1.lan", "mac": "34:94:54:aa:bb:cc", "os": None, "open_ports": []}
"192.168.1.30": {"ip": "192.168.1.30", "hostname": "shelly1.lan", "mac": "34:94:54:aa:bb:cc", "os": None, "open_ports": []},
"192.168.1.31": {"ip": "192.168.1.31", "hostname": None, "mac": None, "os": None, "open_ports": []},
}
# Port scan returns only 192.168.1.31 (e.g., .30 filtered all ports)
mock_nm = MagicMock() mock_nm = MagicMock()
mock_nm.all_hosts.return_value = ["192.168.1.31"] mock_nm.all_hosts.return_value = [] # no results
mock_nm.__getitem__ = lambda self, host: MagicMock(
all_protocols=lambda: [],
get=lambda key, default=None: default,
)
with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm), \ with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm):
patch("app.services.scanner._extract_os", return_value=None): result = _nmap_scan_single(host)
result = _nmap_port_scan(alive)
ips = {h["ip"] for h in result} assert result["ip"] == "192.168.1.30"
assert "192.168.1.30" in ips, "ARP-found device with no open ports must still be returned" assert result["open_ports"] == []
assert "192.168.1.31" in ips assert result["mac"] == "34:94:54:aa:bb:cc" # preserved from Phase 1
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _nmap_scan (integration of both phases) # _nmap_scan
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_nmap_scan_uses_mock_when_nmap_unavailable(): @pytest.mark.asyncio
async def test_nmap_scan_uses_mock_when_nmap_unavailable():
from app.services.scanner import _nmap_scan from app.services.scanner import _nmap_scan
with patch("app.services.scanner._NMAP_AVAILABLE", False): with patch("app.services.scanner._NMAP_AVAILABLE", False):
result = _nmap_scan("192.168.1.0/24") result = await _nmap_scan("192.168.1.0/24")
assert len(result) == 1 assert len(result) == 1
assert result[0]["ip"] == "192.168.1.99" assert result[0]["ip"] == "192.168.1.99"
@pytest.mark.asyncio
async def test_nmap_scan_raises_on_sweep_error():
from app.services.scanner import _nmap_scan
with patch("app.services.scanner._ping_sweep", side_effect=Exception("ping sweep failed")), \
pytest.raises(RuntimeError, match="ping sweep failed"):
await _nmap_scan("192.168.1.0/24")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _mdns_discover # _mdns_discover
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -223,6 +335,47 @@ async def test_mdns_discover_returns_devices():
assert result[0]["hostname"] == "shelly1.local." assert result[0]["hostname"] == "shelly1.local."
# ---------------------------------------------------------------------------
# _nmap_port_scan (Phase 2 concurrency)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_nmap_port_scan_returns_empty_when_no_alive_hosts():
from app.services.scanner import _nmap_port_scan
result = await _nmap_port_scan({})
assert result == []
@pytest.mark.asyncio
async def test_nmap_port_scan_tolerates_single_host_exception():
"""A single per-host failure should not abort the entire Phase 2 gather."""
from app.services.scanner import _nmap_port_scan
hosts = {
"192.168.1.1": {"ip": "192.168.1.1", "hostname": None, "mac": None, "os": None, "open_ports": []},
"192.168.1.2": {"ip": "192.168.1.2", "hostname": None, "mac": None, "os": None, "open_ports": []},
}
call_count = 0
def _flaky_scan(host_dict):
nonlocal call_count
call_count += 1
if host_dict["ip"] == "192.168.1.1":
raise RuntimeError("simulated nmap crash")
return host_dict
with patch("app.services.scanner._nmap_scan_single", side_effect=_flaky_scan), \
patch("app.services.scanner._NMAP_AVAILABLE", True):
result = await _nmap_port_scan(hosts)
assert call_count == 2
# The crashing host is dropped; the healthy one survives
assert len(result) == 1
assert result[0]["ip"] == "192.168.1.2"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# run_scan integration # run_scan integration
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -245,9 +398,7 @@ async def test_run_scan_adds_nmap_devices_as_pending(mem_db):
await run_scan(["192.168.1.0/24"], session, run_id) await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session: async with mem_db() as session:
result = await session.execute( result = await session.execute(sa_select(PendingDevice))
__import__("sqlalchemy", fromlist=["select"]).select(PendingDevice)
)
devices = result.scalars().all() devices = result.scalars().all()
assert any(d.ip == "192.168.1.5" for d in devices) assert any(d.ip == "192.168.1.5" for d in devices)
@@ -272,12 +423,12 @@ async def test_run_scan_mdns_only_device_added(mem_db):
await run_scan(["192.168.1.0/24"], session, run_id) await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session: async with mem_db() as session:
from sqlalchemy import select as sa_select
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.80")) result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.80"))
device = result.scalar_one_or_none() device = result.scalar_one_or_none()
assert device is not None assert device is not None
assert device.status == "pending" assert device.status == "pending"
assert device.discovery_source == "mdns"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -299,8 +450,86 @@ async def test_run_scan_mdns_skipped_if_already_in_nmap(mem_db):
await run_scan(["192.168.1.0/24"], session, run_id) await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session: async with mem_db() as session:
from sqlalchemy import select as sa_select
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.10")) result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.10"))
devices = result.scalars().all() devices = result.scalars().all()
assert len(devices) == 1 # not duplicated assert len(devices) == 1 # not duplicated
@pytest.mark.asyncio
async def test_run_scan_skips_canvas_nodes(mem_db):
"""Hosts already approved onto the canvas must be skipped."""
from app.services.scanner import run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
canvas_node = Node(
id=str(uuid.uuid4()), label="PVE", type="proxmox",
ip="192.168.1.100", status="online",
)
session.add(canvas_node)
await session.commit()
nmap_hosts = [{"ip": "192.168.1.100", "hostname": "pve.lan", "mac": None, "os": None, "open_ports": []}]
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.100"))
assert result.scalar_one_or_none() is None
@pytest.mark.asyncio
async def test_run_scan_skips_hidden_devices(mem_db):
"""Hosts hidden by the user must not re-appear in pending."""
from app.services.scanner import run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
hidden = PendingDevice(ip="192.168.1.55", status="hidden")
session.add(hidden)
await session.commit()
nmap_hosts = [{"ip": "192.168.1.55", "hostname": None, "mac": None, "os": None, "open_ports": []}]
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
result = await session.execute(
sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.55", PendingDevice.status == "pending")
)
assert result.scalar_one_or_none() is None
@pytest.mark.asyncio
async def test_run_scan_cancelled_marks_status_cancelled(mem_db):
"""Cancelling a running scan sets the ScanRun status to 'cancelled'."""
from app.services.scanner import request_cancel, run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
await session.commit()
request_cancel(run_id)
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", return_value=[]), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
run = await session.get(ScanRun, run_id)
assert run is not None
assert run.status == "cancelled"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "1.7.1", "version": "1.8.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+29 -2
View File
@@ -44,6 +44,8 @@ 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<'pending' | undefined>(undefined)
const [highlightPendingId, setHighlightPendingId] = useState<string | undefined>(undefined)
const [shortcutsOpen, setShortcutsOpen] = useState(false) const [shortcutsOpen, setShortcutsOpen] = useState(false)
const [addNodeOpen, setAddNodeOpen] = useState(false) const [addNodeOpen, setAddNodeOpen] = useState(false)
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false) const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
@@ -370,6 +372,8 @@ export default function App() {
onScan={() => setScanConfigOpen(true)} onScan={() => setScanConfigOpen(true)}
onSave={handleSave} onSave={handleSave}
onNodeApproved={setEditNodeId} onNodeApproved={setEditNodeId}
forceView={sidebarForceView}
highlightPendingId={highlightPendingId}
/> />
<div className="flex flex-col flex-1 min-w-0"> <div className="flex flex-col flex-1 min-w-0">
<Toolbar <Toolbar
@@ -386,7 +390,19 @@ export default function App() {
/> />
<div className="flex flex-1 min-h-0"> <div className="flex flex-1 min-h-0">
<div ref={canvasRef} className="flex-1 min-w-0 h-full"> <div ref={canvasRef} className="flex-1 min-w-0 h-full">
<CanvasContainer onConnect={handleEdgeConnect} onEdgeDoubleClick={handleEdgeDoubleClick} onNodeDragStart={snapshotHistory} /> <CanvasContainer
onConnect={handleEdgeConnect}
onEdgeDoubleClick={handleEdgeDoubleClick}
onNodeDragStart={snapshotHistory}
onOpenPending={(deviceId) => {
setHighlightPendingId(undefined)
setSidebarForceView(undefined)
setTimeout(() => {
setHighlightPendingId(deviceId)
setSidebarForceView('pending')
}, 0)
}}
/>
</div> </div>
{(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />} {(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
</div> </div>
@@ -484,7 +500,18 @@ export default function App() {
onClose={() => setThemeModalOpen(false)} onClose={() => setThemeModalOpen(false)}
/> />
<SearchModal open={searchOpen} onClose={() => setSearchOpen(false)} /> <SearchModal
open={searchOpen}
onClose={() => setSearchOpen(false)}
onOpenPending={(deviceId) => {
setHighlightPendingId(undefined)
setSidebarForceView(undefined)
setTimeout(() => {
setHighlightPendingId(deviceId)
setSidebarForceView('pending')
}, 0)
}}
/>
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} /> <ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
<Toaster theme="dark" position="bottom-right" /> <Toaster theme="dark" position="bottom-right" />
+1
View File
@@ -56,6 +56,7 @@ export const scanApi = {
pending: () => api.get('/scan/pending'), pending: () => api.get('/scan/pending'),
hidden: () => api.get('/scan/hidden'), hidden: () => api.get('/scan/hidden'),
runs: () => api.get('/scan/runs'), runs: () => api.get('/scan/runs'),
clearPending: () => api.delete('/scan/pending'),
approve: (id: string, nodeData: object) => api.post(`/scan/pending/${id}/approve`, nodeData), approve: (id: string, nodeData: object) => api.post(`/scan/pending/${id}/approve`, nodeData),
hide: (id: string) => api.post(`/scan/pending/${id}/hide`), hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`), ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
+12 -2
View File
@@ -18,6 +18,7 @@ import {
BackgroundVariant, BackgroundVariant,
Controls, Controls,
ConnectionMode, ConnectionMode,
useReactFlow,
type Node, type Node,
} from '@xyflow/react' } from '@xyflow/react'
import '@xyflow/react/dist/style.css' import '@xyflow/react/dist/style.css'
@@ -36,7 +37,8 @@ const STORAGE_KEY = 'homelable_canvas'
type ViewState = 'loading' | 'disabled' | 'invalid-key' | 'no-key' | 'network-error' | 'ready' type ViewState = 'loading' | 'disabled' | 'invalid-key' | 'no-key' | 'network-error' | 'ready'
function LiveViewCanvas() { function LiveViewCanvas() {
const { nodes, edges, loadCanvas } = useCanvasStore() const { nodes, edges, loadCanvas, fitViewPending, clearFitViewPending } = useCanvasStore()
const { fitView } = useReactFlow()
const activeTheme = useThemeStore((s) => s.activeTheme) const activeTheme = useThemeStore((s) => s.activeTheme)
const theme = THEMES[activeTheme] const theme = THEMES[activeTheme]
// Derive initial view state synchronously (avoids calling setState inside an effect): // Derive initial view state synchronously (avoids calling setState inside an effect):
@@ -87,6 +89,15 @@ function LiveViewCanvas() {
}) })
}, [loadCanvas]) }, [loadCanvas])
useEffect(() => {
if (!fitViewPending || nodes.length === 0) return
const id = setTimeout(() => {
fitView({ padding: 0.12, duration: 350 })
clearFitViewPending()
}, 50)
return () => clearTimeout(id)
}, [fitViewPending, nodes.length, fitView, clearFitViewPending])
const onNodeClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => { const onNodeClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => {
const ip = node.data.ip const ip = node.data.ip
if (ip) window.open(`http://${ip}`, '_blank', 'noopener,noreferrer') if (ip) window.open(`http://${ip}`, '_blank', 'noopener,noreferrer')
@@ -129,7 +140,6 @@ function LiveViewCanvas() {
elementsSelectable={false} elementsSelectable={false}
panOnDrag panOnDrag
zoomOnScroll zoomOnScroll
fitView
colorMode={theme.colors.reactFlowColorMode} colorMode={theme.colors.reactFlowColorMode}
connectionMode={ConnectionMode.Loose} connectionMode={ConnectionMode.Loose}
onNodeClick={onNodeClick} onNodeClick={onNodeClick}
@@ -11,6 +11,7 @@ vi.mock('@xyflow/react', () => ({
Controls: () => null, Controls: () => null,
BackgroundVariant: { Dots: 'dots' }, BackgroundVariant: { Dots: 'dots' },
ConnectionMode: { Loose: 'loose' }, ConnectionMode: { Loose: 'loose' },
useReactFlow: () => ({ fitView: vi.fn() }),
})) }))
vi.mock('@xyflow/react/dist/style.css', () => ({})) vi.mock('@xyflow/react/dist/style.css', () => ({}))
@@ -50,6 +51,8 @@ describe('LiveView (non-standalone)', () => {
useCanvasStore.setState({ nodes: [], edges: [] }) useCanvasStore.setState({ nodes: [], edges: [] })
}) })
afterEach(() => { setSearch('') })
// ── No key ──────────────────────────────────────────────────────────────── // ── No key ────────────────────────────────────────────────────────────────
it('shows no-key error when ?key= is missing', async () => { it('shows no-key error when ?key= is missing', async () => {
@@ -133,11 +136,25 @@ describe('LiveView (non-standalone)', () => {
// ── Standalone mode ──────────────────────────────────────────────────────── // ── Standalone mode ────────────────────────────────────────────────────────
const XYFLOW_MOCK = {
ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
ReactFlow: () => <div data-testid="react-flow" />,
Background: () => null,
Controls: () => null,
BackgroundVariant: { Dots: 'dots' },
ConnectionMode: { Loose: 'loose' },
useReactFlow: () => ({ fitView: vi.fn() }),
}
describe('LiveView (standalone — localStorage)', () => { describe('LiveView (standalone — localStorage)', () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear() localStorage.clear()
useCanvasStore.setState({ nodes: [], edges: [] }) useCanvasStore.setState({ nodes: [], edges: [] })
vi.mocked(liveviewApi.load).mockReset() })
afterEach(() => {
setSearch('')
vi.unstubAllEnvs()
}) })
it('loads canvas from localStorage without calling the API', async () => { it('loads canvas from localStorage without calling the API', async () => {
@@ -151,25 +168,12 @@ describe('LiveView (standalone — localStorage)', () => {
} }
localStorage.setItem('homelable_canvas', JSON.stringify(stored)) localStorage.setItem('homelable_canvas', JSON.stringify(stored))
// Stub VITE_STANDALONE before re-importing
vi.stubEnv('VITE_STANDALONE', 'true')
vi.resetModules()
const { default: LiveViewStandalone } = await import('../LiveView')
setSearch('') // no key needed in standalone
render(<LiveViewStandalone />)
await waitFor(() => {
expect(screen.getByTestId('react-flow')).toBeDefined()
})
expect(liveviewApi.load).not.toHaveBeenCalled()
vi.unstubAllEnvs()
})
it('shows canvas (empty) when localStorage has no saved data', async () => {
vi.stubEnv('VITE_STANDALONE', 'true') vi.stubEnv('VITE_STANDALONE', 'true')
vi.resetModules() vi.resetModules()
const mockLoad = vi.fn()
vi.doMock('@xyflow/react', () => XYFLOW_MOCK)
vi.doMock('@xyflow/react/dist/style.css', () => ({}))
vi.doMock('@/api/client', () => ({ liveviewApi: { load: mockLoad } }))
const { default: LiveViewStandalone } = await import('../LiveView') const { default: LiveViewStandalone } = await import('../LiveView')
setSearch('') setSearch('')
@@ -178,8 +182,24 @@ describe('LiveView (standalone — localStorage)', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('react-flow')).toBeDefined() expect(screen.getByTestId('react-flow')).toBeDefined()
}) })
expect(liveviewApi.load).not.toHaveBeenCalled() expect(mockLoad).not.toHaveBeenCalled()
})
vi.unstubAllEnvs() it('shows canvas (empty) when localStorage has no saved data', async () => {
vi.stubEnv('VITE_STANDALONE', 'true')
vi.resetModules()
const mockLoad = vi.fn()
vi.doMock('@xyflow/react', () => XYFLOW_MOCK)
vi.doMock('@xyflow/react/dist/style.css', () => ({}))
vi.doMock('@/api/client', () => ({ liveviewApi: { load: mockLoad } }))
const { default: LiveViewStandalone } = await import('../LiveView')
setSearch('')
render(<LiveViewStandalone />)
await waitFor(() => {
expect(screen.getByTestId('react-flow')).toBeDefined()
})
expect(mockLoad).not.toHaveBeenCalled()
}) })
}) })
@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { import {
ReactFlow, ReactFlow,
Background, Background,
@@ -7,6 +7,7 @@ import {
BackgroundVariant, BackgroundVariant,
ConnectionMode, ConnectionMode,
SelectionMode, SelectionMode,
useReactFlow,
type Node, type Node,
type Edge, type Edge,
type Connection, type Connection,
@@ -25,15 +26,28 @@ interface CanvasContainerProps {
onConnect?: (connection: Connection) => void onConnect?: (connection: Connection) => void
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
onNodeDragStart?: () => void onNodeDragStart?: () => void
onOpenPending?: (deviceId: string) => void
} }
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart }: CanvasContainerProps) { export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart, onOpenPending }: CanvasContainerProps) {
const [lassoMode, setLassoMode] = useState(true) const [lassoMode, setLassoMode] = useState(true)
const { const {
nodes, edges, nodes, edges,
onNodesChange, onEdgesChange, onNodesChange, onEdgesChange,
setSelectedNode, snapshotHistory, setSelectedNode, snapshotHistory,
fitViewPending, clearFitViewPending,
} = useCanvasStore() } = useCanvasStore()
const { fitView } = useReactFlow()
// Fit view after canvas loads (fitViewPending is set by loadCanvas)
useEffect(() => {
if (!fitViewPending || nodes.length === 0) return
const id = setTimeout(() => {
fitView({ padding: 0.12, duration: 350 })
clearFitViewPending()
}, 50)
return () => clearTimeout(id)
}, [fitViewPending, nodes.length, fitView, clearFitViewPending])
const activeTheme = useThemeStore((s) => s.activeTheme) const activeTheme = useThemeStore((s) => s.activeTheme)
const theme = THEMES[activeTheme] const theme = THEMES[activeTheme]
@@ -77,7 +91,6 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
multiSelectionKeyCode={['Meta', 'Control']} multiSelectionKeyCode={['Meta', 'Control']}
snapToGrid snapToGrid
snapGrid={[16, 16]} snapGrid={[16, 16]}
fitView
colorMode={theme.colors.reactFlowColorMode} colorMode={theme.colors.reactFlowColorMode}
elevateNodesOnSelect={false} elevateNodesOnSelect={false}
connectionMode={ConnectionMode.Loose} connectionMode={ConnectionMode.Loose}
@@ -89,7 +102,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
size={1} size={1}
color={theme.colors.canvasDotColor} color={theme.colors.canvasDotColor}
/> />
<SearchBar /> <SearchBar onOpenPending={onOpenPending} />
<Controls> <Controls>
<ControlButton <ControlButton
onClick={() => setLassoMode((m) => !m)} onClick={() => setLassoMode((m) => !m)}
+64 -6
View File
@@ -2,15 +2,27 @@ import { useState, useEffect, useRef } from 'react'
import { useReactFlow } from '@xyflow/react' import { useReactFlow } from '@xyflow/react'
import { Search, X } from 'lucide-react' import { Search, X } from 'lucide-react'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { scanApi } from '@/api/client'
import { NODE_TYPE_LABELS } from '@/types' import { NODE_TYPE_LABELS } from '@/types'
import type { PendingDevice } from '@/components/modals/PendingDeviceModal'
export function SearchBar() { interface SearchBarProps {
onOpenPending?: (deviceId: string) => void
}
export function SearchBar({ onOpenPending }: SearchBarProps) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [pendingDevices, setPendingDevices] = useState<PendingDevice[]>([])
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const { nodes, setSelectedNode } = useCanvasStore() const { nodes, setSelectedNode } = useCanvasStore()
const { setCenter } = useReactFlow() const { setCenter } = useReactFlow()
useEffect(() => {
if (!open) return
scanApi.pending().then((res) => setPendingDevices(res.data)).catch(() => {})
}, [open])
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') { if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
@@ -31,7 +43,7 @@ export function SearchBar() {
}, [open]) }, [open])
const q = query.toLowerCase().trim() const q = query.toLowerCase().trim()
const results = q const nodeResults = q
? nodes.filter((n) => { ? nodes.filter((n) => {
if (n.data.type === 'groupRect') return false if (n.data.type === 'groupRect') return false
return ( return (
@@ -43,6 +55,19 @@ export function SearchBar() {
}) })
: [] : []
const pendingResults = q
? pendingDevices.filter((d) =>
d.ip.toLowerCase().includes(q) ||
d.hostname?.toLowerCase().includes(q) ||
d.services.some((s) =>
s.service_name?.toLowerCase().includes(q) ||
s.category?.toLowerCase().includes(q)
)
).slice(0, 4)
: []
const totalResults = nodeResults.length + pendingResults.length
const goToNode = (id: string) => { const goToNode = (id: string) => {
const node = nodes.find((n) => n.id === id) const node = nodes.find((n) => n.id === id)
if (!node) return if (!node) return
@@ -101,7 +126,7 @@ export function SearchBar() {
/> />
{query && ( {query && (
<span style={{ fontSize: 11, color: '#6e7681', flexShrink: 0 }}> <span style={{ fontSize: 11, color: '#6e7681', flexShrink: 0 }}>
{results.length} result{results.length !== 1 ? 's' : ''} {totalResults} result{totalResults !== 1 ? 's' : ''}
</span> </span>
)} )}
<button <button
@@ -113,9 +138,9 @@ export function SearchBar() {
</button> </button>
</div> </div>
{results.length > 0 && ( {totalResults > 0 && (
<div style={{ borderTop: '1px solid #30363d', maxHeight: 260, overflowY: 'auto' }}> <div style={{ borderTop: '1px solid #30363d', maxHeight: 260, overflowY: 'auto' }}>
{results.map((n) => ( {nodeResults.map((n) => (
<button <button
key={n.id} key={n.id}
onClick={() => goToNode(n.id)} onClick={() => goToNode(n.id)}
@@ -146,10 +171,43 @@ export function SearchBar() {
</span> </span>
</button> </button>
))} ))}
{pendingResults.length > 0 && nodeResults.length > 0 && (
<div style={{ height: 1, background: '#30363d', margin: '2px 0' }} />
)}
{pendingResults.map((d) => {
const serviceName = d.services.find((s) => s.service_name)?.service_name
return (
<button
key={d.id}
onClick={() => { onOpenPending?.(d.id); setOpen(false); setQuery('') }}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '7px 12px',
background: 'none',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = '#21262d')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}
>
<span style={{ fontSize: 10, color: '#e3b341', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>pending</span>
<span style={{ fontSize: 12, fontWeight: 600, color: '#e6edf3', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{d.hostname ?? d.ip}
</span>
<span style={{ fontSize: 11, color: '#8b949e', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>
{serviceName ?? d.ip}
</span>
</button>
)
})}
</div> </div>
)} )}
{q && results.length === 0 && ( {q && totalResults === 0 && (
<div style={{ borderTop: '1px solid #30363d', padding: '10px 12px', fontSize: 12, color: '#6e7681', textAlign: 'center' }}> <div style={{ borderTop: '1px solid #30363d', padding: '10px 12px', fontSize: 12, color: '#6e7681', textAlign: 'center' }}>
No results for &ldquo;{query}&rdquo; No results for &ldquo;{query}&rdquo;
</div> </div>
@@ -20,6 +20,7 @@ vi.mock('@xyflow/react', () => ({
BackgroundVariant: { Dots: 'dots' }, BackgroundVariant: { Dots: 'dots' },
ConnectionMode: { Loose: 'loose' }, ConnectionMode: { Loose: 'loose' },
SelectionMode: { Partial: 'partial' }, SelectionMode: { Partial: 'partial' },
useReactFlow: () => ({ fitView: vi.fn() }),
})) }))
vi.mock('@xyflow/react/dist/style.css', () => ({})) vi.mock('@xyflow/react/dist/style.css', () => ({}))
@@ -26,7 +26,7 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
const isBidirectional = sourceType === 'proxmox' && targetType === 'proxmox' const isBidirectional = sourceType === 'proxmox' && targetType === 'proxmox'
const pathArgs = { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition } const pathArgs = { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition }
const [edgePath, labelX, labelY] = data?.path_style === 'smooth' const [edgePath, labelX] = data?.path_style === 'smooth'
? getSmoothStepPath({ ...pathArgs, borderRadius: 8 }) ? getSmoothStepPath({ ...pathArgs, borderRadius: 8 })
: getBezierPath(pathArgs) : getBezierPath(pathArgs)
@@ -95,9 +95,9 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
{data?.label && ( {data?.label && (
<EdgeLabelRenderer> <EdgeLabelRenderer>
<div <div
className="absolute pointer-events-none font-mono text-[10px] px-1 rounded" className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded"
style={{ style={{
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`, transform: `translate(-50%, -50%) translate(${labelX}px, ${(sourceY + targetY) / 2}px)`,
background: theme.colors.edgeLabelBackground, background: theme.colors.edgeLabelBackground,
color: theme.colors.edgeLabelColor, color: theme.colors.edgeLabelColor,
border: `1px solid ${theme.colors.edgeLabelBorder}`, border: `1px solid ${theme.colors.edgeLabelBorder}`,
@@ -1,5 +1,5 @@
import { createElement } from 'react' import { createElement, useEffect } from 'react'
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react' import { Handle, Position, NodeResizer, useUpdateNodeInternals, type NodeProps, type Node } from '@xyflow/react'
import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react' import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react'
import type { NodeData } from '@/types' import type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors' import { resolveNodeColors } from '@/utils/nodeColors'
@@ -8,6 +8,7 @@ import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes' import { THEMES } from '@/utils/themes'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { maskIp } from '@/utils/maskIp' import { maskIp } from '@/utils/maskIp'
import { BOTTOM_HANDLE_IDS, BOTTOM_HANDLE_POSITIONS } from '@/utils/handleUtils'
interface BaseNodeProps extends NodeProps<Node<NodeData>> { interface BaseNodeProps extends NodeProps<Node<NodeData>> {
icon: LucideIcon icon: LucideIcon
@@ -18,7 +19,10 @@ function formatStorage(gb: number): string {
return `${gb} GB` return `${gb} GB`
} }
export function BaseNode({ data, selected, icon: typeIcon, width, height }: BaseNodeProps) { export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: BaseNodeProps) {
const updateNodeInternals = useUpdateNodeInternals()
useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals])
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 theme = THEMES[activeTheme] const theme = THEMES[activeTheme]
@@ -141,13 +145,26 @@ export function BaseNode({ data, selected, icon: typeIcon, width, height }: Base
title={data.status} title={data.status}
/> />
<Handle {(BOTTOM_HANDLE_POSITIONS[data.bottom_handles ?? 1] ?? BOTTOM_HANDLE_POSITIONS[1]).map((leftPct, idx) => {
type="source" const sourceId = BOTTOM_HANDLE_IDS[idx]
position={Position.Bottom} const targetId = idx === 0 ? 'bottom-t' : `bottom-${idx + 1}-t`
id="bottom" return (
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }} <span key={sourceId}>
/> <Handle
<Handle type="target" position={Position.Bottom} id="bottom-t" style={{ opacity: 0, width: 12, height: 12 }} /> type="source"
position={Position.Bottom}
id={sourceId}
style={{ left: `${leftPct}%`, background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
/>
<Handle
type="target"
position={Position.Bottom}
id={targetId}
style={{ left: `${leftPct}%`, opacity: 0, width: 12, height: 12 }}
/>
</span>
)
})}
</div> </div>
) )
} }
+81 -58
View File
@@ -7,7 +7,7 @@ import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types' import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors' import { resolveNodeColors } from '@/utils/nodeColors'
import { ICON_REGISTRY, ICON_CATEGORIES } from '@/utils/nodeIcons' import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS } from '@/utils/nodeIcons'
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [ const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
{ label: 'Hardware', types: ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer'] }, { label: 'Hardware', types: ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer'] },
@@ -75,11 +75,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2"> <form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{/* Type */} {/* Type + Icon on the same row */}
<div className="flex flex-col gap-1.5 col-span-2"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Type</Label> <Label className="text-xs text-muted-foreground">Type</Label>
<Select value={form.type} onValueChange={(v) => set('type', v as NodeType)}> <Select value={form.type} onValueChange={(v) => set('type', v as NodeType)}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8"> <SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8 w-full">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]"> <SelectContent className="bg-[#21262d] border-[#30363d]">
@@ -103,7 +103,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
</div> </div>
{/* Icon */} {/* Icon */}
<div className="flex flex-col gap-1.5 col-span-2"> <div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Icon</Label> <Label className="text-xs text-muted-foreground">Icon</Label>
{form.custom_icon && ( {form.custom_icon && (
@@ -120,70 +120,72 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
<button <button
type="button" type="button"
onClick={() => setIconPickerOpen((o) => !o)} onClick={() => setIconPickerOpen((o) => !o)}
className="flex items-center justify-between gap-2 h-8 px-3 rounded-md bg-[#21262d] border border-[#30363d] text-sm hover:border-[#8b949e] transition-colors" className="flex items-center justify-between gap-2 h-8 px-3 rounded-md bg-[#21262d] border border-[#30363d] text-sm hover:border-[#8b949e] transition-colors w-full"
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2 min-w-0">
{(() => { {(() => {
const entry = ICON_REGISTRY.find((e) => e.key === form.custom_icon) const entry = ICON_REGISTRY.find((e) => e.key === form.custom_icon)
if (entry) { if (entry) {
return <>{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff]' })}<span className="text-foreground">{entry.label}</span></> return <>{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff] shrink-0' })}<span className="text-foreground truncate">{entry.label}</span></>
} }
return <span className="text-muted-foreground">Default (from type)</span> const defaultIcon = NODE_TYPE_DEFAULT_ICONS[form.type as NodeType] ?? NODE_TYPE_DEFAULT_ICONS.generic
return <>{createElement(defaultIcon, { size: 13, className: 'text-muted-foreground shrink-0' })}<span className="text-muted-foreground truncate">Default</span></>
})()} })()}
</span> </span>
<ChevronDown size={12} className="text-muted-foreground shrink-0" style={{ transform: iconPickerOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} /> <ChevronDown size={12} className="text-muted-foreground shrink-0" style={{ transform: iconPickerOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} />
</button> </button>
{/* Inline picker panel */}
{iconPickerOpen && (
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d]">
<Input
value={iconSearch}
onChange={(e) => setIconSearch(e.target.value)}
placeholder="Search icons…"
className="bg-[#21262d] border-[#30363d] text-xs h-7"
autoFocus
/>
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
{ICON_CATEGORIES.map((cat) => {
const entries = ICON_REGISTRY.filter(
(e) => e.category === cat &&
(iconSearch === '' || e.label.toLowerCase().includes(iconSearch.toLowerCase()) || e.key.includes(iconSearch.toLowerCase()))
)
if (entries.length === 0) return null
return (
<div key={cat}>
<p className="text-[9px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1">{cat}</p>
<div className="grid grid-cols-7 gap-1">
{entries.map((entry) => {
const isSelected = form.custom_icon === entry.key
return (
<button
key={entry.key}
type="button"
title={entry.label}
onClick={() => { set('custom_icon', isSelected ? undefined : entry.key); setIconPickerOpen(false) }}
className="flex items-center justify-center w-7 h-7 rounded transition-colors"
style={{
background: isSelected ? '#00d4ff22' : 'transparent',
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
color: isSelected ? '#00d4ff' : '#8b949e',
}}
onMouseEnter={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = '#21262d' }}
onMouseLeave={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
{createElement(entry.icon, { size: 13 })}
</button>
)
})}
</div>
</div>
)
})}
</div>
</div>
)}
</div> </div>
{/* Inline icon picker — full width, shown below the type+icon row */}
{iconPickerOpen && (
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d] col-span-2">
<Input
value={iconSearch}
onChange={(e) => setIconSearch(e.target.value)}
placeholder="Search icons…"
className="bg-[#21262d] border-[#30363d] text-xs h-7"
autoFocus
/>
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
{ICON_CATEGORIES.map((cat) => {
const entries = ICON_REGISTRY.filter(
(e) => e.category === cat &&
(iconSearch === '' || e.label.toLowerCase().includes(iconSearch.toLowerCase()) || e.key.includes(iconSearch.toLowerCase()))
)
if (entries.length === 0) return null
return (
<div key={cat}>
<p className="text-[9px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1">{cat}</p>
<div className="grid grid-cols-7 gap-1">
{entries.map((entry) => {
const isSelected = form.custom_icon === entry.key
return (
<button
key={entry.key}
type="button"
title={entry.label}
onClick={() => { set('custom_icon', isSelected ? undefined : entry.key); setIconPickerOpen(false) }}
className="flex items-center justify-center w-7 h-7 rounded transition-colors"
style={{
background: isSelected ? '#00d4ff22' : 'transparent',
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
color: isSelected ? '#00d4ff' : '#8b949e',
}}
onMouseEnter={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = '#21262d' }}
onMouseLeave={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
{createElement(entry.icon, { size: 13 })}
</button>
)
})}
</div>
</div>
)
})}
</div>
</div>
)}
{/* Label */} {/* Label */}
<div className="flex flex-col gap-1.5 col-span-2"> <div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Label *</Label> <Label className="text-xs text-muted-foreground">Label *</Label>
@@ -414,6 +416,27 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
</div> </div>
)} )}
{/* Bottom connection points (not for group containers) */}
{form.type !== 'groupRect' && form.type !== 'group' && (
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Bottom Connection Points</Label>
<Select
value={String(form.bottom_handles ?? 1)}
onValueChange={(v) => set('bottom_handles', parseInt(v ?? '1', 10))}
>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
<SelectItem value="1" className="text-sm">1 center</SelectItem>
<SelectItem value="2" className="text-sm">2 left / right</SelectItem>
<SelectItem value="3" className="text-sm">3 left / center / right</SelectItem>
<SelectItem value="4" className="text-sm">4 evenly spaced</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Notes */} {/* Notes */}
<div className="flex flex-col gap-1.5 col-span-2"> <div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Notes</Label> <Label className="text-xs text-muted-foreground">Notes</Label>
@@ -19,6 +19,7 @@ export interface PendingDevice {
services: Service[] services: Service[]
suggested_type: string | null suggested_type: string | null
status: string status: string
discovery_source: string | null
discovered_at: string discovered_at: string
} }
@@ -77,7 +78,7 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
const TypeIcon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle const TypeIcon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
const handleApprove = () => { onApprove(device); onClose() } const handleApprove = () => { onApprove(device) }
const handleHide = () => { onHide(device); onClose() } const handleHide = () => { onHide(device); onClose() }
const handleIgnore = () => { onIgnore(device); onClose() } const handleIgnore = () => { onIgnore(device); onClose() }
@@ -101,7 +102,10 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
{device.suggested_type && ( {device.suggested_type && (
<InfoRow label="Type" value={device.suggested_type} /> <InfoRow label="Type" value={device.suggested_type} />
)} )}
<InfoRow label="Discovered" value={new Date(device.discovered_at).toLocaleString()} /> {device.discovery_source && (
<InfoRow label="Source" value={device.discovery_source.toUpperCase()} />
)}
<InfoRow label="Discovered" value={new Date(device.discovered_at.endsWith('Z') ? device.discovered_at : device.discovered_at + 'Z').toLocaleString()} />
</div> </div>
{/* Services */} {/* Services */}
@@ -99,7 +99,6 @@ export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalPro
<DialogFooter className="gap-2"> <DialogFooter className="gap-2">
<Button variant="ghost" onClick={onClose}>Cancel</Button> <Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="outline" onClick={handleSave} disabled={saving}>Save</Button>
<Button <Button
onClick={handleScanNow} onClick={handleScanNow}
disabled={saving} disabled={saving}
+61 -14
View File
@@ -1,34 +1,61 @@
import { useState, useCallback } from 'react' import { useState, useCallback, useEffect } from 'react'
import { useReactFlow } from '@xyflow/react' import { useReactFlow } from '@xyflow/react'
import { Search } from 'lucide-react' import { Search } from 'lucide-react'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { scanApi } from '@/api/client'
import type { PendingDevice } from '@/components/modals/PendingDeviceModal'
interface SearchModalProps { interface SearchModalProps {
open: boolean open: boolean
onClose: () => void onClose: () => void
onOpenPending: (deviceId: string) => void
} }
export function SearchModal({ open, onClose }: SearchModalProps) { export function SearchModal({ open, onClose, onOpenPending }: SearchModalProps) {
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [pendingDevices, setPendingDevices] = useState<PendingDevice[]>([])
const nodes = useCanvasStore((s) => s.nodes) const nodes = useCanvasStore((s) => s.nodes)
const setSelectedNode = useCanvasStore((s) => s.setSelectedNode) const setSelectedNode = useCanvasStore((s) => s.setSelectedNode)
const { fitView } = useReactFlow() const { fitView } = useReactFlow()
useEffect(() => {
if (!open) return
scanApi.pending().then((res) => setPendingDevices(res.data)).catch(() => {})
}, [open])
const searchable = nodes.filter((n) => n.data.type !== 'groupRect') const searchable = nodes.filter((n) => n.data.type !== 'groupRect')
const q = query.toLowerCase() const q = query.toLowerCase()
const results = q.length === 0 ? [] : searchable.filter((n) =>
const nodeResults = q.length === 0 ? [] : searchable.filter((n) =>
n.data.label?.toLowerCase().includes(q) || n.data.label?.toLowerCase().includes(q) ||
n.data.ip?.toLowerCase().includes(q) || n.data.ip?.toLowerCase().includes(q) ||
n.data.hostname?.toLowerCase().includes(q) n.data.hostname?.toLowerCase().includes(q)
).slice(0, 8) ).slice(0, 6)
const handleSelect = useCallback((nodeId: string) => { const pendingResults = q.length === 0 ? [] : pendingDevices.filter((d) =>
d.ip.toLowerCase().includes(q) ||
d.hostname?.toLowerCase().includes(q) ||
d.services.some((s) =>
s.service_name?.toLowerCase().includes(q) ||
s.category?.toLowerCase().includes(q)
)
).slice(0, 4)
const totalResults = nodeResults.length + pendingResults.length
const handleSelectNode = useCallback((nodeId: string) => {
setSelectedNode(nodeId) setSelectedNode(nodeId)
fitView({ nodes: [{ id: nodeId }], duration: 600, padding: 0.4, maxZoom: 1.5 }) fitView({ nodes: [{ id: nodeId }], duration: 600, padding: 0.4, maxZoom: 1.5 })
onClose() onClose()
setQuery('') setQuery('')
}, [fitView, setSelectedNode, onClose]) }, [fitView, setSelectedNode, onClose])
const handleSelectPending = useCallback((deviceId: string) => {
onOpenPending(deviceId)
onClose()
setQuery('')
}, [onOpenPending, onClose])
if (!open) return null if (!open) return null
return ( return (
@@ -43,23 +70,24 @@ export function SearchModal({ open, onClose }: SearchModalProps) {
autoFocus autoFocus
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="Search nodes by label, IP, hostname…" placeholder="Search nodes, pending devices by IP or service…"
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none" className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Escape') { onClose(); setQuery('') } if (e.key === 'Escape') { onClose(); setQuery('') }
if (e.key === 'Enter' && results.length > 0) handleSelect(results[0].id) if (e.key === 'Enter' && nodeResults.length > 0) handleSelectNode(nodeResults[0].id)
if (e.key === 'Enter' && nodeResults.length === 0 && pendingResults.length > 0) handleSelectPending(pendingResults[0].id)
}} }}
/> />
<kbd className="text-[10px] text-muted-foreground border border-border rounded px-1">ESC</kbd> <kbd className="text-[10px] text-muted-foreground border border-border rounded px-1">ESC</kbd>
</div> </div>
{results.length > 0 && ( {totalResults > 0 && (
<ul className="py-1 max-h-64 overflow-y-auto"> <ul className="py-1 max-h-72 overflow-y-auto">
{results.map((node) => ( {nodeResults.map((node) => (
<li <li
key={node.id} key={node.id}
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer" className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
onClick={() => handleSelect(node.id)} onClick={() => handleSelectNode(node.id)}
> >
<span className="text-xs font-mono text-[#00d4ff] w-16 shrink-0">{node.data.type}</span> <span className="text-xs font-mono text-[#00d4ff] w-16 shrink-0">{node.data.type}</span>
<span className="text-sm text-foreground font-medium flex-1 truncate">{node.data.label}</span> <span className="text-sm text-foreground font-medium flex-1 truncate">{node.data.label}</span>
@@ -68,15 +96,34 @@ export function SearchModal({ open, onClose }: SearchModalProps) {
)} )}
</li> </li>
))} ))}
{pendingResults.length > 0 && nodeResults.length > 0 && (
<li className="px-4 py-1">
<div className="h-px bg-border" />
</li>
)}
{pendingResults.map((device) => {
const serviceName = device.services.find((s) => s.service_name)?.service_name
return (
<li
key={device.id}
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
onClick={() => handleSelectPending(device.id)}
>
<span className="text-xs font-mono text-[#e3b341] w-16 shrink-0">pending</span>
<span className="text-sm text-foreground font-medium flex-1 truncate font-mono">{device.hostname ?? device.ip}</span>
<span className="text-xs font-mono text-muted-foreground shrink-0">{serviceName ?? device.ip}</span>
</li>
)
})}
</ul> </ul>
)} )}
{q.length > 0 && results.length === 0 && ( {q.length > 0 && totalResults === 0 && (
<p className="px-4 py-3 text-sm text-muted-foreground">No nodes match "{query}"</p> <p className="px-4 py-3 text-sm text-muted-foreground">No results match "{query}"</p>
)} )}
{q.length === 0 && ( {q.length === 0 && (
<p className="px-4 py-3 text-xs text-muted-foreground">Type to search nodes</p> <p className="px-4 py-3 text-xs text-muted-foreground">Type to search nodes and pending devices</p>
)} )}
</div> </div>
</div> </div>
@@ -1,170 +1,414 @@
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react' import { render, screen, fireEvent } from '@testing-library/react'
import { NodeModal } from '../NodeModal' import { NodeModal } from '../NodeModal'
import type { NodeData } from '@/types'
// ── Mock Shadcn Select with native <select> for testability ───────────────
vi.mock('@/components/ui/select', () => ({
Select: ({ value, onValueChange, children }: {
value?: string; onValueChange?: (v: string) => void; children: React.ReactNode
}) => (
<select value={value} onChange={(e) => onValueChange?.(e.target.value)}>
{children}
</select>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectValue: () => null,
SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectGroup: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectLabel: () => null,
SelectItem: ({ value, children }: { value: string; children: React.ReactNode }) => (
<option value={value}>{children}</option>
),
SelectSeparator: () => null,
}))
// ── Helpers ───────────────────────────────────────────────────────────────
function renderModal(props: Partial<Parameters<typeof NodeModal>[0]> = {}) {
const onClose = vi.fn()
const onSubmit = vi.fn()
render(<NodeModal open onClose={onClose} onSubmit={onSubmit} {...props} />)
return { onClose, onSubmit }
}
/** Get <select> elements in document order: [0]=Type, [1]=CheckMethod, [2]=BottomHandles */
function selects() { return screen.getAllByRole('combobox') as HTMLSelectElement[] }
const BASE: Partial<NodeData> = {
type: 'server', label: 'My Server', hostname: 'server.lan',
ip: '192.168.1.10', check_method: 'ping', services: [],
}
// ── Tests ─────────────────────────────────────────────────────────────────
describe('NodeModal', () => { describe('NodeModal', () => {
// ── Visibility ────────────────────────────────────────────────────────
it('renders nothing when closed', () => { it('renders nothing when closed', () => {
const { container } = render( const { container } = render(<NodeModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />)
<NodeModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />
)
expect(container.querySelector('[role="dialog"]')).toBeNull() expect(container.querySelector('[role="dialog"]')).toBeNull()
}) })
it('renders form fields when open', () => { it('renders form fields when open', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />) renderModal()
expect(screen.getByPlaceholderText('My Server')).toBeDefined() expect(screen.getByPlaceholderText('My Server')).toBeDefined()
expect(screen.getByText('Add Node')).toBeDefined() expect(screen.getByText('Add Node')).toBeDefined()
}) })
it('does not call onSubmit when label is empty and shows error', () => { it('shows "Add" button for default title', () => {
const onSubmit = vi.fn() renderModal()
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />) expect(screen.getByRole('button', { name: 'Add' })).toBeDefined()
fireEvent.click(screen.getByText('Add')) })
it('shows "Save" button when title is Edit Node', () => {
renderModal({ title: 'Edit Node' })
expect(screen.getByRole('button', { name: 'Save' })).toBeDefined()
})
it('pre-fills form from initial prop', () => {
renderModal({ initial: BASE })
expect((screen.getByPlaceholderText('My Server') as HTMLInputElement).value).toBe('My Server')
expect((screen.getByPlaceholderText('server.lan') as HTMLInputElement).value).toBe('server.lan')
expect((screen.getByPlaceholderText('192.168.1.x') as HTMLInputElement).value).toBe('192.168.1.10')
})
// ── Cancel ────────────────────────────────────────────────────────────
it('calls onClose when Cancel is clicked', () => {
const { onClose } = renderModal()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onClose).toHaveBeenCalledOnce()
})
// ── Label validation ──────────────────────────────────────────────────
it('blocks submit and shows error when label is empty', () => {
const { onSubmit } = renderModal()
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect(onSubmit).not.toHaveBeenCalled() expect(onSubmit).not.toHaveBeenCalled()
expect(screen.getByText('Label is required')).toBeDefined() expect(screen.getByText('Label is required')).toBeDefined()
}) })
it('calls onSubmit with form data when label is filled', () => { it('blocks submit when label is whitespace only', () => {
const onSubmit = vi.fn() const { onSubmit } = renderModal()
const onClose = vi.fn() fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: ' ' } })
render(<NodeModal open onClose={onClose} onSubmit={onSubmit} />) fireEvent.click(screen.getByRole('button', { name: 'Add' }))
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'My NAS' } }) expect(onSubmit).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('Add'))
expect(onSubmit).toHaveBeenCalledOnce()
expect(onSubmit.mock.calls[0][0].label).toBe('My NAS')
expect(onClose).toHaveBeenCalledOnce()
}) })
it('clears label error when user starts typing', () => { it('clears label error when user starts typing', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />) renderModal()
fireEvent.click(screen.getByText('Add')) fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect(screen.getByText('Label is required')).toBeDefined()
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'x' } }) fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'x' } })
expect(screen.queryByText('Label is required')).toBeNull() expect(screen.queryByText('Label is required')).toBeNull()
}) })
it('pre-fills form from initial prop', () => { // ── Form submission ───────────────────────────────────────────────────
render(
<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ label: 'Pre-filled', ip: '10.0.0.1' }} />
)
const input = screen.getByPlaceholderText('My Server') as HTMLInputElement
expect(input.value).toBe('Pre-filled')
})
it('shows Save button text when title is Edit Node', () => { it('calls onSubmit and onClose with form data on valid submit', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Node" />) const { onSubmit, onClose } = renderModal({ initial: BASE })
expect(screen.getByText('Save')).toBeDefined() fireEvent.click(screen.getByRole('button', { name: 'Add' }))
}) expect(onSubmit).toHaveBeenCalledOnce()
it('calls onClose when Cancel is clicked', () => {
const onClose = vi.fn()
render(<NodeModal open onClose={onClose} onSubmit={vi.fn()} />)
fireEvent.click(screen.getByText('Cancel'))
expect(onClose).toHaveBeenCalledOnce() expect(onClose).toHaveBeenCalledOnce()
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
expect(data.label).toBe('My Server')
expect(data.type).toBe('server')
}) })
describe('Hardware section', () => { it('submits updated hostname, IP and notes', () => {
it('renders Hardware toggle button', () => { const { onSubmit } = renderModal({ initial: BASE })
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />) fireEvent.change(screen.getByPlaceholderText('server.lan'), { target: { value: 'nas.local' } })
expect(screen.getByText('Hardware')).toBeDefined() fireEvent.change(screen.getByPlaceholderText('192.168.1.x'), { target: { value: '10.0.0.1' } })
}) fireEvent.change(screen.getByPlaceholderText('Optional notes'), { target: { value: 'rack A' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
expect(data.hostname).toBe('nas.local')
expect(data.ip).toBe('10.0.0.1')
expect(data.notes).toBe('rack A')
})
it('hardware fields are hidden by default', () => { it('submits check_target', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />) const { onSubmit } = renderModal({ initial: BASE })
expect(screen.queryByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeNull() fireEvent.change(screen.getByPlaceholderText('http://...'), { target: { value: 'http://192.168.1.10:8080' } })
}) fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).check_target).toBe('http://192.168.1.10:8080')
})
it('expands hardware fields on toggle click', () => { // ── Type selector ─────────────────────────────────────────────────────
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
fireEvent.click(screen.getByText('Hardware'))
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
expect(screen.getByPlaceholderText('e.g. 8')).toBeDefined()
expect(screen.getByPlaceholderText('e.g. 32')).toBeDefined()
expect(screen.getByPlaceholderText('e.g. 500')).toBeDefined()
})
it('submits hardware fields when filled', () => { it('pre-fills type from initial', () => {
const onSubmit = vi.fn() renderModal({ initial: { ...BASE, type: 'router' } })
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />) expect(selects()[0].value).toBe('router')
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Homelab' } }) })
fireEvent.click(screen.getByText('Hardware'))
fireEvent.change(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680'), { target: { value: 'Intel i7-12700K' } })
fireEvent.change(screen.getByPlaceholderText('e.g. 8'), { target: { value: '12' } })
fireEvent.change(screen.getByPlaceholderText('e.g. 32'), { target: { value: '64' } })
fireEvent.change(screen.getByPlaceholderText('e.g. 500'), { target: { value: '2000' } })
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0]
expect(submitted.cpu_model).toBe('Intel i7-12700K')
expect(submitted.cpu_count).toBe(12)
expect(submitted.ram_gb).toBe(64)
expect(submitted.disk_gb).toBe(2000)
})
it('auto-expands when initial has hardware data', () => { it('changes type and submits it', () => {
render( const { onSubmit } = renderModal({ initial: BASE })
<NodeModal fireEvent.change(selects()[0], { target: { value: 'nas' } })
open fireEvent.click(screen.getByRole('button', { name: 'Add' }))
onClose={vi.fn()} expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).type).toBe('nas')
onSubmit={vi.fn()} })
initial={{ label: 'Server', cpu_count: 8, ram_gb: 32 }}
/>
)
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
})
it('hides hardware section for groupRect type', () => { // ── Check method ──────────────────────────────────────────────────────
render(
<NodeModal
open
onClose={vi.fn()}
onSubmit={vi.fn()}
initial={{ type: 'groupRect' }}
/>
)
expect(screen.queryByText('Hardware')).toBeNull()
})
it('show on node toggle is hidden when section is collapsed', () => { it('pre-fills check_method from initial', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />) renderModal({ initial: { ...BASE, check_method: 'http' } })
expect(screen.queryByText('Show on node')).toBeNull() expect(selects()[1].value).toBe('http')
}) })
it('show on node toggle appears when section is expanded', () => { it('changes check_method and submits it', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />) const { onSubmit } = renderModal({ initial: BASE })
fireEvent.click(screen.getByText('Hardware')) fireEvent.change(selects()[1], { target: { value: 'ssh' } })
expect(screen.getByText('Show on node')).toBeDefined() fireEvent.click(screen.getByRole('button', { name: 'Add' }))
}) expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).check_method).toBe('ssh')
})
it('show_hardware defaults to false', () => { // ── Icon picker ───────────────────────────────────────────────────────
const onSubmit = vi.fn()
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
fireEvent.click(screen.getByText('Add'))
expect(onSubmit.mock.calls[0][0].show_hardware).toBeFalsy()
})
it('toggling show on node sets show_hardware to true', () => { it('shows "Default" label when no custom icon', () => {
const onSubmit = vi.fn() renderModal({ initial: BASE })
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />) expect(screen.getByText('Default')).toBeDefined()
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } }) })
fireEvent.click(screen.getByText('Hardware'))
fireEvent.click(screen.getByRole('switch'))
fireEvent.click(screen.getByText('Add'))
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
})
it('pre-fills show_hardware from initial prop', () => { it('opens icon picker on trigger button click', () => {
const onSubmit = vi.fn() renderModal({ initial: BASE })
render( expect(screen.queryByPlaceholderText('Search icons…')).toBeNull()
<NodeModal fireEvent.click(screen.getByText('Default'))
open expect(screen.getByPlaceholderText('Search icons…')).toBeDefined()
onClose={vi.fn()} })
onSubmit={onSubmit}
initial={{ label: 'Node', show_hardware: true, cpu_count: 8 }} it('closes picker and shows icon label after selecting an icon', () => {
/> renderModal({ initial: BASE })
) fireEvent.click(screen.getByText('Default'))
fireEvent.click(screen.getByText('Add')) fireEvent.click(screen.getByTitle('Database (SQL/NoSQL)'))
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true) expect(screen.queryByPlaceholderText('Search icons…')).toBeNull()
expect(screen.getByText('Database (SQL/NoSQL)')).toBeDefined()
})
it('submits custom_icon key after picking', () => {
const { onSubmit } = renderModal({ initial: BASE })
fireEvent.click(screen.getByText('Default'))
fireEvent.click(screen.getByTitle('Database (SQL/NoSQL)'))
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).custom_icon).toBe('database')
})
it('shows Reset button when custom_icon is set', () => {
renderModal({ initial: { ...BASE, custom_icon: 'database' } })
expect(screen.getByRole('button', { name: /Reset/i })).toBeDefined()
})
it('hides Reset button when no custom_icon', () => {
renderModal({ initial: BASE })
expect(screen.queryByRole('button', { name: /Reset/i })).toBeNull()
})
it('resets custom_icon and shows Default on Reset click', () => {
renderModal({ initial: { ...BASE, custom_icon: 'database' } })
fireEvent.click(screen.getByRole('button', { name: /Reset/i }))
expect(screen.getByText('Default')).toBeDefined()
})
it('filters icons by search query', () => {
renderModal({ initial: BASE })
fireEvent.click(screen.getByText('Default'))
fireEvent.change(screen.getByPlaceholderText('Search icons…'), { target: { value: 'grafana' } })
expect(screen.getByTitle('Grafana / Kibana')).toBeDefined()
expect(screen.queryByTitle('Router')).toBeNull()
})
// ── Container mode (proxmox only) ─────────────────────────────────────
it('shows Container Mode toggle for proxmox type', () => {
renderModal({ initial: { ...BASE, type: 'proxmox' } })
expect(screen.getByText('Container Mode')).toBeDefined()
})
it('hides Container Mode for non-proxmox types', () => {
renderModal({ initial: BASE })
expect(screen.queryByText('Container Mode')).toBeNull()
})
it('toggles container_mode on click', () => {
const { onSubmit } = renderModal({ initial: { ...BASE, type: 'proxmox', container_mode: true } })
fireEvent.click(screen.getByRole('switch'))
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).container_mode).toBe(false)
})
// ── Parent Proxmox (vm / lxc only) ───────────────────────────────────
it('shows Parent Proxmox for vm with proxmoxNodes', () => {
renderModal({
initial: { ...BASE, type: 'vm' },
proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }],
}) })
expect(screen.getByText('Parent Proxmox')).toBeDefined()
expect(screen.getByText('PVE-01')).toBeDefined()
})
it('shows Parent Proxmox for lxc with proxmoxNodes', () => {
renderModal({
initial: { ...BASE, type: 'lxc' },
proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }],
})
expect(screen.getByText('Parent Proxmox')).toBeDefined()
})
it('hides Parent Proxmox for server type', () => {
renderModal({ initial: BASE, proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }] })
expect(screen.queryByText('Parent Proxmox')).toBeNull()
})
it('hides Parent Proxmox for vm when no proxmoxNodes', () => {
renderModal({ initial: { ...BASE, type: 'vm' } })
expect(screen.queryByText('Parent Proxmox')).toBeNull()
})
// ── Appearance ────────────────────────────────────────────────────────
it('renders 3 color swatch labels (border, background, icon)', () => {
renderModal({ initial: BASE })
expect(screen.getByText('border')).toBeDefined()
expect(screen.getByText('background')).toBeDefined()
expect(screen.getByText('icon')).toBeDefined()
})
it('shows default colors hint when no custom_colors', () => {
renderModal({ initial: BASE })
expect(screen.getByText(/Using default colors for/)).toBeDefined()
})
it('shows Reset to defaults when custom_colors are set', () => {
renderModal({ initial: { ...BASE, custom_colors: { border: '#ff0000' } } })
expect(screen.getByText('Reset to defaults')).toBeDefined()
})
it('resets custom_colors on Reset to defaults click', () => {
renderModal({ initial: { ...BASE, custom_colors: { border: '#ff0000' } } })
fireEvent.click(screen.getByText('Reset to defaults'))
expect(screen.queryByText('Reset to defaults')).toBeNull()
expect(screen.getByText(/Using default colors for/)).toBeDefined()
})
// ── Hardware section ──────────────────────────────────────────────────
it('renders Hardware toggle button', () => {
renderModal()
expect(screen.getByText('Hardware')).toBeDefined()
})
it('hardware fields are hidden by default', () => {
renderModal()
expect(screen.queryByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeNull()
})
it('expands hardware fields on toggle click', () => {
renderModal()
fireEvent.click(screen.getByText('Hardware'))
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
expect(screen.getByPlaceholderText('e.g. 8')).toBeDefined()
expect(screen.getByPlaceholderText('e.g. 32')).toBeDefined()
expect(screen.getByPlaceholderText('e.g. 500')).toBeDefined()
})
it('auto-expands when initial has hardware data', () => {
renderModal({ initial: { ...BASE, cpu_count: 8, ram_gb: 32 } })
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
})
it('pre-fills hardware fields from initial', () => {
renderModal({ initial: { ...BASE, cpu_model: 'Intel i5', cpu_count: 4, ram_gb: 16, disk_gb: 500 } })
expect((screen.getByPlaceholderText('e.g. Intel Xeon E5-2680') as HTMLInputElement).value).toBe('Intel i5')
})
it('submits hardware fields when filled', () => {
const { onSubmit } = renderModal()
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Homelab' } })
fireEvent.click(screen.getByText('Hardware'))
fireEvent.change(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680'), { target: { value: 'Intel i7-12700K' } })
fireEvent.change(screen.getByPlaceholderText('e.g. 8'), { target: { value: '12' } })
fireEvent.change(screen.getByPlaceholderText('e.g. 32'), { target: { value: '64' } })
fireEvent.change(screen.getByPlaceholderText('e.g. 500'), { target: { value: '2000' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
expect(data.cpu_model).toBe('Intel i7-12700K')
expect(data.cpu_count).toBe(12)
expect(data.ram_gb).toBe(64)
expect(data.disk_gb).toBe(2000)
})
it('hides Hardware section for groupRect type', () => {
renderModal({ initial: { type: 'groupRect' } })
expect(screen.queryByText('Hardware')).toBeNull()
})
it('show_hardware toggle hidden when section is collapsed', () => {
renderModal()
expect(screen.queryByText('Show on node')).toBeNull()
})
it('show_hardware toggle appears when section is expanded', () => {
renderModal()
fireEvent.click(screen.getByText('Hardware'))
expect(screen.getByText('Show on node')).toBeDefined()
})
it('show_hardware defaults to falsy', () => {
const { onSubmit } = renderModal()
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect(onSubmit.mock.calls[0][0].show_hardware).toBeFalsy()
})
it('toggling show_hardware sets it to true', () => {
const { onSubmit } = renderModal()
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
fireEvent.click(screen.getByText('Hardware'))
fireEvent.click(screen.getByRole('switch'))
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
})
it('pre-fills show_hardware from initial', () => {
const { onSubmit } = renderModal({ initial: { label: 'Node', show_hardware: true, cpu_count: 8 } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
})
// ── Bottom connection points ───────────────────────────────────────────
it('shows Bottom Connection Points for server type', () => {
renderModal({ initial: BASE })
expect(screen.getByText('Bottom Connection Points')).toBeDefined()
})
it('hides Bottom Connection Points for groupRect', () => {
renderModal({ initial: { ...BASE, type: 'groupRect' } })
expect(screen.queryByText('Bottom Connection Points')).toBeNull()
})
it('hides Bottom Connection Points for group', () => {
renderModal({ initial: { ...BASE, type: 'group' } })
expect(screen.queryByText('Bottom Connection Points')).toBeNull()
})
it('defaults bottom_handles to 1', () => {
renderModal({ initial: BASE })
expect(selects()[2].value).toBe('1')
})
it('pre-fills bottom_handles from initial', () => {
renderModal({ initial: { ...BASE, bottom_handles: 3 } })
expect(selects()[2].value).toBe('3')
})
it('submits updated bottom_handles', () => {
const { onSubmit } = renderModal({ initial: BASE })
fireEvent.change(selects()[2], { target: { value: '4' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(4)
}) })
}) })
@@ -126,7 +126,7 @@ describe('PendingDeviceModal', () => {
// ── Actions ─────────────────────────────────────────────────────────────── // ── Actions ───────────────────────────────────────────────────────────────
it('calls onApprove with the device and onClose when Approve is clicked', () => { it('calls onApprove with the device when Approve is clicked (parent controls close on success)', () => {
const device = makeDevice() const device = makeDevice()
const onApprove = vi.fn() const onApprove = vi.fn()
const onClose = vi.fn() const onClose = vi.fn()
@@ -135,7 +135,7 @@ describe('PendingDeviceModal', () => {
) )
fireEvent.click(screen.getByRole('button', { name: 'Approve' })) fireEvent.click(screen.getByRole('button', { name: 'Approve' }))
expect(onApprove).toHaveBeenCalledWith(device) expect(onApprove).toHaveBeenCalledWith(device)
expect(onClose).toHaveBeenCalledOnce() expect(onClose).not.toHaveBeenCalled()
}) })
it('calls onHide with the device and onClose when Hide is clicked', () => { it('calls onHide with the device and onClose when Hide is clicked', () => {
@@ -38,16 +38,6 @@ describe('ScanConfigModal', () => {
expect(input).toBeDefined() expect(input).toBeDefined()
}) })
it('saves only ranges (interval managed by settings endpoint)', async () => {
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['10.0.0.0/8'] } } as never)
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('10.0.0.0/8')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['10.0.0.0/8'] })
})
})
it('adds a new empty range on "Add range" click', async () => { it('adds a new empty range on "Add range" click', async () => {
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />) render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24') await screen.findByDisplayValue('192.168.1.0/24')
@@ -59,53 +49,29 @@ describe('ScanConfigModal', () => {
it('delete button disabled when only one range', async () => { it('delete button disabled when only one range', async () => {
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />) render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24') await screen.findByDisplayValue('192.168.1.0/24')
// Only 1 range → delete button disabled
const trashButtons = document.querySelectorAll('button[disabled]') const trashButtons = document.querySelectorAll('button[disabled]')
expect(trashButtons.length).toBeGreaterThan(0) expect(trashButtons.length).toBeGreaterThan(0)
}) })
it('can remove a range when more than one exist', async () => { it('can remove a range when more than one exist', async () => {
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['192.168.1.0/24', '10.0.0.0/8'], } } as never) vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['192.168.1.0/24', '10.0.0.0/8'] } } as never)
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />) render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24') await screen.findByDisplayValue('192.168.1.0/24')
// Both trash buttons should be enabled
const trashButtons = screen.getAllByRole('button').filter((b) => !b.hasAttribute('disabled') && b.querySelector('svg')) const trashButtons = screen.getAllByRole('button').filter((b) => !b.hasAttribute('disabled') && b.querySelector('svg'))
expect(trashButtons.length).toBeGreaterThanOrEqual(2) expect(trashButtons.length).toBeGreaterThanOrEqual(2)
}) })
it('shows error toast and does not save when all ranges are empty', async () => { it('shows error toast and does not save when all ranges are empty', async () => {
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: [''], } } as never) vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: [''] } } as never)
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />) render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await waitFor(() => expect(scanApi.getConfig).toHaveBeenCalled()) await waitFor(() => expect(scanApi.getConfig).toHaveBeenCalled())
fireEvent.click(screen.getByRole('button', { name: 'Save' })) fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
await waitFor(() => { await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Add at least one IP range') expect(toast.error).toHaveBeenCalledWith('Add at least one IP range')
}) })
expect(scanApi.saveConfig).not.toHaveBeenCalled() expect(scanApi.saveConfig).not.toHaveBeenCalled()
}) })
it('saves config and closes on Save click', async () => {
const onClose = vi.fn()
render(<ScanConfigModal open onClose={onClose} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'] })
expect(toast.success).toHaveBeenCalledWith('Scan config saved')
expect(onClose).toHaveBeenCalledOnce()
})
})
it('shows error toast when save fails', async () => {
vi.mocked(scanApi.saveConfig).mockRejectedValue(new Error('network'))
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Failed to save config')
})
})
it('calls onScanNow after saving on "Scan Now" click', async () => { it('calls onScanNow after saving on "Scan Now" click', async () => {
const onScanNow = vi.fn() const onScanNow = vi.fn()
const onClose = vi.fn() const onClose = vi.fn()
@@ -113,7 +79,7 @@ describe('ScanConfigModal', () => {
await screen.findByDisplayValue('192.168.1.0/24') await screen.findByDisplayValue('192.168.1.0/24')
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' })) fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
await waitFor(() => { await waitFor(() => {
expect(scanApi.saveConfig).toHaveBeenCalled() expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'] })
expect(onScanNow).toHaveBeenCalledOnce() expect(onScanNow).toHaveBeenCalledOnce()
}) })
}) })
@@ -126,12 +92,11 @@ describe('ScanConfigModal', () => {
expect(onClose).toHaveBeenCalledOnce() expect(onClose).toHaveBeenCalledOnce()
}) })
it('strips whitespace from ranges before saving', async () => { it('strips whitespace from ranges before scanning', async () => {
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />) render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
const input = await screen.findByDisplayValue('192.168.1.0/24') const input = await screen.findByDisplayValue('192.168.1.0/24')
// Type a range with surrounding whitespace
fireEvent.change(input, { target: { value: ' 10.0.0.0/8 ' } }) fireEvent.change(input, { target: { value: ' 10.0.0.0/8 ' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' })) fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
await waitFor(() => { await waitFor(() => {
expect(scanApi.saveConfig).toHaveBeenCalledWith( expect(scanApi.saveConfig).toHaveBeenCalledWith(
expect.objectContaining({ ranges: ['10.0.0.0/8'] }) expect.objectContaining({ ranges: ['10.0.0.0/8'] })
@@ -10,6 +10,12 @@ vi.mock('@xyflow/react', () => ({
useReactFlow: () => ({ fitView: mockFitView }), useReactFlow: () => ({ fitView: mockFitView }),
})) }))
vi.mock('@/api/client', () => ({
scanApi: { pending: vi.fn().mockResolvedValue({ data: [] }) },
}))
const mockOnOpenPending = vi.fn()
function makeNode(id: string, overrides: Partial<NodeData> = {}): Node<NodeData> { function makeNode(id: string, overrides: Partial<NodeData> = {}): Node<NodeData> {
return { return {
id, id,
@@ -32,32 +38,32 @@ describe('SearchModal', () => {
}) })
it('renders nothing when closed', () => { it('renders nothing when closed', () => {
render(<SearchModal open={false} onClose={vi.fn()} />) render(<SearchModal open={false} onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
expect(screen.queryByPlaceholderText(/search nodes/i)).toBeNull() expect(screen.queryByPlaceholderText(/search nodes/i)).toBeNull()
}) })
it('renders search input when open', () => { it('renders search input when open', () => {
render(<SearchModal open onClose={vi.fn()} />) render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
expect(screen.getByPlaceholderText(/search nodes/i)).toBeDefined() expect(screen.getByPlaceholderText(/search nodes/i)).toBeDefined()
}) })
it('shows "Type to search" hint when query is empty', () => { it('shows "Type to search" hint when query is empty', () => {
render(<SearchModal open onClose={vi.fn()} />) render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
expect(screen.getByText(/type to search/i)).toBeDefined() expect(screen.getByText(/type to search/i)).toBeDefined()
}) })
it('shows no results message when query has no matches', () => { it('shows no results message when query has no matches', () => {
useCanvasStore.setState({ nodes: [makeNode('router', { label: 'Router' })] }) useCanvasStore.setState({ nodes: [makeNode('router', { label: 'Router' })] })
render(<SearchModal open onClose={vi.fn()} />) render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zzz' } }) fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zzz' } })
expect(screen.getByText(/no nodes match/i)).toBeDefined() expect(screen.getByText(/no results match/i)).toBeDefined()
}) })
it('filters nodes by label', () => { it('filters nodes by label', () => {
useCanvasStore.setState({ useCanvasStore.setState({
nodes: [makeNode('n1', { label: 'My Router' }), makeNode('n2', { label: 'NAS Server' })], nodes: [makeNode('n1', { label: 'My Router' }), makeNode('n2', { label: 'NAS Server' })],
}) })
render(<SearchModal open onClose={vi.fn()} />) render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'router' } }) fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'router' } })
expect(screen.getByText('My Router')).toBeDefined() expect(screen.getByText('My Router')).toBeDefined()
expect(screen.queryByText('NAS Server')).toBeNull() expect(screen.queryByText('NAS Server')).toBeNull()
@@ -70,7 +76,7 @@ describe('SearchModal', () => {
makeNode('n2', { label: 'Box B', ip: '10.0.0.1' }), makeNode('n2', { label: 'Box B', ip: '10.0.0.1' }),
], ],
}) })
render(<SearchModal open onClose={vi.fn()} />) render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: '192.168' } }) fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: '192.168' } })
expect(screen.getByText('Box A')).toBeDefined() expect(screen.getByText('Box A')).toBeDefined()
expect(screen.queryByText('Box B')).toBeNull() expect(screen.queryByText('Box B')).toBeNull()
@@ -83,7 +89,7 @@ describe('SearchModal', () => {
makeNode('n2', { label: 'B', hostname: 'nas.local' }), makeNode('n2', { label: 'B', hostname: 'nas.local' }),
], ],
}) })
render(<SearchModal open onClose={vi.fn()} />) render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'pve' } }) fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'pve' } })
expect(screen.getByText('A')).toBeDefined() expect(screen.getByText('A')).toBeDefined()
expect(screen.queryByText('B')).toBeNull() expect(screen.queryByText('B')).toBeNull()
@@ -96,25 +102,25 @@ describe('SearchModal', () => {
makeNode('g1', { label: 'Zone A', type: 'groupRect' }), makeNode('g1', { label: 'Zone A', type: 'groupRect' }),
], ],
}) })
render(<SearchModal open onClose={vi.fn()} />) render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zone' } }) fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zone' } })
expect(screen.getByText(/no nodes match/i)).toBeDefined() expect(screen.getByText(/no results match/i)).toBeDefined()
}) })
it('limits results to 8 nodes', () => { it('limits node results to 6', () => {
useCanvasStore.setState({ useCanvasStore.setState({
nodes: Array.from({ length: 12 }, (_, i) => makeNode(`n${i}`, { label: `Server ${i}` })), nodes: Array.from({ length: 12 }, (_, i) => makeNode(`n${i}`, { label: `Server ${i}` })),
}) })
render(<SearchModal open onClose={vi.fn()} />) render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'server' } }) fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'server' } })
const items = screen.getAllByText(/Server \d/) const items = screen.getAllByText(/Server \d/)
expect(items).toHaveLength(8) expect(items).toHaveLength(6)
}) })
it('selects node and closes on result click', () => { it('selects node and closes on result click', () => {
const onClose = vi.fn() const onClose = vi.fn()
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Proxmox' })] }) useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Proxmox' })] })
render(<SearchModal open onClose={onClose} />) render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'prox' } }) fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'prox' } })
fireEvent.click(screen.getByText('Proxmox')) fireEvent.click(screen.getByText('Proxmox'))
expect(useCanvasStore.getState().selectedNodeId).toBe('n1') expect(useCanvasStore.getState().selectedNodeId).toBe('n1')
@@ -125,7 +131,7 @@ describe('SearchModal', () => {
it('selects first result and closes on Enter key', () => { it('selects first result and closes on Enter key', () => {
const onClose = vi.fn() const onClose = vi.fn()
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Switch' })] }) useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Switch' })] })
render(<SearchModal open onClose={onClose} />) render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
const input = screen.getByPlaceholderText(/search nodes/i) const input = screen.getByPlaceholderText(/search nodes/i)
fireEvent.change(input, { target: { value: 'switch' } }) fireEvent.change(input, { target: { value: 'switch' } })
fireEvent.keyDown(input, { key: 'Enter' }) fireEvent.keyDown(input, { key: 'Enter' })
@@ -135,14 +141,14 @@ describe('SearchModal', () => {
it('closes on Escape key', () => { it('closes on Escape key', () => {
const onClose = vi.fn() const onClose = vi.fn()
render(<SearchModal open onClose={onClose} />) render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
fireEvent.keyDown(screen.getByPlaceholderText(/search nodes/i), { key: 'Escape' }) fireEvent.keyDown(screen.getByPlaceholderText(/search nodes/i), { key: 'Escape' })
expect(onClose).toHaveBeenCalledOnce() expect(onClose).toHaveBeenCalledOnce()
}) })
it('closes when clicking backdrop', () => { it('closes when clicking backdrop', () => {
const onClose = vi.fn() const onClose = vi.fn()
render(<SearchModal open onClose={onClose} />) render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
// The backdrop is the fixed inset div — clicking it fires onClose // The backdrop is the fixed inset div — clicking it fires onClose
const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement
fireEvent.click(backdrop) fireEvent.click(backdrop)
@@ -151,14 +157,14 @@ describe('SearchModal', () => {
it('does not close when clicking inside the search box', () => { it('does not close when clicking inside the search box', () => {
const onClose = vi.fn() const onClose = vi.fn()
render(<SearchModal open onClose={onClose} />) render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
fireEvent.click(screen.getByPlaceholderText(/search nodes/i)) fireEvent.click(screen.getByPlaceholderText(/search nodes/i))
expect(onClose).not.toHaveBeenCalled() expect(onClose).not.toHaveBeenCalled()
}) })
it('search is case-insensitive', () => { it('search is case-insensitive', () => {
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'My NAS' })] }) useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'My NAS' })] })
render(<SearchModal open onClose={vi.fn()} />) render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'MY NAS' } }) fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'MY NAS' } })
expect(screen.getByText('My NAS')).toBeDefined() expect(screen.getByText('My NAS')).toBeDefined()
}) })
@@ -85,6 +85,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
const handleAddService = () => { const handleAddService = () => {
const port = parseInt(newSvc.port, 10) const port = parseInt(newSvc.port, 10)
if (!newSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return if (!newSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
snapshotHistory()
const svc: ServiceInfo = { port, protocol: newSvc.protocol, service_name: newSvc.service_name.trim() } const svc: ServiceInfo = { port, protocol: newSvc.protocol, service_name: newSvc.service_name.trim() }
updateNode(node.id, { services: [...services, svc] }) updateNode(node.id, { services: [...services, svc] })
setNewSvc(EMPTY_FORM) setNewSvc(EMPTY_FORM)
@@ -92,6 +93,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
} }
const handleRemoveService = (index: number) => { const handleRemoveService = (index: number) => {
snapshotHistory()
const updated = services.filter((_, i) => i !== index) const updated = services.filter((_, i) => i !== index)
updateNode(node.id, { services: updated }) updateNode(node.id, { services: updated })
if (editingIndex === index) setEditingFor(null) if (editingIndex === index) setEditingFor(null)
@@ -109,6 +111,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
if (editingIndex === null) return if (editingIndex === null) return
const port = parseInt(editSvc.port, 10) const port = parseInt(editSvc.port, 10)
if (!editSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return if (!editSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
snapshotHistory()
const updated = services.map((svc, i) => const updated = services.map((svc, i) =>
i === editingIndex ? { ...svc, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() } : svc i === editingIndex ? { ...svc, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() } : svc
) )
@@ -147,7 +150,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
{data.mac && <DetailRow label="MAC" value={data.mac} mono />} {data.mac && <DetailRow label="MAC" value={data.mac} mono />}
{data.os && <DetailRow label="OS" value={data.os} />} {data.os && <DetailRow label="OS" value={data.os} />}
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />} {data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
{data.last_seen && <DetailRow label="Last Seen" value={new Date(data.last_seen).toLocaleString()} />} {data.last_seen && <DetailRow label="Last Seen" value={new Date(data.last_seen.endsWith('Z') ? data.last_seen : data.last_seen + 'Z').toLocaleString()} />}
</div> </div>
{(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null) && ( {(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null) && (
+50 -13
View File
@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect, useRef } from 'react' import { useState, useCallback, useEffect, useRef } from 'react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle } from 'lucide-react' import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, X } 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'
@@ -35,11 +35,18 @@ interface SidebarProps {
onScan: () => void onScan: () => void
onSave: () => void onSave: () => void
onNodeApproved: (nodeId: string) => void onNodeApproved: (nodeId: string) => void
forceView?: SidebarView
highlightPendingId?: string
} }
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved }: SidebarProps) { export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) {
const [collapsed, setCollapsed] = useState(false) const [_collapsed, setCollapsed] = useState(false)
const [activeView, setActiveView] = useState<SidebarView>('canvas') const [_activeView, setActiveView] = useState<SidebarView>('canvas')
// When forceView is set, override local state without useEffect
const collapsed = forceView ? false : _collapsed
const activeView = forceView ?? _activeView
const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore() const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect') const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect')
@@ -92,7 +99,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
{/* View content (only when expanded) */} {/* View content (only when expanded) */}
{!collapsed && activeView !== 'canvas' && ( {!collapsed && activeView !== 'canvas' && (
<div className="flex-1 min-h-0 overflow-y-auto border-t border-border"> <div className="flex-1 min-h-0 overflow-y-auto border-t border-border">
{activeView === 'pending' && <PendingDevicesPanel onNodeApproved={onNodeApproved} />} {activeView === 'pending' && <PendingDevicesPanel onNodeApproved={onNodeApproved} highlightId={highlightPendingId} />}
{activeView === 'hidden' && <HiddenDevicesPanel />} {activeView === 'hidden' && <HiddenDevicesPanel />}
{activeView === 'history' && <ScanHistoryPanel />} {activeView === 'history' && <ScanHistoryPanel />}
{activeView === 'settings' && <SettingsPanel />} {activeView === 'settings' && <SettingsPanel />}
@@ -156,11 +163,14 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
) )
} }
function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: string) => void }) { const COMMON_PORTS = new Set([22, 80, 443])
function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved: (nodeId: string) => void; highlightId?: string }) {
const [devices, setDevices] = useState<PendingDevice[]>([]) const [devices, setDevices] = useState<PendingDevice[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [selected, setSelected] = useState<PendingDevice | null>(null) const [selected, setSelected] = useState<PendingDevice | null>(null)
const { addNode, scanEventTs } = useCanvasStore() const { addNode, scanEventTs } = useCanvasStore()
const highlightRef = useRef<HTMLButtonElement>(null)
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true) setLoading(true)
@@ -174,12 +184,27 @@ function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: stri
} }
}, []) }, [])
const handleClearAll = async () => {
try {
await scanApi.clearPending()
setDevices([])
toast.success('Pending devices cleared')
} catch {
toast.error('Failed to clear pending devices')
}
}
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
useEffect(() => { useEffect(() => {
if (scanEventTs > 0) load() if (scanEventTs > 0) load()
}, [scanEventTs, load]) }, [scanEventTs, load])
useEffect(() => {
if (!highlightId || loading) return
highlightRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}, [highlightId, loading])
const handleApprove = async (device: PendingDevice) => { const handleApprove = async (device: PendingDevice) => {
try { try {
const nodeData = { const nodeData = {
@@ -200,6 +225,7 @@ function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: stri
}) })
toast.success(`Approved ${nodeData.label}`) toast.success(`Approved ${nodeData.label}`)
setDevices((prev) => prev.filter((d) => d.id !== device.id)) setDevices((prev) => prev.filter((d) => d.id !== device.id))
setSelected(null)
onNodeApproved(nodeId) onNodeApproved(nodeId)
} catch { } catch {
toast.error('Failed to approve device') toast.error('Failed to approve device')
@@ -230,16 +256,22 @@ function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: stri
<div className="p-2"> <div className="p-2">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Pending</span> <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Pending</span>
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5"> <div className="flex items-center gap-1">
<RefreshCw size={12} /> <button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5" title="Refresh">
</button> <RefreshCw size={12} />
</button>
{devices.length > 0 && (
<button onClick={handleClearAll} className="text-muted-foreground hover:text-[#f85149] p-0.5" title="Clear all pending">
<X size={12} />
</button>
)}
</div>
</div> </div>
{loading && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />} {loading && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
{!loading && devices.length === 0 && ( {!loading && devices.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-4">No pending devices</p> <p className="text-xs text-muted-foreground text-center py-4">No pending devices</p>
)} )}
{devices.map((d) => { {devices.map((d) => {
const COMMON_PORTS = new Set([22, 80, 443])
const namedService = d.services.find((s) => s.category != null && !COMMON_PORTS.has(s.port)) const namedService = d.services.find((s) => s.category != null && !COMMON_PORTS.has(s.port))
const titleService = namedService const titleService = namedService
?? d.services.find((s) => s.port === 80) ?? d.services.find((s) => s.port === 80)
@@ -252,11 +284,15 @@ function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: stri
const hasHttps = d.services.some((s) => s.port === 443) const hasHttps = d.services.some((s) => s.port === 443)
const otherCount = d.services.filter((s) => s.port !== 22 && s.port !== 80 && s.port !== 443).length const otherCount = d.services.filter((s) => s.port !== 22 && s.port !== 80 && s.port !== 443).length
const virtualBadge = detectVirtualBadge(d.mac) const virtualBadge = detectVirtualBadge(d.mac)
const sourceColor = d.discovery_source === 'mdns' ? '#a855f7' : '#8b949e'
const sourceLabel = d.discovery_source === 'mdns' ? 'mDNS' : d.discovery_source === 'arp' ? 'ARP' : null
const isHighlighted = d.id === highlightId
return ( return (
<button <button
key={d.id} key={d.id}
ref={isHighlighted ? highlightRef : null}
onClick={() => setSelected(d)} onClick={() => setSelected(d)}
className="w-full mb-1.5 p-2 rounded-md bg-[#21262d] text-xs text-left hover:bg-[#30363d] transition-colors border border-transparent hover:border-[#30363d]" className={`w-full mb-1.5 p-2 rounded-md text-xs text-left transition-colors border ${isHighlighted ? 'bg-[#2d3748] border-[#e3b341]' : 'bg-[#21262d] border-transparent hover:bg-[#30363d] hover:border-[#30363d]'}`}
> >
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-[#e3b341] shrink-0" /> <span className="w-1.5 h-1.5 rounded-full bg-[#e3b341] shrink-0" />
@@ -265,8 +301,9 @@ function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: stri
{showIpBelow && ( {showIpBelow && (
<div className="font-mono text-muted-foreground truncate pl-3 text-[10px] mt-0.5">{d.ip}</div> <div className="font-mono text-muted-foreground truncate pl-3 text-[10px] mt-0.5">{d.ip}</div>
)} )}
{(hasSsh || hasHttp || hasHttps || otherCount > 0 || virtualBadge) && ( {(hasSsh || hasHttp || hasHttps || otherCount > 0 || virtualBadge || sourceLabel) && (
<div className="flex items-center gap-1 pl-3 mt-1.5 flex-wrap"> <div className="flex items-center gap-1 pl-3 mt-1.5 flex-wrap">
{sourceLabel && <ServiceBadge label={sourceLabel} color={sourceColor} />}
{virtualBadge && ( {virtualBadge && (
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
@@ -447,7 +484,7 @@ function ScanHistoryPanel() {
)} )}
</div> </div>
<div className="text-muted-foreground text-[10px] mt-0.5"> <div className="text-muted-foreground text-[10px] mt-0.5">
{new Date(r.started_at).toLocaleString()} {new Date(r.started_at.endsWith('Z') ? r.started_at : r.started_at + 'Z').toLocaleString()}
</div> </div>
{r.ranges.length > 0 && ( {r.ranges.length > 0 && (
<div className="text-[#8b949e] text-[10px] font-mono truncate">{r.ranges.join(', ')}</div> <div className="text-[#8b949e] text-[10px] font-mono truncate">{r.ranges.join(', ')}</div>
@@ -0,0 +1,269 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { Sidebar } from '../Sidebar'
import { useCanvasStore } from '@/stores/canvasStore'
import type { Node } from '@xyflow/react'
import type { NodeData } from '@/types'
// ── Mocks ────────────────────────────────────────────────────────────────────
vi.mock('@/stores/canvasStore')
vi.mock('@/api/client', () => ({
scanApi: {
trigger: vi.fn().mockResolvedValue({}),
pending: vi.fn().mockResolvedValue({ data: [] }),
hidden: vi.fn().mockResolvedValue({ data: [] }),
runs: vi.fn().mockResolvedValue({ data: [] }),
stop: vi.fn().mockResolvedValue({}),
},
settingsApi: {
get: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }),
save: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }),
},
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('@/components/ui/Logo', () => ({
Logo: ({ showText }: { showText: boolean }) => (
<div data-testid="logo" data-show-text={showText} />
),
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: () => null,
}))
vi.mock('@/components/modals/PendingDeviceModal', () => ({
PendingDeviceModal: () => null,
}))
// ── Helpers ───────────────────────────────────────────────────────────────────
const makeNode = (id: string, status: NodeData['status'], type: NodeData['type'] = 'server'): Node<NodeData> => ({
id,
type,
position: { x: 0, y: 0 },
data: { label: id, type, status, services: [] },
})
const mockToggleHideIp = vi.fn()
function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
vi.mocked(useCanvasStore).mockReturnValue({
nodes: [],
hasUnsavedChanges: false,
hideIp: false,
toggleHideIp: mockToggleHideIp,
addNode: vi.fn(),
scanEventTs: 0,
...overrides,
} as ReturnType<typeof useCanvasStore>)
}
const defaultProps = {
onAddNode: vi.fn(),
onAddGroupRect: vi.fn(),
onScan: vi.fn(),
onSave: vi.fn(),
onNodeApproved: vi.fn(),
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('Sidebar', () => {
beforeEach(() => {
mockStore()
vi.clearAllMocks()
})
// ── Render ─────────────────────────────────────────────────────────────────
it('renders logo and nav items', () => {
render(<Sidebar {...defaultProps} />)
expect(screen.getByTestId('logo')).toBeInTheDocument()
expect(screen.getByText('Add Node')).toBeInTheDocument()
expect(screen.getByText('Save Canvas')).toBeInTheDocument()
expect(screen.getByText('Scan Network')).toBeInTheDocument()
})
it('shows all view nav items', () => {
render(<Sidebar {...defaultProps} />)
expect(screen.getByText('Canvas')).toBeInTheDocument()
expect(screen.getByText('Pending Devices')).toBeInTheDocument()
expect(screen.getByText('Hidden Devices')).toBeInTheDocument()
expect(screen.getByText('Scan History')).toBeInTheDocument()
})
// ── Stats ──────────────────────────────────────────────────────────────────
it('displays total / online / offline counts from store', () => {
mockStore({
nodes: [
makeNode('n1', 'online'),
makeNode('n2', 'online'),
makeNode('n3', 'offline'),
makeNode('n4', 'unknown'),
],
})
render(<Sidebar {...defaultProps} />)
// Total (excludes groupRect)
expect(screen.getByText('4')).toBeInTheDocument()
// Online
expect(screen.getByText('2')).toBeInTheDocument()
// Offline
expect(screen.getByText('1')).toBeInTheDocument()
})
it('excludes groupRect nodes from stats', () => {
mockStore({
nodes: [
makeNode('n1', 'unknown'), // 1 real node, not online/offline
makeNode('zone', 'unknown', 'groupRect'),
],
})
render(<Sidebar {...defaultProps} />)
// Total row shows 1 (groupRect excluded), online/offline both 0
const totalRow = screen.getByText('Total').closest('div')!
expect(totalRow).toHaveTextContent('1')
expect(screen.getAllByText('0')).toHaveLength(2) // online=0, offline=0
})
// ── Collapse ───────────────────────────────────────────────────────────────
it('collapses sidebar on toggle button click', () => {
render(<Sidebar {...defaultProps} />)
const aside = screen.getByRole('complementary')
expect(aside).toHaveStyle({ width: '220px' })
const toggle = aside.querySelector('button')!
fireEvent.click(toggle)
expect(aside).toHaveStyle({ width: '48px' })
})
it('hides label text when collapsed', () => {
render(<Sidebar {...defaultProps} />)
const aside = screen.getByRole('complementary')
const toggle = aside.querySelector('button')!
fireEvent.click(toggle)
expect(screen.queryByText('Add Node')).not.toBeInTheDocument()
})
it('hides stats footer when collapsed', () => {
render(<Sidebar {...defaultProps} />)
expect(screen.getByText('Total')).toBeInTheDocument()
const toggle = screen.getByRole('complementary').querySelector('button')!
fireEvent.click(toggle)
expect(screen.queryByText('Total')).not.toBeInTheDocument()
})
it('shows logo with showText=false when collapsed', () => {
render(<Sidebar {...defaultProps} />)
const logo = screen.getByTestId('logo')
expect(logo).toHaveAttribute('data-show-text', 'true')
const toggle = screen.getByRole('complementary').querySelector('button')!
fireEvent.click(toggle)
expect(logo).toHaveAttribute('data-show-text', 'false')
})
// ── Action callbacks ───────────────────────────────────────────────────────
it('calls onAddNode when Add Node is clicked', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Add Node'))
expect(defaultProps.onAddNode).toHaveBeenCalledOnce()
})
it('calls onAddGroupRect when Add Zone is clicked', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Add Zone'))
expect(defaultProps.onAddGroupRect).toHaveBeenCalledOnce()
})
it('calls onSave when Save Canvas is clicked', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Save Canvas'))
expect(defaultProps.onSave).toHaveBeenCalledOnce()
})
it('calls toggleHideIp when Hide IPs is clicked', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Hide IPs'))
expect(mockToggleHideIp).toHaveBeenCalledOnce()
})
it('shows Show IPs label when hideIp is true', () => {
mockStore({ hideIp: true })
render(<Sidebar {...defaultProps} />)
expect(screen.getByText('Show IPs')).toBeInTheDocument()
})
// ── Unsaved changes badge ──────────────────────────────────────────────────
it('shows unsaved badge dot on Save Canvas when hasUnsavedChanges', () => {
mockStore({ hasUnsavedChanges: true })
render(<Sidebar {...defaultProps} />)
// The badge is a span sibling of the Save Canvas button icon
const saveBtn = screen.getByText('Save Canvas').closest('button')!
const badge = saveBtn.querySelector('span.rounded-full')
expect(badge).toBeInTheDocument()
})
it('does not show unsaved badge when no changes', () => {
mockStore({ hasUnsavedChanges: false })
render(<Sidebar {...defaultProps} />)
const saveBtn = screen.getByText('Save Canvas').closest('button')!
const badge = saveBtn.querySelector('span.rounded-full')
expect(badge).not.toBeInTheDocument()
})
// ── Scan action ────────────────────────────────────────────────────────────
it('calls scanApi.trigger and onScan prop when Scan Network is clicked', async () => {
const { scanApi } = await import('@/api/client')
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Scan Network'))
await waitFor(() => expect(scanApi.trigger).toHaveBeenCalledOnce())
expect(defaultProps.onScan).toHaveBeenCalledOnce()
})
it('switches to Scan History view after scan is triggered', async () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Scan Network'))
await waitFor(() => expect(screen.getByText('History')).toBeInTheDocument())
})
// ── Navigation ─────────────────────────────────────────────────────────────
it('shows Pending panel when Pending Devices nav item is clicked', async () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Pending Devices'))
await waitFor(() => expect(screen.getByText('No pending devices')).toBeInTheDocument())
})
it('shows Hidden panel when Hidden Devices nav item is clicked', async () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Hidden Devices'))
await waitFor(() => expect(screen.getByText('No hidden devices')).toBeInTheDocument())
})
it('shows History panel when Scan History nav item is clicked', async () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Scan History'))
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
})
it('toggles Settings panel on Settings click', async () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Settings'))
await waitFor(() =>
expect(screen.getByText('Status check interval (s)')).toBeInTheDocument(),
)
// Click the nav button again to close (use role to avoid matching the panel heading)
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,170 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useStatusPolling } from '../useStatusPolling'
import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore'
vi.mock('@/stores/canvasStore')
vi.mock('@/stores/authStore')
const mockUpdateNode = vi.fn()
const mockNotifyScanDeviceFound = vi.fn()
class MockWebSocket {
static instances: MockWebSocket[] = []
url: string
onopen: (() => void) | null = null
onmessage: ((e: { data: string }) => void) | null = null
onerror: ((e: unknown) => void) | null = null
send = vi.fn()
close = vi.fn()
constructor(url: string) {
this.url = url
MockWebSocket.instances.push(this)
}
}
describe('useStatusPolling', () => {
beforeEach(() => {
MockWebSocket.instances = []
vi.stubGlobal('WebSocket', MockWebSocket)
vi.mocked(useCanvasStore).mockReturnValue({
updateNode: mockUpdateNode,
notifyScanDeviceFound: mockNotifyScanDeviceFound,
} as ReturnType<typeof useCanvasStore>)
vi.mocked(useAuthStore).mockReturnValue({
isAuthenticated: true,
token: 'test-token',
} as ReturnType<typeof useAuthStore>)
Object.defineProperty(window, 'location', {
value: { protocol: 'http:', host: 'localhost:5173' },
writable: true,
})
})
afterEach(() => {
vi.restoreAllMocks()
mockUpdateNode.mockClear()
mockNotifyScanDeviceFound.mockClear()
})
it('does not open WebSocket when not authenticated', () => {
vi.mocked(useAuthStore).mockReturnValue({
isAuthenticated: false,
token: null,
} as ReturnType<typeof useAuthStore>)
renderHook(() => useStatusPolling())
expect(MockWebSocket.instances).toHaveLength(0)
})
it('does not open WebSocket when token is missing', () => {
vi.mocked(useAuthStore).mockReturnValue({
isAuthenticated: true,
token: null,
} as ReturnType<typeof useAuthStore>)
renderHook(() => useStatusPolling())
expect(MockWebSocket.instances).toHaveLength(0)
})
it('connects to correct ws:// URL', () => {
renderHook(() => useStatusPolling())
expect(MockWebSocket.instances).toHaveLength(1)
expect(MockWebSocket.instances[0].url).toBe('ws://localhost:5173/api/v1/status/ws/status')
})
it('uses wss:// when page is served over https', () => {
Object.defineProperty(window, 'location', {
value: { protocol: 'https:', host: 'mylab.local' },
writable: true,
})
renderHook(() => useStatusPolling())
expect(MockWebSocket.instances[0].url).toMatch(/^wss:\/\//)
})
it('sends token as first message on open', () => {
renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
ws.onopen?.()
expect(ws.send).toHaveBeenCalledWith(JSON.stringify({ token: 'test-token' }))
})
it('calls updateNode with correct data on status message', () => {
renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
ws.onmessage?.({
data: JSON.stringify({
node_id: 'node-1',
status: 'online',
checked_at: '2024-01-01T12:00:00Z',
response_time_ms: 42,
}),
})
expect(mockUpdateNode).toHaveBeenCalledWith('node-1', {
status: 'online',
response_time_ms: 42,
last_seen: '2024-01-01T12:00:00Z',
})
})
it('sets last_seen to undefined when status is offline', () => {
renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
ws.onmessage?.({
data: JSON.stringify({
node_id: 'node-1',
status: 'offline',
checked_at: '2024-01-01T12:00:00Z',
}),
})
expect(mockUpdateNode).toHaveBeenCalledWith('node-1', {
status: 'offline',
response_time_ms: undefined,
last_seen: undefined,
})
})
it('sets response_time_ms to undefined when null in message', () => {
renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
ws.onmessage?.({
data: JSON.stringify({ node_id: 'node-1', status: 'online', response_time_ms: null }),
})
expect(mockUpdateNode).toHaveBeenCalledWith(
'node-1',
expect.objectContaining({ response_time_ms: undefined }),
)
})
it('calls notifyScanDeviceFound on scan_device_found message', () => {
renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
ws.onmessage?.({ data: JSON.stringify({ type: 'scan_device_found' }) })
expect(mockNotifyScanDeviceFound).toHaveBeenCalledOnce()
expect(mockUpdateNode).not.toHaveBeenCalled()
})
it('ignores malformed JSON without throwing', () => {
renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
expect(() => ws.onmessage?.({ data: 'not-valid-json{{' })).not.toThrow()
expect(mockUpdateNode).not.toHaveBeenCalled()
})
it('ignores messages with no node_id or status', () => {
renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
ws.onmessage?.({ data: JSON.stringify({ some: 'unknown-field' }) })
expect(mockUpdateNode).not.toHaveBeenCalled()
})
it('closes WebSocket on unmount', () => {
const { unmount } = renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
unmount()
expect(ws.close).toHaveBeenCalledOnce()
})
})
@@ -253,11 +253,11 @@ describe('canvasStore', () => {
expect(useCanvasStore.getState().selectedNodeIds).toEqual([]) expect(useCanvasStore.getState().selectedNodeIds).toEqual([])
}) })
it('setSelectedNode(id) preserves existing selectedNodeIds', () => { it('setSelectedNode(id) sets selectedNodeIds to [id], clearing multi-selection', () => {
useCanvasStore.setState({ selectedNodeIds: ['n1', 'n2'] }) useCanvasStore.setState({ selectedNodeIds: ['n1', 'n2'] })
useCanvasStore.getState().setSelectedNode('n1') useCanvasStore.getState().setSelectedNode('n1')
// does NOT wipe selectedNodeIds when setting a specific id // Single node click resets multi-selection to just the clicked node
expect(useCanvasStore.getState().selectedNodeIds).toEqual(['n1', 'n2']) expect(useCanvasStore.getState().selectedNodeIds).toEqual(['n1'])
}) })
// ── createGroup ─────────────────────────────────────────────────────────── // ── createGroup ───────────────────────────────────────────────────────────
@@ -618,4 +618,61 @@ describe('canvasStore', () => {
expect(stored?.width).toBeUndefined() expect(stored?.width).toBeUndefined()
expect(stored?.height).toBeUndefined() expect(stored?.height).toBeUndefined()
}) })
// ── bottom_handles edge remapping ──────────────────────────────────────────
it('remaps source edges to "bottom" when bottom_handles is reduced', () => {
const node = makeNode('n1', { bottom_handles: 4 })
const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom-3' }
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] })
useCanvasStore.getState().updateNode('n1', { bottom_handles: 2 })
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
expect(updated?.sourceHandle).toBe('bottom')
})
it('remaps target edges to "bottom" when bottom_handles is reduced', () => {
const node = makeNode('n2', { bottom_handles: 3 })
const edge = { ...makeEdge('e1', 'n1', 'n2'), targetHandle: 'bottom-3' }
useCanvasStore.setState({ nodes: [makeNode('n1'), node], edges: [edge] })
useCanvasStore.getState().updateNode('n2', { bottom_handles: 1 })
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
expect(updated?.targetHandle).toBe('bottom')
})
it('does not remap edges that are on handles still present after reduction', () => {
const node = makeNode('n1', { bottom_handles: 4 })
const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom-2' }
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] })
useCanvasStore.getState().updateNode('n1', { bottom_handles: 3 })
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
expect(updated?.sourceHandle).toBe('bottom-2')
})
it('does not remap edges when bottom_handles increases', () => {
const node = makeNode('n1', { bottom_handles: 2 })
const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom' }
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] })
useCanvasStore.getState().updateNode('n1', { bottom_handles: 4 })
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
expect(updated?.sourceHandle).toBe('bottom')
})
it('never remaps the "bottom" handle itself', () => {
const node = makeNode('n1', { bottom_handles: 4 })
const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom' }
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] })
useCanvasStore.getState().updateNode('n1', { bottom_handles: 1 })
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
expect(updated?.sourceHandle).toBe('bottom')
})
}) })
+29 -9
View File
@@ -11,6 +11,7 @@ import {
} from '@xyflow/react' } from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types' import type { NodeData, EdgeData } from '@/types'
import { generateUUID } from '@/utils/uuid' import { generateUUID } from '@/utils/uuid'
import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] } type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
@@ -52,6 +53,8 @@ interface CanvasState {
markSaved: () => void markSaved: () => void
markUnsaved: () => void markUnsaved: () => void
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
fitViewPending: boolean
clearFitViewPending: () => void
notifyScanDeviceFound: () => void notifyScanDeviceFound: () => void
hideIp: boolean hideIp: boolean
toggleHideIp: () => void toggleHideIp: () => void
@@ -66,6 +69,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
editingGroupRectId: null, editingGroupRectId: null,
hideIp: false, hideIp: false,
scanEventTs: 0, scanEventTs: 0,
fitViewPending: false,
past: [], past: [],
future: [], future: [],
@@ -149,10 +153,6 @@ export const useCanvasStore = create<CanvasState>((set) => ({
set((state) => { set((state) => {
const extra = connection as Connection & Partial<EdgeData> const extra = connection as Connection & Partial<EdgeData>
const edgeType = extra.type ?? 'ethernet' const edgeType = extra.type ?? 'ethernet'
// Normalize invisible stub handle IDs so React Flow can locate the handle
// and render the edge immediately (top-t / bottom-t are opacity:0 helpers).
const normalizeHandle = (h: string | null | undefined) =>
h === 'top-t' ? 'top' : h === 'bottom-t' ? 'bottom' : (h ?? null)
return { return {
edges: addEdge({ edges: addEdge({
...connection, ...connection,
@@ -165,10 +165,10 @@ export const useCanvasStore = create<CanvasState>((set) => ({
} }
}), }),
setSelectedNode: (id) => set((state) => ({ setSelectedNode: (id) => set({
selectedNodeId: id, selectedNodeId: id,
selectedNodeIds: id ? state.selectedNodeIds : [], selectedNodeIds: id ? [id] : [],
})), }),
addNode: (node) => addNode: (node) =>
set((state) => { set((state) => {
@@ -226,7 +226,25 @@ export const useCanvasStore = create<CanvasState>((set) => ({
const children = nodes.filter((n) => !!n.parentId) const children = nodes.filter((n) => !!n.parentId)
nodes = [...parents, ...children] nodes = [...parents, ...children]
} }
return { nodes, hasUnsavedChanges: true } // Remap edges when bottom_handles is reduced so no edge disappears
let edges = state.edges
if ('bottom_handles' in data && data.bottom_handles != null) {
const currentNode = state.nodes.find((n) => n.id === id)
const oldCount = currentNode?.data.bottom_handles ?? 1
const newCount = data.bottom_handles
if (newCount < oldCount) {
const removed = removedBottomHandleIds(oldCount, newCount)
edges = state.edges.map((e) => {
if (e.source === id && e.sourceHandle && removed.has(e.sourceHandle))
return { ...e, sourceHandle: 'bottom' }
if (e.target === id && e.targetHandle && removed.has(e.targetHandle))
return { ...e, targetHandle: 'bottom' }
return e
})
}
}
return { nodes, edges, hasUnsavedChanges: true }
}), }),
deleteNode: (id) => deleteNode: (id) =>
@@ -409,6 +427,8 @@ export const useCanvasStore = create<CanvasState>((set) => ({
// React Flow requires parents before children in the array // React Flow requires parents before children in the array
const parents = nodes.filter((n) => !n.parentId) const parents = nodes.filter((n) => !n.parentId)
const children = nodes.filter((n) => !!n.parentId) const children = nodes.filter((n) => !!n.parentId)
set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], clipboard: [] }) set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], clipboard: [], fitViewPending: true })
}, },
clearFitViewPending: () => set({ fitViewPending: false }),
})) }))
+1
View File
@@ -82,6 +82,7 @@ export interface NodeData extends Record<string, unknown> {
height?: number height?: number
} }
custom_icon?: string custom_icon?: string
bottom_handles?: number
} }
export type EdgePathStyle = 'bezier' | 'smooth' export type EdgePathStyle = 'bezier' | 'smooth'
@@ -0,0 +1,111 @@
import { describe, it, expect } from 'vitest'
import {
BOTTOM_HANDLE_IDS,
BOTTOM_HANDLE_POSITIONS,
normalizeHandle,
removedBottomHandleIds,
} from '../handleUtils'
describe('BOTTOM_HANDLE_IDS', () => {
it('first id is always "bottom" for backward compatibility', () => {
expect(BOTTOM_HANDLE_IDS[0]).toBe('bottom')
})
it('has ids for 14 handles', () => {
expect(BOTTOM_HANDLE_IDS).toHaveLength(4)
expect(BOTTOM_HANDLE_IDS).toEqual(['bottom', 'bottom-2', 'bottom-3', 'bottom-4'])
})
})
describe('BOTTOM_HANDLE_POSITIONS', () => {
it('1 handle is centered at 50%', () => {
expect(BOTTOM_HANDLE_POSITIONS[1]).toEqual([50])
})
it('2 handles are symmetric', () => {
const [a, b] = BOTTOM_HANDLE_POSITIONS[2]
expect(a).toBeLessThan(50)
expect(b).toBeGreaterThan(50)
expect(a + b).toBe(100)
})
it('3 handles include a center at 50%', () => {
expect(BOTTOM_HANDLE_POSITIONS[3]).toContain(50)
expect(BOTTOM_HANDLE_POSITIONS[3]).toHaveLength(3)
})
it('4 handles are evenly spaced', () => {
const pos = BOTTOM_HANDLE_POSITIONS[4]
expect(pos).toHaveLength(4)
// All values should be between 0 and 100 exclusive
pos.forEach((p) => {
expect(p).toBeGreaterThan(0)
expect(p).toBeLessThan(100)
})
// Positions should be strictly increasing
for (let i = 1; i < pos.length; i++) {
expect(pos[i]).toBeGreaterThan(pos[i - 1])
}
})
})
describe('normalizeHandle', () => {
it('returns null for null/undefined', () => {
expect(normalizeHandle(null)).toBeNull()
expect(normalizeHandle(undefined)).toBeNull()
})
it('maps top-t → top', () => {
expect(normalizeHandle('top-t')).toBe('top')
})
it('maps bottom-t → bottom', () => {
expect(normalizeHandle('bottom-t')).toBe('bottom')
})
it('maps bottom-2-t → bottom-2', () => {
expect(normalizeHandle('bottom-2-t')).toBe('bottom-2')
})
it('maps bottom-3-t → bottom-3', () => {
expect(normalizeHandle('bottom-3-t')).toBe('bottom-3')
})
it('maps bottom-4-t → bottom-4', () => {
expect(normalizeHandle('bottom-4-t')).toBe('bottom-4')
})
it('passes through non-stub handles unchanged', () => {
expect(normalizeHandle('top')).toBe('top')
expect(normalizeHandle('bottom')).toBe('bottom')
expect(normalizeHandle('bottom-2')).toBe('bottom-2')
expect(normalizeHandle('custom-handle')).toBe('custom-handle')
})
})
describe('removedBottomHandleIds', () => {
it('returns empty set when count does not decrease', () => {
expect(removedBottomHandleIds(2, 2).size).toBe(0)
expect(removedBottomHandleIds(1, 4).size).toBe(0)
})
it('4 → 1 removes bottom-2, bottom-3, bottom-4', () => {
const removed = removedBottomHandleIds(4, 1)
expect(removed).toEqual(new Set(['bottom-2', 'bottom-3', 'bottom-4']))
})
it('4 → 2 removes bottom-3, bottom-4', () => {
const removed = removedBottomHandleIds(4, 2)
expect(removed).toEqual(new Set(['bottom-3', 'bottom-4']))
})
it('3 → 2 removes only bottom-3', () => {
const removed = removedBottomHandleIds(3, 2)
expect(removed).toEqual(new Set(['bottom-3']))
})
it('never removes "bottom" (index 0)', () => {
const removed = removedBottomHandleIds(4, 1)
expect(removed.has('bottom')).toBe(false)
})
})
@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { parseYamlToCanvas } from '../importYaml' import { parseYamlToCanvas } from '../importYaml'
import type { Node, Edge } from '@xyflow/react' import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types' import type { NodeData, EdgeData } from '@/types'
@@ -73,6 +73,38 @@ describe('parseYamlToCanvas', () => {
expect(nodes[0].data.show_hardware).toBeUndefined() expect(nodes[0].data.show_hardware).toBeUndefined()
}) })
it('links edges have bottom→top-t handles', () => {
const yaml = `
- nodeType: switch
label: "SW"
links:
- label: "SRV"
linkType: ethernet
- nodeType: server
label: "SRV"
`
const { edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
expect(edges).toHaveLength(1)
expect(edges[0].sourceHandle).toBe('bottom')
expect(edges[0].targetHandle).toBe('top-t')
})
it('cluster edges have cluster-right→cluster-left handles', () => {
const yaml = `
- nodeType: proxmox
label: "PVE1"
clusterR:
label: "PVE2"
linkType: ethernet
- nodeType: proxmox
label: "PVE2"
`
const { edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
expect(edges).toHaveLength(1)
expect(edges[0].sourceHandle).toBe('cluster-right')
expect(edges[0].targetHandle).toBe('cluster-left')
})
it('parent relationship sets parentId and creates an edge', () => { it('parent relationship sets parentId and creates an edge', () => {
const yaml = ` const yaml = `
- nodeType: proxmox - nodeType: proxmox
+3 -3
View File
@@ -1,5 +1,6 @@
import type { Node, Edge } from '@xyflow/react' import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types' import type { NodeData, EdgeData } from '@/types'
import { normalizeHandle } from '@/utils/handleUtils'
// ── Types ──────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────
@@ -29,6 +30,7 @@ export interface ApiNode extends Record<string, unknown> {
show_hardware?: boolean show_hardware?: boolean
width?: number | null width?: number | null
height?: number | null height?: number | null
bottom_handles?: number
} }
export interface ApiEdge { export interface ApiEdge {
@@ -99,14 +101,12 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
show_hardware: n.data.show_hardware ?? false, show_hardware: n.data.show_hardware ?? false,
width: n.width ?? null, width: n.width ?? null,
height: n.height ?? null, height: n.height ?? null,
bottom_handles: n.data.bottom_handles ?? 1,
pos_x: n.position.x, pos_x: n.position.x,
pos_y: n.position.y, pos_y: n.position.y,
} }
} }
const normalizeHandle = (h: string | null | undefined): string | null =>
h === 'top-t' ? 'top' : h === 'bottom-t' ? 'bottom' : (h ?? null)
export function serializeEdge(e: Edge<EdgeData>): Record<string, unknown> { export function serializeEdge(e: Edge<EdgeData>): Record<string, unknown> {
return { return {
id: e.id, id: e.id,
+45
View File
@@ -0,0 +1,45 @@
/**
* Bottom handle configuration for multi-handle nodes.
*
* Handle IDs: index 0 = 'bottom' (always the default, backward-compatible)
* index 1 = 'bottom-2', index 2 = 'bottom-3', index 3 = 'bottom-4'
*
* Invisible target handles follow the same pattern with a '-t' suffix:
* 'bottom-t', 'bottom-2-t', 'bottom-3-t', 'bottom-4-t'
*/
export const BOTTOM_HANDLE_IDS = ['bottom', 'bottom-2', 'bottom-3', 'bottom-4'] as const
/** Left % position for each handle slot, per count. */
export const BOTTOM_HANDLE_POSITIONS: Record<number, number[]> = {
1: [50],
2: [25, 75],
3: [20, 50, 80],
4: [15, 38, 62, 85],
}
/**
* Normalize a raw handle ID coming from a React Flow connection event.
* Invisible target handles (e.g. 'bottom-2-t') are mapped to their source
* counterpart ('bottom-2') so the stored edge ID is stable and consistent.
*/
export function normalizeHandle(h: string | null | undefined): string | null {
if (!h) return null
if (h === 'top-t') return 'top'
// 'bottom-t' → 'bottom', 'bottom-2-t' → 'bottom-2', etc.
const m = h.match(/^(bottom(?:-\d+)?)-t$/)
if (m) return m[1]
return h
}
/**
* Returns the set of handle IDs that are removed when bottom_handles
* is reduced from `oldCount` to `newCount`.
*/
export function removedBottomHandleIds(oldCount: number, newCount: number): Set<string> {
const removed = new Set<string>()
for (let i = newCount; i < oldCount; i++) {
removed.add(BOTTOM_HANDLE_IDS[i])
}
return removed
}
+13 -9
View File
@@ -34,16 +34,16 @@ export function parseYamlToCanvas(
const yamlNodes: YamlNode[] = [] const yamlNodes: YamlNode[] = []
for (const entry of entries) { for (const entry of entries) {
const raw = entry as Record<string, unknown> const entryRecord = entry as Record<string, unknown>
if (!raw.nodeType || typeof raw.nodeType !== 'string') { if (!entryRecord.nodeType || typeof entryRecord.nodeType !== 'string') {
throw new Error(`Each YAML entry must have a "nodeType" string field`) throw new Error(`Each YAML entry must have a "nodeType" string field`)
} }
if (!raw.label || typeof raw.label !== 'string') { if (!entryRecord.label || typeof entryRecord.label !== 'string') {
throw new Error(`Each YAML entry must have a "label" string field`) throw new Error(`Each YAML entry must have a "label" string field`)
} }
const yn = raw as unknown as YamlNode const yn = entryRecord as unknown as YamlNode
// Skip if a node with this label already exists on the canvas // Skip if a node with this label already exists on the canvas
if (labelToId.has(yn.label)) { if (labelToId.has(yn.label)) {
@@ -95,6 +95,8 @@ export function parseYamlToCanvas(
sourceId: string, sourceId: string,
targetId: string, targetId: string,
conn: YamlNodeConnection, conn: YamlNodeConnection,
sourceHandle = 'bottom',
targetHandle = 'top-t',
) { ) {
const key = `${sourceId}|${targetId}` const key = `${sourceId}|${targetId}`
const reverseKey = `${targetId}|${sourceId}` const reverseKey = `${targetId}|${sourceId}`
@@ -105,6 +107,8 @@ export function parseYamlToCanvas(
id: generateUUID(), id: generateUUID(),
source: sourceId, source: sourceId,
target: targetId, target: targetId,
sourceHandle,
targetHandle,
type: edgeType, type: edgeType,
data: { data: {
type: edgeType, type: edgeType,
@@ -126,8 +130,8 @@ export function parseYamlToCanvas(
node.data = { ...node.data, parent_id: parentId } node.data = { ...node.data, parent_id: parentId }
node.parentId = parentId node.parentId = parentId
node.extent = 'parent' node.extent = 'parent'
// Also create an edge // Also create an edge (parent bottom → child top)
addEdgeIfNew(parentId, node.id, yn.parent) addEdgeIfNew(parentId, node.id, yn.parent, 'bottom', 'top-t')
} }
} }
@@ -137,7 +141,7 @@ export function parseYamlToCanvas(
if (!targetId) { if (!targetId) {
console.warn(`[importYaml] links label not found: "${link.label}" — skipping`) console.warn(`[importYaml] links label not found: "${link.label}" — skipping`)
} else { } else {
addEdgeIfNew(node.id, targetId, link) addEdgeIfNew(node.id, targetId, link, 'bottom', 'top-t')
} }
} }
} }
@@ -147,7 +151,7 @@ export function parseYamlToCanvas(
if (!targetId) { if (!targetId) {
console.warn(`[importYaml] clusterR label not found: "${yn.clusterR.label}" — skipping`) console.warn(`[importYaml] clusterR label not found: "${yn.clusterR.label}" — skipping`)
} else { } else {
addEdgeIfNew(node.id, targetId, yn.clusterR) addEdgeIfNew(node.id, targetId, yn.clusterR, 'cluster-right', 'cluster-left')
} }
} }
@@ -156,7 +160,7 @@ export function parseYamlToCanvas(
if (!sourceId) { if (!sourceId) {
console.warn(`[importYaml] clusterL label not found: "${yn.clusterL.label}" — skipping`) console.warn(`[importYaml] clusterL label not found: "${yn.clusterL.label}" — skipping`)
} else { } else {
addEdgeIfNew(sourceId, node.id, yn.clusterL) addEdgeIfNew(sourceId, node.id, yn.clusterL, 'cluster-right', 'cluster-left')
} }
} }
} }
+22
View File
@@ -1,3 +1,4 @@
import type { NodeType } from '@/types'
import { import {
// Infrastructure (node types) // Infrastructure (node types)
Globe, Router, Network, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Globe, Router, Network, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle,
@@ -116,6 +117,27 @@ export const ICON_MAP: Record<string, LucideIcon> = Object.fromEntries(
ICON_REGISTRY.map((e) => [e.key, e.icon]), ICON_REGISTRY.map((e) => [e.key, e.icon]),
) )
export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = {
isp: Globe,
router: Router,
switch: Network,
server: Server,
proxmox: Layers,
vm: Box,
lxc: Container,
nas: HardDrive,
iot: Cpu,
ap: Wifi,
camera: Cctv,
printer: Printer,
computer: Monitor,
cpl: PlugZap,
docker: Anchor,
generic: Circle,
group: Circle,
groupRect: Circle,
}
/** Resolve the display icon for a node — custom_icon takes priority over type default. */ /** Resolve the display icon for a node — custom_icon takes priority over type default. */
export function resolveNodeIcon( export function resolveNodeIcon(
typeIcon: LucideIcon, typeIcon: LucideIcon,