Compare commits

..

18 Commits

Author SHA1 Message Date
Pouzor 5a3e8ea0b1 chore: bump version to 1.7.1 2026-04-03 00:12:22 +02:00
Pouzor 70907e37bc fix: LXC/VM parent-child UX bugs with Proxmox container mode
- Don't create virtual edge when parent Proxmox is in container mode
  (containment is shown visually — edge was redundant and confusing)
- updateNode now syncs React Flow parentId/extent/position when
  data.parent_id changes, so nesting/un-nesting is immediate without
  requiring a save+reload
- Attaching to container: converts position to parent-relative coords
- Detaching from container: converts position back to absolute coords
  so the node escapes the container box immediately
- Ensure parent nodes precede children in array on attachment
  (React Flow rendering requirement)
2026-04-02 22:30:54 +02:00
Remy e356d433cb Merge pull request #37 from Pouzor/feat/iot-discovery
feat: improve IoT device detection via two-phase scan and mDNS discovery
2026-04-02 17:56:34 +02:00
Pouzor cf7777e0af feat: improve IoT device detection via two-phase scan and mDNS discovery
- Two-phase nmap: ARP sweep first to find all alive hosts (incl. IoT with
  no open TCP ports), then port scan only alive hosts
- mDNS/Bonjour discovery via zeroconf for Shelly, ESPHome, HomeKit devices
- Add CoAP ports (5683, 5684, 4915) to port scan and IoT type hints
- Expand MAC OUI table with Shelly, Espressif, Sonoff, Tapo, Hue, IKEA, Tuya
- IoT vendor MAC takes precedence over generic HTTP port type hints
- Reorder suggest_node_type priority: iot now beats server
2026-04-02 17:41:51 +02:00
Pouzor 255443b8e1 fix: harden scheduler startup and interval validation
- Wrap shutdown() in try/except to prevent double-scheduler if teardown fails
- Guard reschedule_status_checks with interval_seconds >= 10 validation
2026-04-02 17:39:52 +02:00
Pouzor 7e24878077 fix: pass scalars to _check_single_node to prevent DetachedInstanceError
- Refactor _check_single_node to accept plain scalar args (node_id,
  check_method, check_target, ip) instead of a detached ORM Node object
- Extract scalars inside the session in _run_status_checks before it closes
- Capture datetime.now() once per check to keep DB and WebSocket timestamps consistent
2026-04-02 17:32:42 +02:00
Pouzor ff1bc7340d fix: resolve mypy errors in scheduler (dict type params + str | None arg) 2026-04-02 17:24:26 +02:00
Pouzor a7c9abbb9a npm audit fix 2026-04-02 11:48:37 +02:00
Pouzor e4bfab7e58 perf: run status checks concurrently to prevent scheduler overrun
Replace sequential node checks with asyncio.gather so all nodes are
checked in parallel. Add coalesce=True + max_instances=1 to suppress
APScheduler "maximum instances reached" log spam.
2026-04-02 11:41:08 +02:00
Pouzor d5069d9b96 chore: bump version to 1.7.0 2026-04-02 01:22:22 +02:00
Pouzor 7498a10c14 fix: drop -qq on apt-get update and add --fix-missing to handle stale mirror cache in LXC install 2026-04-02 00:24:18 +02:00
Pouzor a8dd41a156 fix: add group NodeType to all theme definitions 2026-04-02 00:20:32 +02:00
Pouzor bdf3b6ea40 feat: add Ctrl+F search bar to canvas
- Ctrl+F / Cmd+F opens floating search bar at top-center of canvas
- Filters nodes by label, IP, hostname and service name (case-insensitive)
- Shows match count and no-results message
- Click result selects node and flies camera to it with animation
- Escape or × closes the bar
- groupRect nodes excluded from results
- Handles grouped nodes with correct absolute position for navigation
2026-04-01 23:32:28 +02:00
Pouzor 0b89244317 feat: lasso selection, multi-select panel, and named groups
- Add lasso/box selection via selectionOnDrag (Space to pan, lasso by default)
- Add lasso/pan toggle button in canvas controls (bottom-left)
- Multi-select panel in right panel when 2+ nodes selected (including zones)
- Create named Group node from selected nodes with bounding box math
- Group node: resizable, inline rename, show/hide border toggle, status summary
- GroupDetailPanel: lists members, online/offline count, ungroup action
- Fix group persistence after save/reload (extend proxmox container map to include group nodes)
- Fix groupRect serialization to preserve parent_id
- Remove background color from group and proxmox container node wrappers
- Add tests for all new store actions, GroupNode, MultiSelectPanel, GroupDetailPanel
2026-04-01 23:15:52 +02:00
Pouzor 45a17b0254 fix: use node:20-slim in build stage to fix lightningcss musl binary error 2026-04-01 14:22:45 +02:00
Pouzor 057891f7d5 feat: add stop scan button in UI with backend cancellation support
- POST /scan/{run_id}/stop endpoint signals running scan to cancel
- Scanner checks cancellation flag between CIDR ranges and hosts, exits early
- Cancelled scans get status 'cancelled' instead of 'done'
- Stop button (red StopCircle) shown in Scan History panel for running scans
- 6 new backend tests, 5 new frontend tests
2026-04-01 14:18:44 +02:00
Remy 5321070720 Update INSTALLATION.md 2026-03-31 14:17:14 +02:00
Pouzor 59e5a95912 docs: clarify bcrypt dollar-sign escaping for .env vs docker-compose.yml
Fixes #31
2026-03-31 14:13:22 +02:00
34 changed files with 2574 additions and 352 deletions
+2 -1
View File
@@ -1,7 +1,8 @@
# Stage 1: build # Stage 1: build
# Use the native build platform so npm ci never runs under QEMU emulation. # Use the native build platform so npm ci never runs under QEMU emulation.
# The build output (static HTML/JS/CSS) is platform-independent. # The build output (static HTML/JS/CSS) is platform-independent.
FROM --platform=$BUILDPLATFORM node:20-alpine AS builder # node:20-slim (Debian/glibc) avoids lightningcss musl binary resolution issues on Alpine.
FROM --platform=$BUILDPLATFORM node:20-slim AS builder
ARG VITE_STANDALONE=false ARG VITE_STANDALONE=false
ENV VITE_STANDALONE=$VITE_STANDALONE ENV VITE_STANDALONE=$VITE_STANDALONE
+12 -3
View File
@@ -11,9 +11,18 @@ Open **http://localhost:3000** — login with `admin` / `admin`.
> Change the password before exposing to a network: edit `.env` and update `AUTH_USERNAME` / `AUTH_PASSWORD_HASH`. > Change the password before exposing to a network: edit `.env` and update `AUTH_USERNAME` / `AUTH_PASSWORD_HASH`.
> >
> Generate a new hash: `docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"` Generate a new hash:
> ```bash
> ⚠️ Keep the single quotes around the hash value in `.env` — bcrypt hashes contain `$` characters that Docker Compose would otherwise misinterpret. docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"
```
⚠️ **bcrypt hashes contain `$` characters** — how to handle them depends on where you set the value:
- **`.env` file** (recommended): wrap the hash in single quotes → `AUTH_PASSWORD_HASH='$2b$12$...'`
- **`docker-compose.yml` `environment:` block**: escape every `$` as `$$` — use this command to generate a pre-escaped hash:
```bash
docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword').replace('\$', '\$\$'))"
```
## Quick Start — Frontend only ## Quick Start — Frontend only
+16 -1
View File
@@ -12,7 +12,7 @@ from app.db.database import AsyncSessionLocal, get_db
from app.db.models import Node, PendingDevice, ScanRun from app.db.models import Node, PendingDevice, ScanRun
from app.schemas.nodes import NodeCreate from app.schemas.nodes import NodeCreate
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
from app.services.scanner import run_scan from app.services.scanner import request_cancel, run_scan
class ScanConfig(BaseModel): class ScanConfig(BaseModel):
@@ -43,6 +43,21 @@ async def trigger_scan(
return run return run
@router.post("/{run_id}/stop", response_model=dict)
async def stop_scan(
run_id: str,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, bool]:
run = await db.get(ScanRun, run_id)
if not run:
raise HTTPException(status_code=404, detail="Scan run not found")
if run.status != "running":
raise HTTPException(status_code=409, detail="Scan is not running")
request_cancel(run_id)
return {"stopping": True}
@router.get("/pending", response_model=list[PendingDeviceResponse]) @router.get("/pending", response_model=list[PendingDeviceResponse])
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]: async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending")) result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending"))
+64 -24
View File
@@ -1,4 +1,5 @@
"""APScheduler setup for background scan and status check jobs.""" """APScheduler setup for background scan and status check jobs."""
import asyncio
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -15,48 +16,87 @@ logger = logging.getLogger(__name__)
scheduler: AsyncIOScheduler = AsyncIOScheduler() scheduler: AsyncIOScheduler = AsyncIOScheduler()
async def _run_status_checks() -> None: async def _check_single_node(
"""Check all nodes and broadcast results via WebSocket.""" node_id: str,
check_method: str,
check_target: str | None,
ip: str | None,
) -> tuple[str, dict[str, object] | None]:
"""Run a single node check; returns (node_id, result_or_None).
Accepts plain scalars — not an ORM object — so there is no risk of
DetachedInstanceError when the originating session has already closed.
"""
from app.api.routes.status import broadcast_status # avoid circular import from app.api.routes.status import broadcast_status # avoid circular import
try:
check_result = await check_node(check_method, check_target, ip)
now = datetime.now(timezone.utc)
async with AsyncSessionLocal() as db:
n = await db.get(Node, node_id)
if n:
n.status = check_result["status"]
n.response_time_ms = check_result["response_time_ms"]
if check_result["status"] == "online":
n.last_seen = now
await db.commit()
await broadcast_status(
node_id=node_id,
status=check_result["status"],
checked_at=now.isoformat(),
response_time_ms=check_result["response_time_ms"],
)
return node_id, check_result
except Exception as exc:
logger.error("Status check failed for node %s: %s", node_id, exc)
return node_id, None
async def _run_status_checks() -> None:
"""Check all nodes concurrently and broadcast results via WebSocket."""
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
result = await db.execute(select(Node)) result = await db.execute(select(Node))
nodes = result.scalars().all() nodes = result.scalars().all()
# Extract scalars while the session is open to avoid DetachedInstanceError
checkable = [
(n.id, n.check_method, n.check_target, n.ip)
for n in nodes
if n.check_method
]
for node in nodes: if not checkable:
if not node.check_method: return
continue
try: await asyncio.gather(*[
check_result = await check_node(node.check_method, node.check_target, node.ip) _check_single_node(node_id, method, target, ip)
async with AsyncSessionLocal() as db: for node_id, method, target, ip in checkable
n = await db.get(Node, node.id) ])
if n:
n.status = check_result["status"]
n.response_time_ms = check_result["response_time_ms"]
n.last_seen = datetime.now(timezone.utc) if check_result["status"] == "online" else n.last_seen
await db.commit()
await broadcast_status(
node_id=node.id,
status=check_result["status"],
checked_at=datetime.now(timezone.utc).isoformat(),
response_time_ms=check_result["response_time_ms"],
)
except Exception as exc:
logger.error("Status check failed for node %s: %s", node.id, exc)
def start_scheduler() -> None: def start_scheduler() -> None:
global scheduler global scheduler
if scheduler.running: if scheduler.running:
scheduler.shutdown(wait=False) try:
scheduler.shutdown(wait=False)
except Exception as exc:
logger.warning("Failed to shut down previous scheduler instance: %s", exc)
scheduler = AsyncIOScheduler() scheduler = AsyncIOScheduler()
scheduler.add_job(_run_status_checks, "interval", seconds=settings.status_checker_interval, id="status_checks") scheduler.add_job(
_run_status_checks,
"interval",
seconds=settings.status_checker_interval,
id="status_checks",
max_instances=1,
coalesce=True,
)
scheduler.start() scheduler.start()
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval) logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
def reschedule_status_checks(interval_seconds: int) -> None: def reschedule_status_checks(interval_seconds: int) -> None:
"""Update the status check interval on the running scheduler.""" """Update the status check interval on the running scheduler."""
if interval_seconds < 10:
raise ValueError(f"interval_seconds must be >= 10, got {interval_seconds}")
if not scheduler.running: if not scheduler.running:
logger.warning("Scheduler not running, skipping reschedule") logger.warning("Scheduler not running, skipping reschedule")
return return
+49 -9
View File
@@ -65,14 +65,46 @@ def fingerprint_ports(open_ports: list[dict[str, Any]]) -> list[dict[str, Any]]:
return results return results
# Known OUI prefixes for virtual machines / hypervisors (lowercase, colon-separated) # Known OUI prefixes lowercase, colon-separated, first 3 octets
_MAC_OUI_TYPES: dict[str, str] = { _MAC_OUI_TYPES: dict[str, str] = {
"52:54:00": "vm", # QEMU/KVM (used by Proxmox VMs) # Hypervisors / VMs
"bc:24:11": "vm", # Proxmox official OUI (VMs and LXC, Proxmox 7.3+) "52:54:00": "vm", # QEMU/KVM (Proxmox VMs)
"bc:24:11": "vm", # Proxmox official OUI (VMs and LXC, 7.3+)
"00:50:56": "vm", # VMware "00:50:56": "vm", # VMware
"00:0c:29": "vm", # VMware Workstation / Fusion "00:0c:29": "vm", # VMware Workstation / Fusion
"08:00:27": "vm", # VirtualBox "08:00:27": "vm", # VirtualBox
"00:15:5d": "vm", # Hyper-V "00:15:5d": "vm", # Hyper-V
# Shelly
"34:94:54": "iot",
"84:f3:eb": "iot",
"ec:fa:bc": "iot",
"30:c6:f7": "iot",
# Espressif (ESP8266 / ESP32 — used by Sonoff, many DIY IoT)
"a0:20:a6": "iot",
"24:62:ab": "iot",
"30:ae:a4": "iot",
"cc:50:e3": "iot",
"ac:67:b2": "iot",
"b4:e6:2d": "iot",
"3c:71:bf": "iot",
"8c:aa:b5": "iot",
# Sonoff / ITEAD
"dc:4f:22": "iot",
"e8:db:84": "iot",
# Tapo / TP-Link smart home
"b0:a7:b9": "iot",
"50:c7:bf": "iot",
"1c:3b:f3": "iot",
"10:27:f5": "iot",
# Philips Hue
"00:17:88": "iot",
"ec:b5:fa": "iot",
# IKEA Tradfri
"34:13:e8": "iot",
"00:21:2e": "iot",
# Tuya / Smart Life (widely used chip in many brands)
"d8:f1:5b": "iot",
"68:57:2d": "iot",
} }
@@ -101,10 +133,13 @@ _PORT_TYPE_HINTS: dict[int, str] = {
37777: "camera", # Dahua 37777: "camera", # Dahua
34567: "camera", # Amcrest 34567: "camera", # Amcrest
2020: "camera", # Tapo 2020: "camera", # Tapo
# Smart-home / MQTT → iot # Smart-home / MQTT / CoAP → iot
1883: "iot", 1883: "iot",
8883: "iot", 8883: "iot",
6052: "iot", # ESPHome 6052: "iot", # ESPHome dashboard
4915: "iot", # Shelly CoIoT
5683: "iot", # CoAP (Shelly Gen1, many IoT devices)
5684: "iot", # CoAP DTLS
# AP / wireless # AP / wireless
8880: "ap", # UniFi HTTP 8880: "ap", # UniFi HTTP
8443: "ap", # UniFi HTTPS 8443: "ap", # UniFi HTTPS
@@ -115,8 +150,13 @@ _PORT_TYPE_HINTS: dict[int, str] = {
def suggest_node_type(open_ports: list[dict[str, Any]], mac: str | None = None) -> str: def suggest_node_type(open_ports: list[dict[str, Any]], mac: str | None = None) -> str:
"""Suggest a node type based on matched signatures and MAC OUI.""" """Suggest a node type based on matched signatures, port hints, and MAC OUI."""
priority = ["proxmox", "nas", "router", "lxc", "vm", "server", "ap", "camera", "iot", "switch"] # IoT vendor MACs are a strong, unambiguous signal — don't let generic HTTP ports override
mac_type = suggest_type_from_mac(mac)
if mac_type == "iot":
return "iot"
priority = ["proxmox", "nas", "router", "lxc", "vm", "ap", "camera", "iot", "server", "switch"]
found: set[str] = set() found: set[str] = set()
for p in open_ports: for p in open_ports:
port = p["port"] port = p["port"]
@@ -126,10 +166,10 @@ def suggest_node_type(open_ports: list[dict[str, Any]], mac: str | None = None)
found.add(sig["suggested_node_type"]) found.add(sig["suggested_node_type"])
if port in _PORT_TYPE_HINTS: if port in _PORT_TYPE_HINTS:
found.add(_PORT_TYPE_HINTS[port]) found.add(_PORT_TYPE_HINTS[port])
# MAC OUI is a lower-priority hint — only used if ports give no better answer
mac_type = suggest_type_from_mac(mac)
if mac_type: if mac_type:
found.add(mac_type) found.add(mac_type)
for t in priority: for t in priority:
if t in found: if t in found:
return t return t
+261 -106
View File
@@ -1,10 +1,11 @@
"""Network scanner: ARP sweep + nmap service detection.""" """Network scanner: ARP sweep + nmap service detection + mDNS discovery."""
import asyncio import asyncio
import logging import logging
import socket import socket
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Node, PendingDevice, ScanRun from app.db.models import Node, PendingDevice, ScanRun
@@ -12,6 +13,30 @@ 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
_cancelled_runs: set[str] = set()
# Port list for service detection (Phase 2)
_EXTRA_PORTS = (
"80,443,22,21,23,25,53,110,143,161,162,179,389,445,548,"
"554,636,873,1883,1880,1935,2020,2375,2376,3000,3001,3306,"
"3389,4711,4915,5000,5001,5432,5601,5683,5684,5900,5984,"
"6052,6379,6432,6443,6767,6789,6800,7878,8000,8006,8080,"
"8081,8086,8088,8090,8096,8112,8123,8200,8291,8428,8443,"
"8554,8686,8789,8843,8880,8883,8971,8989,9000,9001,9090,"
"9091,9092,9093,9100,9117,9200,9300,9411,9443,9696,10051,"
"16686,34567,37777,51413,64738"
)
_MDNS_SERVICE_TYPES = [
"_http._tcp.local.",
"_shelly._tcp.local.",
"_esphomelib._tcp.local.",
"_hap._tcp.local.", # HomeKit Accessory Protocol
"_mqtt._tcp.local.",
"_device-info._tcp.local.",
]
try: try:
import nmap import nmap
_NMAP_AVAILABLE = True _NMAP_AVAILABLE = True
@@ -19,50 +44,22 @@ except ImportError:
_NMAP_AVAILABLE = False _NMAP_AVAILABLE = False
logger.warning("python-nmap not available — scanner will run in mock mode") logger.warning("python-nmap not available — scanner will run in mock mode")
try:
from zeroconf import ServiceStateChange
from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
_ZEROCONF_AVAILABLE = True
except ImportError:
_ZEROCONF_AVAILABLE = False
logger.warning("zeroconf not available — mDNS discovery disabled")
def _nmap_scan(target: str) -> list[dict[str, Any]]:
"""Run nmap -sV --open on target, return list of host dicts."""
if not _NMAP_AVAILABLE:
return _mock_scan(target)
nm = nmap.PortScanner() def request_cancel(run_id: str) -> None:
try: """Signal a running scan to stop early."""
# Home lab port range: standard top-1000 + common self-hosted service ports _cancelled_runs.add(run_id)
extra_ports = (
"80,443,22,21,23,25,53,110,143,161,162,179,389,445,548,"
"554,636,873,1883,1880,1935,2020,2375,2376,3000,3001,3306,"
"3389,4711,5000,5001,5432,5601,5900,5984,6052,6379,6432,6443,"
"6767,6789,6800,7878,8000,8006,8080,8081,8086,8088,8090,8096,"
"8112,8123,8200,8291,8428,8443,8554,8686,8789,8843,8880,8883,"
"8971,8989,9000,9001,9090,9091,9092,9093,9100,9117,9200,9300,"
"9411,9443,9696,10051,16686,34567,37777,51413,64738"
)
nm.scan(hosts=target, arguments=f"-sV --open -T4 --host-timeout 120s -p {extra_ports}")
except Exception as exc:
logger.error("nmap scan failed: %s", exc)
raise RuntimeError(str(exc)) from exc
hosts = []
for host in nm.all_hosts(): def _is_cancelled(run_id: str) -> bool:
if nm[host].state() != "up": return run_id in _cancelled_runs
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", ""),
})
hosts.append({
"ip": host,
"hostname": _resolve_hostname(host),
"mac": nm[host].get("addresses", {}).get("mac"),
"os": _extract_os(nm, host),
"open_ports": open_ports,
})
return hosts
def _resolve_hostname(ip: str) -> str | None: def _resolve_hostname(ip: str) -> str | None:
@@ -82,6 +79,142 @@ def _extract_os(nm: object, host: str) -> str | None:
return None return None
def _nmap_arp_sweep(target: str) -> dict[str, dict[str, Any]]:
"""
Phase 1: ARP ping sweep — finds ALL alive hosts regardless of open ports.
Returns {ip: host_dict} for every host that responds.
"""
nm = nmap.PortScanner()
nm.scan(hosts=target, arguments="-sn -PR -PA80,443 --host-timeout 10s")
alive: dict[str, dict[str, Any]] = {}
for host in nm.all_hosts():
if nm[host].state() == "up":
alive[host] = {
"ip": host,
"hostname": _resolve_hostname(host),
"mac": nm[host].get("addresses", {}).get("mac"),
"os": None,
"open_ports": [],
}
return alive
def _nmap_port_scan(alive: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
"""
Phase 2: Service detection on the alive host set from Phase 1.
Mutates alive in-place with open_ports/os; returns all hosts including
those with zero open ports (IoT devices often have none).
"""
if not alive:
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():
if host not in alive:
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())
def _nmap_scan(target: str) -> list[dict[str, Any]]:
"""
Full two-phase scan for a CIDR range.
Phase 1: ARP sweep to find alive hosts (catches IoT with no open ports).
Phase 2: Service detection on alive hosts only.
"""
if not _NMAP_AVAILABLE:
return _mock_scan(target)
try:
alive = _nmap_arp_sweep(target)
except Exception as exc:
logger.error("nmap ARP sweep failed: %s", exc)
raise RuntimeError(str(exc)) from exc
return _nmap_port_scan(alive)
async def _mdns_discover(timeout: float = 4.0) -> list[dict[str, Any]]:
"""
Passive mDNS/Bonjour sweep.
Returns devices advertising on _shelly._tcp, _esphomelib._tcp, _hap._tcp, etc.
Runs for `timeout` seconds then returns what it found.
"""
if not _ZEROCONF_AVAILABLE:
return []
import ipaddress
found_services: list[tuple[str, str]] = []
def _on_change(
zeroconf: Any,
service_type: str,
name: str,
state_change: Any,
) -> None:
if state_change == ServiceStateChange.Added:
found_services.append((service_type, name))
discovered: dict[str, dict[str, Any]] = {}
try:
async with AsyncZeroconf() as azc:
browser = AsyncServiceBrowser(
azc.zeroconf, _MDNS_SERVICE_TYPES, handlers=[_on_change]
)
await asyncio.sleep(timeout)
await browser.async_cancel()
for service_type, name in found_services:
try:
info = AsyncServiceInfo(service_type, name)
await info.async_request(azc.zeroconf, 3000)
if not info.addresses:
continue
ip = str(ipaddress.IPv4Address(info.addresses[0]))
if ip in discovered:
continue
discovered[ip] = {
"ip": ip,
"hostname": info.server,
"mac": None,
"os": None,
"open_ports": (
[{"port": info.port, "protocol": "tcp", "banner": ""}]
if info.port else []
),
}
except Exception as exc:
logger.debug("mDNS resolution failed for %s: %s", name, exc)
except Exception as exc:
logger.warning("mDNS discovery error: %s", exc)
logger.info("mDNS discovery found %d device(s)", len(discovered))
return list(discovered.values())
def _mock_scan(target: str) -> list[dict[str, Any]]: def _mock_scan(target: str) -> list[dict[str, Any]]:
"""Return fake results for dev/test environments without nmap.""" """Return fake results for dev/test environments without nmap."""
return [ return [
@@ -100,17 +233,13 @@ def _mock_scan(target: str) -> list[dict[str, Any]]:
async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None: async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
"""Execute scan for given CIDR ranges and populate pending_devices.""" """Execute scan for given CIDR ranges and populate pending_devices."""
# Avoid circular import
from sqlalchemy import select
from app.api.routes.status import broadcast_scan_update from app.api.routes.status import broadcast_scan_update
devices_found = 0 devices_found = 0
try: try:
# Clean up stale pending devices whose IPs are already in the canvas # Clean up stale pending devices whose IPs are already in the canvas
# (covers devices approved between scans, or pre-existing canvas nodes)
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 = {row[0] for row in canvas_ips_result.fetchall()} canvas_ips: set[str] = {row[0] for row in canvas_ips_result.fetchall()}
if canvas_ips: if canvas_ips:
stale_result = await db.execute( stale_result = await db.execute(
select(PendingDevice).where( select(PendingDevice).where(
@@ -122,78 +251,102 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
await db.delete(stale) await db.delete(stale)
await db.commit() await db.commit()
for cidr in ranges: # Start mDNS discovery in the background while nmap scans run
# Run nmap in a thread pool — does not block the event loop mdns_task: asyncio.Task[list[dict[str, Any]]] = asyncio.create_task(
hosts = await asyncio.to_thread(_nmap_scan, cidr) _mdns_discover()
)
for host in hosts: # Track IPs found by nmap so mDNS doesn't duplicate them
ip = host["ip"] nmap_ips: set[str] = set()
# Skip if device is already in the canvas (approved node) async def _process_host(host: dict[str, Any]) -> None:
canvas_result = await db.execute( nonlocal devices_found
select(Node).where(Node.ip == ip) ip = host["ip"]
# Skip canvas nodes and user-hidden devices
canvas_result = await db.execute(select(Node).where(Node.ip == ip))
if canvas_result.scalar_one_or_none() is not None:
logger.debug("Skipping %s — already in canvas", ip)
return
hidden_result = await db.execute(
select(PendingDevice).where(
PendingDevice.ip == ip,
PendingDevice.status == "hidden",
) )
if canvas_result.scalar_one_or_none() is not None: )
logger.debug("Skipping %s — already in canvas", ip) if hidden_result.scalar_one_or_none() is not None:
continue logger.debug("Skipping %s — hidden by user", ip)
return
# Skip if device was explicitly hidden by the user services = fingerprint_ports(host["open_ports"])
hidden_result = await db.execute( suggested_type = suggest_node_type(host["open_ports"], host.get("mac"))
select(PendingDevice).where(
PendingDevice.ip == ip, existing_result = await db.execute(
PendingDevice.status == "hidden", select(PendingDevice).where(
) PendingDevice.ip == ip,
PendingDevice.status == "pending",
) )
if hidden_result.scalar_one_or_none() is not None: )
logger.debug("Skipping %s — hidden by user", ip) existing = existing_result.scalar_one_or_none()
continue if existing:
existing.mac = host.get("mac") or existing.mac
existing.hostname = host.get("hostname") or existing.hostname
existing.os = host.get("os") or existing.os
existing.services = services
existing.suggested_type = suggested_type
else:
db.add(PendingDevice(
ip=ip,
mac=host.get("mac"),
hostname=host.get("hostname"),
os=host.get("os"),
services=services,
suggested_type=suggested_type,
status="pending",
))
devices_found += 1
services = fingerprint_ports(host["open_ports"]) await db.commit()
suggested_type = suggest_node_type(host["open_ports"], host.get("mac"))
# Update existing pending device or create a new one run = await db.get(ScanRun, run_id)
existing_result = await db.execute( if run:
select(PendingDevice).where( run.devices_found = devices_found
PendingDevice.ip == ip,
PendingDevice.status == "pending",
)
)
existing = existing_result.scalar_one_or_none()
if existing:
existing.mac = host.get("mac") or existing.mac
existing.hostname = host.get("hostname") or existing.hostname
existing.os = host.get("os") or existing.os
existing.services = services
existing.suggested_type = suggested_type
else:
device = PendingDevice(
ip=ip,
mac=host.get("mac"),
hostname=host.get("hostname"),
os=host.get("os"),
services=services,
suggested_type=suggested_type,
status="pending",
)
db.add(device)
devices_found += 1
# Commit immediately so the device is visible right away
await db.commit() await db.commit()
# Update running count on the scan run record await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
run = await db.get(ScanRun, run_id)
if run:
run.devices_found = devices_found
await db.commit()
# Push WS event so the frontend refreshes pending panel # nmap scan per CIDR — results stream in progressively
await broadcast_scan_update(run_id=run_id, devices_found=devices_found) for cidr in ranges:
if _is_cancelled(run_id):
break
hosts = await asyncio.to_thread(_nmap_scan, cidr)
for host in hosts:
if _is_cancelled(run_id):
break
nmap_ips.add(host["ip"])
await _process_host(host)
# Mark scan as done # Collect mDNS results; add devices not already found by nmap
if not _is_cancelled(run_id):
try:
mdns_hosts = await asyncio.wait_for(mdns_task, timeout=1.0)
except asyncio.TimeoutError:
mdns_task.cancel()
mdns_hosts = []
for host in mdns_hosts:
if _is_cancelled(run_id):
break
if host["ip"] in nmap_ips:
continue # already processed with richer nmap data
await _process_host(host)
else:
mdns_task.cancel()
# Mark scan as done or cancelled
run = await db.get(ScanRun, run_id) run = await db.get(ScanRun, run_id)
if run: if run:
run.status = "done" run.status = "cancelled" if _is_cancelled(run_id) else "done"
run.devices_found = devices_found run.devices_found = devices_found
run.finished_at = datetime.now(timezone.utc) run.finished_at = datetime.now(timezone.utc)
await db.commit() await db.commit()
@@ -206,3 +359,5 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
run.error = str(exc) run.error = str(exc)
run.finished_at = datetime.now(timezone.utc) run.finished_at = datetime.now(timezone.utc)
await db.commit() await db.commit()
finally:
_cancelled_runs.discard(run_id)
+1
View File
@@ -16,6 +16,7 @@ pyyaml==6.0.2
types-PyYAML==6.0.12.20240917 types-PyYAML==6.0.12.20240917
websockets==13.1 websockets==13.1
httpx==0.27.2 httpx==0.27.2
zeroconf==0.131.0
# Dev # Dev
ruff==0.6.9 ruff==0.6.9
+42
View File
@@ -131,3 +131,45 @@ def test_suggest_node_type_camera_from_signature():
]): ]):
result = suggest_node_type([{"port": 554, "protocol": "tcp"}]) result = suggest_node_type([{"port": 554, "protocol": "tcp"}])
assert result == "camera" assert result == "camera"
# ── IoT detection ─────────────────────────────────────────────────────────────
def test_suggest_node_type_iot_from_mqtt_port():
result = suggest_node_type([{"port": 1883, "protocol": "tcp"}])
assert result == "iot"
def test_suggest_node_type_iot_from_coap_port():
result = suggest_node_type([{"port": 5683, "protocol": "tcp"}])
assert result == "iot"
def test_suggest_node_type_iot_from_esphome_port():
result = suggest_node_type([{"port": 6052, "protocol": "tcp"}])
assert result == "iot"
def test_suggest_node_type_shelly_mac_overrides_http_port():
# Shelly exposes port 80 (would suggest "server") but MAC identifies it as IoT
result = suggest_node_type([{"port": 80, "protocol": "tcp"}], mac="34:94:54:aa:bb:cc")
assert result == "iot"
def test_suggest_node_type_espressif_mac_returns_iot():
result = suggest_node_type([], mac="a0:20:a6:11:22:33")
assert result == "iot"
def test_suggest_node_type_tuya_mac_returns_iot():
result = suggest_node_type([{"port": 80, "protocol": "tcp"}], mac="d8:f1:5b:aa:bb:cc")
assert result == "iot"
def test_suggest_node_type_iot_wins_over_server_when_mqtt_present():
# MQTT port + HTTP port → iot wins (iot is higher priority than server now)
result = suggest_node_type([
{"port": 80, "protocol": "tcp"},
{"port": 1883, "protocol": "tcp"},
])
assert result == "iot"
+95 -2
View File
@@ -1,4 +1,4 @@
"""Tests for scan routes: trigger, pending devices, approve/hide/ignore.""" """Tests for scan routes: trigger, pending devices, approve/hide/ignore, stop."""
import uuid import uuid
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
@@ -8,7 +8,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Node, PendingDevice, ScanRun from app.db.models import Node, PendingDevice, ScanRun
from app.services.scanner import run_scan from app.services.scanner import _cancelled_runs, request_cancel, run_scan
@pytest.fixture @pytest.fixture
@@ -312,6 +312,99 @@ async def test_run_scan_skips_hidden_device(db_session: AsyncSession):
assert result.scalar_one_or_none() is None assert result.scalar_one_or_none() is None
# --- Stop scan ---
@pytest.mark.asyncio
async def test_stop_scan_requires_auth(client: AsyncClient):
res = await client.post("/api/v1/scan/fake-id/stop")
assert res.status_code == 401
@pytest.mark.asyncio
async def test_stop_scan_not_found(client: AsyncClient, headers):
res = await client.post("/api/v1/scan/nonexistent-id/stop", headers=headers)
assert res.status_code == 404
@pytest.mark.asyncio
async def test_stop_scan_not_running(client: AsyncClient, headers, db_session: AsyncSession):
run = ScanRun(id=str(uuid.uuid4()), status="done", ranges=["192.168.1.0/24"])
db_session.add(run)
await db_session.commit()
res = await client.post(f"/api/v1/scan/{run.id}/stop", headers=headers)
assert res.status_code == 409
@pytest.mark.asyncio
async def test_stop_scan_success(client: AsyncClient, headers, db_session: AsyncSession):
run = ScanRun(id=str(uuid.uuid4()), status="running", ranges=["192.168.1.0/24"])
db_session.add(run)
await db_session.commit()
res = await client.post(f"/api/v1/scan/{run.id}/stop", headers=headers)
assert res.status_code == 200
assert res.json() == {"stopping": True}
# run_id added to cancel set
assert run.id in _cancelled_runs
# cleanup for other tests
_cancelled_runs.discard(run.id)
# --- run_scan cancellation ---
@pytest.mark.asyncio
async def test_run_scan_cancelled_marks_status(db_session: AsyncSession):
"""When cancel is requested before the scan starts, status becomes 'cancelled'."""
run_id = str(uuid.uuid4())
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
db_session.add(run)
await db_session.commit()
request_cancel(run_id)
with (
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]) as mock_nmap,
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
):
await run_scan(["192.168.1.0/24"], db_session, run_id)
# nmap should not have been called — cancelled before first range
mock_nmap.assert_not_called()
await db_session.refresh(run)
assert run.status == "cancelled"
assert run.finished_at is not None
@pytest.mark.asyncio
async def test_run_scan_cancelled_mid_scan_skips_remaining_cidrs(db_session: AsyncSession):
"""Cancel flag set after first CIDR is started prevents processing of the second CIDR."""
run_id = str(uuid.uuid4())
run = ScanRun(id=run_id, status="running", ranges=["10.0.0.0/24", "10.0.1.0/24"])
db_session.add(run)
await db_session.commit()
call_count = 0
def nmap_side_effect(target: str):
nonlocal call_count
call_count += 1
# Signal cancellation after the first CIDR scan completes
if call_count == 1:
request_cancel(run_id)
return []
with (
patch("app.services.scanner._nmap_scan", side_effect=nmap_side_effect),
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
):
await run_scan(["10.0.0.0/24", "10.0.1.0/24"], db_session, run_id)
assert call_count == 1 # second CIDR was skipped
await db_session.refresh(run)
assert run.status == "cancelled"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession): async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession):
"""Re-scanning the same IP updates services instead of creating a duplicate.""" """Re-scanning the same IP updates services instead of creating a duplicate."""
+306
View File
@@ -0,0 +1,306 @@
"""Tests for scanner: two-phase nmap, mDNS discovery, run_scan integration."""
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.db.database import Base
from app.db.models import PendingDevice, ScanRun
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_run_id() -> str:
return str(uuid.uuid4())
@pytest.fixture
async def mem_db():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
yield factory
await engine.dispose()
def _make_scan_run(run_id: str) -> ScanRun:
return ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
# ---------------------------------------------------------------------------
# _nmap_arp_sweep
# ---------------------------------------------------------------------------
def test_nmap_arp_sweep_returns_alive_hosts():
from app.services.scanner import _nmap_arp_sweep
mock_nm = MagicMock()
mock_nm.all_hosts.return_value = ["192.168.1.1", "192.168.1.2"]
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), \
patch("app.services.scanner._resolve_hostname", return_value=None):
result = _nmap_arp_sweep("192.168.1.0/24")
assert set(result.keys()) == {"192.168.1.1", "192.168.1.2"}
for host in result.values():
assert host["open_ports"] == [] # empty until phase 2
def test_nmap_arp_sweep_skips_down_hosts():
from app.services.scanner import _nmap_arp_sweep
states = {"192.168.1.1": "up", "192.168.1.2": "down"}
mock_nm = MagicMock()
mock_nm.all_hosts.return_value = list(states.keys())
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):
result = _nmap_arp_sweep("192.168.1.0/24")
assert "192.168.1.1" in result
assert "192.168.1.2" not in result
# ---------------------------------------------------------------------------
# _nmap_port_scan
# ---------------------------------------------------------------------------
def test_nmap_port_scan_merges_ports():
from app.services.scanner import _nmap_port_scan
alive = {
"192.168.1.10": {"ip": "192.168.1.10", "hostname": None, "mac": None, "os": None, "open_ports": []},
}
mock_nm = MagicMock()
mock_nm.all_hosts.return_value = ["192.168.1.10"]
mock_nm.__getitem__ = lambda self, host: MagicMock(
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), \
patch("app.services.scanner._extract_os", return_value=None):
result = _nmap_port_scan(alive)
assert len(result) == 1
assert result[0]["open_ports"][0]["port"] == 80
def test_nmap_port_scan_returns_arp_only_on_failure():
from app.services.scanner import _nmap_port_scan
alive = {
"192.168.1.20": {"ip": "192.168.1.20", "hostname": None, "mac": None, "os": None, "open_ports": []},
}
mock_nm = MagicMock()
mock_nm.scan.side_effect = Exception("nmap error")
with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm):
result = _nmap_port_scan(alive)
# Should return the ARP-found host even though port scan failed
assert len(result) == 1
assert result[0]["ip"] == "192.168.1.20"
assert result[0]["open_ports"] == []
def test_nmap_port_scan_includes_hosts_with_no_open_ports():
"""IoT devices found by ARP but with no open TCP ports must still be returned."""
from app.services.scanner import _nmap_port_scan
alive = {
"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.all_hosts.return_value = ["192.168.1.31"]
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), \
patch("app.services.scanner._extract_os", return_value=None):
result = _nmap_port_scan(alive)
ips = {h["ip"] for h in result}
assert "192.168.1.30" in ips, "ARP-found device with no open ports must still be returned"
assert "192.168.1.31" in ips
# ---------------------------------------------------------------------------
# _nmap_scan (integration of both phases)
# ---------------------------------------------------------------------------
def test_nmap_scan_uses_mock_when_nmap_unavailable():
from app.services.scanner import _nmap_scan
with patch("app.services.scanner._NMAP_AVAILABLE", False):
result = _nmap_scan("192.168.1.0/24")
assert len(result) == 1
assert result[0]["ip"] == "192.168.1.99"
# ---------------------------------------------------------------------------
# _mdns_discover
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_mdns_discover_returns_empty_when_zeroconf_unavailable():
from app.services.scanner import _mdns_discover
with patch("app.services.scanner._ZEROCONF_AVAILABLE", False):
result = await _mdns_discover()
assert result == []
@pytest.mark.asyncio
async def test_mdns_discover_returns_devices():
from app.services.scanner import _mdns_discover
mock_info = MagicMock()
mock_info.addresses = [b"\xc0\xa8\x01\x50"] # 192.168.1.80
mock_info.server = "shelly1.local."
mock_info.port = 80
mock_info.async_request = AsyncMock(return_value=True)
mock_browser = AsyncMock()
mock_browser.async_cancel = AsyncMock()
# Simulate a service being found during the sleep
captured_handler: list = []
def fake_browser(zc, types, handlers):
captured_handler.extend(handlers)
return mock_browser
from zeroconf import ServiceStateChange
async def fake_sleep(t):
# Fire the handler as if a device was discovered
for h in captured_handler:
h(None, "_shelly._tcp.local.", "Shelly1._shelly._tcp.local.", ServiceStateChange.Added)
mock_azc = AsyncMock()
mock_azc.__aenter__ = AsyncMock(return_value=mock_azc)
mock_azc.__aexit__ = AsyncMock(return_value=None)
mock_azc.zeroconf = MagicMock()
with patch("app.services.scanner._ZEROCONF_AVAILABLE", True), \
patch("app.services.scanner.AsyncZeroconf", return_value=mock_azc), \
patch("app.services.scanner.AsyncServiceBrowser", side_effect=fake_browser), \
patch("app.services.scanner.AsyncServiceInfo", return_value=mock_info), \
patch("asyncio.sleep", side_effect=fake_sleep):
result = await _mdns_discover(timeout=0.01)
assert len(result) == 1
assert result[0]["ip"] == "192.168.1.80"
assert result[0]["hostname"] == "shelly1.local."
# ---------------------------------------------------------------------------
# run_scan integration
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_run_scan_adds_nmap_devices_as_pending(mem_db):
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))
await session.commit()
nmap_hosts = [{"ip": "192.168.1.5", "hostname": "device.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(
__import__("sqlalchemy", fromlist=["select"]).select(PendingDevice)
)
devices = result.scalars().all()
assert any(d.ip == "192.168.1.5" for d in devices)
@pytest.mark.asyncio
async def test_run_scan_mdns_only_device_added(mem_db):
"""Devices found only by mDNS (not nmap) should appear in pending_devices."""
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))
await session.commit()
mdns_hosts = [{"ip": "192.168.1.80", "hostname": "shelly1.local.", "mac": None, "os": None, "open_ports": [{"port": 80, "protocol": "tcp", "banner": ""}]}]
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=mdns_hosts), \
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:
from sqlalchemy import select as sa_select
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.80"))
device = result.scalar_one_or_none()
assert device is not None
assert device.status == "pending"
@pytest.mark.asyncio
async def test_run_scan_mdns_skipped_if_already_in_nmap(mem_db):
"""If nmap and mDNS both find the same IP, it should not be double-counted."""
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))
await session.commit()
shared_host = {"ip": "192.168.1.10", "hostname": "device.lan", "mac": None, "os": None, "open_ports": []}
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", return_value=[shared_host]), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[shared_host]), \
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:
from sqlalchemy import select as sa_select
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.10"))
devices = result.scalars().all()
assert len(devices) == 1 # not duplicated
+5 -5
View File
@@ -1,12 +1,12 @@
{ {
"name": "frontend", "name": "frontend",
"version": "1.4.0", "version": "1.7.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "frontend", "name": "frontend",
"version": "1.4.0", "version": "1.7.0",
"dependencies": { "dependencies": {
"@base-ui/react": "^1.2.0", "@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4", "@dagrejs/dagre": "^2.0.4",
@@ -6760,9 +6760,9 @@
} }
}, },
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.23", "version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "1.6.0", "version": "1.7.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+10 -7
View File
@@ -35,7 +35,7 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const STANDALONE_STORAGE_KEY = 'homelable_canvas' const STANDALONE_STORAGE_KEY = 'homelable_canvas'
export default function App() { export default function App() {
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore() const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
const canvasRef = useRef<HTMLDivElement>(null) const canvasRef = useRef<HTMLDivElement>(null)
const { isAuthenticated } = useAuthStore() const { isAuthenticated } = useAuthStore()
const { activeTheme, setTheme } = useThemeStore() const { activeTheme, setTheme } = useThemeStore()
@@ -100,8 +100,8 @@ export default function App() {
// Build a map of proxmox container mode to know if children should be nested // Build a map of proxmox container mode to know if children should be nested
const proxmoxContainerMap = new Map<string, boolean>( const proxmoxContainerMap = new Map<string, boolean>(
(apiNodes as ApiNode[]) (apiNodes as ApiNode[])
.filter((n) => n.type === 'proxmox') .filter((n) => n.type === 'proxmox' || n.type === 'group')
.map((n) => [n.id, n.container_mode !== false]) .map((n) => [n.id, n.type === 'group' ? true : n.container_mode !== false])
) )
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)) const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge) const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
@@ -258,10 +258,13 @@ export default function App() {
) )
if (oldEdge) deleteEdge(oldEdge.id) if (oldEdge) deleteEdge(oldEdge.id)
} }
// Create new virtual edge: LXC top → Proxmox bottom // Create virtual edge only when parent is NOT in container mode
// (container mode shows containment visually — no edge needed)
if (newParentId) { if (newParentId) {
// Pass type as extra field — canvasStore.onConnect casts to Connection & Partial<EdgeData> const parentNode = nodes.find((n) => n.id === newParentId)
onConnect({ source: editNodeId, sourceHandle: 'top', target: newParentId, targetHandle: 'bottom', type: 'virtual' } as unknown as Connection) if (!parentNode?.data.container_mode) {
onConnect({ source: editNodeId, sourceHandle: 'top', target: newParentId, targetHandle: 'bottom', type: 'virtual' } as unknown as Connection)
}
} }
} }
} }
@@ -385,7 +388,7 @@ export default function App() {
<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} />
</div> </div>
{selectedNodeId && <DetailPanel onEdit={handleEditNode} />} {(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
</div> </div>
</div> </div>
</div> </div>
+1
View File
@@ -59,6 +59,7 @@ export const scanApi = {
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`),
stop: (runId: string) => api.post(`/scan/${runId}/stop`),
getConfig: () => api.get<{ ranges: string[] }>('/scan/config'), getConfig: () => api.get<{ ranges: string[] }>('/scan/config'),
saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data), saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
} }
+2 -2
View File
@@ -71,8 +71,8 @@ function LiveViewCanvas() {
const { nodes: apiNodes, edges: apiEdges } = res.data const { nodes: apiNodes, edges: apiEdges } = res.data
const proxmoxMap = new Map<string, boolean>( const proxmoxMap = new Map<string, boolean>(
(apiNodes as ApiNode[]) (apiNodes as ApiNode[])
.filter((n: ApiNode) => n.type === 'proxmox') .filter((n: ApiNode) => n.type === 'proxmox' || n.type === 'group')
.map((n: ApiNode) => [n.id, n.container_mode !== false]) .map((n: ApiNode) => [n.id, n.type === 'group' ? true : n.container_mode !== false])
) )
loadCanvas( loadCanvas(
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)), (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
@@ -1,20 +1,24 @@
import { useCallback } from 'react' import { useCallback, useState } from 'react'
import { import {
ReactFlow, ReactFlow,
Background, Background,
Controls, Controls,
ControlButton,
BackgroundVariant, BackgroundVariant,
ConnectionMode, ConnectionMode,
SelectionMode,
type Node, type Node,
type Edge, type Edge,
type Connection, type Connection,
} from '@xyflow/react' } from '@xyflow/react'
import { MousePointer2, Hand } from 'lucide-react'
import '@xyflow/react/dist/style.css' import '@xyflow/react/dist/style.css'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { useThemeStore } from '@/stores/themeStore' import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes' import { THEMES } from '@/utils/themes'
import { nodeTypes } from './nodes/nodeTypes' import { nodeTypes } from './nodes/nodeTypes'
import { edgeTypes } from './edges/edgeTypes' import { edgeTypes } from './edges/edgeTypes'
import { SearchBar } from './SearchBar'
import type { NodeData, EdgeData } from '@/types' import type { NodeData, EdgeData } from '@/types'
interface CanvasContainerProps { interface CanvasContainerProps {
@@ -24,6 +28,7 @@ interface CanvasContainerProps {
} }
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart }: CanvasContainerProps) { export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart }: CanvasContainerProps) {
const [lassoMode, setLassoMode] = useState(true)
const { const {
nodes, edges, nodes, edges,
onNodesChange, onEdgesChange, onNodesChange, onEdgesChange,
@@ -33,8 +38,12 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
const activeTheme = useThemeStore((s) => s.activeTheme) const activeTheme = useThemeStore((s) => s.activeTheme)
const theme = THEMES[activeTheme] const theme = THEMES[activeTheme]
const onNodeClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => { const onNodeClick = useCallback((e: React.MouseEvent, node: Node<NodeData>) => {
setSelectedNode(node.id) if (e.ctrlKey || e.metaKey) {
setSelectedNode(null)
} else {
setSelectedNode(node.id)
}
}, [setSelectedNode]) }, [setSelectedNode])
const onPaneClick = useCallback(() => { const onPaneClick = useCallback(() => {
@@ -61,6 +70,11 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
edgeTypes={edgeTypes} edgeTypes={edgeTypes}
deleteKeyCode={['Backspace', 'Delete']} deleteKeyCode={['Backspace', 'Delete']}
onBeforeDelete={async () => { snapshotHistory(); return true }} onBeforeDelete={async () => { snapshotHistory(); return true }}
selectionOnDrag={lassoMode}
panOnDrag={lassoMode ? [1, 2] : true}
panActivationKeyCode="Space"
selectionMode={SelectionMode.Partial}
multiSelectionKeyCode={['Meta', 'Control']}
snapToGrid snapToGrid
snapGrid={[16, 16]} snapGrid={[16, 16]}
fitView fitView
@@ -75,7 +89,15 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
size={1} size={1}
color={theme.colors.canvasDotColor} color={theme.colors.canvasDotColor}
/> />
<Controls /> <SearchBar />
<Controls>
<ControlButton
onClick={() => setLassoMode((m) => !m)}
title={lassoMode ? 'Switch to pan mode (Space to pan)' : 'Switch to lasso mode'}
>
{lassoMode ? <MousePointer2 size={12} /> : <Hand size={12} />}
</ControlButton>
</Controls>
</ReactFlow> </ReactFlow>
</div> </div>
) )
@@ -0,0 +1,160 @@
import { useState, useEffect, useRef } from 'react'
import { useReactFlow } from '@xyflow/react'
import { Search, X } from 'lucide-react'
import { useCanvasStore } from '@/stores/canvasStore'
import { NODE_TYPE_LABELS } from '@/types'
export function SearchBar() {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const { nodes, setSelectedNode } = useCanvasStore()
const { setCenter } = useReactFlow()
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault()
setOpen(true)
}
if (e.key === 'Escape') {
setOpen(false)
setQuery('')
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [])
useEffect(() => {
if (open) inputRef.current?.focus()
}, [open])
const q = query.toLowerCase().trim()
const results = q
? nodes.filter((n) => {
if (n.data.type === 'groupRect') return false
return (
n.data.label?.toLowerCase().includes(q) ||
n.data.ip?.toLowerCase().includes(q) ||
n.data.hostname?.toLowerCase().includes(q) ||
(n.data.services ?? []).some((s) => s.service_name?.toLowerCase().includes(q))
)
})
: []
const goToNode = (id: string) => {
const node = nodes.find((n) => n.id === id)
if (!node) return
setSelectedNode(id)
// For grouped nodes, add parent's absolute position
let absX = node.position.x
let absY = node.position.y
if (node.parentId) {
const parent = nodes.find((n) => n.id === node.parentId)
if (parent) { absX += parent.position.x; absY += parent.position.y }
}
const w = node.measured?.width ?? node.width ?? 200
const h = node.measured?.height ?? node.height ?? 80
setCenter(absX + w / 2, absY + h / 2, { zoom: 1.5, duration: 500 })
setOpen(false)
setQuery('')
}
if (!open) return null
return (
<div
className="nodrag nowheel"
style={{
position: 'absolute',
top: 16,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 1000,
width: 360,
pointerEvents: 'all',
}}
>
<div style={{
background: '#161b22',
border: '1px solid #30363d',
borderRadius: 8,
boxShadow: '0 8px 24px rgba(0,0,0,0.6)',
overflow: 'hidden',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px' }}>
<Search size={14} style={{ color: '#8b949e', flexShrink: 0 }} />
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by name, IP, hostname or service…"
style={{
flex: 1,
background: 'transparent',
border: 'none',
outline: 'none',
color: '#e6edf3',
fontSize: 13,
}}
/>
{query && (
<span style={{ fontSize: 11, color: '#6e7681', flexShrink: 0 }}>
{results.length} result{results.length !== 1 ? 's' : ''}
</span>
)}
<button
onClick={() => { setOpen(false); setQuery('') }}
aria-label="Close search"
style={{ color: '#8b949e', background: 'none', border: 'none', cursor: 'pointer', padding: 2 }}
>
<X size={14} />
</button>
</div>
{results.length > 0 && (
<div style={{ borderTop: '1px solid #30363d', maxHeight: 260, overflowY: 'auto' }}>
{results.map((n) => (
<button
key={n.id}
onClick={() => goToNode(n.id)}
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: 12, fontWeight: 600, color: '#e6edf3', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{n.data.label}
</span>
{n.data.ip && (
<span style={{ fontSize: 11, color: '#8b949e', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>
{n.data.ip}
</span>
)}
<span style={{ fontSize: 10, color: '#6e7681', flexShrink: 0 }}>
{NODE_TYPE_LABELS[n.data.type] ?? n.data.type}
</span>
</button>
))}
</div>
)}
{q && results.length === 0 && (
<div style={{ borderTop: '1px solid #30363d', padding: '10px 12px', fontSize: 12, color: '#6e7681', textAlign: 'center' }}>
No results for &ldquo;{query}&rdquo;
</div>
)}
</div>
</div>
)
}
@@ -16,8 +16,10 @@ vi.mock('@xyflow/react', () => ({
}, },
Background: () => null, Background: () => null,
Controls: () => null, Controls: () => null,
ControlButton: () => null,
BackgroundVariant: { Dots: 'dots' }, BackgroundVariant: { Dots: 'dots' },
ConnectionMode: { Loose: 'loose' }, ConnectionMode: { Loose: 'loose' },
SelectionMode: { Partial: 'partial' },
})) }))
vi.mock('@xyflow/react/dist/style.css', () => ({})) vi.mock('@xyflow/react/dist/style.css', () => ({}))
@@ -151,6 +153,55 @@ describe('CanvasContainer', () => {
expect(rfProps.deleteKeyCode).toEqual(['Backspace', 'Delete']) expect(rfProps.deleteKeyCode).toEqual(['Backspace', 'Delete'])
}) })
// ── Lasso / multi-select ──────────────────────────────────────────────────
it('enables selectionOnDrag for lasso selection', () => {
render(<CanvasContainer />)
expect(rfProps.selectionOnDrag).toBe(true)
})
it('sets panActivationKeyCode to Space', () => {
render(<CanvasContainer />)
expect(rfProps.panActivationKeyCode).toBe('Space')
})
it('sets panOnDrag to [1, 2]', () => {
render(<CanvasContainer />)
expect(rfProps.panOnDrag).toEqual([1, 2])
})
it('sets selectionMode to Partial', () => {
render(<CanvasContainer />)
expect(rfProps.selectionMode).toBe('partial')
})
it('sets multiSelectionKeyCode to Meta and Control', () => {
render(<CanvasContainer />)
expect(rfProps.multiSelectionKeyCode).toEqual(['Meta', 'Control'])
})
it('clears selectedNode (sets null) on Ctrl+click instead of selecting', () => {
const node = makeNode('n1')
useCanvasStore.setState({ nodes: [node], selectedNodeId: 'n1' })
render(<CanvasContainer />)
;(rfProps.onNodeClick as (...args: unknown[]) => unknown)(
{ ctrlKey: true, metaKey: false } as unknown as MouseEvent,
node,
)
expect(useCanvasStore.getState().selectedNodeId).toBeNull()
})
it('clears selectedNode (sets null) on Cmd+click', () => {
const node = makeNode('n1')
useCanvasStore.setState({ nodes: [node], selectedNodeId: 'n1' })
render(<CanvasContainer />)
;(rfProps.onNodeClick as (...args: unknown[]) => unknown)(
{ ctrlKey: false, metaKey: true } as unknown as MouseEvent,
node,
)
expect(useCanvasStore.getState().selectedNodeId).toBeNull()
})
// ── onBeforeDelete snapshot ─────────────────────────────────────────────── // ── onBeforeDelete snapshot ───────────────────────────────────────────────
it('onBeforeDelete calls snapshotHistory and returns true', async () => { it('onBeforeDelete calls snapshotHistory and returns true', async () => {
@@ -0,0 +1,122 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { GroupNode } from '../nodes/GroupNode'
import * as canvasStore from '@/stores/canvasStore'
import type { Node } from '@xyflow/react'
import type { NodeData } from '@/types'
vi.mock('@/stores/canvasStore')
vi.mock('@xyflow/react', () => ({
NodeResizer: ({ isVisible }: { isVisible: boolean }) => (
<div data-testid="node-resizer" data-visible={isVisible} />
),
useReactFlow: () => ({}),
}))
vi.mock('@xyflow/react/dist/style.css', () => ({}))
function makeGroupNode(overrides: Partial<NodeData> = {}): Node<NodeData> {
return {
id: 'g1',
type: 'group',
position: { x: 0, y: 0 },
width: 400,
height: 250,
data: {
label: 'My Group',
type: 'group',
status: 'unknown',
services: [],
custom_colors: { show_border: true },
...overrides,
},
}
}
function renderGroupNode(props: Partial<Parameters<typeof GroupNode>[0]> = {}, storeNodes: unknown[] = []) {
const node = makeGroupNode(props.data)
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: storeNodes,
updateNode: vi.fn(),
snapshotHistory: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
return render(
<GroupNode
id="g1"
data={node.data}
selected={false}
dragging={false}
zIndex={1}
isConnectable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
{...props}
/>,
)
}
describe('GroupNode', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders the group label when show_border is true', () => {
renderGroupNode()
expect(screen.getByText('My Group')).toBeDefined()
})
it('hides the header when show_border is false and not selected', () => {
renderGroupNode({ data: makeGroupNode({ custom_colors: { show_border: false } }).data, selected: false })
expect(screen.queryByText('My Group')).toBeNull()
})
it('shows header when show_border is false but node is selected', () => {
renderGroupNode({ data: makeGroupNode({ custom_colors: { show_border: false } }).data, selected: true })
expect(screen.getByText('My Group')).toBeDefined()
})
it('shows NodeResizer only when selected', () => {
const { rerender } = renderGroupNode({ selected: false })
expect(screen.getByTestId('node-resizer').getAttribute('data-visible')).toBe('false')
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [],
updateNode: vi.fn(),
snapshotHistory: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
rerender(
<GroupNode
id="g1"
data={makeGroupNode().data}
selected={true}
dragging={false}
zIndex={1}
isConnectable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>,
)
expect(screen.getByTestId('node-resizer').getAttribute('data-visible')).toBe('true')
})
it('shows online/offline status summary from children', () => {
const storeNodes = [
{ id: 'c1', parentId: 'g1', data: { status: 'online' } },
{ id: 'c2', parentId: 'g1', data: { status: 'offline' } },
{ id: 'c3', parentId: 'other', data: { status: 'online' } }, // different group — excluded
]
renderGroupNode({}, storeNodes)
// Two status indicators: one online, one offline (c3 excluded — wrong parent)
const statusSpans = screen.getAllByText(/● \d+/)
expect(statusSpans).toHaveLength(2)
})
it('does not show status summary when group has no children', () => {
renderGroupNode()
expect(screen.queryByText(/●/)).toBeNull()
})
})
@@ -0,0 +1,146 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { SearchBar } from '../SearchBar'
import * as canvasStore from '@/stores/canvasStore'
vi.mock('@/stores/canvasStore')
vi.mock('@xyflow/react', () => ({
useReactFlow: () => ({ setCenter: vi.fn() }),
}))
function makeNode(id: string, overrides = {}) {
return {
id,
type: 'server',
position: { x: 0, y: 0 },
data: { label: id, type: 'server', status: 'online', services: [], ip: null, hostname: null },
...overrides,
}
}
function setupStore(nodes: unknown[] = []) {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes,
setSelectedNode: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
}
function openSearch() {
fireEvent.keyDown(window, { key: 'f', ctrlKey: true })
}
describe('SearchBar', () => {
beforeEach(() => {
setupStore([])
vi.clearAllMocks()
})
it('is hidden by default', () => {
render(<SearchBar />)
expect(screen.queryByPlaceholderText(/search/i)).toBeNull()
})
it('opens on Ctrl+F', () => {
render(<SearchBar />)
openSearch()
expect(screen.getByPlaceholderText(/search/i)).toBeDefined()
})
it('opens on Cmd+F', () => {
render(<SearchBar />)
fireEvent.keyDown(window, { key: 'f', metaKey: true })
expect(screen.getByPlaceholderText(/search/i)).toBeDefined()
})
it('closes on Escape', () => {
render(<SearchBar />)
openSearch()
fireEvent.keyDown(window, { key: 'Escape' })
expect(screen.queryByPlaceholderText(/search/i)).toBeNull()
})
it('closes when X button is clicked', () => {
render(<SearchBar />)
openSearch()
fireEvent.click(screen.getByLabelText('Close search'))
expect(screen.queryByPlaceholderText(/search/i)).toBeNull()
})
it('filters by label', () => {
setupStore([
makeNode('n1', { data: { label: 'My Router', type: 'router', status: 'online', services: [], ip: null, hostname: null } }),
makeNode('n2', { data: { label: 'My NAS', type: 'nas', status: 'online', services: [], ip: null, hostname: null } }),
])
render(<SearchBar />)
openSearch()
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'router' } })
expect(screen.getByText('My Router')).toBeDefined()
expect(screen.queryByText('My NAS')).toBeNull()
})
it('filters by IP', () => {
setupStore([
makeNode('n1', { data: { label: 'Server A', type: 'server', status: 'online', services: [], ip: '192.168.1.10', hostname: null } }),
makeNode('n2', { data: { label: 'Server B', type: 'server', status: 'online', services: [], ip: '10.0.0.1', hostname: null } }),
])
render(<SearchBar />)
openSearch()
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: '192.168' } })
expect(screen.getByText('Server A')).toBeDefined()
expect(screen.queryByText('Server B')).toBeNull()
})
it('filters by service name', () => {
setupStore([
makeNode('n1', { data: { label: 'Web Server', type: 'server', status: 'online', services: [{ service_name: 'nginx', port: 80, protocol: 'tcp' }], ip: null, hostname: null } }),
makeNode('n2', { data: { label: 'DB Server', type: 'server', status: 'online', services: [{ service_name: 'mysql', port: 3306, protocol: 'tcp' }], ip: null, hostname: null } }),
])
render(<SearchBar />)
openSearch()
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'nginx' } })
expect(screen.getByText('Web Server')).toBeDefined()
expect(screen.queryByText('DB Server')).toBeNull()
})
it('excludes groupRect nodes from results', () => {
setupStore([
makeNode('gr1', { data: { label: 'DMZ Zone', type: 'groupRect', status: 'unknown', services: [], ip: null, hostname: null } }),
])
render(<SearchBar />)
openSearch()
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'dmz' } })
expect(screen.queryByText('DMZ Zone')).toBeNull()
})
it('shows no-results message when query has no matches', () => {
render(<SearchBar />)
openSearch()
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'zzznomatch' } })
expect(screen.getByText(/no results/i)).toBeDefined()
})
it('calls setSelectedNode when a result is clicked', () => {
const setSelectedNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode('n1', { data: { label: 'My Server', type: 'server', status: 'online', services: [], ip: null, hostname: null } })],
setSelectedNode,
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<SearchBar />)
openSearch()
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'my server' } })
fireEvent.click(screen.getByText('My Server'))
expect(setSelectedNode).toHaveBeenCalledWith('n1')
})
it('shows result count', () => {
setupStore([
makeNode('n1', { data: { label: 'Alpha', type: 'server', status: 'online', services: [], ip: null, hostname: null } }),
makeNode('n2', { data: { label: 'Beta', type: 'server', status: 'online', services: [], ip: null, hostname: null } }),
])
render(<SearchBar />)
openSearch()
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'a' } })
expect(screen.getByText(/2 results/i)).toBeDefined()
})
})
@@ -0,0 +1,125 @@
import { useState } from 'react'
import { type NodeProps, type Node, NodeResizer } from '@xyflow/react'
import { Layers, Pencil, Check, X } from 'lucide-react'
import { useCanvasStore } from '@/stores/canvasStore'
import { STATUS_COLORS, type NodeData } from '@/types'
export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
const { nodes, updateNode, snapshotHistory } = useCanvasStore()
const showBorder = data.custom_colors?.show_border !== false
const isVisible = showBorder || selected
const [editing, setEditing] = useState(false)
const [labelDraft, setLabelDraft] = useState(data.label)
const children = nodes.filter((n) => n.parentId === id)
const onlineCount = children.filter((n) => n.data.status === 'online').length
const offlineCount = children.filter((n) => n.data.status === 'offline').length
const unknownCount = children.length - onlineCount - offlineCount
const handleRename = () => {
if (labelDraft.trim()) {
snapshotHistory()
updateNode(id, { label: labelDraft.trim() })
}
setEditing(false)
}
const borderColor = selected ? '#00d4ff' : '#30363d'
const borderStyle = selected ? 'solid' : 'dashed'
return (
<div
style={{
width: '100%',
height: '100%',
position: 'relative',
borderRadius: 8,
border: isVisible ? `2px ${borderStyle} ${borderColor}` : '2px solid transparent',
background: 'transparent',
transition: 'border-color 0.15s, background 0.15s',
boxSizing: 'border-box',
}}
>
<NodeResizer
isVisible={selected}
minWidth={120}
minHeight={80}
lineStyle={{ stroke: '#00d4ff', strokeWidth: 1 }}
handleStyle={{ fill: '#00d4ff', stroke: '#0d1117', width: 8, height: 8, borderRadius: 2 }}
/>
{/* Header */}
{isVisible && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
padding: '5px 10px',
display: 'flex',
alignItems: 'center',
gap: 6,
background: selected ? 'rgba(0,212,255,0.08)' : 'rgba(22,27,34,0.8)',
borderRadius: '6px 6px 0 0',
borderBottom: isVisible ? `1px solid ${borderColor}40` : 'none',
pointerEvents: 'auto',
}}
className="nodrag"
>
<Layers size={12} style={{ color: '#00d4ff', flexShrink: 0 }} />
{editing ? (
<input
autoFocus
value={labelDraft}
onChange={(e) => setLabelDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRename()
if (e.key === 'Escape') { setLabelDraft(data.label); setEditing(false) }
}}
style={{
flex: 1,
background: 'transparent',
border: 'none',
outline: 'none',
color: '#e6edf3',
fontSize: 11,
fontWeight: 600,
}}
/>
) : (
<span style={{ flex: 1, fontSize: 11, fontWeight: 600, color: '#e6edf3', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{data.label}
</span>
)}
{editing ? (
<>
<button onClick={handleRename} style={{ color: '#39d353', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><Check size={11} /></button>
<button onClick={() => { setLabelDraft(data.label); setEditing(false) }} style={{ color: '#f85149', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><X size={11} /></button>
</>
) : (
<button
onClick={() => { setLabelDraft(data.label); setEditing(true) }}
style={{ color: '#8b949e', background: 'none', border: 'none', cursor: 'pointer', padding: 1, opacity: selected ? 1 : 0 }}
title="Rename group"
>
<Pencil size={10} />
</button>
)}
{/* Status summary */}
{children.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 10, flexShrink: 0, marginLeft: 4 }}>
{onlineCount > 0 && <span style={{ color: STATUS_COLORS.online }}> {onlineCount}</span>}
{offlineCount > 0 && <span style={{ color: STATUS_COLORS.offline }}> {offlineCount}</span>}
{unknownCount > 0 && <span style={{ color: STATUS_COLORS.unknown }}> {unknownCount}</span>}
</div>
)}
</div>
)}
</div>
)
}
@@ -1,6 +1,7 @@
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerNode, GenericNode } from './index' import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerNode, GenericNode } from './index'
import { ProxmoxGroupNode } from './ProxmoxGroupNode' import { ProxmoxGroupNode } from './ProxmoxGroupNode'
import { GroupRectNode } from './GroupRectNode' import { GroupRectNode } from './GroupRectNode'
import { GroupNode } from './GroupNode'
export const nodeTypes = { export const nodeTypes = {
isp: IspNode, isp: IspNode,
@@ -20,4 +21,5 @@ export const nodeTypes = {
docker: DockerNode, docker: DockerNode,
generic: GenericNode, generic: GenericNode,
groupRect: GroupRectNode, groupRect: GroupRectNode,
group: GroupNode,
} }
+234 -171
View File
@@ -1,33 +1,75 @@
import { useState } from 'react' import { useState } from 'react'
import { X, Edit, Trash2, ExternalLink, Plus, Pencil } from 'lucide-react' import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo } from '@/types' import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData } from '@/types'
import { getServiceUrl } from '@/utils/serviceUrl' import { getServiceUrl } from '@/utils/serviceUrl'
import type { Node } from '@xyflow/react'
interface DetailPanelProps { interface DetailPanelProps {
onEdit: (id: string) => void onEdit: (id: string) => void
} }
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string } type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string }
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '' } const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '' }
export function DetailPanel({ onEdit }: DetailPanelProps) { export function DetailPanel({ onEdit }: DetailPanelProps) {
const { nodes, selectedNodeId, setSelectedNode, deleteNode, updateNode, snapshotHistory } = useCanvasStore() const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup } = useCanvasStore()
const node = nodes.find((n) => n.id === selectedNodeId)
const [addingForNode, setAddingForNode] = useState<string | null>(null) const [addingForNode, setAddingForNode] = useState<string | null>(null)
const [newSvc, setNewSvc] = useState<SvcForm>(EMPTY_FORM) const [newSvc, setNewSvc] = useState<SvcForm>(EMPTY_FORM)
const [editingFor, setEditingFor] = useState<{ nodeId: string; index: number } | null>(null) const [editingFor, setEditingFor] = useState<{ nodeId: string; index: number } | null>(null)
const [editSvc, setEditSvc] = useState<SvcForm>(EMPTY_FORM) const [editSvc, setEditSvc] = useState<SvcForm>(EMPTY_FORM)
const [groupName, setGroupName] = useState('')
const [creatingGroup, setCreatingGroup] = useState(false)
// Multi-select panel
const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id))
if (multiSelected.length > 1) {
return (
<MultiSelectPanel
nodeIds={multiSelected}
nodes={nodes}
groupName={groupName}
setGroupName={setGroupName}
creatingGroup={creatingGroup}
setCreatingGroup={setCreatingGroup}
onCreateGroup={(name) => { createGroup(multiSelected, name); setGroupName(''); setCreatingGroup(false) }}
onClose={() => setSelectedNode(null)}
/>
)
}
const node = nodes.find((n) => n.id === selectedNodeId)
if (!node || node.data.type === 'groupRect') return null if (!node || node.data.type === 'groupRect') return null
// Group detail panel
if (node.data.type === 'group') {
return (
<GroupDetailPanel
node={node}
nodes={nodes}
onUngroup={() => { ungroup(node.id) }}
onToggleBorder={() => {
snapshotHistory()
updateNode(node.id, {
custom_colors: {
...node.data.custom_colors,
show_border: !(node.data.custom_colors?.show_border !== false),
},
})
}}
onClose={() => setSelectedNode(null)}
onSelectChild={(id) => setSelectedNode(id)}
/>
)
}
// Normal single-node panel
const addingService = addingForNode === node.id const addingService = addingForNode === node.id
const editingIndex = editingFor?.nodeId === node.id ? editingFor.index : null const editingIndex = editingFor?.nodeId === node.id ? editingFor.index : null
const { data } = node const { data } = node
const services = data.services ?? [] const services = data.services ?? []
const statusColor = STATUS_COLORS[data.status] const statusColor = STATUS_COLORS[data.status]
@@ -43,11 +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
const svc: ServiceInfo = { const svc: ServiceInfo = { port, protocol: newSvc.protocol, service_name: newSvc.service_name.trim() }
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)
setAddingForNode(null) setAddingForNode(null)
@@ -72,9 +110,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
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
const updated = services.map((svc, i) => const updated = services.map((svc, i) =>
i === editingIndex i === editingIndex ? { ...svc, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() } : svc
? { ...svc, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() }
: svc
) )
updateNode(node.id, { services: updated }) updateNode(node.id, { services: updated })
setEditingFor(null) setEditingFor(null)
@@ -82,19 +118,13 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
return ( return (
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto"> <aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border"> <div className="flex items-center justify-between px-4 py-3 border-b border-border">
<span className="font-semibold text-sm text-foreground truncate">{data.label}</span> <span className="font-semibold text-sm text-foreground truncate">{data.label}</span>
<button <button aria-label="Close panel" onClick={() => setSelectedNode(null)} className="text-muted-foreground hover:text-foreground transition-colors">
aria-label="Close panel"
onClick={() => setSelectedNode(null)}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X size={16} /> <X size={16} />
</button> </button>
</div> </div>
{/* Status */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border"> <div className="flex items-center gap-2 px-4 py-3 border-b border-border">
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: statusColor }} /> <div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: statusColor }} />
<span className="text-sm capitalize" style={{ color: statusColor }}>{data.status}</span> <span className="text-sm capitalize" style={{ color: statusColor }}>{data.status}</span>
@@ -103,21 +133,13 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
)} )}
</div> </div>
{/* Details */}
<div className="flex flex-col gap-3 px-4 py-3 text-sm"> <div className="flex flex-col gap-3 px-4 py-3 text-sm">
<DetailRow label="Type" value={NODE_TYPE_LABELS[data.type]} /> <DetailRow label="Type" value={NODE_TYPE_LABELS[data.type]} />
{data.hostname && ( {data.hostname && (
<div className="flex justify-between gap-2 items-baseline"> <div className="flex justify-between gap-2 items-baseline">
<span className="text-muted-foreground text-xs shrink-0">Hostname</span> <span className="text-muted-foreground text-xs shrink-0">Hostname</span>
<a <a href={`http://${data.hostname}`} target="_blank" rel="noopener noreferrer" className="text-xs font-mono text-[#00d4ff] hover:underline truncate flex items-center gap-1" title={data.hostname}>
href={`http://${data.hostname}`} {data.hostname}<ExternalLink size={10} className="shrink-0" />
target="_blank"
rel="noopener noreferrer"
className="text-xs font-mono text-[#00d4ff] hover:underline truncate flex items-center gap-1"
title={data.hostname}
>
{data.hostname}
<ExternalLink size={10} className="shrink-0" />
</a> </a>
</div> </div>
)} )}
@@ -125,12 +147,9 @@ 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 && ( {data.last_seen && <DetailRow label="Last Seen" value={new Date(data.last_seen).toLocaleString()} />}
<DetailRow label="Last Seen" value={new Date(data.last_seen).toLocaleString()} />
)}
</div> </div>
{/* Hardware */}
{(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) && (
<div className="flex flex-col gap-3 px-4 py-3 text-sm border-t border-border"> <div className="flex flex-col gap-3 px-4 py-3 text-sm border-t border-border">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Hardware</span> <span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Hardware</span>
@@ -141,64 +160,28 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
</div> </div>
)} )}
{/* Services */}
<div className="px-4 py-3 border-t border-border"> <div className="px-4 py-3 border-t border-border">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">Services{services.length > 0 ? ` (${services.length})` : ''}</span>
Services{services.length > 0 ? ` (${services.length})` : ''} <button onClick={() => { setAddingForNode((v) => v === node.id ? null : node.id); setEditingFor(null) }} className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors">
</span>
<button
onClick={() => { setAddingForNode((v) => v === node.id ? null : node.id); setEditingFor(null) }}
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors"
>
<Plus size={10} /> Add <Plus size={10} /> Add
</button> </button>
</div> </div>
{addingService && <ServiceForm form={newSvc} onChange={setNewSvc} onConfirm={handleAddService} onCancel={() => setAddingForNode(null)} confirmLabel="Add" autoFocus />}
{/* Add service form */}
{addingService && (
<ServiceForm
form={newSvc}
onChange={setNewSvc}
onConfirm={handleAddService}
onCancel={() => setAddingForNode(null)}
confirmLabel="Add"
autoFocus
/>
)}
{services.length > 0 && ( {services.length > 0 && (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
{services.map((svc, i) => {services.map((svc, i) =>
editingIndex === i ? ( editingIndex === i ? (
<ServiceForm <ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus />
key={`edit-${i}`}
form={editSvc}
onChange={setEditSvc}
onConfirm={handleSaveEdit}
onCancel={() => setEditingFor(null)}
confirmLabel="Save"
autoFocus
/>
) : ( ) : (
<ServiceBadge <ServiceBadge key={`${svc.port}-${svc.protocol}-${i}`} svc={svc} host={host} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
key={`${svc.port}-${svc.protocol}-${i}`}
svc={svc}
host={host}
onEdit={() => handleStartEdit(i)}
onRemove={() => handleRemoveService(i)}
/>
) )
)} )}
</div> </div>
)} )}
{services.length === 0 && !addingService && <p className="text-[10px] text-muted-foreground/50">No services click Add to register one.</p>}
{services.length === 0 && !addingService && (
<p className="text-[10px] text-muted-foreground/50">No services click Add to register one.</p>
)}
</div> </div>
{/* Notes */}
{data.notes && ( {data.notes && (
<div className="px-4 py-3 border-t border-border"> <div className="px-4 py-3 border-t border-border">
<div className="text-xs text-muted-foreground mb-1">Notes</div> <div className="text-xs text-muted-foreground mb-1">Notes</div>
@@ -206,7 +189,6 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
</div> </div>
)} )}
{/* Actions */}
<div className="mt-auto flex gap-2 px-4 py-3 border-t border-border"> <div className="mt-auto flex gap-2 px-4 py-3 border-t border-border">
<Button size="sm" variant="secondary" className="flex-1 gap-1.5" onClick={() => onEdit(node.id)}> <Button size="sm" variant="secondary" className="flex-1 gap-1.5" onClick={() => onEdit(node.id)}>
<Edit size={14} /> Edit <Edit size={14} /> Edit
@@ -219,6 +201,167 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
) )
} }
// --- Multi-select panel ---
interface MultiSelectPanelProps {
nodeIds: string[]
nodes: Node<NodeData>[]
groupName: string
setGroupName: (v: string) => void
creatingGroup: boolean
setCreatingGroup: (v: boolean) => void
onCreateGroup: (name: string) => void
onClose: () => void
}
function MultiSelectPanel({ nodeIds, nodes, groupName, setGroupName, creatingGroup, setCreatingGroup, onCreateGroup, onClose }: MultiSelectPanelProps) {
const selectedNodes = nodeIds.map((id) => nodes.find((n) => n.id === id)).filter(Boolean) as Node<NodeData>[]
const handleCreate = () => {
const name = groupName.trim() || 'Group'
onCreateGroup(name)
}
return (
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<div className="flex items-center gap-2">
<Layers size={14} className="text-[#00d4ff]" />
<span className="font-semibold text-sm text-foreground">{nodeIds.length} nodes selected</span>
</div>
<button aria-label="Close panel" onClick={onClose} className="text-muted-foreground hover:text-foreground transition-colors">
<X size={16} />
</button>
</div>
<div className="flex-1 px-4 py-3 space-y-1.5 overflow-y-auto">
{selectedNodes.map((n) => (
<div key={n.id} className="flex items-center gap-2 px-2 py-1.5 rounded-md bg-[#21262d] text-xs">
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: STATUS_COLORS[n.data.status] }} />
<span className="truncate text-foreground font-medium">{n.data.label}</span>
<span className="ml-auto text-muted-foreground shrink-0">{NODE_TYPE_LABELS[n.data.type] ?? n.data.type}</span>
</div>
))}
</div>
<div className="px-4 py-3 border-t border-border space-y-2">
{creatingGroup ? (
<>
<Input
autoFocus
placeholder="Group name…"
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); if (e.key === 'Escape') setCreatingGroup(false) }}
className="bg-[#21262d] border-[#30363d] text-xs h-7"
/>
<div className="flex gap-2">
<Button size="sm" className="flex-1 h-7 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={handleCreate}>
Create Group
</Button>
<Button size="sm" variant="ghost" className="h-7 text-[10px]" onClick={() => setCreatingGroup(false)}>
Cancel
</Button>
</div>
</>
) : (
<Button
size="sm"
className="w-full gap-2 bg-[#00d4ff]/10 text-[#00d4ff] border border-[#00d4ff]/30 hover:bg-[#00d4ff]/20"
variant="ghost"
onClick={() => setCreatingGroup(true)}
>
<Layers size={13} /> Create Group
</Button>
)}
</div>
</aside>
)
}
// --- Group detail panel ---
interface GroupDetailPanelProps {
node: Node<NodeData>
nodes: Node<NodeData>[]
onUngroup: () => void
onToggleBorder: () => void
onClose: () => void
onSelectChild: (id: string) => void
}
function GroupDetailPanel({ node, nodes, onUngroup, onToggleBorder, onClose, onSelectChild }: GroupDetailPanelProps) {
const children = nodes.filter((n) => n.parentId === node.id)
const onlineCount = children.filter((n) => n.data.status === 'online').length
const offlineCount = children.filter((n) => n.data.status === 'offline').length
const showBorder = node.data.custom_colors?.show_border !== false
const handleUngroup = () => {
if (confirm(`Ungroup "${node.data.label}"? Nodes will be released to the canvas.`)) {
onUngroup()
}
}
return (
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<div className="flex items-center gap-2 min-w-0">
<Layers size={14} className="text-[#00d4ff] shrink-0" />
<span className="font-semibold text-sm text-foreground truncate">{node.data.label}</span>
</div>
<button aria-label="Close panel" onClick={onClose} className="text-muted-foreground hover:text-foreground transition-colors shrink-0">
<X size={16} />
</button>
</div>
{/* Status summary */}
<div className="flex items-center gap-4 px-4 py-3 border-b border-border text-xs">
<span className="text-muted-foreground">{children.length} node{children.length !== 1 ? 's' : ''}</span>
{onlineCount > 0 && <span style={{ color: STATUS_COLORS.online }}> {onlineCount} online</span>}
{offlineCount > 0 && <span style={{ color: STATUS_COLORS.offline }}> {offlineCount} offline</span>}
</div>
{/* Children list */}
<div className="flex-1 px-4 py-3 space-y-1.5 overflow-y-auto">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Members</span>
{children.length === 0 && <p className="text-xs text-muted-foreground/50">No nodes in this group.</p>}
{children.map((child) => (
<button
key={child.id}
onClick={() => onSelectChild(child.id)}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md bg-[#21262d] text-xs hover:bg-[#30363d] transition-colors text-left"
>
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: STATUS_COLORS[child.data.status] }} />
<span className="truncate text-foreground font-medium">{child.data.label}</span>
<span className="ml-auto text-muted-foreground shrink-0">{NODE_TYPE_LABELS[child.data.type] ?? child.data.type}</span>
</button>
))}
</div>
{/* Actions */}
<div className="px-4 py-3 border-t border-border space-y-2">
<button
onClick={onToggleBorder}
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-xs text-muted-foreground hover:text-foreground hover:bg-[#21262d] transition-colors"
>
{showBorder ? <Eye size={13} /> : <EyeOff size={13} />}
{showBorder ? 'Hide border & title' : 'Show border & title'}
</button>
<Button
size="sm"
variant="destructive"
className="w-full gap-2"
onClick={handleUngroup}
>
<Ungroup size={13} /> Ungroup
</Button>
</div>
</aside>
)
}
// --- Helpers ---
function formatStorage(gb: number): string { function formatStorage(gb: number): string {
if (gb >= 1024) return `${(gb / 1024).toFixed(1).replace(/\.0$/, '')} TB` if (gb >= 1024) return `${(gb / 1024).toFixed(1).replace(/\.0$/, '')} TB`
return `${gb} GB` return `${gb} GB`
@@ -228,24 +371,14 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono?
return ( return (
<div className="flex justify-between gap-2 items-baseline"> <div className="flex justify-between gap-2 items-baseline">
<span className="text-muted-foreground text-xs shrink-0">{label}</span> <span className="text-muted-foreground text-xs shrink-0">{label}</span>
<span <span className={`text-xs text-right truncate ${mono ? 'font-mono text-[#00d4ff]' : 'text-foreground'}`} title={value}>
className={`text-xs text-right truncate ${mono ? 'font-mono text-[#00d4ff]' : 'text-foreground'}`}
title={value}
>
{value} {value}
</span> </span>
</div> </div>
) )
} }
function ServiceForm({ function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFocus }: {
form,
onChange,
onConfirm,
onCancel,
confirmLabel,
autoFocus,
}: {
form: { port: string; protocol: 'tcp' | 'udp'; service_name: string } form: { port: string; protocol: 'tcp' | 'udp'; service_name: string }
onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string }) => void onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string }) => void
onConfirm: () => void onConfirm: () => void
@@ -255,82 +388,31 @@ function ServiceForm({
}) { }) {
return ( return (
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]"> <div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
<Input <Input value={form.service_name} onChange={(e) => onChange({ ...form, service_name: e.target.value })} placeholder="Service name" className="bg-[#21262d] border-[#30363d] text-xs h-7" autoFocus={autoFocus} onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
value={form.service_name}
onChange={(e) => onChange({ ...form, service_name: e.target.value })}
placeholder="Service name"
className="bg-[#21262d] border-[#30363d] text-xs h-7"
autoFocus={autoFocus}
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
/>
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<Input <Input type="number" value={form.port} onChange={(e) => onChange({ ...form, port: e.target.value })} placeholder="Port" min={1} max={65535} className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-20 shrink-0" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
type="number" <select value={form.protocol} onChange={(e) => onChange({ ...form, protocol: e.target.value as 'tcp' | 'udp' })} className="flex-1 bg-[#21262d] border border-[#30363d] rounded-md text-xs h-7 px-1.5 text-foreground">
value={form.port}
onChange={(e) => onChange({ ...form, port: e.target.value })}
placeholder="Port"
min={1}
max={65535}
className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-20 shrink-0"
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
/>
<select
value={form.protocol}
onChange={(e) => onChange({ ...form, protocol: e.target.value as 'tcp' | 'udp' })}
className="flex-1 bg-[#21262d] border border-[#30363d] rounded-md text-xs h-7 px-1.5 text-foreground"
>
<option value="tcp">tcp</option> <option value="tcp">tcp</option>
<option value="udp">udp</option> <option value="udp">udp</option>
</select> </select>
</div> </div>
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<Button <Button size="sm" className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={onConfirm}>{confirmLabel}</Button>
size="sm" <Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}>Cancel</Button>
className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
onClick={onConfirm}
>
{confirmLabel}
</Button>
<Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}>
Cancel
</Button>
</div> </div>
</div> </div>
) )
} }
const CATEGORY_COLORS: Record<string, string> = { const CATEGORY_COLORS: Record<string, string> = {
web: '#00d4ff', web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
database: '#a855f7',
monitoring: '#39d353',
storage: '#e3b341',
security: '#f85149',
remote: '#8b949e',
} }
function ServiceBadge({ function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) {
svc,
host,
onEdit,
onRemove,
}: {
svc: ServiceInfo
host?: string
onEdit: () => void
onRemove: () => void
}) {
const url = getServiceUrl(svc, host) const url = getServiceUrl(svc, host)
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e' const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
const inner = ( const inner = (
<div <div className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors" style={{ background: '#21262d', borderColor: '#30363d', cursor: url ? 'pointer' : 'default' }}>
className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors"
style={{
background: '#21262d',
borderColor: '#30363d',
cursor: url ? 'pointer' : 'default',
}}
>
<div className="flex items-center gap-1.5 min-w-0"> <div className="flex items-center gap-1.5 min-w-0">
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} /> <span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
<span className="font-medium truncate" style={{ color }}>{svc.service_name}</span> <span className="font-medium truncate" style={{ color }}>{svc.service_name}</span>
@@ -338,30 +420,11 @@ function ServiceBadge({
<div className="flex items-center gap-1.5 shrink-0"> <div className="flex items-center gap-1.5 shrink-0">
<span className="font-mono text-[#8b949e]">{svc.port}/{svc.protocol}</span> <span className="font-mono text-[#8b949e]">{svc.port}/{svc.protocol}</span>
{url && <ExternalLink size={10} className="text-muted-foreground" />} {url && <ExternalLink size={10} className="text-muted-foreground" />}
<button <button onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5" title="Edit service"><Pencil size={10} /></button>
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }} <button onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5" title="Remove service"><X size={10} /></button>
className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5"
title="Edit service"
>
<Pencil size={10} />
</button>
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }}
className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5"
title="Remove service"
>
<X size={10} />
</button>
</div> </div>
</div> </div>
) )
if (url) return <a href={url} target="_blank" rel="noopener noreferrer" className="block hover:opacity-80 transition-opacity">{inner}</a>
if (url) {
return (
<a href={url} target="_blank" rel="noopener noreferrer" className="block hover:opacity-80 transition-opacity">
{inner}
</a>
)
}
return inner return inner
} }
+38 -2
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 } from 'lucide-react' import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle } 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'
@@ -387,8 +387,26 @@ function ScanHistoryPanel() {
return () => clearInterval(id) return () => clearInterval(id)
}, [runs, load]) }, [runs, load])
const [stopping, setStopping] = useState<string | null>(null)
const handleStop = async (runId: string) => {
setStopping(runId)
try {
await scanApi.stop(runId)
toast.success('Scan stop requested')
} catch {
toast.error('Failed to stop scan')
} finally {
setStopping(null)
}
}
const statusColor = (s: string) => const statusColor = (s: string) =>
s === 'done' ? '#39d353' : s === 'running' ? '#e3b341' : s === 'error' ? '#f85149' : '#8b949e' s === 'done' ? '#39d353'
: s === 'running' ? '#e3b341'
: s === 'error' ? '#f85149'
: s === 'cancelled' ? '#8b949e'
: '#8b949e'
return ( return (
<div className="p-2"> <div className="p-2">
@@ -409,6 +427,24 @@ function ScanHistoryPanel() {
<span className="font-mono text-foreground capitalize">{r.status}</span> <span className="font-mono text-foreground capitalize">{r.status}</span>
{r.status === 'running' && <Loader2 size={10} className="animate-spin text-[#e3b341]" />} {r.status === 'running' && <Loader2 size={10} className="animate-spin text-[#e3b341]" />}
<span className="ml-auto text-muted-foreground font-mono">{r.devices_found} found</span> <span className="ml-auto text-muted-foreground font-mono">{r.devices_found} found</span>
{r.status === 'running' && (
<Tooltip>
<TooltipTrigger>
<button
aria-label="Stop scan"
onClick={() => handleStop(r.id)}
disabled={stopping === r.id}
className="p-0.5 text-[#f85149] hover:bg-[#f85149]/10 rounded transition-colors disabled:opacity-50"
>
{stopping === r.id
? <Loader2 size={11} className="animate-spin" />
: <StopCircle size={11} />
}
</button>
</TooltipTrigger>
<TooltipContent side="left">Stop scan</TooltipContent>
</Tooltip>
)}
</div> </div>
<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).toLocaleString()}
@@ -26,10 +26,13 @@ function setupStore(nodeData: Partial<NodeData> = {}) {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode(nodeData)], nodes: [makeNode(nodeData)],
selectedNodeId: 'n1', selectedNodeId: 'n1',
selectedNodeIds: [],
setSelectedNode: vi.fn(), setSelectedNode: vi.fn(),
deleteNode: vi.fn(), deleteNode: vi.fn(),
updateNode: vi.fn(), updateNode: vi.fn(),
snapshotHistory: vi.fn(), snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>) } as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
} }
@@ -38,10 +41,13 @@ describe('DetailPanel', () => {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [], nodes: [],
selectedNodeId: null, selectedNodeId: null,
selectedNodeIds: [],
setSelectedNode: vi.fn(), setSelectedNode: vi.fn(),
deleteNode: vi.fn(), deleteNode: vi.fn(),
updateNode: vi.fn(), updateNode: vi.fn(),
snapshotHistory: vi.fn(), snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>) } as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
}) })
@@ -0,0 +1,233 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { DetailPanel } from '../DetailPanel'
import * as canvasStore from '@/stores/canvasStore'
import { TooltipProvider } from '@/components/ui/tooltip'
vi.mock('@/stores/canvasStore')
vi.mock('@/utils/serviceUrl', () => ({ getServiceUrl: () => null }))
function makeNode(id: string, overrides = {}) {
return {
id,
type: 'server',
position: { x: 0, y: 0 },
data: { label: id, type: 'server', status: 'online', services: [] },
...overrides,
}
}
function makeGroupNode(id = 'g1', label = 'My Group', showBorder = true) {
return {
id,
type: 'group',
position: { x: 76, y: 52 },
data: {
label,
type: 'group',
status: 'unknown',
services: [],
custom_colors: { show_border: showBorder },
},
}
}
const mockStore = {
nodes: [],
selectedNodeId: null,
selectedNodeIds: [],
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode: vi.fn(),
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
}
function setupStore(overrides = {}) {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
...mockStore,
...overrides,
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
}
function renderPanel() {
return render(
<TooltipProvider>
<DetailPanel onEdit={vi.fn()} />
</TooltipProvider>,
)
}
describe('MultiSelectPanel', () => {
beforeEach(() => vi.clearAllMocks())
it('renders multi-select panel when 2+ nodes selected', () => {
const n1 = makeNode('n1', { data: { label: 'Router', type: 'router', status: 'online', services: [] } })
const n2 = makeNode('n2', { data: { label: 'Switch', type: 'switch', status: 'offline', services: [] } })
setupStore({
nodes: [n1, n2],
selectedNodeId: null,
selectedNodeIds: ['n1', 'n2'],
})
renderPanel()
expect(screen.getByText('2 nodes selected')).toBeDefined()
})
it('lists selected node labels in multi-select panel', () => {
const n1 = makeNode('n1', { data: { label: 'My Router', type: 'router', status: 'online', services: [] } })
const n2 = makeNode('n2', { data: { label: 'My NAS', type: 'nas', status: 'unknown', services: [] } })
setupStore({ nodes: [n1, n2], selectedNodeId: null, selectedNodeIds: ['n1', 'n2'] })
renderPanel()
expect(screen.getByText('My Router')).toBeDefined()
expect(screen.getByText('My NAS')).toBeDefined()
})
it('shows Create Group button', () => {
const n1 = makeNode('n1')
const n2 = makeNode('n2')
setupStore({ nodes: [n1, n2], selectedNodeId: null, selectedNodeIds: ['n1', 'n2'] })
renderPanel()
expect(screen.getByRole('button', { name: /create group/i })).toBeDefined()
})
it('shows name input when Create Group is clicked', async () => {
const n1 = makeNode('n1')
const n2 = makeNode('n2')
setupStore({ nodes: [n1, n2], selectedNodeId: null, selectedNodeIds: ['n1', 'n2'] })
renderPanel()
fireEvent.click(screen.getByRole('button', { name: /create group/i }))
await waitFor(() => {
expect(screen.getByPlaceholderText(/group name/i)).toBeDefined()
})
})
it('calls createGroup with selected ids and entered name', async () => {
const createGroup = vi.fn()
const n1 = makeNode('n1')
const n2 = makeNode('n2')
setupStore({ nodes: [n1, n2], selectedNodeId: null, selectedNodeIds: ['n1', 'n2'], createGroup })
renderPanel()
fireEvent.click(screen.getByRole('button', { name: /create group/i }))
const input = await screen.findByPlaceholderText(/group name/i)
fireEvent.change(input, { target: { value: 'DMZ' } })
fireEvent.click(screen.getByRole('button', { name: /^create group$/i }))
expect(createGroup).toHaveBeenCalledWith(['n1', 'n2'], 'DMZ')
})
it('uses default name "Group" when input is empty', async () => {
const createGroup = vi.fn()
const n1 = makeNode('n1')
const n2 = makeNode('n2')
setupStore({ nodes: [n1, n2], selectedNodeId: null, selectedNodeIds: ['n1', 'n2'], createGroup })
renderPanel()
fireEvent.click(screen.getByRole('button', { name: /create group/i }))
await screen.findByPlaceholderText(/group name/i)
fireEvent.click(screen.getByRole('button', { name: /^create group$/i }))
expect(createGroup).toHaveBeenCalledWith(['n1', 'n2'], 'Group')
})
it('includes groupRect (zone) nodes in multi-select count', () => {
const n1 = makeNode('n1')
const gr = makeNode('gr1', { data: { label: 'Zone', type: 'groupRect', status: 'unknown', services: [] } })
setupStore({ nodes: [n1, gr], selectedNodeId: null, selectedNodeIds: ['n1', 'gr1'] })
renderPanel()
// groupRect included → 2 nodes selected → multi-select panel shown
expect(screen.getByText('2 nodes selected')).toBeDefined()
})
})
describe('GroupDetailPanel', () => {
beforeEach(() => vi.clearAllMocks())
it('renders group name and members heading', () => {
const group = makeGroupNode()
const child = makeNode('c1', { parentId: 'g1', data: { label: 'Router', type: 'router', status: 'online', services: [] } })
setupStore({ nodes: [group, child], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
renderPanel()
expect(screen.getByText('My Group')).toBeDefined()
expect(screen.getByText('Members')).toBeDefined()
})
it('lists children with their labels', () => {
const group = makeGroupNode()
const c1 = makeNode('c1', { parentId: 'g1', data: { label: 'My Router', type: 'router', status: 'online', services: [] } })
const c2 = makeNode('c2', { parentId: 'g1', data: { label: 'My NAS', type: 'nas', status: 'offline', services: [] } })
setupStore({ nodes: [group, c1, c2], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
renderPanel()
expect(screen.getByText('My Router')).toBeDefined()
expect(screen.getByText('My NAS')).toBeDefined()
})
it('shows online/offline count in status summary', () => {
const group = makeGroupNode()
const c1 = makeNode('c1', { parentId: 'g1', data: { label: 'A', type: 'server', status: 'online', services: [] } })
const c2 = makeNode('c2', { parentId: 'g1', data: { label: 'B', type: 'server', status: 'offline', services: [] } })
setupStore({ nodes: [group, c1, c2], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
renderPanel()
expect(screen.getByText(/1 online/)).toBeDefined()
expect(screen.getByText(/1 offline/)).toBeDefined()
})
it('shows Ungroup button', () => {
const group = makeGroupNode()
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
renderPanel()
expect(screen.getByRole('button', { name: /ungroup/i })).toBeDefined()
})
it('calls ungroup after confirm', () => {
const ungroup = vi.fn()
vi.spyOn(window, 'confirm').mockReturnValue(true)
const group = makeGroupNode()
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], ungroup })
renderPanel()
fireEvent.click(screen.getByRole('button', { name: /ungroup/i }))
expect(ungroup).toHaveBeenCalledWith('g1')
})
it('does not call ungroup when confirm is cancelled', () => {
const ungroup = vi.fn()
vi.spyOn(window, 'confirm').mockReturnValue(false)
const group = makeGroupNode()
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], ungroup })
renderPanel()
fireEvent.click(screen.getByRole('button', { name: /ungroup/i }))
expect(ungroup).not.toHaveBeenCalled()
})
it('shows "Hide border & title" when show_border is true', () => {
const group = makeGroupNode('g1', 'G', true)
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
renderPanel()
expect(screen.getByText(/hide border/i)).toBeDefined()
})
it('shows "Show border & title" when show_border is false', () => {
const group = makeGroupNode('g1', 'G', false)
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
renderPanel()
expect(screen.getByText(/show border/i)).toBeDefined()
})
it('calls updateNode to toggle show_border off', () => {
const updateNode = vi.fn()
const group = makeGroupNode('g1', 'G', true)
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], updateNode })
renderPanel()
fireEvent.click(screen.getByText(/hide border/i))
expect(updateNode).toHaveBeenCalledWith('g1', expect.objectContaining({
custom_colors: expect.objectContaining({ show_border: false }),
}))
})
it('calls setSelectedNode when a child node is clicked', () => {
const setSelectedNode = vi.fn()
const group = makeGroupNode()
const child = makeNode('c1', { parentId: 'g1', data: { label: 'Child Node Alpha', type: 'server', status: 'online', services: [] } })
setupStore({ nodes: [group, child], selectedNodeId: 'g1', selectedNodeIds: ['g1'], setSelectedNode })
renderPanel()
fireEvent.click(screen.getByText('Child Node Alpha'))
expect(setSelectedNode).toHaveBeenCalledWith('c1')
})
})
@@ -0,0 +1,155 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { Sidebar } from '../Sidebar'
import * as canvasStore from '@/stores/canvasStore'
import { TooltipProvider } from '@/components/ui/tooltip'
vi.mock('@/stores/canvasStore')
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('@/api/client', () => ({
scanApi: {
trigger: vi.fn(),
pending: vi.fn().mockResolvedValue({ data: [] }),
hidden: vi.fn().mockResolvedValue({ data: [] }),
runs: vi.fn().mockResolvedValue({ data: [] }),
stop: vi.fn(),
getConfig: vi.fn().mockResolvedValue({ data: { ranges: [] } }),
},
settingsApi: { get: vi.fn(), save: vi.fn() },
}))
import { scanApi } from '@/api/client'
import { toast } from 'sonner'
const RUNNING_RUN = {
id: 'run-1',
status: 'running',
ranges: ['192.168.1.0/24'],
devices_found: 2,
started_at: new Date().toISOString(),
finished_at: null,
error: null,
}
const DONE_RUN = {
id: 'run-2',
status: 'done',
ranges: ['192.168.1.0/24'],
devices_found: 3,
started_at: new Date().toISOString(),
finished_at: new Date().toISOString(),
error: null,
}
const CANCELLED_RUN = {
id: 'run-3',
status: 'cancelled',
ranges: ['192.168.1.0/24'],
devices_found: 1,
started_at: new Date().toISOString(),
finished_at: new Date().toISOString(),
error: null,
}
function renderSidebar() {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [],
hasUnsavedChanges: false,
hideIp: false,
toggleHideIp: vi.fn(),
addNode: vi.fn(),
scanEventTs: 0,
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
return render(
<TooltipProvider>
<Sidebar
onAddNode={vi.fn()}
onAddGroupRect={vi.fn()}
onScan={vi.fn()}
onSave={vi.fn()}
onNodeApproved={vi.fn()}
/>
</TooltipProvider>
)
}
async function openHistory() {
fireEvent.click(screen.getByRole('button', { name: 'Scan History' }))
// Wait for runs to load
await waitFor(() => expect(scanApi.runs).toHaveBeenCalled())
}
describe('ScanHistoryPanel — stop scan', () => {
beforeEach(() => {
vi.mocked(toast.success).mockReset()
vi.mocked(toast.error).mockReset()
vi.mocked(scanApi.stop).mockReset()
vi.mocked(scanApi.runs).mockResolvedValue({ data: [] } as never)
})
it('shows stop button only for running scans', async () => {
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN, DONE_RUN] } as never)
renderSidebar()
await openHistory()
await waitFor(() => expect(screen.getByText('running')).toBeDefined())
// Exactly one stop button rendered (for the running scan only)
const stopButtons = screen.getAllByRole('button', { name: 'Stop scan' })
expect(stopButtons).toHaveLength(1)
})
it('calls scanApi.stop with the correct run ID on click', async () => {
vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never)
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
renderSidebar()
await openHistory()
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
fireEvent.click(stopBtn)
await waitFor(() => {
expect(scanApi.stop).toHaveBeenCalledWith('run-1')
})
})
it('shows success toast when stop succeeds', async () => {
vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never)
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
renderSidebar()
await openHistory()
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
fireEvent.click(stopBtn)
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith('Scan stop requested')
})
})
it('shows error toast when stop fails', async () => {
vi.mocked(scanApi.stop).mockRejectedValue(new Error('network'))
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
renderSidebar()
await openHistory()
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
fireEvent.click(stopBtn)
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Failed to stop scan')
})
})
it('renders cancelled status without stop button or spinner', async () => {
vi.mocked(scanApi.runs).mockResolvedValue({ data: [CANCELLED_RUN] } as never)
renderSidebar()
await openHistory()
await waitFor(() => expect(screen.getByText('cancelled')).toBeDefined())
// No stop button
expect(screen.queryByRole('button', { name: 'Stop scan' })).toBeNull()
})
})
+9
View File
@@ -111,6 +111,15 @@
background-color: var(--surface-card) !important; background-color: var(--surface-card) !important;
} }
/* Transparent wrapper for container node types */
.react-flow__node-proxmox,
.react-flow__node-group {
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
}
/* Mono font utility */ /* Mono font utility */
.font-mono { .font-mono {
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
@@ -25,6 +25,7 @@ describe('canvasStore', () => {
edges: [], edges: [],
hasUnsavedChanges: false, hasUnsavedChanges: false,
selectedNodeId: null, selectedNodeId: null,
selectedNodeIds: [],
editingGroupRectId: null, editingGroupRectId: null,
past: [], past: [],
future: [], future: [],
@@ -56,6 +57,57 @@ describe('canvasStore', () => {
expect(node?.data.ip).toBe('10.0.0.1') expect(node?.data.ip).toBe('10.0.0.1')
}) })
it('updateNode setting parent_id on container-mode proxmox sets parentId and relative position', () => {
const proxmox = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 100, y: 100 } }
const lxc = { ...makeNode('lxc1', { type: 'lxc' }), position: { x: 160, y: 180 } }
useCanvasStore.getState().addNode(proxmox)
useCanvasStore.getState().addNode(lxc)
useCanvasStore.getState().updateNode('lxc1', { parent_id: 'px1' })
const node = useCanvasStore.getState().nodes.find((n) => n.id === 'lxc1')
expect(node?.parentId).toBe('px1')
expect(node?.extent).toBe('parent')
// Position should be relative to parent (160-100=60, 180-100=80)
expect(node?.position.x).toBe(60)
expect(node?.position.y).toBe(80)
})
it('updateNode setting parent_id on non-container proxmox does NOT set React Flow parentId', () => {
const proxmox = { ...makeNode('px1', { type: 'proxmox', container_mode: false }), position: { x: 100, y: 100 } }
const lxc = { ...makeNode('lxc1', { type: 'lxc' }), position: { x: 160, y: 180 } }
useCanvasStore.getState().addNode(proxmox)
useCanvasStore.getState().addNode(lxc)
useCanvasStore.getState().updateNode('lxc1', { parent_id: 'px1' })
const node = useCanvasStore.getState().nodes.find((n) => n.id === 'lxc1')
expect(node?.parentId).toBeUndefined()
expect(node?.extent).toBeUndefined()
})
it('updateNode clearing parent_id converts position to absolute and clears parentId', () => {
const proxmox = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 100, y: 100 } }
const lxc = { ...makeNode('lxc1', { type: 'lxc', parent_id: 'px1' }), position: { x: 30, y: 40 }, parentId: 'px1', extent: 'parent' as const }
useCanvasStore.getState().addNode(proxmox)
useCanvasStore.getState().addNode(lxc)
useCanvasStore.getState().updateNode('lxc1', { parent_id: undefined })
const node = useCanvasStore.getState().nodes.find((n) => n.id === 'lxc1')
expect(node?.parentId).toBeUndefined()
expect(node?.extent).toBeUndefined()
// Position should be absolute (100+30=130, 100+40=140)
expect(node?.position.x).toBe(130)
expect(node?.position.y).toBe(140)
})
it('updateNode with parent_id puts parents before children in array', () => {
const proxmox = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } }
const lxc = { ...makeNode('lxc1', { type: 'lxc' }), position: { x: 10, y: 10 } }
useCanvasStore.getState().addNode(proxmox)
useCanvasStore.getState().addNode(lxc)
useCanvasStore.getState().updateNode('lxc1', { parent_id: 'px1' })
const { nodes } = useCanvasStore.getState()
const pxIdx = nodes.findIndex((n) => n.id === 'px1')
const lxcIdx = nodes.findIndex((n) => n.id === 'lxc1')
expect(pxIdx).toBeLessThan(lxcIdx)
})
it('deleteNode removes node and its connected edges', () => { it('deleteNode removes node and its connected edges', () => {
const store = useCanvasStore.getState() const store = useCanvasStore.getState()
store.addNode(makeNode('n1')) store.addNode(makeNode('n1'))
@@ -178,6 +230,178 @@ describe('canvasStore', () => {
expect(child?.extent).toBe('parent') expect(child?.extent).toBe('parent')
}) })
// ── selectedNodeIds ───────────────────────────────────────────────────────
it('selectedNodeIds starts empty', () => {
expect(useCanvasStore.getState().selectedNodeIds).toEqual([])
})
it('onNodesChange syncs selectedNodeIds from select changes', () => {
useCanvasStore.getState().addNode(makeNode('n1'))
useCanvasStore.getState().addNode(makeNode('n2'))
useCanvasStore.getState().onNodesChange([
{ type: 'select', id: 'n1', selected: true },
{ type: 'select', id: 'n2', selected: true },
])
expect(useCanvasStore.getState().selectedNodeIds).toEqual(expect.arrayContaining(['n1', 'n2']))
expect(useCanvasStore.getState().selectedNodeIds).toHaveLength(2)
})
it('setSelectedNode(null) resets selectedNodeIds to empty', () => {
useCanvasStore.setState({ selectedNodeIds: ['n1', 'n2'] })
useCanvasStore.getState().setSelectedNode(null)
expect(useCanvasStore.getState().selectedNodeIds).toEqual([])
})
it('setSelectedNode(id) preserves existing selectedNodeIds', () => {
useCanvasStore.setState({ selectedNodeIds: ['n1', 'n2'] })
useCanvasStore.getState().setSelectedNode('n1')
// does NOT wipe selectedNodeIds when setting a specific id
expect(useCanvasStore.getState().selectedNodeIds).toEqual(['n1', 'n2'])
})
// ── createGroup ───────────────────────────────────────────────────────────
it('createGroup creates a group node at the bounding box of selected nodes', () => {
// n1 at (100,100), n2 at (300,200); both default to 200x80
const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 }, width: 200, height: 80 }
const n2 = { ...makeNode('n2'), position: { x: 300, y: 200 }, width: 200, height: 80 }
useCanvasStore.setState({ nodes: [n1, n2] })
useCanvasStore.getState().createGroup(['n1', 'n2'], 'My Group')
const { nodes } = useCanvasStore.getState()
const group = nodes.find((n) => n.data.type === 'group')
expect(group).toBeDefined()
expect(group?.data.label).toBe('My Group')
// groupX = 100-24=76, groupY = 100-48=52
expect(group?.position.x).toBe(76)
expect(group?.position.y).toBe(52)
// groupW = (500-100)+48=448, groupH = (280-100)+48+24=252
expect(group?.width).toBe(448)
expect(group?.height).toBe(252)
})
it('createGroup converts children to relative positions', () => {
const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 }, width: 200, height: 80 }
const n2 = { ...makeNode('n2'), position: { x: 300, y: 200 }, width: 200, height: 80 }
useCanvasStore.setState({ nodes: [n1, n2] })
useCanvasStore.getState().createGroup(['n1', 'n2'], 'G')
const { nodes } = useCanvasStore.getState()
const c1 = nodes.find((n) => n.id === 'n1')
const c2 = nodes.find((n) => n.id === 'n2')
// groupX=76, groupY=52 → relative: n1=(24,48), n2=(224,148)
expect(c1?.position).toEqual({ x: 24, y: 48 })
expect(c2?.position).toEqual({ x: 224, y: 148 })
})
it('createGroup sets parentId and extent on children', () => {
const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 } }
const n2 = { ...makeNode('n2'), position: { x: 200, y: 100 } }
useCanvasStore.setState({ nodes: [n1, n2] })
useCanvasStore.getState().createGroup(['n1', 'n2'], 'G')
const { nodes } = useCanvasStore.getState()
const group = nodes.find((n) => n.data.type === 'group')!
const c1 = nodes.find((n) => n.id === 'n1')
const c2 = nodes.find((n) => n.id === 'n2')
expect(c1?.parentId).toBe(group.id)
expect(c1?.extent).toBe('parent')
expect(c2?.parentId).toBe(group.id)
})
it('createGroup places the group node before its children in the array', () => {
const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 } }
const n2 = { ...makeNode('n2'), position: { x: 200, y: 100 } }
useCanvasStore.setState({ nodes: [n1, n2] })
useCanvasStore.getState().createGroup(['n1', 'n2'], 'G')
const { nodes } = useCanvasStore.getState()
const groupIdx = nodes.findIndex((n) => n.data.type === 'group')
const c1Idx = nodes.findIndex((n) => n.id === 'n1')
const c2Idx = nodes.findIndex((n) => n.id === 'n2')
expect(groupIdx).toBeLessThan(c1Idx)
expect(groupIdx).toBeLessThan(c2Idx)
})
it('createGroup snapshots history and marks unsaved', () => {
const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 } }
useCanvasStore.setState({ nodes: [n1] })
useCanvasStore.getState().markSaved()
useCanvasStore.getState().createGroup(['n1'], 'G')
expect(useCanvasStore.getState().past).toHaveLength(1)
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
it('createGroup clears selection', () => {
const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 } }
useCanvasStore.setState({ nodes: [n1], selectedNodeId: 'n1', selectedNodeIds: ['n1'] })
useCanvasStore.getState().createGroup(['n1'], 'G')
expect(useCanvasStore.getState().selectedNodeId).toBeNull()
expect(useCanvasStore.getState().selectedNodeIds).toEqual([])
})
// ── ungroup ───────────────────────────────────────────────────────────────
it('ungroup restores children to absolute positions', () => {
const group = {
...makeNode('g1', { type: 'group', label: 'G' }),
position: { x: 76, y: 52 },
}
const c1 = { ...makeNode('n1'), position: { x: 24, y: 48 }, parentId: 'g1', extent: 'parent' as const }
const c2 = { ...makeNode('n2'), position: { x: 224, y: 148 }, parentId: 'g1', extent: 'parent' as const }
useCanvasStore.setState({ nodes: [group, c1, c2] })
useCanvasStore.getState().ungroup('g1')
const { nodes } = useCanvasStore.getState()
const r1 = nodes.find((n) => n.id === 'n1')
const r2 = nodes.find((n) => n.id === 'n2')
expect(r1?.position).toEqual({ x: 100, y: 100 })
expect(r2?.position).toEqual({ x: 300, y: 200 })
})
it('ungroup removes parentId and extent from children', () => {
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
const child = { ...makeNode('n1'), position: { x: 50, y: 50 }, parentId: 'g1', extent: 'parent' as const }
useCanvasStore.setState({ nodes: [group, child] })
useCanvasStore.getState().ungroup('g1')
const { nodes } = useCanvasStore.getState()
const released = nodes.find((n) => n.id === 'n1')
expect(released?.parentId).toBeUndefined()
expect(released?.extent).toBeUndefined()
})
it('ungroup deletes the group node', () => {
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
useCanvasStore.setState({ nodes: [group] })
useCanvasStore.getState().ungroup('g1')
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'g1')).toBeUndefined()
})
it('ungroup snapshots history and marks unsaved', () => {
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
useCanvasStore.setState({ nodes: [group] })
useCanvasStore.getState().markSaved()
useCanvasStore.getState().ungroup('g1')
expect(useCanvasStore.getState().past).toHaveLength(1)
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
it('updateEdge updates edge data and marks unsaved', () => { it('updateEdge updates edge data and marks unsaved', () => {
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] })) useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] }))
useCanvasStore.getState().markSaved() useCanvasStore.getState().markSaved()
+164 -11
View File
@@ -19,6 +19,7 @@ interface CanvasState {
edges: Edge<EdgeData>[] edges: Edge<EdgeData>[]
hasUnsavedChanges: boolean hasUnsavedChanges: boolean
selectedNodeId: string | null selectedNodeId: string | null
selectedNodeIds: string[]
scanEventTs: number scanEventTs: number
// History // History
@@ -46,6 +47,8 @@ interface CanvasState {
setNodeZIndex: (id: string, zIndex: number) => void setNodeZIndex: (id: string, zIndex: number) => void
editingGroupRectId: string | null editingGroupRectId: string | null
setEditingGroupRectId: (id: string | null) => void setEditingGroupRectId: (id: string | null) => void
createGroup: (nodeIds: string[], name: string) => void
ungroup: (groupId: string) => void
markSaved: () => void markSaved: () => void
markUnsaved: () => void markUnsaved: () => void
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
@@ -59,6 +62,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
edges: [], edges: [],
hasUnsavedChanges: false, hasUnsavedChanges: false,
selectedNodeId: null, selectedNodeId: null,
selectedNodeIds: [],
editingGroupRectId: null, editingGroupRectId: null,
hideIp: false, hideIp: false,
scanEventTs: 0, scanEventTs: 0,
@@ -125,10 +129,15 @@ export const useCanvasStore = create<CanvasState>((set) => ({
}), }),
onNodesChange: (changes) => onNodesChange: (changes) =>
set((state) => ({ set((state) => {
nodes: applyNodeChanges(changes, state.nodes), const nodes = applyNodeChanges(changes, state.nodes)
hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'), const selectedNodeIds = nodes.filter((n) => n.selected).map((n) => n.id)
})), return {
nodes,
selectedNodeIds,
hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'),
}
}),
onEdgesChange: (changes) => onEdgesChange: (changes) =>
set((state) => ({ set((state) => ({
@@ -156,7 +165,10 @@ export const useCanvasStore = create<CanvasState>((set) => ({
} }
}), }),
setSelectedNode: (id) => set({ selectedNodeId: id }), setSelectedNode: (id) => set((state) => ({
selectedNodeId: id,
selectedNodeIds: id ? state.selectedNodeIds : [],
})),
addNode: (node) => addNode: (node) =>
set((state) => { set((state) => {
@@ -175,12 +187,47 @@ export const useCanvasStore = create<CanvasState>((set) => ({
}), }),
updateNode: (id, data) => updateNode: (id, data) =>
set((state) => ({ set((state) => {
nodes: state.nodes.map((n) => let nodes = state.nodes.map((n) => {
n.id === id ? { ...n, data: { ...n.data, ...data } } : n if (n.id !== id) return n
), const updated: Node<NodeData> = { ...n, data: { ...n.data, ...data } }
hasUnsavedChanges: true, if ('parent_id' in data) {
})), const newParentId = data.parent_id ?? undefined
if (!newParentId && n.parentId) {
// Detaching from a container: convert position back to absolute canvas coords
const parent = state.nodes.find((p) => p.id === n.parentId)
if (parent) {
updated.position = {
x: parent.position.x + n.position.x,
y: parent.position.y + n.position.y,
}
}
updated.parentId = undefined
updated.extent = undefined
} else if (newParentId && newParentId !== n.parentId) {
const parent = state.nodes.find((p) => p.id === newParentId)
if (parent?.data.container_mode) {
// Attaching to a container-mode Proxmox: nest visually
updated.parentId = newParentId
updated.extent = 'parent' as const
// Convert absolute position to parent-relative (keep node visible inside)
updated.position = {
x: Math.max(10, n.position.x - parent.position.x),
y: Math.max(10, n.position.y - parent.position.y),
}
}
}
}
return updated
})
// React Flow requires parent nodes to precede their children in the array
if ('parent_id' in data) {
const parents = nodes.filter((n) => !n.parentId)
const children = nodes.filter((n) => !!n.parentId)
nodes = [...parents, ...children]
}
return { nodes, hasUnsavedChanges: true }
}),
deleteNode: (id) => deleteNode: (id) =>
set((state) => { set((state) => {
@@ -244,6 +291,112 @@ export const useCanvasStore = create<CanvasState>((set) => ({
setEditingGroupRectId: (id) => set({ editingGroupRectId: id }), setEditingGroupRectId: (id) => set({ editingGroupRectId: id }),
createGroup: (nodeIds, name) =>
set((state) => {
const PADDING_H = 24
const PADDING_TOP = 48
const PADDING_BOTTOM = 24
const targets = state.nodes.filter((n) => nodeIds.includes(n.id))
if (targets.length === 0) return state
// Bounding box in absolute coordinates
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
for (const n of targets) {
const w = n.width ?? 200
const h = n.height ?? 80
minX = Math.min(minX, n.position.x)
minY = Math.min(minY, n.position.y)
maxX = Math.max(maxX, n.position.x + w)
maxY = Math.max(maxY, n.position.y + h)
}
const groupX = minX - PADDING_H
const groupY = minY - PADDING_TOP
const groupW = maxX - minX + PADDING_H * 2
const groupH = maxY - minY + PADDING_TOP + PADDING_BOTTOM
const groupId = generateUUID()
const groupNode: Node<NodeData> = {
id: groupId,
type: 'group',
position: { x: groupX, y: groupY },
width: groupW,
height: groupH,
data: {
label: name,
type: 'group',
status: 'unknown',
services: [],
custom_colors: { show_border: true },
},
selected: false,
}
// Convert children to relative positions and assign parentId
const updatedNodes = state.nodes.map((n) => {
if (!nodeIds.includes(n.id)) return n
return {
...n,
parentId: groupId,
extent: 'parent' as const,
position: {
x: n.position.x - groupX,
y: n.position.y - groupY,
},
selected: false,
data: { ...n.data, parent_id: groupId },
}
})
// Group node must come before its children
const withoutTargets = updatedNodes.filter((n) => !nodeIds.includes(n.id))
const children = updatedNodes.filter((n) => nodeIds.includes(n.id))
const nodes = [...withoutTargets, groupNode, ...children]
return {
nodes,
selectedNodeIds: [],
selectedNodeId: null,
hasUnsavedChanges: true,
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
future: [],
}
}),
ungroup: (groupId) =>
set((state) => {
const group = state.nodes.find((n) => n.id === groupId)
if (!group) return state
const groupAbsX = group.position.x
const groupAbsY = group.position.y
const nodes = state.nodes
.filter((n) => n.id !== groupId)
.map((n) => {
if (n.parentId !== groupId) return n
return {
...n,
parentId: undefined,
extent: undefined,
position: {
x: n.position.x + groupAbsX,
y: n.position.y + groupAbsY,
},
data: { ...n.data, parent_id: undefined },
}
})
return {
nodes,
selectedNodeId: null,
selectedNodeIds: [],
hasUnsavedChanges: true,
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
future: [],
}
}),
markSaved: () => set({ hasUnsavedChanges: false }), markSaved: () => set({ hasUnsavedChanges: false }),
markUnsaved: () => set({ hasUnsavedChanges: true }), markUnsaved: () => set({ hasUnsavedChanges: true }),
+3
View File
@@ -16,6 +16,7 @@ export type NodeType =
| 'docker' | 'docker'
| 'generic' | 'generic'
| 'groupRect' | 'groupRect'
| 'group'
export type TextPosition = export type TextPosition =
| 'top-left' | 'top-left'
@@ -76,6 +77,7 @@ export interface NodeData extends Record<string, unknown> {
label_position?: 'inside' | 'outside' label_position?: 'inside' | 'outside'
text_size?: number text_size?: number
z_order?: number z_order?: number
show_border?: boolean
width?: number width?: number
height?: number height?: number
} }
@@ -112,6 +114,7 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
docker: 'Docker Host', docker: 'Docker Host',
generic: 'Generic Device', generic: 'Generic Device',
groupRect: 'Group Rectangle', groupRect: 'Group Rectangle',
group: 'Node Group',
} }
export const STATUS_COLORS: Record<NodeStatus, string> = { export const STATUS_COLORS: Record<NodeStatus, string> = {
+2 -1
View File
@@ -63,7 +63,7 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
check_target: null, check_target: null,
services: [], services: [],
notes: null, notes: null,
parent_id: null, parent_id: n.data.parent_id ?? null,
container_mode: false, container_mode: false,
custom_icon: null, custom_icon: null,
pos_x: n.position.x, pos_x: n.position.x,
@@ -142,6 +142,7 @@ export function deserializeApiNode(
width: w, width: w,
height: h, height: h,
zIndex: z - 10, zIndex: z - 10,
...(n.parent_id ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
} }
} }
const parentIsContainer = n.parent_id ? (proxmoxContainerMap.get(n.parent_id) ?? false) : false const parentIsContainer = n.parent_id ? (proxmoxContainerMap.get(n.parent_id) ?? false) : false
+5
View File
@@ -59,6 +59,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
docker: { border: '#2496ED', icon: '#2496ED' }, docker: { border: '#2496ED', icon: '#2496ED' },
generic: { border: '#8b949e', icon: '#8b949e' }, generic: { border: '#8b949e', icon: '#8b949e' },
groupRect:{ border: '#00d4ff', icon: '#00d4ff' }, groupRect:{ border: '#00d4ff', icon: '#00d4ff' },
group: { border: '#00d4ff', icon: '#00d4ff' },
}, },
nodeCardBackground: '#21262d', nodeCardBackground: '#21262d',
nodeIconBackground: '#161b22', nodeIconBackground: '#161b22',
@@ -113,6 +114,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
docker: { border: '#2496ED', icon: '#2496ED' }, docker: { border: '#2496ED', icon: '#2496ED' },
generic: { border: '#94a3b8', icon: '#94a3b8' }, generic: { border: '#94a3b8', icon: '#94a3b8' },
groupRect:{ border: '#22d3ee', icon: '#22d3ee' }, groupRect:{ border: '#22d3ee', icon: '#22d3ee' },
group: { border: '#22d3ee', icon: '#22d3ee' },
}, },
nodeCardBackground: '#0a0a0a', nodeCardBackground: '#0a0a0a',
nodeIconBackground: '#111111', nodeIconBackground: '#111111',
@@ -167,6 +169,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
docker: { border: '#2496ED', icon: '#2496ED' }, docker: { border: '#2496ED', icon: '#2496ED' },
generic: { border: '#6b7280', icon: '#6b7280' }, generic: { border: '#6b7280', icon: '#6b7280' },
groupRect:{ border: '#0284c7', icon: '#0284c7' }, groupRect:{ border: '#0284c7', icon: '#0284c7' },
group: { border: '#0284c7', icon: '#0284c7' },
}, },
nodeCardBackground: '#ffffff', nodeCardBackground: '#ffffff',
nodeIconBackground: '#f0f6ff', nodeIconBackground: '#f0f6ff',
@@ -221,6 +224,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
docker: { border: '#00aaff', icon: '#00aaff' }, docker: { border: '#00aaff', icon: '#00aaff' },
generic: { border: '#8888ff', icon: '#8888ff' }, generic: { border: '#8888ff', icon: '#8888ff' },
groupRect:{ border: '#00ffff', icon: '#00ffff' }, groupRect:{ border: '#00ffff', icon: '#00ffff' },
group: { border: '#00ffff', icon: '#00ffff' },
}, },
nodeCardBackground: '#0f0f2a', nodeCardBackground: '#0f0f2a',
nodeIconBackground: '#0a0a1a', nodeIconBackground: '#0a0a1a',
@@ -275,6 +279,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
docker: { border: '#00cc88', icon: '#00cc88' }, docker: { border: '#00cc88', icon: '#00cc88' },
generic: { border: '#006600', icon: '#006600' }, generic: { border: '#006600', icon: '#006600' },
groupRect:{ border: '#00ff41', icon: '#00ff41' }, groupRect:{ border: '#00ff41', icon: '#00ff41' },
group: { border: '#00ff41', icon: '#00ff41' },
}, },
nodeCardBackground: '#001100', nodeCardBackground: '#001100',
nodeIconBackground: '#002200', nodeIconBackground: '#002200',
+2 -2
View File
@@ -30,8 +30,8 @@ info "Detected: $PRETTY_NAME"
# ── System deps ─────────────────────────────────────────────────────────────── # ── System deps ───────────────────────────────────────────────────────────────
info "Installing system dependencies..." info "Installing system dependencies..."
apt-get update -qq apt-get update
apt-get install -y -qq python3 python3-pip python3-venv nmap curl git nginx apt-get install -y --fix-missing python3 python3-pip python3-venv nmap curl git nginx
# ── Node.js 20 ──────────────────────────────────────────────────────────────── # ── Node.js 20 ────────────────────────────────────────────────────────────────
if ! command -v node &>/dev/null; then if ! command -v node &>/dev/null; then