Compare commits

...

64 Commits

Author SHA1 Message Date
Pouzor 8cf6aec987 feat: add docker_container node type with migrations and fixes
- Fix CONTAINER_MODE_TYPES to only include proxmox and docker_host (not vm/lxc)
- Add DB migration: existing proxmox nodes get container_mode=1 (they were always containers)
- Add DB migration: legacy 'docker' type renamed to 'docker_container'
- Update canvasStore tests: parent nodes need container_mode=true to nest children
- Fix updateNode test: pass absolute position so relative recalc yields expected coords
- Add backend tests for both migrations (test_docker_migration.py)
2026-04-19 00:08:32 +02:00
Remy ea6e3dc02e Merge branch 'main' into feature/node-type-docker 2026-04-18 23:13:45 +02:00
Remy 718aff5918 Merge pull request #73 from Pouzor/feature/extended-zoom
feat: extended zoom range + zoom-aware node borders
2026-04-18 23:10:24 +02:00
Pouzor 70311e6331 fix: propertyIcons mock, key stability, isValidConnection memoized
- Add resolvePropertyIcon mock to BaseNode tests
- Reset mockZoom in top-level beforeEach to prevent test bleed
- Use prop.key instead of index as list key in BaseNode properties
- Extract isValidConnection into useCallback in CanvasContainer
2026-04-18 23:03:21 +02:00
Pouzor 6a3da5aded fix: memoize onBeforeDelete and fix test mock selectors
Extract onBeforeDelete into useCallback to avoid new fn ref each render.
Fix useThemeStore/useCanvasStore mocks to call the selector fn rather
than ignoring it, and reset mockZoom in beforeEach.
2026-04-18 23:03:21 +02:00
Pouzor 35c3d00f17 fix: memoize borderWidth and sync boxShadow glow ring with zoom
- Wrap borderWidth in useMemo to avoid re-computing on unrelated renders
- Replace hardcoded 1px in boxShadow with borderWidth so glow ring
  matches border thickness at all zoom levels
- Add useViewport mock + 4 zoom-scaling tests to BaseNode.test.tsx
2026-04-18 23:01:27 +02:00
findthelorax 3a5cb0de21 feature: extend zoom out and border width math 2026-04-18 23:01:27 +02:00
Remy f72d44d5e5 Merge pull request #71 from Pouzor/feature/double-click-to-edit
feat: double-click node to open edit modal
2026-04-18 21:55:01 +02:00
Pouzor a7b244502e fix: bump pytest-asyncio to 1.3.0 (first version supporting pytest 9) 2026-04-18 21:52:32 +02:00
Pouzor 72d5a51b44 fix: bump pytest-asyncio to 1.0.0 for pytest 9 compatibility
pytest-asyncio 0.26.0 requires pytest<9; 1.0.0 supports pytest 9.x
2026-04-18 21:45:10 +02:00
Pouzor 12f46715c1 fix: bump python-multipart, pytest, pytest-asyncio to fix CVEs
- python-multipart 0.0.22 → 0.0.26 (CVE-2026-40347)
- pytest 8.3.3 → 9.0.3 (CVE-2025-71176)
- pytest-asyncio 0.24.0 → 0.26.0 (pytest 9 compat)
2026-04-18 21:36:09 +02:00
Pouzor 62f674b15d fix: extract handleNodeDoubleClick into useCallback and add tests
Replace inline arrow with named useCallback handler to avoid creating
a new fn ref on every render. Add two CanvasContainer tests covering
the double-click callback and the no-op path when prop is omitted.
2026-04-18 21:09:49 +02:00
Brett Ferrante 2a79161106 Merge branch 'develop' into feature/node-type-docker 2026-04-17 20:40:37 -04:00
Brett Ferrante 97a7d7da3d Merge pull request #3 from findthelorax/feature/container-mode
feature: added a Container Mode toggle to the Virtualization group of node types
2026-04-17 20:31:56 -04:00
findthelorax 7e3abf8889 feature: added a docker container type 2026-04-16 13:48:10 -04:00
findthelorax 548da952c5 feature: added a Container Mode toggle to the Virtualization group of node types 2026-04-16 12:58:40 -04:00
Brett Ferrante fda5e6c16c Merge pull request #2 from findthelorax/feature/extended-zoom
feature: extend zoom out and border width math
2026-04-16 11:48:05 -04:00
findthelorax b64716c15c feature: extend zoom out and border width math 2026-04-16 11:44:24 -04:00
Brett Ferrante c842ff1ff3 Merge pull request #1 from findthelorax/feature/double-click-to-edit
feature: double-click on a node to open an edit modal
2026-04-16 11:32:03 -04:00
findthelorax 04069e080a feature: double-click on a node to open an edit modal 2026-04-16 10:10:30 -04:00
Pouzor dd1f690892 feat: add logo assets in docs/logo
Icon (house + network nodes, dark bg) and horizontal logo (icon +
"Home"/"lable" color-split text) in multiple sizes:
- icon.svg + icon-16/32/64/128/256/512.svg
- logo.svg + logo-sm/md/lg.svg
2026-04-10 12:04:02 +02:00
Remy 8b04deb608 Merge pull request #56 from Pouzor/1.9
v1.9.0 — Node properties, edge waypoints, version display
2026-04-09 19:17:14 +02:00
Pouzor 531fb12eab fix: replace SVG animate with CSS animation for basic edge to prevent bounce
SVG <animate> restarts on every React re-render (especially visible under
StrictMode double-invoke in dev), causing a visible bounce. CSS animation
runs on the compositor thread independently of React renders.
2026-04-09 16:52:43 +02:00
Pouzor e666abefad chore: remove db backup from tracking and add *.db.back to gitignore 2026-04-09 16:34:25 +02:00
Pouzor f1e9fd7cf8 test: verify standalone mode round-trips for properties, waypoints and animations
- Fix ApiEdge type: add 'basic' to animated union
- Add 5 standalone storage tests: properties, empty properties, waypoints,
  basic animation, and all three animation types round-trip through localStorage
2026-04-09 16:28:20 +02:00
Pouzor 9134812e32 feat: add Basic edge animation with consistent downward direction
Adds a "basic" animation type that uses React Flow's native moving-dash
style — the entire stroke is animated dashes with no solid line underneath.
Direction is always physically downward (top node → bottom node) regardless
of which end was the source when the edge was drawn.
2026-04-09 16:04:43 +02:00
Pouzor 4844576c3b feat: add Basic animation type for edges
Adds a new "basic" animation mode that uses React Flow's native animated
dashes — the entire stroke moves as dashes, with no solid line underneath.
Distinct from "snake" (moving dot) and "flow" (overlay on solid line).

- Frontend: new Basic button in EdgeModal, animMode mapping, passes
  animated={true} to BaseEdge when mode is "basic"
- Backend: normalize_animated accepts "basic" as a valid value
- Tests: EdgeModal + canvas round-trip tests for basic mode
2026-04-09 15:46:52 +02:00
Pouzor 4976f2e694 test: improve canvas route coverage to 100% and fix async tracing
- Add 22 new tests for canvas save/load: properties round-trip,
  edge waypoints, edge handles, animated, parent/child nodes,
  group nodes, all node fields, bottom_handles, services/notes,
  check_method/target, viewport update branches, edge update branch
- Fix coverage.py async tracing by setting core = "sysmon" in
  pyproject.toml (Python 3.12+ sys.monitoring API), resolving the
  sys.settrace drop-after-await issue
2026-04-09 15:17:58 +02:00
Pouzor d6a7b062f4 fix: clear node height when properties change to prevent overflow outside border 2026-04-09 14:34:01 +02:00
Pouzor 94c6ac7fa7 fix: add type parameters to bare list annotations for mypy compliance 2026-04-09 14:23:13 +02:00
Pouzor e0f96001e2 chore: bump version to 1.9.0 2026-04-09 14:06:24 +02:00
Pouzor 3d89ba3b6f feat: replace static hardware fields with dynamic node properties
Replaces the 4 fixed hardware columns (cpu_count, cpu_model, ram_gb,
disk_gb) with a flexible properties system. Each property has a key,
value, icon (from a curated Lucide picker), and a visibility toggle
that controls whether it appears on the canvas node card.

- Backend: add `properties` JSON column to Node model; data migration
  converts existing hardware rows to properties with correct icons
  (idempotent, old columns kept for safety)
- Backend: add `properties` to NodeBase, NodeUpdate, NodeSave schemas
  and canvasSerializer so values survive canvas save/load
- Frontend: add NodeProperty type; new propertyIcons.ts registry (20
  icons); BaseNode renders visible properties with legacy hardware
  fallback for unmigrated nodes
- Frontend: DetailPanel gains interactive properties section (add /
  edit / remove / toggle visibility / icon picker) replacing the
  read-only hardware block; hardware section removed from NodeModal
- Tests: 6 migration tests, 7 API tests, 8 DetailPanel property tests,
  6 BaseNode render/fallback tests, 9 propertyIcons util tests
2026-04-09 13:47:36 +02:00
Pouzor 3afc8ed3d8 fix: persist edge waypoints in backend
Add waypoints JSON column to edges table, include it in all edge
schemas (EdgeBase, EdgeUpdate, canvas CanvasEdge) and add the
idempotent ALTER TABLE migration so existing databases are upgraded
on next startup.
2026-04-08 23:31:53 +02:00
Pouzor 9e8bab5dec feat: add interactive edge waypoints with smooth path editing
- Drag waypoints to reshape edges; double-click a waypoint to remove it
- + handles at segment midpoints to insert new waypoints
- Bezier style: catmull-rom smooth curves through waypoints
- Smooth style: rounded-corner polyline with soft 45° snap (snaps within 15px)
  - First + handle biased to source axis for perpendicular node exit
  - snap45both: ray-intersection solver ensures both adjacent segments snap to 45° simultaneously
- Clear path button in EdgeModal when waypoints exist
- Waypoints serialised/deserialised with canvas state
2026-04-08 22:42:18 +02:00
Pouzor 9d9fdd61e9 Merge branch 'feat/version-display' into 1.9 2026-04-08 16:19:52 +02:00
Pouzor b0df8f389a fix: align dot grid to snap grid and fix node selection layout shift 2026-04-08 13:58:27 +02:00
Pouzor 75c7f25a30 feat: display app version in sidebar with GitHub release check 2026-04-08 12:22:39 +02:00
Pouzor 8bd1c48976 chore: bump version to 1.8.3 2026-04-07 01:04:10 +02:00
Remy 05c98355a6 Update INSTALLATION.md 2026-04-07 00:51:25 +02:00
Pouzor 323dea6798 Remove custom proxmox script and doc 2026-04-07 00:49:52 +02:00
Pouzor 19cb4b71f5 chore: upgrade lucide-react to v1.7.0 2026-04-07 00:06:21 +02:00
Pouzor fd86c0f6ad chore: update frontend npm dependencies (patch/minor) 2026-04-07 00:02:33 +02:00
Pouzor 00d44abfad feat: reduce snap grid from 16px to 8px for finer node positioning 2026-04-06 23:54:56 +02:00
Pouzor 3bd18ab543 chore: bump version to 1.8.2 2026-04-05 15:01:57 +02:00
Pouzor 4d8bb246f1 fix: scan no longer triggers before user confirms in ScanConfigModal 2026-04-05 14:35:10 +02:00
Pouzor 07da498d18 fix: use correct ping args on Windows (-n/-w instead of -c/-W) 2026-04-05 14:08:36 +02:00
Pouzor 0e59f15608 chore: bump version to 1.8.1 2026-04-05 10:32:37 +02:00
Pouzor bd22891fab fix: correct UTC timestamp display and pre-existing code quality issues 2026-04-05 03:06:29 +02:00
Pouzor d96b502524 fix: correct UTC timestamp display in scan history, pending devices, and detail panel 2026-04-05 02:50:23 +02:00
Pouzor 9cb9d02459 feat: extend search (Ctrl+F and Ctrl+K) to include pending devices
Both SearchBar and SearchModal now fetch and search pending devices by
IP, hostname, and service name. Selecting a pending result opens the
sidebar to the Pending tab and highlights the matching device.
2026-04-05 02:40:12 +02:00
Pouzor 08a3a689c5 chore: bump version to 1.8.0 2026-04-04 23:29:20 +02:00
Remy 034ba599f4 Merge pull request #40 from Pouzor/feat/bottom-handles
feat: configurable bottom handles, scanner rewrite, UI polish
2026-04-04 23:23:20 +02:00
Pouzor 38c5bcb606 feat: configurable bottom handles, scanner rewrite, UI polish
## New features
- Configurable bottom connection points per node (1–4 handles)
- Fit view on load
- LiveView improvements
- Node modal: inline Type/Icon picker, default icon in trigger
- Remove redundant Save button from ScanConfigModal

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

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

- Regular links: bottom → top-t
- Parent edges: bottom → top-t
- clusterR/clusterL: cluster-right → cluster-left
2026-04-03 00:50:34 +02:00
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
92 changed files with 6609 additions and 2017 deletions
+1
View File
@@ -45,6 +45,7 @@ htmlcov/
*.db
*.db-shm
*.db-wal
*.db.back
# Docker
.docker/
+5 -29
View File
@@ -53,37 +53,13 @@ docker compose up -d
## Proxmox LXC Install
Run this **on the Proxmox host** — it creates a Debian 12 LXC container and installs Homelable inside automatically:
You can now install Homelable with community-scripts (proxmox-VE) :
`https://community-scripts.org/scripts/homelable`
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/install-proxmox.sh)
```
Default container settings: 2 cores, 1 GB RAM, 8 GB disk, DHCP on `vmbr0`. Override before running:
```bash
CTID=150 RAM=2048 STORAGE=local-zfs bash <(curl -fsSL .../install-proxmox.sh)
```
The backend runs as a systemd service, the frontend is served via nginx on port 80.
> To install manually inside an existing Debian/Ubuntu machine or LXC:
> ```bash
> bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/lxc-install.sh)
> ```
### Update (LXC)
Run the update script inside the container (pulls latest code, rebuilds frontend, restarts services — `.env` and database are never touched):
```bash
sudo bash /opt/homelable/scripts/update.sh
```
Or directly from GitHub:
```bash
sudo bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/update.sh)
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/homelable.sh)"
```
---
+40 -8
View File
@@ -1,8 +1,10 @@
import ipaddress
import logging
import uuid
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -18,6 +20,16 @@ from app.services.scanner import request_cancel, run_scan
class ScanConfig(BaseModel):
ranges: list[str]
@field_validator("ranges")
@classmethod
def validate_cidr(cls, v: list[str]) -> list[str]:
for r in v:
try:
ipaddress.ip_network(r, strict=False)
except ValueError as exc:
raise ValueError(f"Invalid CIDR range: {r!r}") from exc
return v
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -49,6 +61,10 @@ async def stop_scan(
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, bool]:
try:
uuid.UUID(run_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid run_id format") from None
run = await db.get(ScanRun, run_id)
if not run:
raise HTTPException(status_code=404, detail="Scan run not found")
@@ -64,6 +80,19 @@ async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_
return list(result.scalars().all())
@router.delete("/pending", response_model=dict)
async def clear_pending(
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, int]:
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending"))
devices = result.scalars().all()
for device in devices:
await db.delete(device)
await db.commit()
return {"deleted": len(devices)}
@router.get("/hidden", response_model=list[PendingDeviceResponse])
async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "hidden"))
@@ -92,9 +121,10 @@ async def hide_device(
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
) -> dict[str, bool]:
device = await db.get(PendingDevice, device_id)
if device:
device.status = "hidden"
await db.commit()
if not device:
raise HTTPException(status_code=404, detail="Device not found")
device.status = "hidden"
await db.commit()
return {"hidden": True}
@@ -103,9 +133,10 @@ async def ignore_device(
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
) -> dict[str, bool]:
device = await db.get(PendingDevice, device_id)
if device:
await db.delete(device)
await db.commit()
if not device:
raise HTTPException(status_code=404, detail="Device not found")
await db.delete(device)
await db.commit()
return {"ignored": True}
@@ -127,4 +158,5 @@ async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_u
settings.save_overrides()
return payload
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
logger.error("Failed to save scan config: %s", exc)
raise HTTPException(status_code=500, detail="Failed to save scan config") from exc
+64 -24
View File
@@ -1,4 +1,5 @@
"""APScheduler setup for background scan and status check jobs."""
import asyncio
import logging
from datetime import datetime, timezone
@@ -15,48 +16,87 @@ logger = logging.getLogger(__name__)
scheduler: AsyncIOScheduler = AsyncIOScheduler()
async def _run_status_checks() -> None:
"""Check all nodes and broadcast results via WebSocket."""
async def _check_single_node(
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
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:
result = await db.execute(select(Node))
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 node.check_method:
continue
try:
check_result = await check_node(node.check_method, node.check_target, node.ip)
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"]
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)
if not checkable:
return
await asyncio.gather(*[
_check_single_node(node_id, method, target, ip)
for node_id, method, target, ip in checkable
])
def start_scheduler() -> None:
global scheduler
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.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()
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
def reschedule_status_checks(interval_seconds: int) -> None:
"""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:
logger.warning("Scheduler not running, skipping reschedule")
return
+41
View File
@@ -57,12 +57,53 @@ async def init_db() -> None:
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN width REAL")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN height REAL")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN bottom_handles INTEGER NOT NULL DEFAULT 1")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_source TEXT")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN properties JSON")
# Migrate hardware columns → properties JSON (idempotent: only runs on nodes where properties IS NULL)
with suppress(OperationalError):
rows = await conn.exec_driver_sql(
"SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware "
"FROM nodes WHERE properties IS NULL"
)
for row in rows.fetchall():
node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = row
props = []
visible = bool(show_hardware)
if cpu_model:
props.append({"key": "CPU Model", "value": str(cpu_model), "icon": "Cpu", "visible": visible})
if cpu_count is not None:
props.append({"key": "CPU Cores", "value": str(cpu_count), "icon": "Cpu", "visible": visible})
if ram_gb is not None:
props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible})
if disk_gb is not None:
props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible})
import json as _json
await conn.exec_driver_sql(
"UPDATE nodes SET properties = ? WHERE id = ?",
(_json.dumps(props), node_id),
)
# Migrate animated column from boolean (0/1) to string ('none'/'snake')
with suppress(OperationalError):
await conn.exec_driver_sql("UPDATE edges SET animated = 'snake' WHERE animated = '1' OR animated = 1")
with suppress(OperationalError):
sql = "UPDATE edges SET animated = 'none' WHERE animated = '0' OR animated = 0 OR animated IS NULL"
await conn.exec_driver_sql(sql)
# Ensure existing proxmox nodes have container_mode=1 (they were always containers before the flag existed)
with suppress(OperationalError):
await conn.exec_driver_sql(
"UPDATE nodes SET container_mode = 1 WHERE type = 'proxmox' AND container_mode = 0"
)
# Rename legacy 'docker' type → 'docker_container'
with suppress(OperationalError):
await conn.exec_driver_sql(
"UPDATE nodes SET type = 'docker_container' WHERE type = 'docker'"
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
+4 -1
View File
@@ -42,13 +42,14 @@ class Node(Base):
ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
show_hardware: Mapped[bool] = mapped_column(Boolean, default=False)
properties: Mapped[list[Any]] = mapped_column(JSON, default=list)
width: Mapped[float | None] = mapped_column(Float, nullable=True)
height: Mapped[float | None] = mapped_column(Float, nullable=True)
bottom_handles: Mapped[int] = mapped_column(Integer, default=1)
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
response_time_ms: Mapped[int | None] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
children: Mapped[list["Node"]] = relationship("Node", back_populates="parent")
parent: Mapped["Node | None"] = relationship("Node", back_populates="children", remote_side=[id])
@@ -68,6 +69,7 @@ class Edge(Base):
animated: Mapped[str] = mapped_column(String, nullable=False, default='none')
source_handle: Mapped[str | None] = mapped_column(String)
target_handle: Mapped[str | None] = mapped_column(String)
waypoints: Mapped[list[dict[str, float]] | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
@@ -90,6 +92,7 @@ class PendingDevice(Base):
services: Mapped[list[Any]] = mapped_column(JSON, default=list)
suggested_type: Mapped[str | None] = mapped_column(String)
status: Mapped[str] = mapped_column(String, default="pending")
discovery_source: Mapped[str | None] = mapped_column(String)
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+13 -1
View File
@@ -1,3 +1,5 @@
import logging
import logging.config
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import Any
@@ -14,6 +16,16 @@ from app.db.database import init_db
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# Ensure app logs are visible: attach a handler to the root logger if none
# exists (uvicorn only installs handlers on its own loggers, not the root).
root_logger = logging.getLogger()
if not root_logger.handlers:
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("%(levelname)s:%(name)s:%(message)s"))
root_logger.addHandler(handler)
root_logger.setLevel(logging.INFO)
logging.getLogger("app").setLevel(logging.INFO)
logging.getLogger("app.services.scanner").setLevel(logging.INFO)
await init_db()
settings.load_overrides()
start_scheduler()
@@ -23,7 +35,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
app = FastAPI(
title="Homelable API",
version="1.4.0",
version="1.9.0",
lifespan=lifespan,
)
+3
View File
@@ -29,8 +29,10 @@ class NodeSave(BaseModel):
ram_gb: float | None = None
disk_gb: float | None = None
show_hardware: bool = False
properties: list[Any] = []
width: float | None = None
height: float | None = None
bottom_handles: int = 1
pos_x: float = 0
pos_y: float = 0
@@ -48,6 +50,7 @@ class EdgeSave(BaseModel):
animated: str = 'none'
source_handle: str | None = None
target_handle: str | None = None
waypoints: list[dict[str, float]] | None = None
@field_validator('animated', mode='before')
@classmethod
+2
View File
@@ -17,6 +17,7 @@ class EdgeBase(BaseModel):
animated: str = 'none'
source_handle: str | None = None
target_handle: str | None = None
waypoints: list[dict[str, float]] | None = None
@field_validator('animated', mode='before')
@classmethod
@@ -38,6 +39,7 @@ class EdgeUpdate(BaseModel):
animated: str | None = None
source_handle: str | None = None
target_handle: str | None = None
waypoints: list[dict[str, float]] | None = None
@field_validator('animated', mode='before')
@classmethod
+4
View File
@@ -27,8 +27,10 @@ class NodeBase(BaseModel):
ram_gb: float | None = None
disk_gb: float | None = None
show_hardware: bool = False
properties: list[dict[str, Any]] = []
width: float | None = None
height: float | None = None
bottom_handles: int = 1
class NodeCreate(NodeBase):
@@ -58,8 +60,10 @@ class NodeUpdate(BaseModel):
ram_gb: float | None = None
disk_gb: float | None = None
show_hardware: bool | None = None
properties: list[dict[str, Any]] | None = None
width: float | None = None
height: float | None = None
bottom_handles: int | None = None
class NodeResponse(NodeBase):
+1
View File
@@ -13,6 +13,7 @@ class PendingDeviceResponse(BaseModel):
services: list[Any]
suggested_type: str | None
status: str
discovery_source: str | None
discovered_at: datetime
model_config = {"from_attributes": True}
+1 -1
View File
@@ -4,6 +4,6 @@ def normalize_animated(v: object) -> str:
return 'snake'
if v is False or v == 0 or v == '0' or v is None or v == 'none':
return 'none'
if v in ('snake', 'flow'):
if v in ('snake', 'flow', 'basic'):
return str(v)
return 'none'
+49 -9
View File
@@ -65,14 +65,46 @@ def fingerprint_ports(open_ports: list[dict[str, Any]]) -> list[dict[str, Any]]:
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] = {
"52:54:00": "vm", # QEMU/KVM (used by Proxmox VMs)
"bc:24:11": "vm", # Proxmox official OUI (VMs and LXC, Proxmox 7.3+)
# Hypervisors / VMs
"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:0c:29": "vm", # VMware Workstation / Fusion
"08:00:27": "vm", # VirtualBox
"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
34567: "camera", # Amcrest
2020: "camera", # Tapo
# Smart-home / MQTT → iot
# Smart-home / MQTT / CoAP → iot
1883: "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
8880: "ap", # UniFi HTTP
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:
"""Suggest a node type based on matched signatures and MAC OUI."""
priority = ["proxmox", "nas", "router", "lxc", "vm", "server", "ap", "camera", "iot", "switch"]
"""Suggest a node type based on matched signatures, port hints, and MAC OUI."""
# 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()
for p in open_ports:
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"])
if port in _PORT_TYPE_HINTS:
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:
found.add(mac_type)
for t in priority:
if t in found:
return t
+409 -125
View File
@@ -1,10 +1,16 @@
"""Network scanner: ARP sweep + nmap service detection."""
"""Network scanner: ARP sweep + nmap service detection + mDNS discovery."""
import asyncio
import ipaddress
import logging
import os
import re
import socket
import subprocess
import threading
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Node, PendingDevice, ScanRun
@@ -12,18 +18,30 @@ from app.services.fingerprint import fingerprint_ports, suggest_node_type
logger = logging.getLogger(__name__)
# Run IDs that have been requested to cancel
# Run IDs that have been requested to cancel (thread-safe via lock)
_cancelled_runs: set[str] = set()
_cancelled_lock = threading.Lock()
# 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"
)
def request_cancel(run_id: str) -> None:
"""Signal a running scan to stop early."""
_cancelled_runs.add(run_id)
def _is_cancelled(run_id: str) -> bool:
return run_id in _cancelled_runs
_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:
import nmap
@@ -32,50 +50,24 @@ except ImportError:
_NMAP_AVAILABLE = False
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()
try:
# Home lab port range: standard top-1000 + common self-hosted service ports
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
def request_cancel(run_id: str) -> None:
"""Signal a running scan to stop early."""
with _cancelled_lock:
_cancelled_runs.add(run_id)
hosts = []
for host in nm.all_hosts():
if nm[host].state() != "up":
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 _is_cancelled(run_id: str) -> bool:
with _cancelled_lock:
return run_id in _cancelled_runs
def _resolve_hostname(ip: str) -> str | None:
@@ -95,6 +87,278 @@ def _extract_os(nm: object, host: str) -> str | None:
return None
def _arp_table_hosts(network: str) -> dict[str, dict[str, Any]]:
"""
Read the OS ARP cache for recently-seen hosts in the target network.
Works without root on both Linux (/proc/net/arp) and macOS (arp -a).
Supplements nmap discovery — catches IoT and devices with all ports filtered.
"""
try:
net = ipaddress.ip_network(network, strict=False)
found: dict[str, dict[str, Any]] = {}
# Linux: parse /proc/net/arp — present on any Linux kernel (including Docker)
proc_arp = "/proc/net/arp"
try:
with open(proc_arp) as f:
for line in f.readlines()[1:]: # skip header row
parts = line.split()
if len(parts) >= 4:
ip, mac = parts[0], parts[3]
if mac == "00:00:00:00:00:00":
continue
try:
if ipaddress.ip_address(ip) in net:
found[ip] = {
"ip": ip, "mac": mac,
"hostname": _resolve_hostname(ip),
"os": None, "open_ports": [],
}
except ValueError:
pass
# /proc/net/arp opened successfully — return whatever we found (may be empty)
# Don't fall through to `arp -a` since we're on Linux
return found
except FileNotFoundError:
pass # Not Linux — fall through to macOS `arp -a`
# macOS: parse `arp -a` output
result = subprocess.run(["arp", "-a"], capture_output=True, text=True, timeout=5)
for line in result.stdout.splitlines():
m = re.search(r"\((\d+\.\d+\.\d+\.\d+)\)\s+at\s+([0-9a-f:]+)", line)
if not m:
continue
ip, mac = m.group(1), m.group(2)
if mac in ("(incomplete)", "ff:ff:ff:ff:ff:ff"):
continue
try:
if ipaddress.ip_address(ip) in net:
found[ip] = {"ip": ip, "mac": mac, "hostname": _resolve_hostname(ip), "os": None, "open_ports": []}
except ValueError:
pass
return found
except Exception as exc:
logger.warning("[Phase 1] ARP cache lookup failed: %s", exc)
return {}
async def _ping_sweep(target: str) -> dict[str, dict[str, Any]]:
"""
Phase 1: Concurrent ICMP ping sweep + ARP cache.
Pings all IPs in the CIDR in parallel (up to 50 at once, 1s timeout each).
Supplements with the OS ARP cache to catch devices that block ICMP.
Works in Docker with CAP_NET_RAW — no nmap, no false positives.
"""
net = ipaddress.ip_network(target, strict=False)
all_ips = [str(ip) for ip in net.hosts()]
logger.info("[Phase 1] Pinging %d hosts in %s ...", len(all_ips), target)
sem = asyncio.Semaphore(50)
async def _ping(ip: str) -> str | None:
async with sem:
try:
proc = await asyncio.create_subprocess_exec(
"ping", "-c", "1", "-W", "1", ip,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
return ip if proc.returncode == 0 else None
except Exception:
return None
ping_results = await asyncio.gather(*[_ping(ip) for ip in all_ips])
alive_ips: set[str] = {ip for ip in ping_results if ip is not None}
logger.info("[Phase 1] %d/%d hosts responded to ping", len(alive_ips), len(all_ips))
# ARP cache: catch devices that block ICMP but were recently active,
# and enrich ping-alive hosts with their MAC addresses.
arp_cache = await asyncio.to_thread(_arp_table_hosts, target)
alive: dict[str, dict[str, Any]] = {}
for ip in alive_ips:
mac = arp_cache.get(ip, {}).get("mac")
hostname = await asyncio.to_thread(_resolve_hostname, ip)
logger.info("[Phase 1] %s mac=%s hostname=%s (ping)", ip, mac or "n/a", hostname or "n/a")
alive[ip] = {"ip": ip, "mac": mac, "hostname": hostname, "os": None, "open_ports": []}
for ip, host in arp_cache.items():
if ip not in alive:
logger.info(
"[Phase 1] %s mac=%s hostname=%s (ARP cache only)",
ip, host.get("mac") or "n/a", host.get("hostname") or "n/a",
)
alive[ip] = host
return alive
def _nmap_scan_single(host_dict: dict[str, Any]) -> dict[str, Any]:
"""
Phase 2 — single-IP port scan with service detection.
Runs in a thread (blocking). Returns the host dict enriched with open_ports.
"""
ip = host_dict["ip"]
logger.info("[Phase 2] Scanning %s ...", ip)
if not _NMAP_AVAILABLE:
logger.warning("[Phase 2] nmap not available, skipping %s", ip)
return host_dict
is_root = os.geteuid() == 0
if is_root:
# SYN scan + version detection (fastest, most accurate)
scan_args = f"-sS -sV --open -T4 -Pn --host-timeout 60s -p {_EXTRA_PORTS}"
else:
# TCP connect scan (-sT) — no raw sockets needed, works without root.
# nmap auto-selects -sT without root but being explicit avoids edge cases.
scan_args = f"-sT -sV --open -T4 -Pn --host-timeout 60s -p {_EXTRA_PORTS}"
logger.debug("[Phase 2] %s args: %s", ip, scan_args)
nm = nmap.PortScanner()
try:
nm.scan(hosts=ip, arguments=scan_args)
except Exception as exc:
logger.warning("[Phase 2] nmap FAILED for %s (%s: %s) — skipping port scan", ip, type(exc).__name__, exc)
return host_dict
all_scanned = nm.all_hosts()
logger.debug("[Phase 2] %s — nmap returned %d host(s) in results", ip, len(all_scanned))
if ip not in all_scanned:
logger.info("[Phase 2] %s — no open ports found (all closed/filtered or nmap had no results)", ip)
return host_dict
open_ports = []
for proto in nm[ip].all_protocols():
for port, info in nm[ip][proto].items():
if info["state"] == "open":
banner = (info.get("product", "") + " " + info.get("version", "")).strip()
open_ports.append({"port": port, "protocol": proto, "banner": banner})
if open_ports:
port_summary = ", ".join(
f"{p['port']}/{p['protocol']} ({p['banner'] or 'unknown'})" for p in open_ports
)
logger.info("[Phase 2] %s%d open port(s): %s", ip, len(open_ports), port_summary)
else:
logger.info("[Phase 2] %s — 0 open ports detected", ip)
host_dict["open_ports"] = open_ports
if not host_dict["mac"]:
host_dict["mac"] = nm[ip].get("addresses", {}).get("mac")
host_dict["os"] = _extract_os(nm, ip)
return host_dict
async def _nmap_port_scan(alive: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
"""
Phase 2: Per-IP service detection with bounded concurrency.
Each host is scanned independently in a thread — no inter-host timeout interference.
Up to 10 hosts scanned concurrently.
"""
if not alive:
return []
logger.info("[Phase 2] Starting per-IP port scan for %d host(s)", len(alive))
semaphore = asyncio.Semaphore(10)
async def _scan_with_sem(host_dict: dict[str, Any]) -> dict[str, Any]:
async with semaphore:
return await asyncio.to_thread(_nmap_scan_single, host_dict)
raw = await asyncio.gather(*[_scan_with_sem(h) for h in alive.values()], return_exceptions=True)
results = []
for item in raw:
if isinstance(item, BaseException):
logger.warning("[Phase 2] Unexpected error in gather: %s", item)
else:
results.append(item)
logger.info("[Phase 2] Completed — %d/%d host(s) scanned", len(results), len(alive))
return results
async def _nmap_scan(target: str) -> list[dict[str, Any]]:
"""
Two-phase scan for a CIDR range.
Phase 1: Concurrent ping sweep to find alive hosts (fast, no false positives).
Phase 2: Per-IP nmap port scan with service detection (bounded concurrency, 10 at a time).
"""
logger.info("[Scan] Starting scan for %s — nmap available: %s", target, _NMAP_AVAILABLE)
if not _NMAP_AVAILABLE:
logger.warning("[Scan] nmap not available — returning mock data")
return _mock_scan(target)
try:
alive = await _ping_sweep(target)
logger.info("[Phase 1] Found %d alive host(s) in %s: %s",
len(alive), target, ", ".join(sorted(alive.keys())))
except Exception as exc:
logger.error("Phase 1 ping sweep failed: %s", exc)
raise RuntimeError(str(exc)) from exc
return await _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]]:
"""Return fake results for dev/test environments without nmap."""
return [
@@ -113,100 +377,117 @@ def _mock_scan(target: str) -> list[dict[str, Any]]:
async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
"""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
devices_found = 0
mdns_task: asyncio.Task[list[dict[str, Any]]] | None = None
try:
# Clean up stale pending devices whose IPs are already in the canvas
# (covers devices approved between scans, or pre-existing canvas nodes)
# Validate all ranges are valid CIDRs before passing anything to nmap
for r in ranges:
try:
ipaddress.ip_network(r, strict=False)
except ValueError:
raise ValueError(f"Invalid CIDR range: {r!r}") from None
# Pre-fetch canvas IPs and hidden IPs once — avoids N+1 queries per host
canvas_ips_result = await db.execute(select(Node.ip).where(Node.ip.isnot(None)))
canvas_ips = {row[0] for row in canvas_ips_result.fetchall()}
canvas_ips: set[str] = {row[0] for row in canvas_ips_result.fetchall()}
hidden_ips_result = await db.execute(
select(PendingDevice.ip).where(PendingDevice.status == "hidden")
)
hidden_ips: set[str] = {row[0] for row in hidden_ips_result.fetchall()}
# Clean up stale pending devices whose IPs are already in the canvas
if canvas_ips:
stale_result = await db.execute(
select(PendingDevice).where(
from sqlalchemy import delete as sa_delete
await db.execute(
sa_delete(PendingDevice).where(
PendingDevice.status == "pending",
PendingDevice.ip.in_(canvas_ips),
)
)
for stale in stale_result.scalars().all():
await db.delete(stale)
await db.commit()
# Start mDNS discovery in the background while nmap scans run
mdns_task = asyncio.create_task(_mdns_discover())
# Track IPs found by nmap so mDNS doesn't duplicate them
nmap_ips: set[str] = set()
async def _process_host(host: dict[str, Any], discovery_source: str = "arp") -> None:
nonlocal devices_found
ip = host["ip"]
# Skip canvas nodes and user-hidden devices (sets pre-fetched before loop)
if ip in canvas_ips:
logger.debug("Skipping %s — already in canvas", ip)
return
if ip in hidden_ips:
logger.debug("Skipping %s — hidden by user", ip)
return
services = fingerprint_ports(host["open_ports"])
suggested_type = suggest_node_type(host["open_ports"], host.get("mac"))
existing_result = await db.execute(
select(PendingDevice).where(
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:
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",
discovery_source=discovery_source,
))
devices_found += 1
await db.commit()
await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
# nmap scan per CIDR — results stream in progressively
for cidr in ranges:
if _is_cancelled(run_id):
break
# Run nmap in a thread pool — does not block the event loop
hosts = await asyncio.to_thread(_nmap_scan, cidr)
hosts = await _nmap_scan(cidr)
for host in hosts:
if _is_cancelled(run_id):
break
ip = host["ip"]
nmap_ips.add(host["ip"])
await _process_host(host)
# Skip if device is already in the canvas (approved node)
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)
continue
# Update ScanRun count once after all CIDR ranges
run = await db.get(ScanRun, run_id)
if run:
run.devices_found = devices_found
await db.commit()
# Skip if device was explicitly hidden by the user
hidden_result = await db.execute(
select(PendingDevice).where(
PendingDevice.ip == ip,
PendingDevice.status == "hidden",
)
)
if hidden_result.scalar_one_or_none() is not None:
logger.debug("Skipping %s — hidden by user", ip)
continue
# Collect mDNS results — task already has its own 4s internal timeout
if not _is_cancelled(run_id):
mdns_hosts = await mdns_task
services = fingerprint_ports(host["open_ports"])
suggested_type = suggest_node_type(host["open_ports"], host.get("mac"))
# Update existing pending device or create a new one
existing_result = await db.execute(
select(PendingDevice).where(
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()
# Update running count on the scan run record
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
await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
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, discovery_source="mdns")
else:
mdns_task.cancel()
# Mark scan as done or cancelled
run = await db.get(ScanRun, run_id)
@@ -218,6 +499,8 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
except Exception as exc:
logger.error("Scan failed: %s", exc)
if mdns_task is not None and not mdns_task.done():
mdns_task.cancel()
run = await db.get(ScanRun, run_id)
if run:
run.status = "error"
@@ -225,4 +508,5 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
run.finished_at = datetime.now(timezone.utc)
await db.commit()
finally:
_cancelled_runs.discard(run_id)
with _cancelled_lock:
_cancelled_runs.discard(run_id)
+6 -1
View File
@@ -2,6 +2,7 @@
import asyncio
import logging
import socket
import sys
import time
from typing import Any
@@ -57,8 +58,12 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
async def _ping(host: str) -> bool:
if sys.platform == "win32":
args = ["ping", "-n", "1", "-w", "1000", host]
else:
args = ["ping", "-c", "1", "-W", "1", host]
proc = await asyncio.create_subprocess_exec(
"ping", "-c", "1", "-W", "1", host,
*args,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
+2
View File
@@ -25,6 +25,8 @@ addopts = "--tb=short -q"
[tool.coverage.run]
source = ["app"]
omit = ["*/migrations/*", "*/tests/*"]
concurrency = ["thread"]
core = "sysmon"
[tool.coverage.report]
skip_empty = true
+4 -3
View File
@@ -9,17 +9,18 @@ pydantic-settings==2.5.2
python-jose[cryptography]==3.5.0
passlib[bcrypt]==1.7.4
bcrypt==4.0.1
python-multipart==0.0.22
python-multipart==0.0.26
apscheduler==3.10.4
python-nmap==0.7.1
pyyaml==6.0.2
types-PyYAML==6.0.12.20240917
websockets==13.1
httpx==0.27.2
zeroconf==0.131.0
# Dev
ruff==0.6.9
mypy==1.11.2
pytest==8.3.3
pytest-asyncio==0.24.0
pytest==9.0.3
pytest-asyncio==1.3.0
pytest-cov==5.0.0
+292
View File
@@ -256,3 +256,295 @@ async def test_save_canvas_dimensions_cleared_when_null(client: AsyncClient, hea
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["width"] is None
assert canvas["nodes"][0]["height"] is None
# ── properties ────────────────────────────────────────────────────────────────
async def test_save_canvas_properties_default_empty(client: AsyncClient, headers: dict):
n1 = node_payload()
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["properties"] == []
async def test_save_canvas_persists_properties(client: AsyncClient, headers: dict):
props = [
{"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": True},
{"key": "CPU", "value": "Intel i9", "icon": "Cpu", "visible": False},
]
n1 = node_payload(properties=props)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
returned = canvas["nodes"][0]["properties"]
assert len(returned) == 2
assert returned[0] == {"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": True}
assert returned[1] == {"key": "CPU", "value": "Intel i9", "icon": "Cpu", "visible": False}
async def test_save_canvas_properties_updated_on_second_save(client: AsyncClient, headers: dict):
n1 = node_payload(properties=[{"key": "RAM", "value": "16 GB", "icon": None, "visible": True}])
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
n1_updated = {**n1, "properties": [
{"key": "RAM", "value": "64 GB", "icon": "MemoryStick", "visible": True},
{"key": "Disk", "value": "2 TB", "icon": "HardDrive", "visible": True},
]}
await client.post("/api/v1/canvas/save", json={"nodes": [n1_updated], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
props = canvas["nodes"][0]["properties"]
assert len(props) == 2
assert props[0]["value"] == "64 GB"
assert props[1]["key"] == "Disk"
async def test_save_canvas_properties_with_null_icon(client: AsyncClient, headers: dict):
props = [{"key": "Note", "value": "custom rack", "icon": None, "visible": True}]
n1 = node_payload(properties=props)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["properties"][0]["icon"] is None
async def test_save_canvas_properties_cleared_to_empty(client: AsyncClient, headers: dict):
n1 = node_payload(properties=[{"key": "RAM", "value": "32 GB", "icon": None, "visible": True}])
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
n1_cleared = {**n1, "properties": []}
await client.post("/api/v1/canvas/save", json={"nodes": [n1_cleared], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["properties"] == []
# ── edge waypoints & handles ──────────────────────────────────────────────────
async def test_save_canvas_edge_waypoints_default_null(client: AsyncClient, headers: dict):
n1 = node_payload()
n2 = node_payload()
e1 = edge_payload(n1["id"], n2["id"])
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["edges"][0]["waypoints"] is None
async def test_save_canvas_persists_waypoints_on_edge(client: AsyncClient, headers: dict):
n1 = node_payload()
n2 = node_payload()
waypoints = [{"x": 100.0, "y": 200.0}, {"x": 300.0, "y": 150.0}]
e1 = edge_payload(n1["id"], n2["id"], waypoints=waypoints)
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
returned = canvas["edges"][0]["waypoints"]
assert returned == [{"x": 100.0, "y": 200.0}, {"x": 300.0, "y": 150.0}]
async def test_save_canvas_waypoints_updated_on_second_save(client: AsyncClient, headers: dict):
n1 = node_payload()
n2 = node_payload()
e1 = edge_payload(n1["id"], n2["id"], waypoints=[{"x": 10.0, "y": 20.0}])
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
e1_updated = {**e1, "waypoints": [{"x": 50.0, "y": 60.0}, {"x": 70.0, "y": 80.0}]}
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1_updated], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["edges"][0]["waypoints"] == [{"x": 50.0, "y": 60.0}, {"x": 70.0, "y": 80.0}]
async def test_save_canvas_persists_edge_handles(client: AsyncClient, headers: dict):
n1 = node_payload(bottom_handles=3)
n2 = node_payload()
e1 = edge_payload(n1["id"], n2["id"], source_handle="bottom-1", target_handle="top")
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
edge = canvas["edges"][0]
assert edge["source_handle"] == "bottom-1"
assert edge["target_handle"] == "top"
async def test_save_canvas_persists_animated_edge(client: AsyncClient, headers: dict):
n1 = node_payload()
n2 = node_payload()
e1 = edge_payload(n1["id"], n2["id"], animated="snake")
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["edges"][0]["animated"] == "snake"
async def test_save_canvas_persists_animated_basic(client: AsyncClient, headers: dict):
n1 = node_payload()
n2 = node_payload()
e1 = edge_payload(n1["id"], n2["id"], animated="basic")
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["edges"][0]["animated"] == "basic"
# ── node fields ───────────────────────────────────────────────────────────────
async def test_save_canvas_persists_all_node_fields(client: AsyncClient, headers: dict):
n1 = node_payload(
type="server",
label="Main Server",
hostname="server.local",
ip="192.168.1.10",
mac="aa:bb:cc:dd:ee:ff",
os="Ubuntu 22.04",
status="online",
check_method="http",
check_target="http://192.168.1.10",
services=[{"name": "nginx", "port": 80}],
notes="Primary web server",
pos_x=150.0,
pos_y=250.0,
bottom_handles=2,
)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
node = canvas["nodes"][0]
assert node["hostname"] == "server.local"
assert node["ip"] == "192.168.1.10"
assert node["mac"] == "aa:bb:cc:dd:ee:ff"
assert node["os"] == "Ubuntu 22.04"
assert node["status"] == "online"
assert node["check_method"] == "http"
assert node["check_target"] == "http://192.168.1.10"
assert node["services"] == [{"name": "nginx", "port": 80}]
assert node["notes"] == "Primary web server"
assert node["pos_x"] == 150.0
assert node["pos_y"] == 250.0
assert node["bottom_handles"] == 2
async def test_save_canvas_persists_bottom_handles(client: AsyncClient, headers: dict):
n1 = node_payload(bottom_handles=4)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["bottom_handles"] == 4
async def test_save_canvas_bottom_handles_defaults_one(client: AsyncClient, headers: dict):
n1 = node_payload()
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["bottom_handles"] == 1
async def test_save_canvas_persists_services_and_notes(client: AsyncClient, headers: dict):
services = [{"name": "ssh", "port": 22}, {"name": "http", "port": 80}]
n1 = node_payload(services=services, notes="My NAS device")
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
node = canvas["nodes"][0]
assert node["services"] == services
assert node["notes"] == "My NAS device"
async def test_save_canvas_persists_check_fields(client: AsyncClient, headers: dict):
n1 = node_payload(check_method="ping", check_target="192.168.1.1")
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
node = canvas["nodes"][0]
assert node["check_method"] == "ping"
assert node["check_target"] == "192.168.1.1"
# ── parent/child nodes ────────────────────────────────────────────────────────
async def test_save_canvas_persists_parent_child_nodes(client: AsyncClient, headers: dict):
parent = node_payload(type="proxmox", label="PVE Host")
child = node_payload(type="vm", label="VM-100", parent_id=parent["id"])
await client.post("/api/v1/canvas/save", json={"nodes": [parent, child], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
node_map = {n["id"]: n for n in canvas["nodes"]}
assert node_map[child["id"]]["parent_id"] == parent["id"]
assert node_map[parent["id"]]["parent_id"] is None
async def test_save_canvas_child_removed_with_parent(client: AsyncClient, headers: dict):
parent = node_payload(type="proxmox", label="PVE Host")
child = node_payload(type="lxc", label="LXC-101", parent_id=parent["id"])
await client.post("/api/v1/canvas/save", json={"nodes": [parent, child], "edges": [], "viewport": {}}, headers=headers)
# Remove both parent and child
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"] == []
# ── groupRect / group node ────────────────────────────────────────────────────
async def test_save_canvas_persists_group_node(client: AsyncClient, headers: dict):
group = node_payload(type="group", label="Network Zone", width=400.0, height=300.0)
member = node_payload(type="server", label="Member", parent_id=group["id"])
await client.post("/api/v1/canvas/save", json={"nodes": [group, member], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
node_map = {n["id"]: n for n in canvas["nodes"]}
assert node_map[group["id"]]["type"] == "group"
assert node_map[group["id"]]["width"] == 400.0
assert node_map[group["id"]]["height"] == 300.0
assert node_map[member["id"]]["parent_id"] == group["id"]
# ── viewport ──────────────────────────────────────────────────────────────────
async def test_load_canvas_returns_default_viewport_when_no_state(client: AsyncClient, headers: dict):
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["viewport"] == {"x": 0, "y": 0, "zoom": 1}
async def test_save_canvas_updates_existing_canvas_state(client: AsyncClient, headers: dict):
"""Second save updates the existing CanvasState row (exercises the state.viewport branch)."""
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {"x": 1, "y": 2, "zoom": 1}}, headers=headers)
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {"x": 99, "y": 88, "zoom": 0.75}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["viewport"] == {"x": 99, "y": 88, "zoom": 0.75}
# ── edge types ────────────────────────────────────────────────────────────────
async def test_save_canvas_persists_edge_type_vlan(client: AsyncClient, headers: dict):
n1 = node_payload()
n2 = node_payload()
e1 = edge_payload(n1["id"], n2["id"], type="vlan", vlan_id=10, label="VLAN 10")
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
edge = canvas["edges"][0]
assert edge["type"] == "vlan"
assert edge["vlan_id"] == 10
assert edge["label"] == "VLAN 10"
async def test_save_canvas_edge_update_existing(client: AsyncClient, headers: dict):
"""Second save updates an existing edge (exercises the db_edge branch)."""
n1 = node_payload()
n2 = node_payload()
e1 = edge_payload(n1["id"], n2["id"], label="original")
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
e1_updated = {**e1, "label": "updated", "custom_color": "#ff0000"}
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1_updated], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
edge = canvas["edges"][0]
assert edge["label"] == "updated"
assert edge["custom_color"] == "#ff0000"
+96
View File
@@ -0,0 +1,96 @@
"""
Tests for docker type rename and proxmox container_mode migrations.
"""
import os
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
TEST_DB_URL = "sqlite+aiosqlite:///:memory:"
async def _setup_table(conn):
await conn.exec_driver_sql("""
CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY,
type TEXT NOT NULL DEFAULT 'generic',
label TEXT NOT NULL DEFAULT '',
container_mode BOOLEAN NOT NULL DEFAULT 0
)
""")
async def _run_migrations(conn):
await conn.exec_driver_sql(
"UPDATE nodes SET container_mode = 1 WHERE type = 'proxmox' AND container_mode = 0"
)
await conn.exec_driver_sql(
"UPDATE nodes SET type = 'docker_container' WHERE type = 'docker'"
)
@pytest.mark.asyncio
async def test_proxmox_container_mode_set_to_true():
engine = create_async_engine(TEST_DB_URL)
async with engine.begin() as conn:
await _setup_table(conn)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, type, label, container_mode) VALUES ('p1', 'proxmox', 'PVE', 0)"
)
await _run_migrations(conn)
row = (await conn.exec_driver_sql("SELECT container_mode FROM nodes WHERE id = 'p1'")).fetchone()
assert row[0] == 1
@pytest.mark.asyncio
async def test_proxmox_already_true_unchanged():
engine = create_async_engine(TEST_DB_URL)
async with engine.begin() as conn:
await _setup_table(conn)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, type, label, container_mode) VALUES ('p2', 'proxmox', 'PVE', 1)"
)
await _run_migrations(conn)
row = (await conn.exec_driver_sql("SELECT container_mode FROM nodes WHERE id = 'p2'")).fetchone()
assert row[0] == 1
@pytest.mark.asyncio
async def test_non_proxmox_container_mode_untouched():
engine = create_async_engine(TEST_DB_URL)
async with engine.begin() as conn:
await _setup_table(conn)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, type, label, container_mode) VALUES ('s1', 'server', 'Srv', 0)"
)
await _run_migrations(conn)
row = (await conn.exec_driver_sql("SELECT container_mode FROM nodes WHERE id = 's1'")).fetchone()
assert row[0] == 0
@pytest.mark.asyncio
async def test_docker_type_renamed_to_docker_container():
engine = create_async_engine(TEST_DB_URL)
async with engine.begin() as conn:
await _setup_table(conn)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, type, label) VALUES ('d1', 'docker', 'My Docker')"
)
await _run_migrations(conn)
row = (await conn.exec_driver_sql("SELECT type FROM nodes WHERE id = 'd1'")).fetchone()
assert row[0] == 'docker_container'
@pytest.mark.asyncio
async def test_docker_host_type_untouched():
engine = create_async_engine(TEST_DB_URL)
async with engine.begin() as conn:
await _setup_table(conn)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, type, label) VALUES ('d2', 'docker_host', 'Docker Host')"
)
await _run_migrations(conn)
row = (await conn.exec_driver_sql("SELECT type FROM nodes WHERE id = 'd2'")).fetchone()
assert row[0] == 'docker_host'
+42
View File
@@ -131,3 +131,45 @@ def test_suggest_node_type_camera_from_signature():
]):
result = suggest_node_type([{"port": 554, "protocol": "tcp"}])
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"
+94
View File
@@ -115,3 +115,97 @@ async def test_update_node_parent_id(client: AsyncClient, headers: dict):
async def test_create_node_requires_auth(client: AsyncClient):
res = await client.post("/api/v1/nodes", json={"type": "server", "label": "N", "status": "unknown"})
assert res.status_code == 401
# --- Properties tests ---
async def test_create_node_default_properties_empty(client: AsyncClient, headers: dict):
"""New node has an empty properties list by default."""
res = await client.post("/api/v1/nodes", json={"type": "server", "label": "Srv", "status": "unknown"}, headers=headers)
assert res.status_code == 201
assert res.json()["properties"] == []
async def test_create_node_with_properties(client: AsyncClient, headers: dict):
"""Node created with properties round-trips correctly."""
props = [
{"key": "CPU Model", "value": "i7-12700K", "icon": "Cpu", "visible": True},
{"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": False},
]
res = await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
headers=headers,
)
assert res.status_code == 201
assert res.json()["properties"] == props
async def test_patch_node_properties(client: AsyncClient, headers: dict):
"""PATCH with properties replaces the full properties array."""
create = await client.post("/api/v1/nodes", json={"type": "server", "label": "Srv", "status": "unknown"}, headers=headers)
node_id = create.json()["id"]
props = [{"key": "Disk", "value": "2 TB", "icon": "HardDrive", "visible": True}]
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"properties": props}, headers=headers)
assert res.status_code == 200
assert res.json()["properties"] == props
async def test_patch_node_without_properties_does_not_wipe(client: AsyncClient, headers: dict):
"""PATCH that omits properties leaves existing properties untouched."""
props = [{"key": "GPU", "value": "RTX 4090", "icon": "Monitor", "visible": True}]
create = await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
headers=headers,
)
node_id = create.json()["id"]
# PATCH only the label — properties must survive
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"label": "Updated"}, headers=headers)
assert res.status_code == 200
assert res.json()["properties"] == props
assert res.json()["label"] == "Updated"
async def test_patch_node_clears_properties_with_empty_array(client: AsyncClient, headers: dict):
"""PATCH with properties=[] explicitly clears all properties."""
props = [{"key": "CPU Model", "value": "i5", "icon": "Cpu", "visible": True}]
create = await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
headers=headers,
)
node_id = create.json()["id"]
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"properties": []}, headers=headers)
assert res.status_code == 200
assert res.json()["properties"] == []
async def test_get_node_returns_properties(client: AsyncClient, headers: dict):
"""GET /nodes/:id returns the properties field."""
props = [{"key": "OS", "value": "Debian 12", "icon": "Server", "visible": True}]
create = await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
headers=headers,
)
node_id = create.json()["id"]
res = await client.get(f"/api/v1/nodes/{node_id}", headers=headers)
assert res.status_code == 200
assert res.json()["properties"] == props
async def test_properties_icon_can_be_null(client: AsyncClient, headers: dict):
"""A property with icon=null is valid and round-trips correctly."""
props = [{"key": "Notes", "value": "custom value", "icon": None, "visible": False}]
create = await client.post(
"/api/v1/nodes",
json={"type": "generic", "label": "G", "status": "unknown", "properties": props},
headers=headers,
)
assert create.status_code == 201
assert create.json()["properties"] == props
+176
View File
@@ -0,0 +1,176 @@
"""
Tests for the hardware → properties migration logic.
We test the migration function directly against an in-memory SQLite database
so we can set up legacy rows (with hardware columns, NULL properties) and
verify the migration produces the expected properties JSON.
"""
import json
import os
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
TEST_DB_URL = "sqlite+aiosqlite:///:memory:"
async def _setup_legacy_table(conn):
"""Create a minimal nodes table that mimics the pre-migration schema."""
await conn.exec_driver_sql("""
CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY,
type TEXT NOT NULL DEFAULT 'generic',
label TEXT NOT NULL DEFAULT '',
cpu_model TEXT,
cpu_count INTEGER,
ram_gb REAL,
disk_gb REAL,
show_hardware BOOLEAN NOT NULL DEFAULT 0,
properties JSON
)
""")
async def _run_migration(conn):
"""Run only the properties migration portion (extracted from init_db)."""
rows = await conn.exec_driver_sql(
"SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware "
"FROM nodes WHERE properties IS NULL"
)
for row in rows.fetchall():
node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = row
props = []
visible = bool(show_hardware)
if cpu_model:
props.append({"key": "CPU Model", "value": str(cpu_model), "icon": "Cpu", "visible": visible})
if cpu_count is not None:
props.append({"key": "CPU Cores", "value": str(cpu_count), "icon": "Cpu", "visible": visible})
if ram_gb is not None:
props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible})
if disk_gb is not None:
props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible})
await conn.exec_driver_sql(
"UPDATE nodes SET properties = ? WHERE id = ?",
(json.dumps(props), node_id),
)
async def _get_properties(conn, node_id: str) -> list:
rows = await conn.exec_driver_sql("SELECT properties FROM nodes WHERE id = ?", (node_id,))
raw = rows.fetchone()[0]
return json.loads(raw) if raw else []
@pytest.mark.asyncio
async def test_migration_full_hardware():
"""Node with all 4 hardware fields → 4 property entries with correct icons."""
engine = create_async_engine(TEST_DB_URL)
async with engine.begin() as conn:
await _setup_legacy_table(conn)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware) "
"VALUES (?, ?, ?, ?, ?, ?)",
("node-1", "i7-12700K", 12, 32.0, 2000.0, 1),
)
await _run_migration(conn)
props = await _get_properties(conn, "node-1")
assert len(props) == 4
assert props[0] == {"key": "CPU Model", "value": "i7-12700K", "icon": "Cpu", "visible": True}
assert props[1] == {"key": "CPU Cores", "value": "12", "icon": "Cpu", "visible": True}
assert props[2] == {"key": "RAM", "value": "32.0 GB", "icon": "MemoryStick", "visible": True}
assert props[3] == {"key": "Disk", "value": "2000.0 GB", "icon": "HardDrive", "visible": True}
await engine.dispose()
@pytest.mark.asyncio
async def test_migration_partial_hardware():
"""Node with only cpu_model and ram_gb → 2 property entries."""
engine = create_async_engine(TEST_DB_URL)
async with engine.begin() as conn:
await _setup_legacy_table(conn)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware) VALUES (?, ?, ?, ?)",
("node-2", "Ryzen 5 5600", 16.0, 0),
)
await _run_migration(conn)
props = await _get_properties(conn, "node-2")
assert len(props) == 2
assert props[0]["key"] == "CPU Model"
assert props[0]["visible"] is False
assert props[1]["key"] == "RAM"
assert props[1]["icon"] == "MemoryStick"
await engine.dispose()
@pytest.mark.asyncio
async def test_migration_no_hardware():
"""Node with no hardware fields → empty properties array."""
engine = create_async_engine(TEST_DB_URL)
async with engine.begin() as conn:
await _setup_legacy_table(conn)
await conn.exec_driver_sql(
"INSERT INTO nodes (id) VALUES (?)",
("node-3",),
)
await _run_migration(conn)
props = await _get_properties(conn, "node-3")
assert props == []
await engine.dispose()
@pytest.mark.asyncio
async def test_migration_idempotent():
"""Running migration twice does not duplicate properties."""
engine = create_async_engine(TEST_DB_URL)
async with engine.begin() as conn:
await _setup_legacy_table(conn)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, cpu_model, show_hardware) VALUES (?, ?, ?)",
("node-4", "Core i5", 1),
)
await _run_migration(conn)
await _run_migration(conn) # second pass — node already has properties, should be skipped
props = await _get_properties(conn, "node-4")
assert len(props) == 1
await engine.dispose()
@pytest.mark.asyncio
async def test_migration_show_hardware_false_sets_visible_false():
"""show_hardware=0 means all migrated properties have visible=False."""
engine = create_async_engine(TEST_DB_URL)
async with engine.begin() as conn:
await _setup_legacy_table(conn)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware) VALUES (?, ?, ?, ?)",
("node-5", "ARM Cortex-A72", 4.0, 0),
)
await _run_migration(conn)
props = await _get_properties(conn, "node-5")
assert all(p["visible"] is False for p in props)
await engine.dispose()
@pytest.mark.asyncio
async def test_migration_already_migrated_node_not_touched():
"""Node that already has properties is skipped — existing properties preserved."""
existing = [{"key": "GPU", "value": "RTX 4090", "icon": "Monitor", "visible": True}]
engine = create_async_engine(TEST_DB_URL)
async with engine.begin() as conn:
await _setup_legacy_table(conn)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware, properties) VALUES (?, ?, ?, ?, ?)",
("node-6", "i9-13900K", 64.0, 1, json.dumps(existing)),
)
await _run_migration(conn)
props = await _get_properties(conn, "node-6")
assert props == existing
await engine.dispose()
+2 -1
View File
@@ -322,7 +322,8 @@ async def test_stop_scan_requires_auth(client: AsyncClient):
@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)
import uuid as _uuid
res = await client.post(f"/api/v1/scan/{_uuid.uuid4()}/stop", headers=headers)
assert res.status_code == 404
+535
View File
@@ -0,0 +1,535 @@
"""Tests for scanner: two-phase nmap, mDNS discovery, run_scan integration."""
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from sqlalchemy import select as sa_select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from app.db.database import Base
from app.db.models import Node, 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:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
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"])
# ---------------------------------------------------------------------------
# _ping_sweep
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_ping_sweep_returns_alive_hosts():
from app.services.scanner import _ping_sweep
async def fake_ping(ip: str) -> str | None:
return ip if ip in {"192.168.1.1", "192.168.1.2"} else None
with patch("app.services.scanner._ping_sweep", wraps=None):
pass # just ensure import is fine
# Patch asyncio.create_subprocess_exec to simulate ping responses
responding = {"192.168.1.1", "192.168.1.2"}
async def mock_subprocess(*args, **kwargs):
ip = args[-1]
proc = MagicMock()
proc.returncode = 0 if ip in responding else 1
proc.wait = AsyncMock(return_value=proc.returncode)
return proc
with patch("asyncio.create_subprocess_exec", side_effect=mock_subprocess), \
patch("app.services.scanner._arp_table_hosts", return_value={}), \
patch("app.services.scanner._resolve_hostname", return_value=None):
result = await _ping_sweep("192.168.1.0/30") # .1 .2 only in /30
assert "192.168.1.1" in result
assert "192.168.1.2" in result
for host in result.values():
assert host["open_ports"] == []
@pytest.mark.asyncio
async def test_ping_sweep_excludes_non_responding():
from app.services.scanner import _ping_sweep
async def mock_subprocess(*args, **kwargs):
ip = args[-1]
proc = MagicMock()
proc.returncode = 0 if ip == "192.168.1.1" else 1
proc.wait = AsyncMock(return_value=proc.returncode)
return proc
with patch("asyncio.create_subprocess_exec", side_effect=mock_subprocess), \
patch("app.services.scanner._arp_table_hosts", return_value={}), \
patch("app.services.scanner._resolve_hostname", return_value=None):
result = await _ping_sweep("192.168.1.0/30")
assert "192.168.1.1" in result
assert "192.168.1.2" not in result
@pytest.mark.asyncio
async def test_ping_sweep_supplements_with_arp_cache():
"""Devices that block ICMP but appear in ARP cache should still be discovered."""
from app.services.scanner import _ping_sweep
async def mock_subprocess(*args, **kwargs):
proc = MagicMock()
proc.returncode = 1 # all pings fail
proc.wait = AsyncMock(return_value=1)
return proc
arp_extra = {
"192.168.1.10": {"ip": "192.168.1.10", "mac": "aa:bb:cc:dd:ee:10", "hostname": None, "os": None, "open_ports": []},
}
with patch("asyncio.create_subprocess_exec", side_effect=mock_subprocess), \
patch("app.services.scanner._arp_table_hosts", return_value=arp_extra), \
patch("app.services.scanner._resolve_hostname", return_value=None):
result = await _ping_sweep("192.168.1.0/24")
assert "192.168.1.10" in result
assert result["192.168.1.10"]["mac"] == "aa:bb:cc:dd:ee:10"
@pytest.mark.asyncio
async def test_ping_sweep_enriches_mac_from_arp_cache():
"""Ping-alive hosts with no ARP entry get their MAC from the ARP cache."""
from app.services.scanner import _ping_sweep
async def mock_subprocess(*args, **kwargs):
ip = args[-1]
proc = MagicMock()
proc.returncode = 0 if ip == "192.168.1.1" else 1
proc.wait = AsyncMock(return_value=proc.returncode)
return proc
arp_extra = {
"192.168.1.1": {"ip": "192.168.1.1", "mac": "de:ad:be:ef:00:01", "hostname": None, "os": None, "open_ports": []},
}
with patch("asyncio.create_subprocess_exec", side_effect=mock_subprocess), \
patch("app.services.scanner._arp_table_hosts", return_value=arp_extra), \
patch("app.services.scanner._resolve_hostname", return_value=None):
result = await _ping_sweep("192.168.1.0/30")
assert result["192.168.1.1"]["mac"] == "de:ad:be:ef:00:01"
# ---------------------------------------------------------------------------
# _arp_table_hosts
# ---------------------------------------------------------------------------
def test_arp_table_hosts_parses_proc_net_arp():
import io # noqa: PLC0415
from app.services.scanner import _arp_table_hosts
arp_content = (
"IP address HW type Flags HW address Mask Device\n"
"192.168.1.1 0x1 0x2 aa:bb:cc:dd:ee:01 * eth0\n"
"192.168.1.50 0x1 0x2 aa:bb:cc:dd:ee:02 * eth0\n"
"10.0.0.1 0x1 0x2 aa:bb:cc:dd:ee:03 * eth0\n" # outside subnet
"192.168.1.99 0x1 0x2 00:00:00:00:00:00 * eth0\n" # incomplete
)
mock_file = MagicMock()
mock_file.__enter__ = MagicMock(return_value=io.StringIO(arp_content))
mock_file.__exit__ = MagicMock(return_value=False)
with patch("builtins.open", return_value=mock_file), \
patch("app.services.scanner._resolve_hostname", return_value=None):
result = _arp_table_hosts("192.168.1.0/24")
assert "192.168.1.1" in result
assert "192.168.1.50" in result
assert "10.0.0.1" not in result # outside target subnet
assert "192.168.1.99" not in result # zero MAC skipped
def test_arp_table_hosts_parses_macos_arp_output():
from app.services.scanner import _arp_table_hosts
arp_output = (
"router.lan (192.168.1.1) at aa:bb:cc:dd:ee:01 on en0 ifscope [ethernet]\n"
"device.lan (192.168.1.20) at aa:bb:cc:dd:ee:02 on en0 ifscope [ethernet]\n"
"? (192.168.1.99) at (incomplete) on en0 ifscope [ethernet]\n"
"? (10.0.0.1) at aa:bb:cc:dd:ee:04 on en0 ifscope [ethernet]\n" # outside subnet
)
mock_result = MagicMock()
mock_result.stdout = arp_output
with patch("builtins.open", side_effect=FileNotFoundError), \
patch("subprocess.run", return_value=mock_result), \
patch("app.services.scanner._resolve_hostname", return_value=None):
result = _arp_table_hosts("192.168.1.0/24")
assert "192.168.1.1" in result
assert "192.168.1.20" in result
assert "192.168.1.99" not in result # incomplete MAC
assert "10.0.0.1" not in result # outside subnet
# ---------------------------------------------------------------------------
# _nmap_scan_single (Phase 2 per-IP worker)
# ---------------------------------------------------------------------------
def test_nmap_scan_single_detects_open_ports():
from app.services.scanner import _nmap_scan_single
host = {"ip": "192.168.1.10", "hostname": None, "mac": None, "os": None, "open_ports": []}
# Build a realistic host entry: protocols → ports → port info
port_info = {80: {"state": "open", "product": "nginx", "version": "1.24"}}
mock_host = MagicMock()
mock_host.all_protocols.return_value = ["tcp"]
mock_host.__getitem__ = MagicMock(return_value=port_info)
mock_host.get.return_value = {}
mock_nm = MagicMock()
mock_nm.all_hosts.return_value = ["192.168.1.10"]
mock_nm.__getitem__ = MagicMock(return_value=mock_host)
with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm), \
patch("app.services.scanner._extract_os", return_value=None):
result = _nmap_scan_single(host)
assert len(result["open_ports"]) == 1
assert result["open_ports"][0]["port"] == 80
assert result["open_ports"][0]["banner"] == "nginx 1.24"
def test_nmap_scan_single_returns_host_unchanged_on_error():
from app.services.scanner import _nmap_scan_single
host = {"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_scan_single(host)
assert result["ip"] == "192.168.1.20"
assert result["open_ports"] == []
def test_nmap_scan_single_returns_host_unchanged_when_no_results():
"""Host confirmed alive in Phase 1 but all ports filtered — keep it with empty ports."""
from app.services.scanner import _nmap_scan_single
host = {"ip": "192.168.1.30", "hostname": "shelly1.lan", "mac": "34:94:54:aa:bb:cc", "os": None, "open_ports": []}
mock_nm = MagicMock()
mock_nm.all_hosts.return_value = [] # no results
with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm):
result = _nmap_scan_single(host)
assert result["ip"] == "192.168.1.30"
assert result["open_ports"] == []
assert result["mac"] == "34:94:54:aa:bb:cc" # preserved from Phase 1
# ---------------------------------------------------------------------------
# _nmap_scan
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async 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 = await _nmap_scan("192.168.1.0/24")
assert len(result) == 1
assert result[0]["ip"] == "192.168.1.99"
@pytest.mark.asyncio
async def test_nmap_scan_raises_on_sweep_error():
from app.services.scanner import _nmap_scan
with patch("app.services.scanner._ping_sweep", side_effect=Exception("ping sweep failed")), \
pytest.raises(RuntimeError, match="ping sweep failed"):
await _nmap_scan("192.168.1.0/24")
# ---------------------------------------------------------------------------
# _mdns_discover
# ---------------------------------------------------------------------------
@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."
# ---------------------------------------------------------------------------
# _nmap_port_scan (Phase 2 concurrency)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_nmap_port_scan_returns_empty_when_no_alive_hosts():
from app.services.scanner import _nmap_port_scan
result = await _nmap_port_scan({})
assert result == []
@pytest.mark.asyncio
async def test_nmap_port_scan_tolerates_single_host_exception():
"""A single per-host failure should not abort the entire Phase 2 gather."""
from app.services.scanner import _nmap_port_scan
hosts = {
"192.168.1.1": {"ip": "192.168.1.1", "hostname": None, "mac": None, "os": None, "open_ports": []},
"192.168.1.2": {"ip": "192.168.1.2", "hostname": None, "mac": None, "os": None, "open_ports": []},
}
call_count = 0
def _flaky_scan(host_dict):
nonlocal call_count
call_count += 1
if host_dict["ip"] == "192.168.1.1":
raise RuntimeError("simulated nmap crash")
return host_dict
with patch("app.services.scanner._nmap_scan_single", side_effect=_flaky_scan), \
patch("app.services.scanner._NMAP_AVAILABLE", True):
result = await _nmap_port_scan(hosts)
assert call_count == 2
# The crashing host is dropped; the healthy one survives
assert len(result) == 1
assert result[0]["ip"] == "192.168.1.2"
# ---------------------------------------------------------------------------
# run_scan integration
# ---------------------------------------------------------------------------
@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(sa_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:
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"
assert device.discovery_source == "mdns"
@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:
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.10"))
devices = result.scalars().all()
assert len(devices) == 1 # not duplicated
@pytest.mark.asyncio
async def test_run_scan_skips_canvas_nodes(mem_db):
"""Hosts already approved onto the canvas must be skipped."""
from app.services.scanner import run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
canvas_node = Node(
id=str(uuid.uuid4()), label="PVE", type="proxmox",
ip="192.168.1.100", status="online",
)
session.add(canvas_node)
await session.commit()
nmap_hosts = [{"ip": "192.168.1.100", "hostname": "pve.lan", "mac": None, "os": None, "open_ports": []}]
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.100"))
assert result.scalar_one_or_none() is None
@pytest.mark.asyncio
async def test_run_scan_skips_hidden_devices(mem_db):
"""Hosts hidden by the user must not re-appear in pending."""
from app.services.scanner import run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
hidden = PendingDevice(ip="192.168.1.55", status="hidden")
session.add(hidden)
await session.commit()
nmap_hosts = [{"ip": "192.168.1.55", "hostname": None, "mac": None, "os": None, "open_ports": []}]
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
result = await session.execute(
sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.55", PendingDevice.status == "pending")
)
assert result.scalar_one_or_none() is None
@pytest.mark.asyncio
async def test_run_scan_cancelled_marks_status_cancelled(mem_db):
"""Cancelling a running scan sets the ScanRun status to 'cancelled'."""
from app.services.scanner import request_cancel, run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
await session.commit()
request_cancel(run_id)
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", return_value=[]), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
run = await session.get(ScanRun, run_id)
assert run is not None
assert run.status == "cancelled"
+43 -1
View File
@@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.services.status_checker import _tcp_connect, check_node
from app.services.status_checker import _ping, _tcp_connect, check_node
# --- check_node dispatcher ---
@@ -149,6 +149,48 @@ async def test_check_node_exception_returns_offline():
assert result["response_time_ms"] is None
# --- _ping platform args ---
@pytest.mark.asyncio
async def test_ping_uses_unix_args_on_non_windows():
captured = {}
async def fake_exec(*args, **kwargs):
captured["args"] = args
proc = MagicMock()
proc.returncode = 0
proc.wait = AsyncMock()
return proc
with patch("app.services.status_checker.sys.platform", "linux"), \
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
await _ping("192.168.1.1")
assert "-c" in captured["args"]
assert "-W" in captured["args"]
assert "-n" not in captured["args"]
@pytest.mark.asyncio
async def test_ping_uses_windows_args_on_win32():
captured = {}
async def fake_exec(*args, **kwargs):
captured["args"] = args
proc = MagicMock()
proc.returncode = 0
proc.wait = AsyncMock()
return proc
with patch("app.services.status_checker.sys.platform", "win32"), \
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
await _ping("192.168.1.1")
assert "-n" in captured["args"]
assert "-w" in captured["args"]
assert "-c" not in captured["args"]
# --- _tcp_connect ---
@pytest.mark.asyncio
+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="128" height="128" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="16" height="16" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="256" height="256" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="32" height="32" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="512" height="512" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+46
View File
@@ -0,0 +1,46 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<!-- Background -->
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<!-- House body -->
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<!-- Floor line (subtle) -->
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<!-- Door -->
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<!-- Network lines (drawn under nodes) -->
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<!-- Center hub glow -->
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<!-- Center hub -->
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<!-- Left node -->
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<!-- Right node -->
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<!-- Top node -->
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+33
View File
@@ -0,0 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 72" width="640" height="164" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<g transform="translate(4, 4)">
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</g>
<text x="80" y="42" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="600" font-size="30" letter-spacing="-0.5">
<tspan fill="#e6edf3">Home</tspan><tspan fill="#00d4ff">lable</tspan>
</text>
<text x="81" y="58" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="400" font-size="11" fill="#8b949e" letter-spacing="0.5">HomeLab Visualizer</text>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+33
View File
@@ -0,0 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 72" width="360" height="92" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<g transform="translate(4, 4)">
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</g>
<text x="80" y="42" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="600" font-size="30" letter-spacing="-0.5">
<tspan fill="#e6edf3">Home</tspan><tspan fill="#00d4ff">lable</tspan>
</text>
<text x="81" y="58" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="400" font-size="11" fill="#8b949e" letter-spacing="0.5">HomeLab Visualizer</text>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+33
View File
@@ -0,0 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 72" width="200" height="51" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<g transform="translate(4, 4)">
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</g>
<text x="80" y="42" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="600" font-size="30" letter-spacing="-0.5">
<tspan fill="#e6edf3">Home</tspan><tspan fill="#00d4ff">lable</tspan>
</text>
<text x="81" y="58" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="400" font-size="11" fill="#8b949e" letter-spacing="0.5">HomeLab Visualizer</text>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+48
View File
@@ -0,0 +1,48 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 72" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<!-- Icon (72×72, scaled from 64 viewBox) -->
<g transform="translate(4, 4) scale(1)">
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</g>
<!-- Text -->
<text x="80" y="42"
font-family="Inter, system-ui, -apple-system, sans-serif"
font-weight="600"
font-size="30"
letter-spacing="-0.5">
<tspan fill="#e6edf3">Home</tspan><tspan fill="#00d4ff">lable</tspan>
</text>
<!-- Subtitle -->
<text x="81" y="58"
font-family="Inter, system-ui, -apple-system, sans-serif"
font-weight="400"
font-size="11"
fill="#8b949e"
letter-spacing="0.5">HomeLab Visualizer</text>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+793 -878
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.7.0",
"version": "1.9.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -53,7 +53,7 @@
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^28.1.0",
"lucide-react": "^0.577.0",
"lucide-react": "^1.7.0",
"tailwindcss": "^4.2.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
+64 -12
View File
@@ -33,6 +33,7 @@ import type { NodeData, EdgeData } from '@/types'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
const CONTAINER_MODE_TYPES = new Set<NodeData['type']>(['proxmox', 'docker_host'])
export default function App() {
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
@@ -44,6 +45,8 @@ export default function App() {
const [themeModalOpen, setThemeModalOpen] = useState(false)
const [searchOpen, setSearchOpen] = useState(false)
const [sidebarForceView, setSidebarForceView] = useState<'pending' | 'history' | undefined>(undefined)
const [highlightPendingId, setHighlightPendingId] = useState<string | undefined>(undefined)
const [shortcutsOpen, setShortcutsOpen] = useState(false)
const [addNodeOpen, setAddNodeOpen] = useState(false)
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
@@ -100,8 +103,8 @@ export default function App() {
// Build a map of proxmox container mode to know if children should be nested
const proxmoxContainerMap = new Map<string, boolean>(
(apiNodes as ApiNode[])
.filter((n) => n.type === 'proxmox' || n.type === 'group')
.map((n) => [n.id, n.type === 'group' ? true : n.container_mode !== false])
.filter((n) => n.type === 'group' || n.container_mode === true)
.map((n) => [n.id, true])
)
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
@@ -239,8 +242,8 @@ export default function App() {
snapshotHistory()
const existingNode = nodes.find((n) => n.id === editNodeId)
updateNode(editNodeId, data)
// If proxmox container_mode changed, apply structural changes (children parentId, node dimensions)
if (data.type === 'proxmox' && typeof data.container_mode === 'boolean') {
// If container_mode changed, apply structural changes (children parentId, node dimensions)
if (typeof data.container_mode === 'boolean') {
setProxmoxContainerMode(editNodeId, data.container_mode)
}
// Sync virtual edge when parent_id changes on an LXC/VM node
@@ -258,10 +261,13 @@ export default function App() {
)
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) {
// Pass type as extra field — canvasStore.onConnect casts to Connection & Partial<EdgeData>
onConnect({ source: editNodeId, sourceHandle: 'top', target: newParentId, targetHandle: 'bottom', type: 'virtual' } as unknown as Connection)
const parentNode = nodes.find((n) => n.id === newParentId)
if (!parentNode?.data.container_mode) {
onConnect({ source: editNodeId, sourceHandle: 'top', target: newParentId, targetHandle: 'bottom', type: 'virtual' } as unknown as Connection)
}
}
}
}
@@ -338,6 +344,10 @@ export default function App() {
setEditEdgeId(edge.id)
}, [])
const handleNodeDoubleClick = useCallback((node: Node<NodeData>) => {
handleEditNode(node.id)
}, [handleEditNode])
const handleEdgeUpdate = useCallback((data: EdgeData) => {
if (!editEdgeId) return
snapshotHistory()
@@ -352,6 +362,13 @@ export default function App() {
setEditEdgeId(null)
}, [editEdgeId, deleteEdge, snapshotHistory])
const handleClearWaypoints = useCallback(() => {
if (!editEdgeId) return
snapshotHistory()
updateEdge(editEdgeId, { waypoints: [] })
setEditEdgeId(null)
}, [editEdgeId, updateEdge, snapshotHistory])
const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null
const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null
@@ -367,6 +384,8 @@ export default function App() {
onScan={() => setScanConfigOpen(true)}
onSave={handleSave}
onNodeApproved={setEditNodeId}
forceView={sidebarForceView}
highlightPendingId={highlightPendingId}
/>
<div className="flex flex-col flex-1 min-w-0">
<Toolbar
@@ -383,7 +402,20 @@ export default function App() {
/>
<div className="flex flex-1 min-h-0">
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
<CanvasContainer onConnect={handleEdgeConnect} onEdgeDoubleClick={handleEdgeDoubleClick} onNodeDragStart={snapshotHistory} />
<CanvasContainer
onConnect={handleEdgeConnect}
onEdgeDoubleClick={handleEdgeDoubleClick}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeDragStart={snapshotHistory}
onOpenPending={(deviceId) => {
setHighlightPendingId(undefined)
setSidebarForceView(undefined)
setTimeout(() => {
setHighlightPendingId(deviceId)
setSidebarForceView('pending')
}, 0)
}}
/>
</div>
{(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
</div>
@@ -395,7 +427,9 @@ export default function App() {
onClose={() => setAddNodeOpen(false)}
onSubmit={handleAddNode}
title="Add Node"
proxmoxNodes={nodes.filter((n) => n.type === 'proxmox').map((n) => ({ id: n.id, label: n.data.label }))}
parentContainerNodes={nodes
.filter((n) => CONTAINER_MODE_TYPES.has(n.data.type) && n.data.container_mode)
.map((n) => ({ id: n.id, label: n.data.label }))}
/>
{/* key forces re-mount when editing a different node, resetting form state */}
@@ -406,7 +440,9 @@ export default function App() {
onSubmit={handleUpdateNode}
initial={editNode?.data}
title="Edit Node"
proxmoxNodes={nodes.filter((n) => n.type === 'proxmox').map((n) => ({ id: n.id, label: n.data.label }))}
parentContainerNodes={nodes
.filter((n) => n.id !== editNodeId && CONTAINER_MODE_TYPES.has(n.data.type) && n.data.container_mode)
.map((n) => ({ id: n.id, label: n.data.label }))}
/>
<EdgeModal
@@ -427,6 +463,7 @@ export default function App() {
onClose={() => setEditEdgeId(null)}
onSubmit={handleEdgeUpdate}
onDelete={handleEdgeDelete}
onClearWaypoints={handleClearWaypoints}
initial={editEdge?.data}
title="Edit Link"
/>
@@ -435,7 +472,11 @@ export default function App() {
<ScanConfigModal
open={scanConfigOpen}
onClose={() => setScanConfigOpen(false)}
onScanNow={() => toast.success('Scan triggered')}
onScanNow={() => {
toast.success('Network scan started — check Scan History for results')
setSidebarForceView(undefined)
setTimeout(() => setSidebarForceView('history'), 0)
}}
/>
)}
@@ -481,7 +522,18 @@ export default function App() {
onClose={() => setThemeModalOpen(false)}
/>
<SearchModal open={searchOpen} onClose={() => setSearchOpen(false)} />
<SearchModal
open={searchOpen}
onClose={() => setSearchOpen(false)}
onOpenPending={(deviceId) => {
setHighlightPendingId(undefined)
setSidebarForceView(undefined)
setTimeout(() => {
setHighlightPendingId(deviceId)
setSidebarForceView('pending')
}, 0)
}}
/>
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
<Toaster theme="dark" position="bottom-right" />
+1
View File
@@ -56,6 +56,7 @@ export const scanApi = {
pending: () => api.get('/scan/pending'),
hidden: () => api.get('/scan/hidden'),
runs: () => api.get('/scan/runs'),
clearPending: () => api.delete('/scan/pending'),
approve: (id: string, nodeData: object) => api.post(`/scan/pending/${id}/approve`, nodeData),
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
+12 -2
View File
@@ -18,6 +18,7 @@ import {
BackgroundVariant,
Controls,
ConnectionMode,
useReactFlow,
type Node,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
@@ -36,7 +37,8 @@ const STORAGE_KEY = 'homelable_canvas'
type ViewState = 'loading' | 'disabled' | 'invalid-key' | 'no-key' | 'network-error' | 'ready'
function LiveViewCanvas() {
const { nodes, edges, loadCanvas } = useCanvasStore()
const { nodes, edges, loadCanvas, fitViewPending, clearFitViewPending } = useCanvasStore()
const { fitView } = useReactFlow()
const activeTheme = useThemeStore((s) => s.activeTheme)
const theme = THEMES[activeTheme]
// Derive initial view state synchronously (avoids calling setState inside an effect):
@@ -87,6 +89,15 @@ function LiveViewCanvas() {
})
}, [loadCanvas])
useEffect(() => {
if (!fitViewPending || nodes.length === 0) return
const id = setTimeout(() => {
fitView({ padding: 0.12, duration: 350 })
clearFitViewPending()
}, 50)
return () => clearTimeout(id)
}, [fitViewPending, nodes.length, fitView, clearFitViewPending])
const onNodeClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => {
const ip = node.data.ip
if (ip) window.open(`http://${ip}`, '_blank', 'noopener,noreferrer')
@@ -129,7 +140,6 @@ function LiveViewCanvas() {
elementsSelectable={false}
panOnDrag
zoomOnScroll
fitView
colorMode={theme.colors.reactFlowColorMode}
connectionMode={ConnectionMode.Loose}
onNodeClick={onNodeClick}
@@ -11,6 +11,7 @@ vi.mock('@xyflow/react', () => ({
Controls: () => null,
BackgroundVariant: { Dots: 'dots' },
ConnectionMode: { Loose: 'loose' },
useReactFlow: () => ({ fitView: vi.fn() }),
}))
vi.mock('@xyflow/react/dist/style.css', () => ({}))
@@ -50,6 +51,8 @@ describe('LiveView (non-standalone)', () => {
useCanvasStore.setState({ nodes: [], edges: [] })
})
afterEach(() => { setSearch('') })
// ── No key ────────────────────────────────────────────────────────────────
it('shows no-key error when ?key= is missing', async () => {
@@ -133,11 +136,25 @@ describe('LiveView (non-standalone)', () => {
// ── Standalone mode ────────────────────────────────────────────────────────
const XYFLOW_MOCK = {
ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
ReactFlow: () => <div data-testid="react-flow" />,
Background: () => null,
Controls: () => null,
BackgroundVariant: { Dots: 'dots' },
ConnectionMode: { Loose: 'loose' },
useReactFlow: () => ({ fitView: vi.fn() }),
}
describe('LiveView (standalone — localStorage)', () => {
beforeEach(() => {
localStorage.clear()
useCanvasStore.setState({ nodes: [], edges: [] })
vi.mocked(liveviewApi.load).mockReset()
})
afterEach(() => {
setSearch('')
vi.unstubAllEnvs()
})
it('loads canvas from localStorage without calling the API', async () => {
@@ -151,25 +168,12 @@ describe('LiveView (standalone — localStorage)', () => {
}
localStorage.setItem('homelable_canvas', JSON.stringify(stored))
// Stub VITE_STANDALONE before re-importing
vi.stubEnv('VITE_STANDALONE', 'true')
vi.resetModules()
const { default: LiveViewStandalone } = await import('../LiveView')
setSearch('') // no key needed in standalone
render(<LiveViewStandalone />)
await waitFor(() => {
expect(screen.getByTestId('react-flow')).toBeDefined()
})
expect(liveviewApi.load).not.toHaveBeenCalled()
vi.unstubAllEnvs()
})
it('shows canvas (empty) when localStorage has no saved data', async () => {
vi.stubEnv('VITE_STANDALONE', 'true')
vi.resetModules()
const mockLoad = vi.fn()
vi.doMock('@xyflow/react', () => XYFLOW_MOCK)
vi.doMock('@xyflow/react/dist/style.css', () => ({}))
vi.doMock('@/api/client', () => ({ liveviewApi: { load: mockLoad } }))
const { default: LiveViewStandalone } = await import('../LiveView')
setSearch('')
@@ -178,8 +182,24 @@ describe('LiveView (standalone — localStorage)', () => {
await waitFor(() => {
expect(screen.getByTestId('react-flow')).toBeDefined()
})
expect(liveviewApi.load).not.toHaveBeenCalled()
expect(mockLoad).not.toHaveBeenCalled()
})
vi.unstubAllEnvs()
it('shows canvas (empty) when localStorage has no saved data', async () => {
vi.stubEnv('VITE_STANDALONE', 'true')
vi.resetModules()
const mockLoad = vi.fn()
vi.doMock('@xyflow/react', () => XYFLOW_MOCK)
vi.doMock('@xyflow/react/dist/style.css', () => ({}))
vi.doMock('@/api/client', () => ({ liveviewApi: { load: mockLoad } }))
const { default: LiveViewStandalone } = await import('../LiveView')
setSearch('')
render(<LiveViewStandalone />)
await waitFor(() => {
expect(screen.getByTestId('react-flow')).toBeDefined()
})
expect(mockLoad).not.toHaveBeenCalled()
})
})
@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import {
ReactFlow,
Background,
@@ -7,6 +7,7 @@ import {
BackgroundVariant,
ConnectionMode,
SelectionMode,
useReactFlow,
type Node,
type Edge,
type Connection,
@@ -24,16 +25,30 @@ import type { NodeData, EdgeData } from '@/types'
interface CanvasContainerProps {
onConnect?: (connection: Connection) => void
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
onNodeDoubleClick?: (node: Node<NodeData>) => void
onNodeDragStart?: () => void
onOpenPending?: (deviceId: string) => void
}
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart }: CanvasContainerProps) {
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onOpenPending }: CanvasContainerProps) {
const [lassoMode, setLassoMode] = useState(true)
const {
nodes, edges,
onNodesChange, onEdgesChange,
setSelectedNode, snapshotHistory,
fitViewPending, clearFitViewPending,
} = useCanvasStore()
const { fitView } = useReactFlow()
// Fit view after canvas loads (fitViewPending is set by loadCanvas)
useEffect(() => {
if (!fitViewPending || nodes.length === 0) return
const id = setTimeout(() => {
fitView({ padding: 0.12, duration: 350 })
clearFitViewPending()
}, 50)
return () => clearTimeout(id)
}, [fitViewPending, nodes.length, fitView, clearFitViewPending])
const activeTheme = useThemeStore((s) => s.activeTheme)
const theme = THEMES[activeTheme]
@@ -54,6 +69,20 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
onEdgeDoubleClick?.(edge)
}, [onEdgeDoubleClick])
const handleNodeDoubleClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => {
onNodeDoubleClick?.(node)
}, [onNodeDoubleClick])
const handleBeforeDelete = useCallback(async () => {
snapshotHistory()
return true
}, [snapshotHistory])
const isValidConnection = useCallback(
(connection: { source: string | null; target: string | null }) => connection.source !== connection.target,
[]
)
return (
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}>
<ReactFlow
@@ -65,31 +94,33 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
onEdgeDoubleClick={handleEdgeDoubleClick}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeDragStart={onNodeDragStart}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
deleteKeyCode={['Backspace', 'Delete']}
onBeforeDelete={async () => { snapshotHistory(); return true }}
onBeforeDelete={handleBeforeDelete}
selectionOnDrag={lassoMode}
panOnDrag={lassoMode ? [1, 2] : true}
panActivationKeyCode="Space"
selectionMode={SelectionMode.Partial}
multiSelectionKeyCode={['Meta', 'Control']}
minZoom={0.25}
maxZoom={2.5}
snapToGrid
snapGrid={[16, 16]}
fitView
snapGrid={[8, 8]}
colorMode={theme.colors.reactFlowColorMode}
elevateNodesOnSelect={false}
connectionMode={ConnectionMode.Loose}
isValidConnection={(connection) => connection.source !== connection.target}
isValidConnection={isValidConnection}
>
<Background
variant={BackgroundVariant.Dots}
gap={24}
gap={16}
size={1}
color={theme.colors.canvasDotColor}
/>
<SearchBar />
<SearchBar onOpenPending={onOpenPending} />
<Controls>
<ControlButton
onClick={() => setLassoMode((m) => !m)}
+64 -6
View File
@@ -2,15 +2,27 @@ import { useState, useEffect, useRef } from 'react'
import { useReactFlow } from '@xyflow/react'
import { Search, X } from 'lucide-react'
import { useCanvasStore } from '@/stores/canvasStore'
import { scanApi } from '@/api/client'
import { NODE_TYPE_LABELS } from '@/types'
import type { PendingDevice } from '@/components/modals/PendingDeviceModal'
export function SearchBar() {
interface SearchBarProps {
onOpenPending?: (deviceId: string) => void
}
export function SearchBar({ onOpenPending }: SearchBarProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [pendingDevices, setPendingDevices] = useState<PendingDevice[]>([])
const inputRef = useRef<HTMLInputElement>(null)
const { nodes, setSelectedNode } = useCanvasStore()
const { setCenter } = useReactFlow()
useEffect(() => {
if (!open) return
scanApi.pending().then((res) => setPendingDevices(res.data)).catch(() => {})
}, [open])
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
@@ -31,7 +43,7 @@ export function SearchBar() {
}, [open])
const q = query.toLowerCase().trim()
const results = q
const nodeResults = q
? nodes.filter((n) => {
if (n.data.type === 'groupRect') return false
return (
@@ -43,6 +55,19 @@ export function SearchBar() {
})
: []
const pendingResults = q
? pendingDevices.filter((d) =>
d.ip.toLowerCase().includes(q) ||
d.hostname?.toLowerCase().includes(q) ||
d.services.some((s) =>
s.service_name?.toLowerCase().includes(q) ||
s.category?.toLowerCase().includes(q)
)
).slice(0, 4)
: []
const totalResults = nodeResults.length + pendingResults.length
const goToNode = (id: string) => {
const node = nodes.find((n) => n.id === id)
if (!node) return
@@ -101,7 +126,7 @@ export function SearchBar() {
/>
{query && (
<span style={{ fontSize: 11, color: '#6e7681', flexShrink: 0 }}>
{results.length} result{results.length !== 1 ? 's' : ''}
{totalResults} result{totalResults !== 1 ? 's' : ''}
</span>
)}
<button
@@ -113,9 +138,9 @@ export function SearchBar() {
</button>
</div>
{results.length > 0 && (
{totalResults > 0 && (
<div style={{ borderTop: '1px solid #30363d', maxHeight: 260, overflowY: 'auto' }}>
{results.map((n) => (
{nodeResults.map((n) => (
<button
key={n.id}
onClick={() => goToNode(n.id)}
@@ -146,10 +171,43 @@ export function SearchBar() {
</span>
</button>
))}
{pendingResults.length > 0 && nodeResults.length > 0 && (
<div style={{ height: 1, background: '#30363d', margin: '2px 0' }} />
)}
{pendingResults.map((d) => {
const serviceName = d.services.find((s) => s.service_name)?.service_name
return (
<button
key={d.id}
onClick={() => { onOpenPending?.(d.id); setOpen(false); setQuery('') }}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '7px 12px',
background: 'none',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = '#21262d')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}
>
<span style={{ fontSize: 10, color: '#e3b341', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>pending</span>
<span style={{ fontSize: 12, fontWeight: 600, color: '#e6edf3', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{d.hostname ?? d.ip}
</span>
<span style={{ fontSize: 11, color: '#8b949e', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>
{serviceName ?? d.ip}
</span>
</button>
)
})}
</div>
)}
{q && results.length === 0 && (
{q && totalResults === 0 && (
<div style={{ borderTop: '1px solid #30363d', padding: '10px 12px', fontSize: 12, color: '#6e7681', textAlign: 'center' }}>
No results for &ldquo;{query}&rdquo;
</div>
@@ -0,0 +1,199 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Server } from 'lucide-react'
import { BaseNode } from '../nodes/BaseNode'
import type { NodeData } from '@/types'
import type { Node } from '@xyflow/react'
let mockZoom = 1
vi.mock('@xyflow/react', () => ({
Handle: () => null,
Position: { Top: 'top', Bottom: 'bottom' },
NodeResizer: () => null,
useUpdateNodeInternals: () => vi.fn(),
useViewport: () => ({ zoom: mockZoom }),
}))
vi.mock('@/stores/themeStore', () => ({
useThemeStore: (sel: (s: { activeTheme: string }) => unknown) => sel({ activeTheme: 'dark' }),
}))
vi.mock('@/stores/canvasStore', () => ({
useCanvasStore: (sel: (s: { hideIp: boolean }) => unknown) => sel({ hideIp: false }),
}))
vi.mock('@/utils/themes', () => ({
THEMES: {
dark: {
colors: {
statusColors: { online: '#39d353', offline: '#f85149', pending: '#e3b341', unknown: '#8b949e' },
nodeSubtextColor: '#8b949e',
nodeLabelColor: '#e6edf3',
nodeIconBackground: '#21262d',
handleBackground: '#30363d',
handleBorder: '#30363d',
},
},
},
}))
vi.mock('@/utils/nodeColors', () => ({
resolveNodeColors: () => ({ background: '#161b22', border: '#30363d', icon: '#00d4ff' }),
}))
vi.mock('@/utils/nodeIcons', () => ({
resolveNodeIcon: (_typeIcon: unknown) => _typeIcon,
}))
vi.mock('@/utils/maskIp', () => ({
maskIp: (ip: string) => ip,
}))
vi.mock('@/utils/propertyIcons', () => ({
resolvePropertyIcon: (icon: string | null) => icon ? Server : null,
}))
vi.mock('@/utils/handleUtils', () => ({
BOTTOM_HANDLE_IDS: ['bottom'],
BOTTOM_HANDLE_POSITIONS: { 1: [50] },
}))
beforeEach(() => { mockZoom = 1 })
function makeNode(data: Partial<NodeData>): Node<NodeData> {
return {
id: 'n1',
type: data.type ?? 'server',
position: { x: 0, y: 0 },
data: {
label: 'Test Node',
type: 'server',
status: 'online',
services: [],
...data,
},
}
}
function renderBaseNode(data: Partial<NodeData>) {
const node = makeNode(data)
return render(
<BaseNode
id={node.id}
data={node.data}
selected={false}
icon={Server}
type="server"
dragging={false}
zIndex={0}
isConnectable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
)
}
describe('BaseNode — borderWidth zoom scaling', () => {
beforeEach(() => { mockZoom = 1 })
it('borderWidth is 1px at zoom=1', () => {
mockZoom = 1
const { container } = renderBaseNode({})
expect((container.firstChild as HTMLElement).style.borderWidth).toBe('1px')
})
it('borderWidth scales to 2px at zoom=0.5', () => {
mockZoom = 0.5
const { container } = renderBaseNode({})
expect((container.firstChild as HTMLElement).style.borderWidth).toBe('2px')
})
it('borderWidth is clamped to 1px at zoom=2', () => {
mockZoom = 2
const { container } = renderBaseNode({})
expect((container.firstChild as HTMLElement).style.borderWidth).toBe('1px')
})
it('boxShadow glow ring uses borderWidth when selected + online at zoom=0.5', () => {
mockZoom = 0.5
const node = makeNode({ status: 'online' })
const { container } = render(
<BaseNode id={node.id} data={node.data} selected={true} icon={Server}
type="server" dragging={false} zIndex={0} isConnectable={true}
positionAbsoluteX={0} positionAbsoluteY={0} />
)
expect((container.firstChild as HTMLElement).style.boxShadow).toContain('0 0 0 2px')
})
})
describe('BaseNode — properties rendering', () => {
it('renders visible properties on the node', () => {
renderBaseNode({
properties: [
{ key: 'CPU Model', value: 'i7-12700K', icon: 'Cpu', visible: true },
{ key: 'RAM', value: '32 GB', icon: 'MemoryStick', visible: true },
],
})
expect(screen.getByText('CPU Model')).toBeDefined()
// Value is rendered with a middle-dot prefix: "· 32 GB"
expect(screen.getByText(/32 GB/)).toBeDefined()
})
it('does not render properties with visible=false', () => {
renderBaseNode({
properties: [
{ key: 'Secret', value: 'hidden', icon: null, visible: false },
],
})
expect(screen.queryByText('Secret')).toBeNull()
})
it('renders nothing when properties array is empty', () => {
const { container } = renderBaseNode({ properties: [] })
// No properties section — only the main node card
expect(container.querySelectorAll('.flex.flex-col.gap-1').length).toBe(0)
})
it('renders label and ip regardless of properties', () => {
renderBaseNode({
label: 'My Server',
ip: '192.168.1.10',
properties: [{ key: 'OS', value: 'Debian 12', icon: 'Server', visible: true }],
})
expect(screen.getByText('My Server')).toBeDefined()
expect(screen.getByText('192.168.1.10')).toBeDefined()
expect(screen.getByText('OS')).toBeDefined()
})
})
describe('BaseNode — legacy hardware fallback', () => {
it('renders legacy hardware when properties is undefined and show_hardware is true', () => {
renderBaseNode({
properties: undefined,
show_hardware: true,
cpu_model: 'Intel Xeon E5-2680',
ram_gb: 32,
})
expect(screen.getByText('Intel Xeon E5-2680')).toBeDefined()
})
it('does not render legacy hardware when properties array is present (even if empty)', () => {
renderBaseNode({
properties: [],
show_hardware: true,
cpu_model: 'Intel Xeon E5-2680',
})
// properties array exists → new system, legacy section skipped
expect(screen.queryByText('Intel Xeon E5-2680')).toBeNull()
})
it('does not render legacy hardware when show_hardware is false', () => {
renderBaseNode({
properties: undefined,
show_hardware: false,
cpu_model: 'Intel Xeon E5-2680',
})
expect(screen.queryByText('Intel Xeon E5-2680')).toBeNull()
})
})
@@ -20,6 +20,7 @@ vi.mock('@xyflow/react', () => ({
BackgroundVariant: { Dots: 'dots' },
ConnectionMode: { Loose: 'loose' },
SelectionMode: { Partial: 'partial' },
useReactFlow: () => ({ fitView: vi.fn() }),
}))
vi.mock('@xyflow/react/dist/style.css', () => ({}))
@@ -103,6 +104,24 @@ describe('CanvasContainer', () => {
}).not.toThrow()
})
// ── Node double-click ─────────────────────────────────────────────────────
it('calls onNodeDoubleClick prop when a node is double-clicked', () => {
const onNodeDoubleClick = vi.fn()
const node = makeNode('n1')
render(<CanvasContainer onNodeDoubleClick={onNodeDoubleClick} />)
;(rfProps.onNodeDoubleClick as (...args: unknown[]) => unknown)({} as MouseEvent, node)
expect(onNodeDoubleClick).toHaveBeenCalledWith(node)
})
it('does not throw when onNodeDoubleClick is not provided', () => {
const node = makeNode('n1')
render(<CanvasContainer />)
expect(() => {
;(rfProps.onNodeDoubleClick as (...args: unknown[]) => unknown)({} as MouseEvent, node)
}).not.toThrow()
})
// ── Connection validation ─────────────────────────────────────────────────
it('isValidConnection returns false for self-connections', () => {
@@ -141,9 +160,9 @@ describe('CanvasContainer', () => {
expect(rfProps.snapToGrid).toBe(true)
})
it('sets snapGrid to [16, 16]', () => {
it('sets snapGrid to [8, 8]', () => {
render(<CanvasContainer />)
expect(rfProps.snapGrid).toEqual([16, 16])
expect(rfProps.snapGrid).toEqual([8, 8])
})
// ── Delete key ────────────────────────────────────────────────────────────
@@ -0,0 +1,175 @@
import { describe, it, expect } from 'vitest'
import { buildWaypointPath, distToSegment, findInsertIndex, snap45, snap45both } from '../waypointUtils'
describe('buildWaypointPath — bezier (default)', () => {
it('builds a catmull-rom curve with no waypoints (start = end clamp)', () => {
// With only 2 pts (src + target), catmull-rom = cubic bezier
const path = buildWaypointPath(0, 0, [], 100, 100)
expect(path).toMatch(/^M 0 0 C/)
})
it('routes through a single waypoint with smooth curve', () => {
const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }], 100, 100)
expect(path).toMatch(/^M 0 0 C/)
// Should not be a straight polyline
expect(path).not.toContain(' L ')
})
it('routes through multiple waypoints', () => {
const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100)
expect(path).toMatch(/^M 0 0 C/)
})
})
describe('buildWaypointPath — smooth style', () => {
it('builds a direct straight line with no waypoints (no bend)', () => {
// Only 2 points → no intermediate vertex → no rounding needed
expect(buildWaypointPath(0, 0, [], 100, 100, 'smooth')).toBe('M 0 0 L 100 100')
})
it('routes through a single waypoint with straight lines (no intermediate bend)', () => {
// 3 pts: src → wp → target — only 1 intermediate → rounded corners at wp
const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }], 100, 100, 'smooth')
// Should start at source and end at target
expect(path).toMatch(/^M 0 0/)
expect(path).toMatch(/100 100$/)
// Should contain a quadratic bezier at the waypoint corner
expect(path).toContain('Q')
})
it('routes through multiple waypoints with rounded corners', () => {
const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100, 'smooth')
expect(path).toMatch(/^M 0 0/)
expect(path).toMatch(/100 100$/)
expect(path).toContain('Q')
})
it('does not round corners when segment is too short (r clamped to 0)', () => {
// Adjacent waypoints very close together — r → 0, falls back to L
const path = buildWaypointPath(0, 0, [{ x: 1, y: 0 }, { x: 2, y: 0 }], 100, 0, 'smooth')
expect(path).toMatch(/^M 0 0/)
})
})
describe('snap45', () => {
// Use positions very close to a 45° angle so deviation < SNAP_THRESHOLD (15px)
it('snaps horizontal direction when close (deviation < threshold)', () => {
// (100, 3) — nearly horizontal, deviation from 0° ≈ 3px → snaps
const r = snap45({ x: 0, y: 0 }, { x: 100, y: 3 })
expect(r.y).toBe(0)
expect(r.x).toBeGreaterThan(0)
})
it('snaps vertical direction when close', () => {
const r = snap45({ x: 0, y: 0 }, { x: 3, y: 100 })
expect(r.x).toBe(0)
expect(r.y).toBeGreaterThan(0)
})
it('snaps 45° diagonal when close', () => {
// (80, 83) — nearly 45°, deviation ≈ 2px → snaps
const r = snap45({ x: 0, y: 0 }, { x: 80, y: 83 })
expect(r.x).toBe(r.y)
})
it('does NOT snap when deviation exceeds threshold', () => {
// (100, 40) — deviation from 0° is ~40px > 15 → no snap
const pos = { x: 100, y: 40 }
const r = snap45({ x: 0, y: 0 }, pos)
expect(r).toEqual(pos)
})
it('returns pos unchanged when distance < 1', () => {
const pos = { x: 5, y: 5 }
expect(snap45({ x: 5, y: 5 }, pos)).toBe(pos)
})
it('preserves distance from origin when snapping', () => {
const from = { x: 0, y: 0 }
const pos = { x: 100, y: 3 } // close to horizontal
const r = snap45(from, pos)
const origDist = Math.hypot(pos.x - from.x, pos.y - from.y)
const snapDist = Math.hypot(r.x - from.x, r.y - from.y)
expect(snapDist).toBeCloseTo(origDist, 0)
})
})
describe('snap45both', () => {
it('finds intersection satisfying 45° from both adjacent points (axis-aligned)', () => {
// prev=(0,0), next=(100,100): diagonal — midpoint (50,50) should satisfy both
const r = snap45both({ x: 0, y: 0 }, { x: 100, y: 100 }, { x: 50, y: 50 })
// Result must be on a 45°-ray from (0,0)
const a1 = Math.atan2(r.y - 0, r.x - 0) / (Math.PI / 4)
expect(Math.abs(a1 - Math.round(a1))).toBeLessThan(0.05)
// Result must be on a 45°-ray from (100,100)
const a2 = Math.atan2(r.y - 100, r.x - 100) / (Math.PI / 4)
expect(Math.abs(a2 - Math.round(a2))).toBeLessThan(0.05)
})
it('snaps so both incoming and outgoing segments are at 45° when within threshold', () => {
// prev=(0,0), next=(200,0) — valid intersection at (100,100) (45° from each)
// pos=(100,93) is 7px away → within 15px threshold → should snap to (100,100)
const r = snap45both({ x: 0, y: 0 }, { x: 200, y: 0 }, { x: 100, y: 93 })
const a1 = Math.atan2(r.y - 0, r.x - 0) / (Math.PI / 4)
expect(Math.abs(a1 - Math.round(a1))).toBeLessThan(0.05)
const a2 = Math.atan2(r.y - 0, r.x - 200) / (Math.PI / 4)
expect(Math.abs(a2 - Math.round(a2))).toBeLessThan(0.05)
})
it('returns raw pos when beyond threshold', () => {
// pos=(100,80) is 20px from nearest intersection (100,100) → no snap
const pos = { x: 100, y: 80 }
const r = snap45both({ x: 0, y: 0 }, { x: 200, y: 0 }, pos)
expect(r).toEqual(pos)
})
it('falls back gracefully when prev === next', () => {
// No valid intersection → fallback to snap45
const r = snap45both({ x: 50, y: 50 }, { x: 50, y: 50 }, { x: 100, y: 90 })
expect(r).toBeDefined()
})
})
describe('distToSegment', () => {
it('returns 0 when point is on the segment', () => {
expect(distToSegment({ x: 50, y: 0 }, { x: 0, y: 0 }, { x: 100, y: 0 })).toBeCloseTo(0)
})
it('returns perpendicular distance when point is beside segment', () => {
expect(distToSegment({ x: 50, y: 10 }, { x: 0, y: 0 }, { x: 100, y: 0 })).toBeCloseTo(10)
})
it('returns distance to nearest endpoint when point is past the segment', () => {
expect(distToSegment({ x: 200, y: 0 }, { x: 0, y: 0 }, { x: 100, y: 0 })).toBeCloseTo(100)
})
it('handles zero-length segment (a === b)', () => {
expect(distToSegment({ x: 3, y: 4 }, { x: 0, y: 0 }, { x: 0, y: 0 })).toBeCloseTo(5)
})
})
describe('findInsertIndex', () => {
it('returns 0 when there are no waypoints (only one segment)', () => {
expect(findInsertIndex(0, 0, [], 100, 0, { x: 50, y: 5 })).toBe(0)
})
it('inserts before first waypoint when click is on first segment', () => {
const idx = findInsertIndex(0, 0, [{ x: 100, y: 0 }], 200, 0, { x: 30, y: 5 })
expect(idx).toBe(0)
})
it('inserts after first waypoint when click is on second segment', () => {
const idx = findInsertIndex(0, 0, [{ x: 100, y: 0 }], 200, 0, { x: 160, y: 5 })
expect(idx).toBe(1)
})
it('picks the closest segment among multiple', () => {
const idx = findInsertIndex(
0, 0,
[{ x: 100, y: 0 }, { x: 100, y: 100 }],
200, 100,
{ x: 150, y: 105 },
)
expect(idx).toBe(2)
})
})
+254 -12
View File
@@ -1,15 +1,19 @@
import { useCallback } from 'react'
import {
BaseEdge,
EdgeLabelRenderer,
getBezierPath,
getSmoothStepPath,
useReactFlow,
useStore,
type EdgeProps,
type Edge,
} from '@xyflow/react'
import type { EdgeData, EdgeType } from '@/types'
import type { EdgeData, EdgeType, Waypoint } from '@/types'
import { useThemeStore } from '@/stores/themeStore'
import { useCanvasStore } from '@/stores/canvasStore'
import { THEMES } from '@/utils/themes'
import { buildWaypointPath, snap45, snap45both } from './waypointUtils'
const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149']
@@ -18,6 +22,165 @@ function getVlanColor(vlanId?: number): string {
return VLAN_COLORS[vlanId % VLAN_COLORS.length]
}
// ── Waypoint drag handle ─────────────────────────────────────────────────────
interface WaypointHandleProps {
edgeId: string
index: number
waypoint: Waypoint
waypoints: Waypoint[]
color: string
pathStyle?: string
prevPoint: Waypoint
nextPoint: Waypoint
}
function WaypointHandle({ edgeId, index, waypoint, waypoints, color, pathStyle, prevPoint, nextPoint }: WaypointHandleProps) {
const { screenToFlowPosition } = useReactFlow()
const updateEdge = useCanvasStore((s) => s.updateEdge)
const handlePointerDown = useCallback((e: React.PointerEvent) => {
e.stopPropagation()
e.currentTarget.setPointerCapture(e.pointerId)
}, [])
const handlePointerMove = useCallback((e: React.PointerEvent) => {
if (e.buttons !== 1) return
let pos = screenToFlowPosition({ x: e.clientX, y: e.clientY })
if (pathStyle === 'smooth') {
// Find the intersection of 45°-rays from both adjacent points so that
// ALL segments (prev→this and this→next) snap to 45° simultaneously.
pos = snap45both(prevPoint, nextPoint, pos)
}
const next = [...waypoints]
next[index] = pos
updateEdge(edgeId, { waypoints: next })
}, [screenToFlowPosition, waypoints, index, edgeId, updateEdge, pathStyle, prevPoint, nextPoint])
const handlePointerUp = useCallback((e: React.PointerEvent) => {
e.currentTarget.releasePointerCapture(e.pointerId)
}, [])
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
updateEdge(edgeId, { waypoints: waypoints.filter((_, i) => i !== index) })
}, [edgeId, waypoints, index, updateEdge])
return (
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${waypoint.x}px, ${waypoint.y}px)`,
width: 10,
height: 10,
borderRadius: '50%',
background: color,
border: '2px solid #0d1117',
cursor: 'grab',
pointerEvents: 'all',
zIndex: 10,
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onDoubleClick={handleDoubleClick}
title="Drag to move · Double-click to remove"
/>
)
}
// ── Add waypoint handle (+ button at segment midpoints) ──────────────────────
interface AddWaypointHandleProps {
edgeId: string
insertIndex: number
x: number
y: number
waypoints: Waypoint[]
color: string
pathStyle?: string
prevPoint: Waypoint
}
function AddWaypointHandle({ edgeId, insertIndex, x, y, waypoints, color, pathStyle, prevPoint }: AddWaypointHandleProps) {
const updateEdge = useCanvasStore((s) => s.updateEdge)
const handleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
let pos = { x, y }
if (pathStyle === 'smooth') pos = snap45(prevPoint, pos)
const next = [...waypoints.slice(0, insertIndex), pos, ...waypoints.slice(insertIndex)]
updateEdge(edgeId, { waypoints: next })
}, [edgeId, insertIndex, x, y, waypoints, updateEdge, pathStyle, prevPoint])
return (
<div
onClick={handleClick}
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${x}px, ${y}px)`,
width: 14,
height: 14,
borderRadius: '50%',
background: '#0d1117',
border: `1.5px solid ${color}`,
color,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 12,
lineHeight: 1,
cursor: 'crosshair',
pointerEvents: 'all',
zIndex: 9,
opacity: 0.7,
}}
title="Click to add waypoint"
>
+
</div>
)
}
// ── Segment midpoints ────────────────────────────────────────────────────────
/**
* Compute + handle positions for each path segment.
* For smooth style: bias the first + handle to the source handle axis and the
* last + handle to the target handle axis, so clicking always gives a clean
* perpendicular exit/entry (no diagonal guesswork near the nodes).
*/
function segmentMidpoints(
sourceX: number, sourceY: number,
waypoints: Waypoint[],
targetX: number, targetY: number,
pathStyle?: string,
sourcePosition?: string,
): { x: number; y: number; insertIndex: number }[] {
const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }]
const isSmooth = pathStyle === 'smooth'
return pts.slice(0, -1).map((a, i) => {
const b = pts[i + 1]
let mx = (a.x + b.x) / 2
const my = (a.y + b.y) / 2
// For smooth style with no existing waypoints, bias the single + handle onto
// the source handle axis so clicking it creates a perpendicular exit.
// Only applies to bottom/top handles (vertical exits) and only when the edge
// has no waypoints yet — once waypoints exist, all + handles stay at the
// real segment midpoint so they remain visually on the edge.
if (isSmooth && i === 0 && pts.length === 2) {
const vertSrc = sourcePosition === 'bottom' || sourcePosition === 'top'
if (vertSrc) mx = a.x // same X as source → + sits directly below/above node
}
return { x: mx, y: my, insertIndex: i }
})
}
// ── Main edge component ──────────────────────────────────────────────────────
export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
const activeTheme = useThemeStore((s) => s.activeTheme)
const theme = THEMES[activeTheme]
@@ -25,11 +188,26 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
const targetType = useStore((s) => s.nodeLookup.get(target)?.type)
const isBidirectional = sourceType === 'proxmox' && targetType === 'proxmox'
const waypoints: Waypoint[] = Array.isArray(data?.waypoints) && data.waypoints.length > 0
? data.waypoints as Waypoint[]
: []
const hasWaypoints = waypoints.length > 0
const pathStyle = data?.path_style as string | undefined
const pathArgs = { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition }
const [edgePath, labelX, labelY] = data?.path_style === 'smooth'
const [autoPath, labelX] = pathStyle === 'smooth'
? getSmoothStepPath({ ...pathArgs, borderRadius: 8 })
: getBezierPath(pathArgs)
const edgePath = hasWaypoints
? buildWaypointPath(sourceX, sourceY, waypoints, targetX, targetY, pathStyle)
: autoPath
const midX = hasWaypoints ? (sourceX + targetX) / 2 : labelX
const midY = (sourceY + targetY) / 2
const edgeType: EdgeType = data?.type ?? 'ethernet'
const edgeColors = theme.colors.edgeColors
@@ -43,6 +221,11 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
}
const customColor = data?.custom_color as string | undefined
const strokeColor: string = selected
? theme.colors.edgeSelectedColor
: customColor
?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : (BASE_STYLES[edgeType].stroke as string ?? edgeColors.ethernet))
const style: React.CSSProperties = {
...BASE_STYLES[edgeType],
...(edgeType === 'vlan' ? { stroke: getVlanColor(data?.vlan_id as number | undefined) } : {}),
@@ -50,16 +233,36 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}),
}
// Normalize animated value — supports legacy boolean (true → 'snake')
const animMode: 'none' | 'snake' | 'flow' =
const animMode: 'none' | 'snake' | 'flow' | 'basic' =
data?.animated === true || data?.animated === 'snake' ? 'snake' :
data?.animated === 'flow' ? 'flow' : 'none'
data?.animated === 'flow' ? 'flow' :
data?.animated === 'basic' ? 'basic' : 'none'
const animColor = customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : edgeColors[edgeType as keyof typeof edgeColors] as string)
const midpoints = selected
? segmentMidpoints(sourceX, sourceY, waypoints, targetX, targetY, pathStyle, sourcePosition)
: []
return (
<>
<BaseEdge id={id} path={edgePath} style={style} />
<BaseEdge id={id} path={edgePath} style={animMode === 'basic' ? { ...style, stroke: 'transparent' } : style} interactionWidth={16} />
{animMode === 'basic' && (
<path
d={edgePath}
fill="none"
stroke={strokeColor}
strokeWidth={style.strokeWidth as number ?? 2}
strokeDasharray="5"
style={{
pointerEvents: 'none',
animation: 'homelable-basic-dash 0.5s linear infinite',
animationDirection: sourceY <= targetY ? 'normal' : 'reverse',
}}
/>
)}
{animMode === 'snake' && (
<path
d={edgePath}
@@ -92,12 +295,12 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
</path>
)}
{data?.label && (
<EdgeLabelRenderer>
<EdgeLabelRenderer>
{data?.label && (
<div
className="absolute pointer-events-none font-mono text-[10px] px-1 rounded"
className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded"
style={{
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
transform: `translate(-50%, -50%) translate(${midX}px, ${midY}px)`,
background: theme.colors.edgeLabelBackground,
color: theme.colors.edgeLabelColor,
border: `1px solid ${theme.colors.edgeLabelBorder}`,
@@ -105,8 +308,47 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
>
{data.label as string}
</div>
</EdgeLabelRenderer>
)}
)}
{/* Existing waypoint drag handles */}
{selected && waypoints.map((wp, idx) => {
const prevPoint = idx === 0 ? { x: sourceX, y: sourceY } : waypoints[idx - 1]
const nextPoint = idx === waypoints.length - 1 ? { x: targetX, y: targetY } : waypoints[idx + 1]
return (
<WaypointHandle
key={`wp-${idx}`}
edgeId={id}
index={idx}
waypoint={wp}
waypoints={waypoints}
color={strokeColor}
pathStyle={pathStyle}
prevPoint={prevPoint}
nextPoint={nextPoint}
/>
)
})}
{/* + handles at segment midpoints to add new waypoints */}
{selected && midpoints.map((mp) => {
const prevPoint = mp.insertIndex === 0
? { x: sourceX, y: sourceY }
: waypoints[mp.insertIndex - 1]
return (
<AddWaypointHandle
key={`add-${mp.insertIndex}`}
edgeId={id}
insertIndex={mp.insertIndex}
x={mp.x}
y={mp.y}
waypoints={waypoints}
color={strokeColor}
pathStyle={pathStyle}
prevPoint={prevPoint}
/>
)
})}
</EdgeLabelRenderer>
</>
)
}
@@ -0,0 +1,167 @@
import type { Waypoint } from '@/types'
// ── Path builders ─────────────────────────────────────────────────────────────
/** Catmull-Rom → cubic bezier for smooth curves through waypoints */
function buildCatmullRomPath(pts: Waypoint[]): string {
if (pts.length < 2) return `M ${pts[0].x} ${pts[0].y}`
let d = `M ${pts[0].x} ${pts[0].y}`
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[Math.max(i - 1, 0)]
const p1 = pts[i]
const p2 = pts[i + 1]
const p3 = pts[Math.min(i + 2, pts.length - 1)]
const cp1x = p1.x + (p2.x - p0.x) / 6
const cp1y = p1.y + (p2.y - p0.y) / 6
const cp2x = p2.x - (p3.x - p1.x) / 6
const cp2y = p2.y - (p3.y - p1.y) / 6
d += ` C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${p2.x} ${p2.y}`
}
return d
}
/** Polyline with rounded corners at each waypoint vertex (quadratic bezier) */
function buildRoundedPolylinePath(pts: Waypoint[], radius = 8): string {
if (pts.length < 2) return `M ${pts[0].x} ${pts[0].y}`
if (pts.length === 2) return `M ${pts[0].x} ${pts[0].y} L ${pts[1].x} ${pts[1].y}`
let d = `M ${pts[0].x} ${pts[0].y}`
for (let i = 1; i < pts.length - 1; i++) {
const prev = pts[i - 1]
const curr = pts[i]
const next = pts[i + 1]
const dx1 = curr.x - prev.x
const dy1 = curr.y - prev.y
const len1 = Math.hypot(dx1, dy1)
const dx2 = next.x - curr.x
const dy2 = next.y - curr.y
const len2 = Math.hypot(dx2, dy2)
if (len1 < 1 || len2 < 1) {
d += ` L ${curr.x} ${curr.y}`
continue
}
const r = Math.min(radius, len1 / 2, len2 / 2)
// Approach point (on segment prev→curr, r units before corner)
const bx = curr.x - (dx1 / len1) * r
const by = curr.y - (dy1 / len1) * r
// Departure point (on segment curr→next, r units after corner)
const ax = curr.x + (dx2 / len2) * r
const ay = curr.y + (dy2 / len2) * r
d += ` L ${bx} ${by} Q ${curr.x} ${curr.y} ${ax} ${ay}`
}
d += ` L ${pts[pts.length - 1].x} ${pts[pts.length - 1].y}`
return d
}
export function buildWaypointPath(
sourceX: number, sourceY: number,
waypoints: Waypoint[],
targetX: number, targetY: number,
pathStyle: string = 'bezier',
): string {
const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }]
return pathStyle === 'smooth' ? buildRoundedPolylinePath(pts) : buildCatmullRomPath(pts)
}
// ── 45° snapping ──────────────────────────────────────────────────────────────
/**
* Snap `pos` to the nearest 45°-multiple direction from `from`.
* Only snaps when within SNAP_THRESHOLD px of a 45° position.
*/
export function snap45(from: Waypoint, pos: Waypoint): Waypoint {
const dx = pos.x - from.x
const dy = pos.y - from.y
const dist = Math.hypot(dx, dy)
if (dist < 1) return pos
const angle = Math.atan2(dy, dx)
const snapped = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4)
const candidate = {
x: Math.round(from.x + dist * Math.cos(snapped)),
y: Math.round(from.y + dist * Math.sin(snapped)),
}
const deviation = Math.hypot(candidate.x - pos.x, candidate.y - pos.y)
return deviation <= SNAP_THRESHOLD ? candidate : pos
}
/** Snap threshold in flow-space pixels. Only snap when this close to a 45° position. */
const SNAP_THRESHOLD = 15
/**
* Find the position closest to `pos` that lies simultaneously on a 45°-ray
* from `prev` AND on a 45°-ray from `next`.
*
* Only snaps when the nearest valid intersection is within SNAP_THRESHOLD px —
* outside that zone the raw drag position is returned, allowing free placement.
*/
export function snap45both(prev: Waypoint, next: Waypoint, pos: Waypoint): Waypoint {
let best: Waypoint | null = null
let bestDist = Infinity
for (let i = 0; i < 8; i++) {
const a1 = i * Math.PI / 4
const c1 = Math.cos(a1), s1 = Math.sin(a1)
for (let j = 0; j < 8; j++) {
const a2 = j * Math.PI / 4
const c2 = Math.cos(a2), s2 = Math.sin(a2)
const dx = next.x - prev.x
const dy = next.y - prev.y
const det = -c1 * s2 + c2 * s1
if (Math.abs(det) < 1e-6) continue
const t = (-dx * s2 + c2 * dy) / det
const s = (c1 * dy - s1 * dx) / det
if (t < -1e-6 || s < -1e-6) continue
const ix = prev.x + t * c1
const iy = prev.y + t * s1
const d = Math.hypot(ix - pos.x, iy - pos.y)
if (d < bestDist) {
bestDist = d
best = { x: Math.round(ix), y: Math.round(iy) }
}
}
}
// Only snap if close enough — otherwise let the waypoint move freely
if (best === null || bestDist > SNAP_THRESHOLD) return pos
return best
}
// ── Geometry helpers ──────────────────────────────────────────────────────────
export function distToSegment(p: Waypoint, a: Waypoint, b: Waypoint): number {
const dx = b.x - a.x
const dy = b.y - a.y
const lenSq = dx * dx + dy * dy
if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y)
const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq))
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy))
}
export function findInsertIndex(
sourceX: number, sourceY: number,
waypoints: Waypoint[],
targetX: number, targetY: number,
point: Waypoint,
): number {
const allPts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }]
let minDist = Infinity
let best = 0
for (let i = 0; i < allPts.length - 1; i++) {
const d = distToSegment(point, allPts[i], allPts[i + 1])
if (d < minDist) { minDist = d; best = i }
}
return best
}
@@ -1,13 +1,15 @@
import { createElement } from 'react'
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
import { createElement, useEffect, useMemo } from 'react'
import { Handle, Position, NodeResizer, useUpdateNodeInternals, useViewport, type NodeProps, type Node } from '@xyflow/react'
import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react'
import type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors'
import { resolveNodeIcon } from '@/utils/nodeIcons'
import { resolvePropertyIcon } from '@/utils/propertyIcons'
import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
import { useCanvasStore } from '@/stores/canvasStore'
import { maskIp } from '@/utils/maskIp'
import { BOTTOM_HANDLE_IDS, BOTTOM_HANDLE_POSITIONS } from '@/utils/handleUtils'
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
icon: LucideIcon
@@ -18,7 +20,13 @@ function formatStorage(gb: number): string {
return `${gb} GB`
}
export function BaseNode({ data, selected, icon: typeIcon, width, height }: BaseNodeProps) {
export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: BaseNodeProps) {
const updateNodeInternals = useUpdateNodeInternals()
useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals])
const { zoom } = useViewport()
const borderWidth = useMemo(() => Math.max(1, 1 / zoom), [zoom])
const activeTheme = useThemeStore((s) => s.activeTheme)
const hideIp = useCanvasStore((s) => s.hideIp)
const theme = THEMES[activeTheme]
@@ -27,7 +35,11 @@ export function BaseNode({ data, selected, icon: typeIcon, width, height }: Base
const colors = resolveNodeColors(data, activeTheme)
const statusColor = theme.colors.statusColors[data.status]
const isOnline = data.status === 'online'
const showHardware = data.show_hardware && (data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null)
// Properties: prefer new system; fall back to legacy hardware fields for unmigrated nodes
const visibleProperties = data.properties?.filter((p) => p.visible) ?? null
const showLegacyHardware = !data.properties && data.show_hardware &&
(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null)
return (
<div
@@ -35,11 +47,13 @@ export function BaseNode({ data, selected, icon: typeIcon, width, height }: Base
style={{
background: colors.background,
borderColor: colors.border,
borderWidth: selected ? 2 : 1,
boxShadow: isOnline
borderWidth,
boxShadow: isOnline && selected
? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
: isOnline
? `0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
: selected
? `0 0 8px ${colors.border}44`
? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 8px ${colors.border}44`
: 'none',
opacity: data.status === 'offline' ? 0.55 : 1,
minWidth: 140,
@@ -51,7 +65,7 @@ export function BaseNode({ data, selected, icon: typeIcon, width, height }: Base
isVisible={selected}
minWidth={140}
minHeight={50}
lineStyle={{ borderColor: colors.border, borderWidth: 1 }}
lineStyle={{ borderColor: 'transparent' }}
handleStyle={{ borderColor: colors.border, background: colors.border, width: 8, height: 8 }}
/>
<Handle
@@ -96,12 +110,30 @@ export function BaseNode({ data, selected, icon: typeIcon, width, height }: Base
</div>
</div>
{/* Hardware section */}
{showHardware && (
{/* Properties section (new system) */}
{visibleProperties && visibleProperties.length > 0 && (
<>
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
<div className="flex flex-col gap-1 px-2.5 py-1.5">
{visibleProperties.map((prop) => {
const Icon = resolvePropertyIcon(prop.icon)
return (
<div key={prop.key} className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
{Icon && <Icon size={9} className="shrink-0" />}
<span className="truncate max-w-[60px] shrink-0" title={prop.key}>{prop.key}</span>
<span className="truncate" title={prop.value}>· {prop.value}</span>
</div>
)
})}
</div>
</>
)}
{/* Legacy hardware section — fallback for nodes not yet migrated */}
{showLegacyHardware && (
<>
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
<div className="flex flex-col gap-1 px-2.5 py-1.5">
{/* Line 1: CPU */}
{(data.cpu_model || data.cpu_count != null) && (
<div className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
<Cpu size={9} className="shrink-0" />
@@ -113,7 +145,6 @@ export function BaseNode({ data, selected, icon: typeIcon, width, height }: Base
)}
</div>
)}
{/* Line 2: RAM + Disk */}
{(data.ram_gb != null || data.disk_gb != null) && (
<div className="flex items-center gap-2 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
{data.ram_gb != null && (
@@ -141,13 +172,26 @@ export function BaseNode({ data, selected, icon: typeIcon, width, height }: Base
title={data.status}
/>
<Handle
type="source"
position={Position.Bottom}
id="bottom"
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
/>
<Handle type="target" position={Position.Bottom} id="bottom-t" style={{ opacity: 0, width: 12, height: 12 }} />
{(BOTTOM_HANDLE_POSITIONS[data.bottom_handles ?? 1] ?? BOTTOM_HANDLE_POSITIONS[1]).map((leftPct, idx) => {
const sourceId = BOTTOM_HANDLE_IDS[idx]
const targetId = idx === 0 ? 'bottom-t' : `bottom-${idx + 1}-t`
return (
<span key={sourceId}>
<Handle
type="source"
position={Position.Bottom}
id={sourceId}
style={{ left: `${leftPct}%`, background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
/>
<Handle
type="target"
position={Position.Bottom}
id={targetId}
style={{ left: `${leftPct}%`, opacity: 0, width: 12, height: 12 }}
/>
</span>
)
})}
</div>
)
}
@@ -73,7 +73,7 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
background: '#00d4ff',
border: '1px solid #0d1117',
}}
lineStyle={{ borderColor: '#00d4ff55', borderWidth: 1 }}
lineStyle={{ borderColor: 'transparent' }}
/>
<div
style={{
@@ -86,7 +86,8 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
justifyContent: posStyle.justifyContent,
padding: 12,
background: backgroundColor,
border: `${selected ? borderWidth + 1 : borderWidth}px ${selected ? 'solid' : borderStyle} ${selected ? '#00d4ff' : borderColor}`,
border: `${borderWidth}px ${borderStyle} ${borderColor}`,
boxShadow: selected ? '0 0 0 1px #00d4ff, 0 0 8px #00d4ff44' : 'none',
borderRadius: 10,
boxSizing: 'border-box',
cursor: 'default',
@@ -23,4 +23,5 @@ export const PrinterNode = (props: N) => <BaseNode {...props} icon={Printer} />
export const ComputerNode = (props: N) => <BaseNode {...props} icon={Monitor} />
export const CplNode = (props: N) => <BaseNode {...props} icon={PlugZap} />
export const DockerNode = (props: N) => <BaseNode {...props} icon={Anchor} />
export const DockerContainerNode = (props: N) => <BaseNode {...props} icon={Container} />
export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
@@ -1,4 +1,4 @@
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, DockerContainerNode, GenericNode } from './index'
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
import { GroupRectNode } from './GroupRectNode'
import { GroupNode } from './GroupNode'
@@ -18,7 +18,8 @@ export const nodeTypes = {
printer: PrinterNode,
computer: ComputerNode,
cpl: CplNode,
docker: DockerNode,
docker_container: DockerContainerNode,
docker_host: DockerNode,
generic: GenericNode,
groupRect: GroupRectNode,
group: GroupNode,
+17 -5
View File
@@ -10,11 +10,12 @@ import { EDGE_DEFAULT_COLORS } from '@/utils/edgeColors'
const EDGE_TYPES = Object.entries(EDGE_TYPE_LABELS) as [EdgeType, string][]
type AnimMode = 'none' | 'snake' | 'flow'
type AnimMode = 'none' | 'basic' | 'snake' | 'flow'
function toAnimMode(v: EdgeData['animated']): AnimMode {
if (v === true || v === 'snake') return 'snake'
if (v === 'flow') return 'flow'
if (v === 'basic') return 'basic'
return 'none'
}
@@ -23,11 +24,12 @@ interface EdgeModalProps {
onClose: () => void
onSubmit: (data: EdgeData) => void
onDelete?: () => void
onClearWaypoints?: () => void
initial?: Partial<EdgeData>
title?: string
}
export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title = 'Connect Nodes' }: EdgeModalProps) {
export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints, initial, title = 'Connect Nodes' }: EdgeModalProps) {
const [type, setType] = useState<EdgeType>(initial?.type ?? 'ethernet')
const [label, setLabel] = useState(initial?.label ?? '')
const [vlanId, setVlanId] = useState(initial?.vlan_id?.toString() ?? '')
@@ -126,7 +128,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Animation</Label>
<div className="flex rounded-md overflow-hidden border border-[#30363d]">
{(['none', 'snake', 'flow'] as AnimMode[]).map((mode, i) => (
{(['none', 'basic', 'snake', 'flow'] as AnimMode[]).map((mode, i) => (
<button
key={mode}
type="button"
@@ -135,10 +137,10 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
style={{
background: animation === mode ? '#00d4ff22' : '#21262d',
color: animation === mode ? '#00d4ff' : '#8b949e',
borderRight: i < 2 ? '1px solid #30363d' : undefined,
borderRight: i < 3 ? '1px solid #30363d' : undefined,
}}
>
{mode === 'none' ? 'None' : mode === 'snake' ? 'Snake' : 'Flow'}
{mode === 'none' ? 'None' : mode === 'basic' ? 'Basic' : mode === 'snake' ? 'Snake' : 'Flow'}
</button>
))}
</div>
@@ -175,6 +177,16 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
</label>
</div>
{onClearWaypoints && initial?.waypoints && initial.waypoints.length > 0 && (
<button
type="button"
onClick={() => { onClearWaypoints(); onClose() }}
className="text-[10px] text-muted-foreground hover:text-[#e3b341] transition-colors text-left"
>
Clear path ({initial.waypoints.length} point{initial.waypoints.length !== 1 ? 's' : ''})
</button>
)}
<div className="flex justify-between gap-2 pt-1">
{onDelete ? (
<Button type="button" variant="ghost" size="sm" className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10" onClick={handleDelete}>
+103 -160
View File
@@ -1,4 +1,4 @@
import { createElement, useState } from 'react'
import { Fragment, createElement, useState } from 'react'
import { RotateCcw, ChevronDown } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
@@ -7,16 +7,17 @@ import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors'
import { ICON_REGISTRY, ICON_CATEGORIES } from '@/utils/nodeIcons'
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS } from '@/utils/nodeIcons'
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
{ label: 'Hardware', types: ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer'] },
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker'] },
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
{ label: 'Generic', types: ['computer', 'generic', 'groupRect'] },
]
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
const CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host']
const DEFAULT_DATA: Partial<NodeData> = {
type: 'server',
@@ -26,7 +27,7 @@ const DEFAULT_DATA: Partial<NodeData> = {
status: 'unknown',
check_method: 'ping',
services: [],
container_mode: true,
container_mode: false,
custom_colors: undefined,
custom_icon: undefined,
}
@@ -37,20 +38,16 @@ interface NodeModalProps {
onSubmit: (data: Partial<NodeData>) => void
initial?: Partial<NodeData>
title?: string
proxmoxNodes?: { id: string; label: string }[]
parentContainerNodes?: { id: string; label: string }[]
}
const CHILD_TYPES: NodeType[] = ['vm', 'lxc']
// NodeModal is always mounted with a key that changes on open/edit, so useState
// initial value is enough no need for a reset effect.
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', proxmoxNodes = [] }: NodeModalProps) {
// initial value is enough - no need for a reset effect.
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentContainerNodes = [] }: NodeModalProps) {
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
const [iconSearch, setIconSearch] = useState('')
const [iconPickerOpen, setIconPickerOpen] = useState(false)
const [labelError, setLabelError] = useState(false)
const hasHardwareData = !!(initial?.cpu_count || initial?.cpu_model || initial?.ram_gb || initial?.disk_gb)
const [hardwareOpen, setHardwareOpen] = useState(hasHardwareData)
const set = (key: keyof NodeData, value: unknown) =>
setForm((f) => ({ ...f, [key]: value }))
@@ -62,7 +59,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
return
}
setLabelError(false)
onSubmit(form)
const selectedType = (form.type ?? 'generic') as NodeType
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
onSubmit({
...form,
container_mode: canUseContainerMode ? !!form.container_mode : false,
})
onClose()
}
@@ -75,18 +77,18 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
<div className="grid grid-cols-2 gap-3">
{/* Type */}
<div className="flex flex-col gap-1.5 col-span-2">
{/* Type + Icon on the same row */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Type</Label>
<Select value={form.type} onValueChange={(v) => set('type', v as NodeType)}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
<SelectValue />
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8 w-full">
<SelectValue>{NODE_TYPE_LABELS[(form.type ?? 'server') as NodeType]}</SelectValue>
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
{NODE_TYPE_GROUPS.map((group, i) => (
<>
{i > 0 && <SelectSeparator key={`sep-${group.label}`} className="bg-[#30363d]" />}
<SelectGroup key={group.label}>
<Fragment key={group.label}>
{i > 0 && <SelectSeparator className="bg-[#30363d]" />}
<SelectGroup>
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50 px-2 py-1">
{group.label}
</SelectLabel>
@@ -96,14 +98,14 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
</SelectItem>
))}
</SelectGroup>
</>
</Fragment>
))}
</SelectContent>
</Select>
</div>
{/* Icon */}
<div className="flex flex-col gap-1.5 col-span-2">
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Icon</Label>
{form.custom_icon && (
@@ -120,70 +122,72 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
<button
type="button"
onClick={() => setIconPickerOpen((o) => !o)}
className="flex items-center justify-between gap-2 h-8 px-3 rounded-md bg-[#21262d] border border-[#30363d] text-sm hover:border-[#8b949e] transition-colors"
className="flex items-center justify-between gap-2 h-8 px-3 rounded-md bg-[#21262d] border border-[#30363d] text-sm hover:border-[#8b949e] transition-colors w-full"
>
<span className="flex items-center gap-2">
<span className="flex items-center gap-2 min-w-0">
{(() => {
const entry = ICON_REGISTRY.find((e) => e.key === form.custom_icon)
if (entry) {
return <>{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff]' })}<span className="text-foreground">{entry.label}</span></>
return <>{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff] shrink-0' })}<span className="text-foreground truncate">{entry.label}</span></>
}
return <span className="text-muted-foreground">Default (from type)</span>
const defaultIcon = NODE_TYPE_DEFAULT_ICONS[form.type as NodeType] ?? NODE_TYPE_DEFAULT_ICONS.generic
return <>{createElement(defaultIcon, { size: 13, className: 'text-muted-foreground shrink-0' })}<span className="text-muted-foreground truncate">Default</span></>
})()}
</span>
<ChevronDown size={12} className="text-muted-foreground shrink-0" style={{ transform: iconPickerOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} />
</button>
{/* Inline picker panel */}
{iconPickerOpen && (
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d]">
<Input
value={iconSearch}
onChange={(e) => setIconSearch(e.target.value)}
placeholder="Search icons…"
className="bg-[#21262d] border-[#30363d] text-xs h-7"
autoFocus
/>
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
{ICON_CATEGORIES.map((cat) => {
const entries = ICON_REGISTRY.filter(
(e) => e.category === cat &&
(iconSearch === '' || e.label.toLowerCase().includes(iconSearch.toLowerCase()) || e.key.includes(iconSearch.toLowerCase()))
)
if (entries.length === 0) return null
return (
<div key={cat}>
<p className="text-[9px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1">{cat}</p>
<div className="grid grid-cols-7 gap-1">
{entries.map((entry) => {
const isSelected = form.custom_icon === entry.key
return (
<button
key={entry.key}
type="button"
title={entry.label}
onClick={() => { set('custom_icon', isSelected ? undefined : entry.key); setIconPickerOpen(false) }}
className="flex items-center justify-center w-7 h-7 rounded transition-colors"
style={{
background: isSelected ? '#00d4ff22' : 'transparent',
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
color: isSelected ? '#00d4ff' : '#8b949e',
}}
onMouseEnter={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = '#21262d' }}
onMouseLeave={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
{createElement(entry.icon, { size: 13 })}
</button>
)
})}
</div>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Inline icon picker - full width, shown below the type+icon row */}
{iconPickerOpen && (
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d] col-span-2">
<Input
value={iconSearch}
onChange={(e) => setIconSearch(e.target.value)}
placeholder="Search icons…"
className="bg-[#21262d] border-[#30363d] text-xs h-7"
autoFocus
/>
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
{ICON_CATEGORIES.map((cat) => {
const entries = ICON_REGISTRY.filter(
(e) => e.category === cat &&
(iconSearch === '' || e.label.toLowerCase().includes(iconSearch.toLowerCase()) || e.key.includes(iconSearch.toLowerCase()))
)
if (entries.length === 0) return null
return (
<div key={cat}>
<p className="text-[9px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1">{cat}</p>
<div className="grid grid-cols-7 gap-1">
{entries.map((entry) => {
const isSelected = form.custom_icon === entry.key
return (
<button
key={entry.key}
type="button"
title={entry.label}
onClick={() => { set('custom_icon', isSelected ? undefined : entry.key); setIconPickerOpen(false) }}
className="flex items-center justify-center w-7 h-7 rounded transition-colors"
style={{
background: isSelected ? '#00d4ff22' : 'transparent',
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
color: isSelected ? '#00d4ff' : '#8b949e',
}}
onMouseEnter={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = '#21262d' }}
onMouseLeave={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
{createElement(entry.icon, { size: 13 })}
</button>
)
})}
</div>
</div>
)
})}
</div>
</div>
)}
{/* Label */}
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Label *</Label>
@@ -244,10 +248,10 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
/>
</div>
{/* Parent Proxmox (VM / LXC only) */}
{CHILD_TYPES.includes(form.type as NodeType) && proxmoxNodes.length > 0 && (
{/* Parent container */}
{form.type !== 'groupRect' && form.type !== 'group' && parentContainerNodes.length > 0 && (
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Parent Proxmox</Label>
<Label className="text-xs text-muted-foreground">Parent Container</Label>
<Select
value={form.parent_id ?? 'none'}
onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)}
@@ -257,7 +261,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
<SelectItem value="none" className="text-sm">None (standalone)</SelectItem>
{proxmoxNodes.map((n) => (
{parentContainerNodes.map((n) => (
<SelectItem key={n.id} value={n.id} className="text-sm">{n.label}</SelectItem>
))}
</SelectContent>
@@ -265,12 +269,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
</div>
)}
{/* Container mode (proxmox only) */}
{form.type === 'proxmox' && (
{/* Container mode */}
{CONTAINER_MODE_TYPES.includes((form.type ?? 'generic') as NodeType) && (
<div className="flex items-center justify-between col-span-2 py-1">
<div className="flex flex-col gap-0.5">
<Label className="text-xs text-muted-foreground">Container Mode</Label>
<span className="text-[10px] text-muted-foreground/60">Show VM/LXC nodes nested inside</span>
<span className="text-[10px] text-muted-foreground/60">Allow other nodes to nest inside this node</span>
</div>
<button
type="button"
@@ -332,85 +336,24 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
)}
</div>
{/* Hardware specs (hidden for groupRect) */}
{form.type !== 'groupRect' && (
<div className="flex flex-col gap-2 col-span-2">
<div className="flex items-center justify-between w-full">
<button
type="button"
onClick={() => setHardwareOpen((o) => !o)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<span className="font-medium">Hardware</span>
<ChevronDown size={12} style={{ transform: hardwareOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} />
</button>
{hardwareOpen && (
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground/60">Show on node</span>
<button
type="button"
role="switch"
aria-checked={!!form.show_hardware}
onClick={() => set('show_hardware', !form.show_hardware)}
className="relative inline-flex h-4 w-7 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus:outline-none"
style={{ background: form.show_hardware ? '#00d4ff' : '#30363d' }}
>
<span
className="pointer-events-none inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform"
style={{ transform: form.show_hardware ? 'translateX(12px)' : 'translateX(0)' }}
/>
</button>
</div>
)}
</div>
{hardwareOpen && (
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">CPU Model</Label>
<Input
value={form.cpu_model ?? ''}
onChange={(e) => set('cpu_model', e.target.value || undefined)}
placeholder="e.g. Intel Xeon E5-2680"
className="bg-[#21262d] border-[#30363d] text-sm h-8"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">CPU Cores</Label>
<Input
type="number"
min={1}
value={form.cpu_count ?? ''}
onChange={(e) => set('cpu_count', e.target.value ? parseInt(e.target.value, 10) : undefined)}
placeholder="e.g. 8"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">RAM (GB)</Label>
<Input
type="number"
min={0}
step={0.5}
value={form.ram_gb ?? ''}
onChange={(e) => set('ram_gb', e.target.value ? parseFloat(e.target.value) : undefined)}
placeholder="e.g. 32"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
</div>
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Disk (GB)</Label>
<Input
type="number"
min={0}
step={1}
value={form.disk_gb ?? ''}
onChange={(e) => set('disk_gb', e.target.value ? parseFloat(e.target.value) : undefined)}
placeholder="e.g. 500"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
</div>
</div>
)}
{/* Bottom connection points (not for group containers) */}
{form.type !== 'groupRect' && form.type !== 'group' && (
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Bottom Connection Points</Label>
<Select
value={String(form.bottom_handles ?? 1)}
onValueChange={(v) => set('bottom_handles', parseInt(v ?? '1', 10))}
>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
<SelectItem value="1" className="text-sm">1 - center</SelectItem>
<SelectItem value="2" className="text-sm">2 - left / right</SelectItem>
<SelectItem value="3" className="text-sm">3 - left / center / right</SelectItem>
<SelectItem value="4" className="text-sm">4 - evenly spaced</SelectItem>
</SelectContent>
</Select>
</div>
)}
@@ -19,6 +19,7 @@ export interface PendingDevice {
services: Service[]
suggested_type: string | null
status: string
discovery_source: string | null
discovered_at: string
}
@@ -77,7 +78,7 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
const TypeIcon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
const handleApprove = () => { onApprove(device); onClose() }
const handleApprove = () => { onApprove(device) }
const handleHide = () => { onHide(device); onClose() }
const handleIgnore = () => { onIgnore(device); onClose() }
@@ -101,7 +102,10 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
{device.suggested_type && (
<InfoRow label="Type" value={device.suggested_type} />
)}
<InfoRow label="Discovered" value={new Date(device.discovered_at).toLocaleString()} />
{device.discovery_source && (
<InfoRow label="Source" value={device.discovery_source.toUpperCase()} />
)}
<InfoRow label="Discovered" value={new Date(device.discovered_at.endsWith('Z') ? device.discovered_at : device.discovered_at + 'Z').toLocaleString()} />
</div>
{/* Services */}
@@ -24,29 +24,22 @@ export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalPro
.catch(() => {/* use defaults */})
}, [open])
const handleSave = async () => {
const handleScanNow = async () => {
const cleaned = ranges.map((r) => r.trim()).filter(Boolean)
if (cleaned.length === 0) { toast.error('Add at least one IP range'); return }
setSaving(true)
try {
await scanApi.saveConfig({ ranges: cleaned })
toast.success('Scan config saved')
await scanApi.trigger()
onScanNow()
onClose()
} catch {
toast.error('Failed to save config')
toast.error('Failed to start scan')
} finally {
setSaving(false)
}
}
const handleScanNow = async () => {
const cleaned = ranges.map((r) => r.trim()).filter(Boolean)
if (cleaned.length === 0) { toast.error('Add at least one IP range'); return }
await handleSave()
onScanNow()
onClose()
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="bg-[#161b22] border-border max-w-md">
@@ -99,7 +92,6 @@ export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalPro
<DialogFooter className="gap-2">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="outline" onClick={handleSave} disabled={saving}>Save</Button>
<Button
onClick={handleScanNow}
disabled={saving}
+63 -15
View File
@@ -1,34 +1,62 @@
import { useState, useCallback } from 'react'
import { useState, useCallback, useEffect } from 'react'
import { useReactFlow } from '@xyflow/react'
import { Search } from 'lucide-react'
import { useCanvasStore } from '@/stores/canvasStore'
import { scanApi } from '@/api/client'
import type { PendingDevice } from '@/components/modals/PendingDeviceModal'
import { NODE_TYPE_LABELS } from '@/types'
interface SearchModalProps {
open: boolean
onClose: () => void
onOpenPending: (deviceId: string) => void
}
export function SearchModal({ open, onClose }: SearchModalProps) {
export function SearchModal({ open, onClose, onOpenPending }: SearchModalProps) {
const [query, setQuery] = useState('')
const [pendingDevices, setPendingDevices] = useState<PendingDevice[]>([])
const nodes = useCanvasStore((s) => s.nodes)
const setSelectedNode = useCanvasStore((s) => s.setSelectedNode)
const { fitView } = useReactFlow()
useEffect(() => {
if (!open) return
scanApi.pending().then((res) => setPendingDevices(res.data)).catch(() => {})
}, [open])
const searchable = nodes.filter((n) => n.data.type !== 'groupRect')
const q = query.toLowerCase()
const results = q.length === 0 ? [] : searchable.filter((n) =>
const nodeResults = q.length === 0 ? [] : searchable.filter((n) =>
n.data.label?.toLowerCase().includes(q) ||
n.data.ip?.toLowerCase().includes(q) ||
n.data.hostname?.toLowerCase().includes(q)
).slice(0, 8)
).slice(0, 6)
const handleSelect = useCallback((nodeId: string) => {
const pendingResults = q.length === 0 ? [] : pendingDevices.filter((d) =>
d.ip.toLowerCase().includes(q) ||
d.hostname?.toLowerCase().includes(q) ||
d.services.some((s) =>
s.service_name?.toLowerCase().includes(q) ||
s.category?.toLowerCase().includes(q)
)
).slice(0, 4)
const totalResults = nodeResults.length + pendingResults.length
const handleSelectNode = useCallback((nodeId: string) => {
setSelectedNode(nodeId)
fitView({ nodes: [{ id: nodeId }], duration: 600, padding: 0.4, maxZoom: 1.5 })
onClose()
setQuery('')
}, [fitView, setSelectedNode, onClose])
const handleSelectPending = useCallback((deviceId: string) => {
onOpenPending(deviceId)
onClose()
setQuery('')
}, [onOpenPending, onClose])
if (!open) return null
return (
@@ -43,40 +71,60 @@ export function SearchModal({ open, onClose }: SearchModalProps) {
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search nodes by label, IP, hostname…"
placeholder="Search nodes, pending devices by IP or service…"
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
onKeyDown={(e) => {
if (e.key === 'Escape') { onClose(); setQuery('') }
if (e.key === 'Enter' && results.length > 0) handleSelect(results[0].id)
if (e.key === 'Enter' && nodeResults.length > 0) handleSelectNode(nodeResults[0].id)
if (e.key === 'Enter' && nodeResults.length === 0 && pendingResults.length > 0) handleSelectPending(pendingResults[0].id)
}}
/>
<kbd className="text-[10px] text-muted-foreground border border-border rounded px-1">ESC</kbd>
</div>
{results.length > 0 && (
<ul className="py-1 max-h-64 overflow-y-auto">
{results.map((node) => (
{totalResults > 0 && (
<ul className="py-1 max-h-72 overflow-y-auto">
{nodeResults.map((node) => (
<li
key={node.id}
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
onClick={() => handleSelect(node.id)}
onClick={() => handleSelectNode(node.id)}
>
<span className="text-xs font-mono text-[#00d4ff] w-16 shrink-0">{node.data.type}</span>
<span className="text-xs font-mono text-[#00d4ff] w-16 shrink-0">{NODE_TYPE_LABELS[node.data.type] ?? node.data.type}</span>
<span className="text-sm text-foreground font-medium flex-1 truncate">{node.data.label}</span>
{node.data.ip && (
<span className="text-xs font-mono text-muted-foreground shrink-0">{node.data.ip}</span>
)}
</li>
))}
{pendingResults.length > 0 && nodeResults.length > 0 && (
<li className="px-4 py-1">
<div className="h-px bg-border" />
</li>
)}
{pendingResults.map((device) => {
const serviceName = device.services.find((s) => s.service_name)?.service_name
return (
<li
key={device.id}
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
onClick={() => handleSelectPending(device.id)}
>
<span className="text-xs font-mono text-[#e3b341] w-16 shrink-0">pending</span>
<span className="text-sm text-foreground font-medium flex-1 truncate font-mono">{device.hostname ?? device.ip}</span>
<span className="text-xs font-mono text-muted-foreground shrink-0">{serviceName ?? device.ip}</span>
</li>
)
})}
</ul>
)}
{q.length > 0 && results.length === 0 && (
<p className="px-4 py-3 text-sm text-muted-foreground">No nodes match "{query}"</p>
{q.length > 0 && totalResults === 0 && (
<p className="px-4 py-3 text-sm text-muted-foreground">No results match "{query}"</p>
)}
{q.length === 0 && (
<p className="px-4 py-3 text-xs text-muted-foreground">Type to search nodes</p>
<p className="px-4 py-3 text-xs text-muted-foreground">Type to search nodes and pending devices</p>
)}
</div>
</div>
@@ -122,6 +122,21 @@ describe('EdgeModal', () => {
expect(onSubmit.mock.calls[0][0].animated).toBe('flow')
})
it('selecting Basic sends animated: "basic"', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Basic'))
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].animated).toBe('basic')
})
it('pre-fills animation from initial "basic" string', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ animated: 'basic' }} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].animated).toBe('basic')
})
it('selecting None after Snake omits animated from payload', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
@@ -187,4 +202,55 @@ describe('EdgeModal', () => {
expect(onDelete).toHaveBeenCalledOnce()
expect(onClose).toHaveBeenCalledOnce()
})
// ── Waypoints / Clear path ────────────────────────────────────────────────
it('does not show Clear path button when onClearWaypoints is not provided', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ type: 'ethernet', waypoints: [{ x: 1, y: 2 }] }} />)
expect(screen.queryByText(/Clear path/)).toBeNull()
})
it('does not show Clear path button when waypoints are empty', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} onClearWaypoints={vi.fn()} initial={{ type: 'ethernet', waypoints: [] }} />)
expect(screen.queryByText(/Clear path/)).toBeNull()
})
it('does not show Clear path button when no initial waypoints', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} onClearWaypoints={vi.fn()} />)
expect(screen.queryByText(/Clear path/)).toBeNull()
})
it('shows Clear path button with count when waypoints exist', () => {
render(
<EdgeModal
open onClose={vi.fn()} onSubmit={vi.fn()} onClearWaypoints={vi.fn()}
initial={{ type: 'ethernet', waypoints: [{ x: 1, y: 2 }, { x: 3, y: 4 }] }}
/>,
)
expect(screen.getByText('Clear path (2 points)')).toBeDefined()
})
it('shows singular "point" when only one waypoint', () => {
render(
<EdgeModal
open onClose={vi.fn()} onSubmit={vi.fn()} onClearWaypoints={vi.fn()}
initial={{ type: 'ethernet', waypoints: [{ x: 1, y: 2 }] }}
/>,
)
expect(screen.getByText('Clear path (1 point)')).toBeDefined()
})
it('calls onClearWaypoints and onClose when Clear path is clicked', () => {
const onClearWaypoints = vi.fn()
const onClose = vi.fn()
render(
<EdgeModal
open onClose={onClose} onSubmit={vi.fn()} onClearWaypoints={onClearWaypoints}
initial={{ type: 'ethernet', waypoints: [{ x: 1, y: 2 }] }}
/>,
)
fireEvent.click(screen.getByText('Clear path (1 point)'))
expect(onClearWaypoints).toHaveBeenCalledOnce()
expect(onClose).toHaveBeenCalledOnce()
})
})
@@ -1,170 +1,326 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { NodeModal } from '../NodeModal'
import type { NodeData } from '@/types'
// ── Mock Shadcn Select with native <select> for testability ───────────────
vi.mock('@/components/ui/select', () => ({
Select: ({ value, onValueChange, children }: {
value?: string; onValueChange?: (v: string) => void; children: React.ReactNode
}) => (
<select value={value} onChange={(e) => onValueChange?.(e.target.value)}>
{children}
</select>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectValue: () => null,
SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectGroup: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectLabel: () => null,
SelectItem: ({ value, children }: { value: string; children: React.ReactNode }) => (
<option value={value}>{children}</option>
),
SelectSeparator: () => null,
}))
// ── Helpers ───────────────────────────────────────────────────────────────
function renderModal(props: Partial<Parameters<typeof NodeModal>[0]> = {}) {
const onClose = vi.fn()
const onSubmit = vi.fn()
render(<NodeModal open onClose={onClose} onSubmit={onSubmit} {...props} />)
return { onClose, onSubmit }
}
/** Get <select> elements in document order: [0]=Type, [1]=CheckMethod, [2]=BottomHandles */
function selects() { return screen.getAllByRole('combobox') as HTMLSelectElement[] }
const BASE: Partial<NodeData> = {
type: 'server', label: 'My Server', hostname: 'server.lan',
ip: '192.168.1.10', check_method: 'ping', services: [],
}
// ── Tests ─────────────────────────────────────────────────────────────────
describe('NodeModal', () => {
// ── Visibility ────────────────────────────────────────────────────────
it('renders nothing when closed', () => {
const { container } = render(
<NodeModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />
)
const { container } = render(<NodeModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(container.querySelector('[role="dialog"]')).toBeNull()
})
it('renders form fields when open', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
renderModal()
expect(screen.getByPlaceholderText('My Server')).toBeDefined()
expect(screen.getByText('Add Node')).toBeDefined()
})
it('does not call onSubmit when label is empty and shows error', () => {
const onSubmit = vi.fn()
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Add'))
it('shows "Add" button for default title', () => {
renderModal()
expect(screen.getByRole('button', { name: 'Add' })).toBeDefined()
})
it('shows "Save" button when title is Edit Node', () => {
renderModal({ title: 'Edit Node' })
expect(screen.getByRole('button', { name: 'Save' })).toBeDefined()
})
it('pre-fills form from initial prop', () => {
renderModal({ initial: BASE })
expect((screen.getByPlaceholderText('My Server') as HTMLInputElement).value).toBe('My Server')
expect((screen.getByPlaceholderText('server.lan') as HTMLInputElement).value).toBe('server.lan')
expect((screen.getByPlaceholderText('192.168.1.x') as HTMLInputElement).value).toBe('192.168.1.10')
})
// ── Cancel ────────────────────────────────────────────────────────────
it('calls onClose when Cancel is clicked', () => {
const { onClose } = renderModal()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onClose).toHaveBeenCalledOnce()
})
// ── Label validation ──────────────────────────────────────────────────
it('blocks submit and shows error when label is empty', () => {
const { onSubmit } = renderModal()
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect(onSubmit).not.toHaveBeenCalled()
expect(screen.getByText('Label is required')).toBeDefined()
})
it('calls onSubmit with form data when label is filled', () => {
const onSubmit = vi.fn()
const onClose = vi.fn()
render(<NodeModal open onClose={onClose} onSubmit={onSubmit} />)
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'My NAS' } })
fireEvent.click(screen.getByText('Add'))
expect(onSubmit).toHaveBeenCalledOnce()
expect(onSubmit.mock.calls[0][0].label).toBe('My NAS')
expect(onClose).toHaveBeenCalledOnce()
it('blocks submit when label is whitespace only', () => {
const { onSubmit } = renderModal()
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: ' ' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect(onSubmit).not.toHaveBeenCalled()
})
it('clears label error when user starts typing', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
fireEvent.click(screen.getByText('Add'))
expect(screen.getByText('Label is required')).toBeDefined()
renderModal()
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'x' } })
expect(screen.queryByText('Label is required')).toBeNull()
})
it('pre-fills form from initial prop', () => {
render(
<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ label: 'Pre-filled', ip: '10.0.0.1' }} />
)
const input = screen.getByPlaceholderText('My Server') as HTMLInputElement
expect(input.value).toBe('Pre-filled')
})
// ── Form submission ───────────────────────────────────────────────────
it('shows Save button text when title is Edit Node', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Node" />)
expect(screen.getByText('Save')).toBeDefined()
})
it('calls onClose when Cancel is clicked', () => {
const onClose = vi.fn()
render(<NodeModal open onClose={onClose} onSubmit={vi.fn()} />)
fireEvent.click(screen.getByText('Cancel'))
it('calls onSubmit and onClose with form data on valid submit', () => {
const { onSubmit, onClose } = renderModal({ initial: BASE })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect(onSubmit).toHaveBeenCalledOnce()
expect(onClose).toHaveBeenCalledOnce()
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
expect(data.label).toBe('My Server')
expect(data.type).toBe('server')
})
describe('Hardware section', () => {
it('renders Hardware toggle button', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByText('Hardware')).toBeDefined()
})
it('submits updated hostname, IP and notes', () => {
const { onSubmit } = renderModal({ initial: BASE })
fireEvent.change(screen.getByPlaceholderText('server.lan'), { target: { value: 'nas.local' } })
fireEvent.change(screen.getByPlaceholderText('192.168.1.x'), { target: { value: '10.0.0.1' } })
fireEvent.change(screen.getByPlaceholderText('Optional notes'), { target: { value: 'rack A' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
expect(data.hostname).toBe('nas.local')
expect(data.ip).toBe('10.0.0.1')
expect(data.notes).toBe('rack A')
})
it('hardware fields are hidden by default', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.queryByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeNull()
})
it('submits check_target', () => {
const { onSubmit } = renderModal({ initial: BASE })
fireEvent.change(screen.getByPlaceholderText('http://...'), { target: { value: 'http://192.168.1.10:8080' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).check_target).toBe('http://192.168.1.10:8080')
})
it('expands hardware fields on toggle click', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
fireEvent.click(screen.getByText('Hardware'))
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
expect(screen.getByPlaceholderText('e.g. 8')).toBeDefined()
expect(screen.getByPlaceholderText('e.g. 32')).toBeDefined()
expect(screen.getByPlaceholderText('e.g. 500')).toBeDefined()
})
// ── Type selector ─────────────────────────────────────────────────────
it('submits hardware fields when filled', () => {
const onSubmit = vi.fn()
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Homelab' } })
fireEvent.click(screen.getByText('Hardware'))
fireEvent.change(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680'), { target: { value: 'Intel i7-12700K' } })
fireEvent.change(screen.getByPlaceholderText('e.g. 8'), { target: { value: '12' } })
fireEvent.change(screen.getByPlaceholderText('e.g. 32'), { target: { value: '64' } })
fireEvent.change(screen.getByPlaceholderText('e.g. 500'), { target: { value: '2000' } })
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0]
expect(submitted.cpu_model).toBe('Intel i7-12700K')
expect(submitted.cpu_count).toBe(12)
expect(submitted.ram_gb).toBe(64)
expect(submitted.disk_gb).toBe(2000)
})
it('pre-fills type from initial', () => {
renderModal({ initial: { ...BASE, type: 'router' } })
expect(selects()[0].value).toBe('router')
})
it('auto-expands when initial has hardware data', () => {
render(
<NodeModal
open
onClose={vi.fn()}
onSubmit={vi.fn()}
initial={{ label: 'Server', cpu_count: 8, ram_gb: 32 }}
/>
)
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
})
it('changes type and submits it', () => {
const { onSubmit } = renderModal({ initial: BASE })
fireEvent.change(selects()[0], { target: { value: 'nas' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).type).toBe('nas')
})
it('hides hardware section for groupRect type', () => {
render(
<NodeModal
open
onClose={vi.fn()}
onSubmit={vi.fn()}
initial={{ type: 'groupRect' }}
/>
)
expect(screen.queryByText('Hardware')).toBeNull()
})
// ── Check method ──────────────────────────────────────────────────────
it('show on node toggle is hidden when section is collapsed', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.queryByText('Show on node')).toBeNull()
})
it('pre-fills check_method from initial', () => {
renderModal({ initial: { ...BASE, check_method: 'http' } })
expect(selects()[1].value).toBe('http')
})
it('show on node toggle appears when section is expanded', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
fireEvent.click(screen.getByText('Hardware'))
expect(screen.getByText('Show on node')).toBeDefined()
})
it('changes check_method and submits it', () => {
const { onSubmit } = renderModal({ initial: BASE })
fireEvent.change(selects()[1], { target: { value: 'ssh' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).check_method).toBe('ssh')
})
it('show_hardware defaults to false', () => {
const onSubmit = vi.fn()
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
fireEvent.click(screen.getByText('Add'))
expect(onSubmit.mock.calls[0][0].show_hardware).toBeFalsy()
})
// ── Icon picker ───────────────────────────────────────────────────────
it('toggling show on node sets show_hardware to true', () => {
const onSubmit = vi.fn()
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
fireEvent.click(screen.getByText('Hardware'))
fireEvent.click(screen.getByRole('switch'))
fireEvent.click(screen.getByText('Add'))
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
})
it('shows "Default" label when no custom icon', () => {
renderModal({ initial: BASE })
expect(screen.getByText('Default')).toBeDefined()
})
it('pre-fills show_hardware from initial prop', () => {
const onSubmit = vi.fn()
render(
<NodeModal
open
onClose={vi.fn()}
onSubmit={onSubmit}
initial={{ label: 'Node', show_hardware: true, cpu_count: 8 }}
/>
)
fireEvent.click(screen.getByText('Add'))
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
it('opens icon picker on trigger button click', () => {
renderModal({ initial: BASE })
expect(screen.queryByPlaceholderText('Search icons…')).toBeNull()
fireEvent.click(screen.getByText('Default'))
expect(screen.getByPlaceholderText('Search icons…')).toBeDefined()
})
it('closes picker and shows icon label after selecting an icon', () => {
renderModal({ initial: BASE })
fireEvent.click(screen.getByText('Default'))
fireEvent.click(screen.getByTitle('Database (SQL/NoSQL)'))
expect(screen.queryByPlaceholderText('Search icons…')).toBeNull()
expect(screen.getByText('Database (SQL/NoSQL)')).toBeDefined()
})
it('submits custom_icon key after picking', () => {
const { onSubmit } = renderModal({ initial: BASE })
fireEvent.click(screen.getByText('Default'))
fireEvent.click(screen.getByTitle('Database (SQL/NoSQL)'))
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).custom_icon).toBe('database')
})
it('shows Reset button when custom_icon is set', () => {
renderModal({ initial: { ...BASE, custom_icon: 'database' } })
expect(screen.getByRole('button', { name: /Reset/i })).toBeDefined()
})
it('hides Reset button when no custom_icon', () => {
renderModal({ initial: BASE })
expect(screen.queryByRole('button', { name: /Reset/i })).toBeNull()
})
it('resets custom_icon and shows Default on Reset click', () => {
renderModal({ initial: { ...BASE, custom_icon: 'database' } })
fireEvent.click(screen.getByRole('button', { name: /Reset/i }))
expect(screen.getByText('Default')).toBeDefined()
})
it('filters icons by search query', () => {
renderModal({ initial: BASE })
fireEvent.click(screen.getByText('Default'))
fireEvent.change(screen.getByPlaceholderText('Search icons…'), { target: { value: 'grafana' } })
expect(screen.getByTitle('Grafana / Kibana')).toBeDefined()
expect(screen.queryByTitle('Router')).toBeNull()
})
// ── Container mode ─────────────────────────────────────────────────────
it('shows Container Mode toggle for proxmox type', () => {
renderModal({ initial: { ...BASE, type: 'proxmox' } })
expect(screen.getByText('Container Mode')).toBeDefined()
})
it('hides Container Mode for server type', () => {
renderModal({ initial: { ...BASE, type: 'server' } })
expect(screen.queryByText('Container Mode')).toBeNull()
})
it('hides Container Mode for groupRect type', () => {
renderModal({ initial: { ...BASE, type: 'groupRect' } })
expect(screen.queryByText('Container Mode')).toBeNull()
})
it('toggles container_mode on click', () => {
const { onSubmit } = renderModal({ initial: { ...BASE, type: 'proxmox', container_mode: true } })
fireEvent.click(screen.getByRole('switch'))
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).container_mode).toBe(false)
})
// ── Parent container ──────────────────────────────────────────────────
it('shows Parent Container when options are provided', () => {
renderModal({
initial: { ...BASE, type: 'server' },
parentContainerNodes: [{ id: 'c1', label: 'Container 01' }],
})
expect(screen.getByText('Parent Container')).toBeDefined()
expect(screen.getByText('Container 01')).toBeDefined()
})
it('hides Parent Container for groupRect type', () => {
renderModal({ initial: { ...BASE, type: 'groupRect' }, parentContainerNodes: [{ id: 'c1', label: 'Container 01' }] })
expect(screen.queryByText('Parent Container')).toBeNull()
})
it('hides Parent Container when no container options are available', () => {
renderModal({ initial: { ...BASE, type: 'server' } })
expect(screen.queryByText('Parent Container')).toBeNull()
})
// ── Appearance ────────────────────────────────────────────────────────
it('renders 3 color swatch labels (border, background, icon)', () => {
renderModal({ initial: BASE })
expect(screen.getByText('border')).toBeDefined()
expect(screen.getByText('background')).toBeDefined()
expect(screen.getByText('icon')).toBeDefined()
})
it('shows default colors hint when no custom_colors', () => {
renderModal({ initial: BASE })
expect(screen.getByText(/Using default colors for/)).toBeDefined()
})
it('shows Reset to defaults when custom_colors are set', () => {
renderModal({ initial: { ...BASE, custom_colors: { border: '#ff0000' } } })
expect(screen.getByText('Reset to defaults')).toBeDefined()
})
it('resets custom_colors on Reset to defaults click', () => {
renderModal({ initial: { ...BASE, custom_colors: { border: '#ff0000' } } })
fireEvent.click(screen.getByText('Reset to defaults'))
expect(screen.queryByText('Reset to defaults')).toBeNull()
expect(screen.getByText(/Using default colors for/)).toBeDefined()
})
// ── Bottom connection points ───────────────────────────────────────────
it('shows Bottom Connection Points for server type', () => {
renderModal({ initial: BASE })
expect(screen.getByText('Bottom Connection Points')).toBeDefined()
})
it('hides Bottom Connection Points for groupRect', () => {
renderModal({ initial: { ...BASE, type: 'groupRect' } })
expect(screen.queryByText('Bottom Connection Points')).toBeNull()
})
it('hides Bottom Connection Points for group', () => {
renderModal({ initial: { ...BASE, type: 'group' } })
expect(screen.queryByText('Bottom Connection Points')).toBeNull()
})
it('defaults bottom_handles to 1', () => {
renderModal({ initial: BASE })
expect(selects()[2].value).toBe('1')
})
it('pre-fills bottom_handles from initial', () => {
renderModal({ initial: { ...BASE, bottom_handles: 3 } })
expect(selects()[2].value).toBe('3')
})
it('submits updated bottom_handles', () => {
const { onSubmit } = renderModal({ initial: BASE })
fireEvent.change(selects()[2], { target: { value: '4' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(4)
})
})
@@ -126,7 +126,7 @@ describe('PendingDeviceModal', () => {
// ── Actions ───────────────────────────────────────────────────────────────
it('calls onApprove with the device and onClose when Approve is clicked', () => {
it('calls onApprove with the device when Approve is clicked (parent controls close on success)', () => {
const device = makeDevice()
const onApprove = vi.fn()
const onClose = vi.fn()
@@ -135,7 +135,7 @@ describe('PendingDeviceModal', () => {
)
fireEvent.click(screen.getByRole('button', { name: 'Approve' }))
expect(onApprove).toHaveBeenCalledWith(device)
expect(onClose).toHaveBeenCalledOnce()
expect(onClose).not.toHaveBeenCalled()
})
it('calls onHide with the device and onClose when Hide is clicked', () => {
@@ -6,6 +6,7 @@ vi.mock('@/api/client', () => ({
scanApi: {
getConfig: vi.fn(),
saveConfig: vi.fn(),
trigger: vi.fn(),
},
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
@@ -20,6 +21,8 @@ describe('ScanConfigModal', () => {
vi.mocked(scanApi.getConfig).mockResolvedValue(defaultConfig as never)
vi.mocked(scanApi.saveConfig).mockReset()
vi.mocked(scanApi.saveConfig).mockResolvedValue({} as never)
vi.mocked(scanApi.trigger).mockReset()
vi.mocked(scanApi.trigger).mockResolvedValue({} as never)
vi.mocked(toast.success).mockReset()
vi.mocked(toast.error).mockReset()
})
@@ -38,16 +41,6 @@ describe('ScanConfigModal', () => {
expect(input).toBeDefined()
})
it('saves only ranges (interval managed by settings endpoint)', async () => {
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['10.0.0.0/8'] } } as never)
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('10.0.0.0/8')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['10.0.0.0/8'] })
})
})
it('adds a new empty range on "Add range" click', async () => {
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
@@ -59,62 +52,40 @@ describe('ScanConfigModal', () => {
it('delete button disabled when only one range', async () => {
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
// Only 1 range → delete button disabled
const trashButtons = document.querySelectorAll('button[disabled]')
expect(trashButtons.length).toBeGreaterThan(0)
})
it('can remove a range when more than one exist', async () => {
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['192.168.1.0/24', '10.0.0.0/8'], } } as never)
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['192.168.1.0/24', '10.0.0.0/8'] } } as never)
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
// Both trash buttons should be enabled
const trashButtons = screen.getAllByRole('button').filter((b) => !b.hasAttribute('disabled') && b.querySelector('svg'))
expect(trashButtons.length).toBeGreaterThanOrEqual(2)
})
it('shows error toast and does not save when all ranges are empty', async () => {
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: [''], } } as never)
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: [''] } } as never)
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await waitFor(() => expect(scanApi.getConfig).toHaveBeenCalled())
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Add at least one IP range')
})
expect(scanApi.saveConfig).not.toHaveBeenCalled()
})
it('saves config and closes on Save click', async () => {
const onClose = vi.fn()
render(<ScanConfigModal open onClose={onClose} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'] })
expect(toast.success).toHaveBeenCalledWith('Scan config saved')
expect(onClose).toHaveBeenCalledOnce()
})
})
it('shows error toast when save fails', async () => {
vi.mocked(scanApi.saveConfig).mockRejectedValue(new Error('network'))
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Failed to save config')
})
})
it('calls onScanNow after saving on "Scan Now" click', async () => {
it('saves config, triggers scan, calls onScanNow and closes on "Scan Now" click', async () => {
const onScanNow = vi.fn()
const onClose = vi.fn()
render(<ScanConfigModal open onClose={onClose} onScanNow={onScanNow} />)
await screen.findByDisplayValue('192.168.1.0/24')
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
await waitFor(() => {
expect(scanApi.saveConfig).toHaveBeenCalled()
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'] })
expect(scanApi.trigger).toHaveBeenCalledOnce()
expect(onScanNow).toHaveBeenCalledOnce()
expect(onClose).toHaveBeenCalledOnce()
})
})
@@ -126,12 +97,11 @@ describe('ScanConfigModal', () => {
expect(onClose).toHaveBeenCalledOnce()
})
it('strips whitespace from ranges before saving', async () => {
it('strips whitespace from ranges before scanning', async () => {
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
const input = await screen.findByDisplayValue('192.168.1.0/24')
// Type a range with surrounding whitespace
fireEvent.change(input, { target: { value: ' 10.0.0.0/8 ' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
await waitFor(() => {
expect(scanApi.saveConfig).toHaveBeenCalledWith(
expect.objectContaining({ ranges: ['10.0.0.0/8'] })
@@ -10,6 +10,12 @@ vi.mock('@xyflow/react', () => ({
useReactFlow: () => ({ fitView: mockFitView }),
}))
vi.mock('@/api/client', () => ({
scanApi: { pending: vi.fn().mockResolvedValue({ data: [] }) },
}))
const mockOnOpenPending = vi.fn()
function makeNode(id: string, overrides: Partial<NodeData> = {}): Node<NodeData> {
return {
id,
@@ -32,32 +38,32 @@ describe('SearchModal', () => {
})
it('renders nothing when closed', () => {
render(<SearchModal open={false} onClose={vi.fn()} />)
render(<SearchModal open={false} onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
expect(screen.queryByPlaceholderText(/search nodes/i)).toBeNull()
})
it('renders search input when open', () => {
render(<SearchModal open onClose={vi.fn()} />)
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
expect(screen.getByPlaceholderText(/search nodes/i)).toBeDefined()
})
it('shows "Type to search" hint when query is empty', () => {
render(<SearchModal open onClose={vi.fn()} />)
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
expect(screen.getByText(/type to search/i)).toBeDefined()
})
it('shows no results message when query has no matches', () => {
useCanvasStore.setState({ nodes: [makeNode('router', { label: 'Router' })] })
render(<SearchModal open onClose={vi.fn()} />)
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zzz' } })
expect(screen.getByText(/no nodes match/i)).toBeDefined()
expect(screen.getByText(/no results match/i)).toBeDefined()
})
it('filters nodes by label', () => {
useCanvasStore.setState({
nodes: [makeNode('n1', { label: 'My Router' }), makeNode('n2', { label: 'NAS Server' })],
})
render(<SearchModal open onClose={vi.fn()} />)
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'router' } })
expect(screen.getByText('My Router')).toBeDefined()
expect(screen.queryByText('NAS Server')).toBeNull()
@@ -70,7 +76,7 @@ describe('SearchModal', () => {
makeNode('n2', { label: 'Box B', ip: '10.0.0.1' }),
],
})
render(<SearchModal open onClose={vi.fn()} />)
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: '192.168' } })
expect(screen.getByText('Box A')).toBeDefined()
expect(screen.queryByText('Box B')).toBeNull()
@@ -83,7 +89,7 @@ describe('SearchModal', () => {
makeNode('n2', { label: 'B', hostname: 'nas.local' }),
],
})
render(<SearchModal open onClose={vi.fn()} />)
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'pve' } })
expect(screen.getByText('A')).toBeDefined()
expect(screen.queryByText('B')).toBeNull()
@@ -96,25 +102,25 @@ describe('SearchModal', () => {
makeNode('g1', { label: 'Zone A', type: 'groupRect' }),
],
})
render(<SearchModal open onClose={vi.fn()} />)
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zone' } })
expect(screen.getByText(/no nodes match/i)).toBeDefined()
expect(screen.getByText(/no results match/i)).toBeDefined()
})
it('limits results to 8 nodes', () => {
it('limits node results to 6', () => {
useCanvasStore.setState({
nodes: Array.from({ length: 12 }, (_, i) => makeNode(`n${i}`, { label: `Server ${i}` })),
})
render(<SearchModal open onClose={vi.fn()} />)
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'server' } })
const items = screen.getAllByText(/Server \d/)
expect(items).toHaveLength(8)
expect(items).toHaveLength(6)
})
it('selects node and closes on result click', () => {
const onClose = vi.fn()
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Proxmox' })] })
render(<SearchModal open onClose={onClose} />)
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'prox' } })
fireEvent.click(screen.getByText('Proxmox'))
expect(useCanvasStore.getState().selectedNodeId).toBe('n1')
@@ -125,7 +131,7 @@ describe('SearchModal', () => {
it('selects first result and closes on Enter key', () => {
const onClose = vi.fn()
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Switch' })] })
render(<SearchModal open onClose={onClose} />)
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
const input = screen.getByPlaceholderText(/search nodes/i)
fireEvent.change(input, { target: { value: 'switch' } })
fireEvent.keyDown(input, { key: 'Enter' })
@@ -135,14 +141,14 @@ describe('SearchModal', () => {
it('closes on Escape key', () => {
const onClose = vi.fn()
render(<SearchModal open onClose={onClose} />)
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
fireEvent.keyDown(screen.getByPlaceholderText(/search nodes/i), { key: 'Escape' })
expect(onClose).toHaveBeenCalledOnce()
})
it('closes when clicking backdrop', () => {
const onClose = vi.fn()
render(<SearchModal open onClose={onClose} />)
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
// The backdrop is the fixed inset div — clicking it fires onClose
const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement
fireEvent.click(backdrop)
@@ -151,14 +157,14 @@ describe('SearchModal', () => {
it('does not close when clicking inside the search box', () => {
const onClose = vi.fn()
render(<SearchModal open onClose={onClose} />)
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
fireEvent.click(screen.getByPlaceholderText(/search nodes/i))
expect(onClose).not.toHaveBeenCalled()
})
it('search is case-insensitive', () => {
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'My NAS' })] })
render(<SearchModal open onClose={vi.fn()} />)
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'MY NAS' } })
expect(screen.getByText('My NAS')).toBeDefined()
})
+216 -16
View File
@@ -1,10 +1,11 @@
import { useState } from 'react'
import { createElement, useState } from 'react'
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useCanvasStore } from '@/stores/canvasStore'
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData } from '@/types'
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types'
import { getServiceUrl } from '@/utils/serviceUrl'
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
import type { Node } from '@xyflow/react'
interface DetailPanelProps {
@@ -14,6 +15,9 @@ interface DetailPanelProps {
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string }
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '' }
type PropForm = { key: string; value: string; icon: string | null; visible: boolean }
const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true }
export function DetailPanel({ onEdit }: DetailPanelProps) {
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup } = useCanvasStore()
@@ -24,6 +28,12 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
const [groupName, setGroupName] = useState('')
const [creatingGroup, setCreatingGroup] = useState(false)
// Properties state
const [addingProp, setAddingProp] = useState(false)
const [newProp, setNewProp] = useState<PropForm>(EMPTY_PROP)
const [editingPropIndex, setEditingPropIndex] = useState<number | null>(null)
const [editProp, setEditProp] = useState<PropForm>(EMPTY_PROP)
// Multi-select panel
const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id))
@@ -85,6 +95,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
const handleAddService = () => {
const port = parseInt(newSvc.port, 10)
if (!newSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
snapshotHistory()
const svc: ServiceInfo = { port, protocol: newSvc.protocol, service_name: newSvc.service_name.trim() }
updateNode(node.id, { services: [...services, svc] })
setNewSvc(EMPTY_FORM)
@@ -92,6 +103,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
}
const handleRemoveService = (index: number) => {
snapshotHistory()
const updated = services.filter((_, i) => i !== index)
updateNode(node.id, { services: updated })
if (editingIndex === index) setEditingFor(null)
@@ -109,6 +121,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
if (editingIndex === null) return
const port = parseInt(editSvc.port, 10)
if (!editSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
snapshotHistory()
const updated = services.map((svc, i) =>
i === editingIndex ? { ...svc, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() } : svc
)
@@ -116,6 +129,52 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
setEditingFor(null)
}
// --- Property handlers ---
const properties: NodeProperty[] = data.properties ?? []
const handleAddProp = () => {
if (!newProp.key.trim() || !newProp.value.trim()) return
snapshotHistory()
const prop: NodeProperty = { key: newProp.key.trim(), value: newProp.value.trim(), icon: newProp.icon, visible: newProp.visible }
updateNode(node.id, { properties: [...properties, prop] })
setNewProp(EMPTY_PROP)
setAddingProp(false)
}
const handleRemoveProp = (index: number) => {
snapshotHistory()
updateNode(node.id, { properties: properties.filter((_, i) => i !== index) })
if (editingPropIndex === index) setEditingPropIndex(null)
}
const handleTogglePropVisible = (index: number) => {
snapshotHistory()
updateNode(node.id, {
properties: properties.map((p, i) => i === index ? { ...p, visible: !p.visible } : p),
})
}
const handleStartEditProp = (index: number) => {
const p = properties[index]
if (!p) return
setEditProp({ key: p.key, value: p.value, icon: p.icon, visible: p.visible })
setEditingPropIndex(index)
setAddingProp(false)
}
const handleSaveEditProp = () => {
if (editingPropIndex === null || !editProp.key.trim() || !editProp.value.trim()) return
snapshotHistory()
updateNode(node.id, {
properties: properties.map((p, i) =>
i === editingPropIndex
? { key: editProp.key.trim(), value: editProp.value.trim(), icon: editProp.icon, visible: editProp.visible }
: p
),
})
setEditingPropIndex(null)
}
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">
@@ -147,18 +206,57 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
{data.os && <DetailRow label="OS" value={data.os} />}
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
{data.last_seen && <DetailRow label="Last Seen" value={new Date(data.last_seen).toLocaleString()} />}
{data.last_seen && <DetailRow label="Last Seen" value={new Date(data.last_seen.endsWith('Z') ? data.last_seen : data.last_seen + 'Z').toLocaleString()} />}
</div>
{(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">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Hardware</span>
{data.cpu_model && <DetailRow label="CPU" value={data.cpu_model} />}
{data.cpu_count != null && <DetailRow label="Cores" value={String(data.cpu_count)} mono />}
{data.ram_gb != null && <DetailRow label="RAM" value={formatStorage(data.ram_gb)} mono />}
{data.disk_gb != null && <DetailRow label="Disk" value={formatStorage(data.disk_gb)} mono />}
{/* Properties section */}
<div className="px-4 py-3 border-t border-border">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground">Properties{properties.length > 0 ? ` (${properties.length})` : ''}</span>
<button
onClick={() => { setAddingProp((v) => !v); setEditingPropIndex(null) }}
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors"
>
<Plus size={10} /> Add
</button>
</div>
)}
{addingProp && (
<PropertyForm
form={newProp}
onChange={setNewProp}
onConfirm={handleAddProp}
onCancel={() => { setAddingProp(false); setNewProp(EMPTY_PROP) }}
confirmLabel="Add"
/>
)}
{properties.length > 0 && (
<div className="flex flex-col gap-1.5">
{properties.map((prop, i) =>
editingPropIndex === i ? (
<PropertyForm
key={`edit-${i}`}
form={editProp}
onChange={setEditProp}
onConfirm={handleSaveEditProp}
onCancel={() => setEditingPropIndex(null)}
confirmLabel="Save"
/>
) : (
<PropertyBadge
key={`${prop.key}-${i}`}
prop={prop}
onToggleVisible={() => handleTogglePropVisible(i)}
onEdit={() => handleStartEditProp(i)}
onRemove={() => handleRemoveProp(i)}
/>
)
)}
</div>
)}
{properties.length === 0 && !addingProp && (
<p className="text-[10px] text-muted-foreground/50">No properties click Add to define one.</p>
)}
</div>
<div className="px-4 py-3 border-t border-border">
<div className="flex items-center justify-between mb-2">
@@ -362,11 +460,6 @@ function GroupDetailPanel({ node, nodes, onUngroup, onToggleBorder, onClose, onS
// --- Helpers ---
function formatStorage(gb: number): string {
if (gb >= 1024) return `${(gb / 1024).toFixed(1).replace(/\.0$/, '')} TB`
return `${gb} GB`
}
function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex justify-between gap-2 items-baseline">
@@ -404,6 +497,113 @@ function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFo
)
}
// --- Property components ---
function PropertyForm({ form, onChange, onConfirm, onCancel, confirmLabel }: {
form: PropForm
onChange: (f: PropForm) => void
onConfirm: () => void
onCancel: () => void
confirmLabel: string
}) {
return (
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
<Input
value={form.key}
onChange={(e) => onChange({ ...form, key: e.target.value })}
placeholder="Label (e.g. CPU Model)"
className="bg-[#21262d] border-[#30363d] text-xs h-7"
autoFocus
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
/>
<Input
value={form.value}
onChange={(e) => onChange({ ...form, value: e.target.value })}
placeholder="Value (e.g. i7-12700K)"
className="bg-[#21262d] border-[#30363d] text-xs h-7"
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
/>
{/* Icon picker */}
<div className="flex flex-wrap gap-1 pt-0.5">
<button
onClick={() => onChange({ ...form, icon: null })}
title="No icon"
className={`w-6 h-6 rounded flex items-center justify-center text-[10px] border transition-colors ${
form.icon === null ? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]' : 'border-[#30363d] text-muted-foreground hover:border-[#8b949e]'
}`}
>
</button>
{PROPERTY_ICON_NAMES.map((name) => {
const Icon = PROPERTY_ICONS[name]
const active = form.icon === name
return (
<button
key={name}
onClick={() => onChange({ ...form, icon: name })}
title={name}
className={`w-6 h-6 rounded flex items-center justify-center border transition-colors ${
active ? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]' : 'border-[#30363d] text-muted-foreground hover:border-[#8b949e]'
}`}
>
{createElement(Icon, { size: 11 })}
</button>
)
})}
</div>
{/* Visible toggle */}
<label className="flex items-center gap-2 cursor-pointer pt-0.5">
<input
type="checkbox"
checked={form.visible}
onChange={(e) => onChange({ ...form, visible: e.target.checked })}
className="accent-[#00d4ff] w-3 h-3"
/>
<span className="text-[10px] text-muted-foreground">Show on node</span>
</label>
<div className="flex gap-1.5">
<Button size="sm" 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>
)
}
function PropertyBadge({ prop, onToggleVisible, onEdit, onRemove }: {
prop: NodeProperty
onToggleVisible: () => void
onEdit: () => void
onRemove: () => void
}) {
const Icon = resolvePropertyIcon(prop.icon)
return (
<div className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors" style={{ background: '#21262d', borderColor: '#30363d' }}>
<div className="flex items-center gap-1.5 min-w-0">
{Icon && createElement(Icon, { size: 11, className: 'shrink-0 text-muted-foreground' })}
<span className="font-medium truncate text-foreground" title={prop.key}>{prop.key}</span>
<span className="text-muted-foreground truncate" title={prop.value}>· {prop.value}</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={onToggleVisible}
title={prop.visible ? 'Hide on node' : 'Show on node'}
className="text-[#8b949e] hover:text-[#00d4ff] transition-colors"
>
{prop.visible ? <Eye size={10} /> : <EyeOff size={10} />}
</button>
<button onClick={onEdit} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff]" title="Edit property">
<Pencil size={10} />
</button>
<button onClick={onRemove} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149]" title="Remove property">
<X size={10} />
</button>
</div>
</div>
)
}
const CATEGORY_COLORS: Record<string, string> = {
web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
}
+83 -22
View File
@@ -1,10 +1,11 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle } from 'lucide-react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, X } from 'lucide-react'
import { Logo } from '@/components/ui/Logo'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvasStore'
import { scanApi, settingsApi } from '@/api/client'
import { toast } from 'sonner'
import { useLatestRelease } from '@/hooks/useLatestRelease'
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
@@ -35,26 +36,26 @@ interface SidebarProps {
onScan: () => void
onSave: () => void
onNodeApproved: (nodeId: string) => void
forceView?: SidebarView
highlightPendingId?: string
}
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved }: SidebarProps) {
const [collapsed, setCollapsed] = useState(false)
const [activeView, setActiveView] = useState<SidebarView>('canvas')
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) {
const [_collapsed, setCollapsed] = useState(false)
const [_activeView, setActiveView] = useState<SidebarView>('canvas')
// When forceView is set, override local state without useEffect
const collapsed = forceView ? false : _collapsed
const activeView = forceView ?? _activeView
const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect')
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length
const handleScan = useCallback(async () => {
try {
await scanApi.trigger()
toast.success('Network scan started — check Scan History for results')
setActiveView('history')
onScan()
} catch {
toast.error('Failed to trigger scan')
}
const handleScan = useCallback(() => {
onScan()
}, [onScan])
return (
@@ -92,7 +93,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
{/* View content (only when expanded) */}
{!collapsed && activeView !== 'canvas' && (
<div className="flex-1 min-h-0 overflow-y-auto border-t border-border">
{activeView === 'pending' && <PendingDevicesPanel onNodeApproved={onNodeApproved} />}
{activeView === 'pending' && <PendingDevicesPanel onNodeApproved={onNodeApproved} highlightId={highlightPendingId} />}
{activeView === 'hidden' && <HiddenDevicesPanel />}
{activeView === 'history' && <ScanHistoryPanel />}
{activeView === 'settings' && <SettingsPanel />}
@@ -152,15 +153,20 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
/>
)}
</div>
{!collapsed && <VersionBadge />}
</aside>
)
}
function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: string) => void }) {
const COMMON_PORTS = new Set([22, 80, 443])
function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved: (nodeId: string) => void; highlightId?: string }) {
const [devices, setDevices] = useState<PendingDevice[]>([])
const [loading, setLoading] = useState(false)
const [selected, setSelected] = useState<PendingDevice | null>(null)
const { addNode, scanEventTs } = useCanvasStore()
const highlightRef = useRef<HTMLButtonElement>(null)
const load = useCallback(async () => {
setLoading(true)
@@ -174,12 +180,27 @@ function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: stri
}
}, [])
const handleClearAll = async () => {
try {
await scanApi.clearPending()
setDevices([])
toast.success('Pending devices cleared')
} catch {
toast.error('Failed to clear pending devices')
}
}
useEffect(() => { load() }, [load])
useEffect(() => {
if (scanEventTs > 0) load()
}, [scanEventTs, load])
useEffect(() => {
if (!highlightId || loading) return
highlightRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}, [highlightId, loading])
const handleApprove = async (device: PendingDevice) => {
try {
const nodeData = {
@@ -200,6 +221,7 @@ function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: stri
})
toast.success(`Approved ${nodeData.label}`)
setDevices((prev) => prev.filter((d) => d.id !== device.id))
setSelected(null)
onNodeApproved(nodeId)
} catch {
toast.error('Failed to approve device')
@@ -230,16 +252,22 @@ function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: stri
<div className="p-2">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Pending</span>
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5">
<RefreshCw size={12} />
</button>
<div className="flex items-center gap-1">
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5" title="Refresh">
<RefreshCw size={12} />
</button>
{devices.length > 0 && (
<button onClick={handleClearAll} className="text-muted-foreground hover:text-[#f85149] p-0.5" title="Clear all pending">
<X size={12} />
</button>
)}
</div>
</div>
{loading && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
{!loading && devices.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-4">No pending devices</p>
)}
{devices.map((d) => {
const COMMON_PORTS = new Set([22, 80, 443])
const namedService = d.services.find((s) => s.category != null && !COMMON_PORTS.has(s.port))
const titleService = namedService
?? d.services.find((s) => s.port === 80)
@@ -252,11 +280,15 @@ function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: stri
const hasHttps = d.services.some((s) => s.port === 443)
const otherCount = d.services.filter((s) => s.port !== 22 && s.port !== 80 && s.port !== 443).length
const virtualBadge = detectVirtualBadge(d.mac)
const sourceColor = d.discovery_source === 'mdns' ? '#a855f7' : '#8b949e'
const sourceLabel = d.discovery_source === 'mdns' ? 'mDNS' : d.discovery_source === 'arp' ? 'ARP' : null
const isHighlighted = d.id === highlightId
return (
<button
key={d.id}
ref={isHighlighted ? highlightRef : null}
onClick={() => setSelected(d)}
className="w-full mb-1.5 p-2 rounded-md bg-[#21262d] text-xs text-left hover:bg-[#30363d] transition-colors border border-transparent hover:border-[#30363d]"
className={`w-full mb-1.5 p-2 rounded-md text-xs text-left transition-colors border ${isHighlighted ? 'bg-[#2d3748] border-[#e3b341]' : 'bg-[#21262d] border-transparent hover:bg-[#30363d] hover:border-[#30363d]'}`}
>
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-[#e3b341] shrink-0" />
@@ -265,8 +297,9 @@ function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: stri
{showIpBelow && (
<div className="font-mono text-muted-foreground truncate pl-3 text-[10px] mt-0.5">{d.ip}</div>
)}
{(hasSsh || hasHttp || hasHttps || otherCount > 0 || virtualBadge) && (
{(hasSsh || hasHttp || hasHttps || otherCount > 0 || virtualBadge || sourceLabel) && (
<div className="flex items-center gap-1 pl-3 mt-1.5 flex-wrap">
{sourceLabel && <ServiceBadge label={sourceLabel} color={sourceColor} />}
{virtualBadge && (
<Tooltip>
<TooltipTrigger>
@@ -447,7 +480,7 @@ function ScanHistoryPanel() {
)}
</div>
<div className="text-muted-foreground text-[10px] mt-0.5">
{new Date(r.started_at).toLocaleString()}
{new Date(r.started_at.endsWith('Z') ? r.started_at : r.started_at + 'Z').toLocaleString()}
</div>
{r.ranges.length > 0 && (
<div className="text-[#8b949e] text-[10px] font-mono truncate">{r.ranges.join(', ')}</div>
@@ -518,6 +551,34 @@ function SettingsPanel() {
)
}
function VersionBadge() {
const current = __APP_VERSION__
const { latest, hasUpdate } = useLatestRelease(current)
return (
<div className="px-3 py-2 border-t border-border flex flex-col gap-1">
<a
href={`https://github.com/Pouzor/homelable/releases/tag/v${current}`}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-[11px] text-muted-foreground hover:text-foreground transition-colors"
>
v{current}
</a>
{hasUpdate && latest && (
<a
href={latest.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-[#e3b341]/15 text-[#e3b341] border border-[#e3b341]/30 hover:bg-[#e3b341]/25 transition-colors self-start"
>
v{latest.version} available
</a>
)}
</div>
)
}
const MAC_OUI: Record<string, { label: string; title: string }> = {
'52:54:00': { label: 'QEMU', title: 'QEMU/KVM Virtual Machine' },
'bc:24:11': { label: 'PVE', title: 'Proxmox Virtual Machine or LXC' },
@@ -69,58 +69,137 @@ describe('DetailPanel', () => {
expect(container.firstChild).toBeNull()
})
describe('Hardware section', () => {
it('does not render hardware section when no hardware data', () => {
setupStore({ label: 'Server' })
describe('Properties section', () => {
it('renders empty state when no properties', () => {
setupStore({ properties: [] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.queryByText('Hardware')).toBeNull()
expect(screen.getByText(/No properties/)).toBeDefined()
})
it('renders hardware section when cpu_count is set', () => {
setupStore({ cpu_count: 8 })
it('renders properties with key and value', () => {
setupStore({
properties: [
{ key: 'CPU Model', value: 'i7-12700K', icon: 'Cpu', visible: true },
{ key: 'RAM', value: '32 GB', icon: 'MemoryStick', visible: false },
],
})
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('Hardware')).toBeDefined()
expect(screen.getByText('8')).toBeDefined()
expect(screen.getByText('CPU Model')).toBeDefined()
// Value is rendered with a middle-dot prefix: "· 32 GB"
expect(screen.getByText(/32 GB/)).toBeDefined()
})
it('renders cpu_model', () => {
setupStore({ cpu_model: 'Intel Xeon E5-2680' })
it('shows Properties count when properties exist', () => {
setupStore({
properties: [
{ key: 'CPU Model', value: 'i7', icon: null, visible: true },
{ key: 'RAM', value: '16 GB', icon: null, visible: true },
],
})
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('Intel Xeon E5-2680')).toBeDefined()
expect(screen.getByText('Properties (2)')).toBeDefined()
})
it('formats ram_gb in GB', () => {
setupStore({ ram_gb: 32 })
it('shows add form when Add is clicked', () => {
setupStore({ properties: [] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('32 GB')).toBeDefined()
// There are multiple "Add" buttons (services + properties) — find the one after "Properties"
const addButtons = screen.getAllByText('Add')
fireEvent.click(addButtons[0]) // first Add = properties (rendered above services)
expect(screen.getByPlaceholderText('Label (e.g. CPU Model)')).toBeDefined()
})
it('formats ram_gb >= 1024 as TB', () => {
setupStore({ ram_gb: 2048 })
it('calls updateNode with new property on Add confirm', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ properties: [] })],
selectedNodeId: 'n1',
selectedNodeIds: [],
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode,
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('2 TB')).toBeDefined()
const addButtons = screen.getAllByText('Add')
fireEvent.click(addButtons[0]) // first Add = properties
// Form is now open — fill key and value
fireEvent.change(screen.getByPlaceholderText('Label (e.g. CPU Model)'), { target: { value: 'GPU' } })
fireEvent.change(screen.getByPlaceholderText('Value (e.g. i7-12700K)'), { target: { value: 'RTX 4090' } })
// The PropertyForm confirm button is labeled "Add" — use the form's confirm button
fireEvent.keyDown(screen.getByPlaceholderText('Value (e.g. i7-12700K)'), { key: 'Enter' })
expect(updateNode).toHaveBeenCalledOnce()
const [, payload] = updateNode.mock.calls[0]
expect(payload.properties[0]).toMatchObject({ key: 'GPU', value: 'RTX 4090', visible: true })
})
it('formats disk_gb in GB', () => {
setupStore({ disk_gb: 500 })
it('calls updateNode with toggled visibility when eye button is clicked', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ properties: [{ key: 'RAM', value: '32 GB', icon: 'MemoryStick', visible: true }] })],
selectedNodeId: 'n1',
selectedNodeIds: [],
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode,
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('500 GB')).toBeDefined()
fireEvent.click(screen.getByTitle('Hide on node'))
expect(updateNode).toHaveBeenCalledOnce()
const [, payload] = updateNode.mock.calls[0]
expect(payload.properties[0].visible).toBe(false)
})
it('formats disk_gb >= 1024 as TB', () => {
setupStore({ disk_gb: 1536 })
it('calls updateNode without the property when remove button is clicked', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ properties: [{ key: 'GPU', value: 'RTX 4090', icon: null, visible: true }] })],
selectedNodeId: 'n1',
selectedNodeIds: [],
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode,
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('1.5 TB')).toBeDefined()
fireEvent.click(screen.getByTitle('Remove property'))
expect(updateNode).toHaveBeenCalledOnce()
const [, payload] = updateNode.mock.calls[0]
expect(payload.properties).toHaveLength(0)
})
it('renders all hardware fields together', () => {
setupStore({ cpu_count: 16, cpu_model: 'AMD EPYC', ram_gb: 128, disk_gb: 4096 })
it('does not submit add form when key is empty', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ properties: [] })],
selectedNodeId: 'n1',
selectedNodeIds: [],
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode,
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('Hardware')).toBeDefined()
expect(screen.getByText('AMD EPYC')).toBeDefined()
expect(screen.getByText('16')).toBeDefined()
expect(screen.getByText('128 GB')).toBeDefined()
expect(screen.getByText('4 TB')).toBeDefined()
const addButtons = screen.getAllByText('Add')
fireEvent.click(addButtons[0])
// Only fill value, leave key empty
fireEvent.change(screen.getByPlaceholderText('Value (e.g. i7-12700K)'), { target: { value: 'some value' } })
const confirmButtons = screen.getAllByRole('button', { name: 'Add' })
fireEvent.click(confirmButtons[confirmButtons.length - 1])
expect(updateNode).not.toHaveBeenCalled()
})
})
@@ -189,7 +268,9 @@ describe('DetailPanel', () => {
it('shows add form when Add is clicked', () => {
setupStore({})
render(<DetailPanel onEdit={vi.fn()} />)
fireEvent.click(screen.getByText('Add'))
// Two "Add" buttons: first = properties, second = services
const addButtons = screen.getAllByText('Add')
fireEvent.click(addButtons[addButtons.length - 1])
expect(screen.getByPlaceholderText('Service name')).toBeDefined()
})
@@ -198,18 +279,21 @@ describe('DetailPanel', () => {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({})],
selectedNodeId: 'n1',
selectedNodeIds: [],
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode,
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
fireEvent.click(screen.getByText('Add'))
// Two "Add" header buttons: first = properties, second = services
const addHeaders = screen.getAllByText('Add')
fireEvent.click(addHeaders[addHeaders.length - 1])
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } })
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } })
// Two "Add" buttons exist: the header toggle and the form confirm — pick the form's
const addButtons = screen.getAllByRole('button', { name: 'Add' })
fireEvent.click(addButtons[addButtons.length - 1])
fireEvent.keyDown(screen.getByPlaceholderText('Port'), { key: 'Enter' })
expect(updateNode).toHaveBeenCalledOnce()
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp' })
})
@@ -0,0 +1,261 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { Sidebar } from '../Sidebar'
import { useCanvasStore } from '@/stores/canvasStore'
import type { Node } from '@xyflow/react'
import type { NodeData } from '@/types'
// ── Mocks ────────────────────────────────────────────────────────────────────
vi.mock('@/stores/canvasStore')
vi.mock('@/api/client', () => ({
scanApi: {
trigger: vi.fn().mockResolvedValue({}),
pending: vi.fn().mockResolvedValue({ data: [] }),
hidden: vi.fn().mockResolvedValue({ data: [] }),
runs: vi.fn().mockResolvedValue({ data: [] }),
stop: vi.fn().mockResolvedValue({}),
},
settingsApi: {
get: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }),
save: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }),
},
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('@/components/ui/Logo', () => ({
Logo: ({ showText }: { showText: boolean }) => (
<div data-testid="logo" data-show-text={showText} />
),
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: () => null,
}))
vi.mock('@/components/modals/PendingDeviceModal', () => ({
PendingDeviceModal: () => null,
}))
// ── Helpers ───────────────────────────────────────────────────────────────────
const makeNode = (id: string, status: NodeData['status'], type: NodeData['type'] = 'server'): Node<NodeData> => ({
id,
type,
position: { x: 0, y: 0 },
data: { label: id, type, status, services: [] },
})
const mockToggleHideIp = vi.fn()
function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
vi.mocked(useCanvasStore).mockReturnValue({
nodes: [],
hasUnsavedChanges: false,
hideIp: false,
toggleHideIp: mockToggleHideIp,
addNode: vi.fn(),
scanEventTs: 0,
...overrides,
} as ReturnType<typeof useCanvasStore>)
}
const defaultProps = {
onAddNode: vi.fn(),
onAddGroupRect: vi.fn(),
onScan: vi.fn(),
onSave: vi.fn(),
onNodeApproved: vi.fn(),
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('Sidebar', () => {
beforeEach(() => {
mockStore()
vi.clearAllMocks()
})
// ── Render ─────────────────────────────────────────────────────────────────
it('renders logo and nav items', () => {
render(<Sidebar {...defaultProps} />)
expect(screen.getByTestId('logo')).toBeInTheDocument()
expect(screen.getByText('Add Node')).toBeInTheDocument()
expect(screen.getByText('Save Canvas')).toBeInTheDocument()
expect(screen.getByText('Scan Network')).toBeInTheDocument()
})
it('shows all view nav items', () => {
render(<Sidebar {...defaultProps} />)
expect(screen.getByText('Canvas')).toBeInTheDocument()
expect(screen.getByText('Pending Devices')).toBeInTheDocument()
expect(screen.getByText('Hidden Devices')).toBeInTheDocument()
expect(screen.getByText('Scan History')).toBeInTheDocument()
})
// ── Stats ──────────────────────────────────────────────────────────────────
it('displays total / online / offline counts from store', () => {
mockStore({
nodes: [
makeNode('n1', 'online'),
makeNode('n2', 'online'),
makeNode('n3', 'offline'),
makeNode('n4', 'unknown'),
],
})
render(<Sidebar {...defaultProps} />)
// Total (excludes groupRect)
expect(screen.getByText('4')).toBeInTheDocument()
// Online
expect(screen.getByText('2')).toBeInTheDocument()
// Offline
expect(screen.getByText('1')).toBeInTheDocument()
})
it('excludes groupRect nodes from stats', () => {
mockStore({
nodes: [
makeNode('n1', 'unknown'), // 1 real node, not online/offline
makeNode('zone', 'unknown', 'groupRect'),
],
})
render(<Sidebar {...defaultProps} />)
// Total row shows 1 (groupRect excluded), online/offline both 0
const totalRow = screen.getByText('Total').closest('div')!
expect(totalRow).toHaveTextContent('1')
expect(screen.getAllByText('0')).toHaveLength(2) // online=0, offline=0
})
// ── Collapse ───────────────────────────────────────────────────────────────
it('collapses sidebar on toggle button click', () => {
render(<Sidebar {...defaultProps} />)
const aside = screen.getByRole('complementary')
expect(aside).toHaveStyle({ width: '220px' })
const toggle = aside.querySelector('button')!
fireEvent.click(toggle)
expect(aside).toHaveStyle({ width: '48px' })
})
it('hides label text when collapsed', () => {
render(<Sidebar {...defaultProps} />)
const aside = screen.getByRole('complementary')
const toggle = aside.querySelector('button')!
fireEvent.click(toggle)
expect(screen.queryByText('Add Node')).not.toBeInTheDocument()
})
it('hides stats footer when collapsed', () => {
render(<Sidebar {...defaultProps} />)
expect(screen.getByText('Total')).toBeInTheDocument()
const toggle = screen.getByRole('complementary').querySelector('button')!
fireEvent.click(toggle)
expect(screen.queryByText('Total')).not.toBeInTheDocument()
})
it('shows logo with showText=false when collapsed', () => {
render(<Sidebar {...defaultProps} />)
const logo = screen.getByTestId('logo')
expect(logo).toHaveAttribute('data-show-text', 'true')
const toggle = screen.getByRole('complementary').querySelector('button')!
fireEvent.click(toggle)
expect(logo).toHaveAttribute('data-show-text', 'false')
})
// ── Action callbacks ───────────────────────────────────────────────────────
it('calls onAddNode when Add Node is clicked', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Add Node'))
expect(defaultProps.onAddNode).toHaveBeenCalledOnce()
})
it('calls onAddGroupRect when Add Zone is clicked', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Add Zone'))
expect(defaultProps.onAddGroupRect).toHaveBeenCalledOnce()
})
it('calls onSave when Save Canvas is clicked', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Save Canvas'))
expect(defaultProps.onSave).toHaveBeenCalledOnce()
})
it('calls toggleHideIp when Hide IPs is clicked', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Hide IPs'))
expect(mockToggleHideIp).toHaveBeenCalledOnce()
})
it('shows Show IPs label when hideIp is true', () => {
mockStore({ hideIp: true })
render(<Sidebar {...defaultProps} />)
expect(screen.getByText('Show IPs')).toBeInTheDocument()
})
// ── Unsaved changes badge ──────────────────────────────────────────────────
it('shows unsaved badge dot on Save Canvas when hasUnsavedChanges', () => {
mockStore({ hasUnsavedChanges: true })
render(<Sidebar {...defaultProps} />)
// The badge is a span sibling of the Save Canvas button icon
const saveBtn = screen.getByText('Save Canvas').closest('button')!
const badge = saveBtn.querySelector('span.rounded-full')
expect(badge).toBeInTheDocument()
})
it('does not show unsaved badge when no changes', () => {
mockStore({ hasUnsavedChanges: false })
render(<Sidebar {...defaultProps} />)
const saveBtn = screen.getByText('Save Canvas').closest('button')!
const badge = saveBtn.querySelector('span.rounded-full')
expect(badge).not.toBeInTheDocument()
})
// ── Scan action ────────────────────────────────────────────────────────────
it('calls onScan prop when Scan Network is clicked (scan trigger moved to ScanConfigModal)', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Scan Network'))
expect(defaultProps.onScan).toHaveBeenCalledOnce()
})
// ── Navigation ─────────────────────────────────────────────────────────────
it('shows Pending panel when Pending Devices nav item is clicked', async () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Pending Devices'))
await waitFor(() => expect(screen.getByText('No pending devices')).toBeInTheDocument())
})
it('shows Hidden panel when Hidden Devices nav item is clicked', async () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Hidden Devices'))
await waitFor(() => expect(screen.getByText('No hidden devices')).toBeInTheDocument())
})
it('shows History panel when Scan History nav item is clicked', async () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Scan History'))
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
})
it('toggles Settings panel on Settings click', async () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Settings'))
await waitFor(() =>
expect(screen.getByText('Status check interval (s)')).toBeInTheDocument(),
)
// Click the nav button again to close (use role to avoid matching the panel heading)
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,121 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { Sidebar } from '../Sidebar'
import { useCanvasStore } from '@/stores/canvasStore'
// ── Mocks ─────────────────────────────────────────────────────────────────────
vi.mock('@/stores/canvasStore')
vi.mock('@/api/client', () => ({
scanApi: {
trigger: vi.fn().mockResolvedValue({}),
pending: vi.fn().mockResolvedValue({ data: [] }),
hidden: vi.fn().mockResolvedValue({ data: [] }),
runs: vi.fn().mockResolvedValue({ data: [] }),
stop: vi.fn().mockResolvedValue({}),
getConfig: vi.fn().mockResolvedValue({ data: { ranges: [] } }),
},
settingsApi: {
get: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }),
save: vi.fn().mockResolvedValue({}),
},
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('@/components/ui/Logo', () => ({ Logo: () => null }))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: () => null,
}))
vi.mock('@/components/modals/PendingDeviceModal', () => ({ PendingDeviceModal: () => null }))
vi.mock('@/components/modals/StatusTimelineModal', () => ({ StatusTimelineModal: () => null }))
vi.mock('@/hooks/useLatestRelease', () => ({
useLatestRelease: vi.fn(),
}))
import { useLatestRelease } from '@/hooks/useLatestRelease'
// ── Helpers ───────────────────────────────────────────────────────────────────
function renderSidebar() {
vi.mocked(useCanvasStore).mockReturnValue({
nodes: [],
hasUnsavedChanges: false,
hideIp: false,
toggleHideIp: vi.fn(),
addNode: vi.fn(),
scanEventTs: 0,
} as unknown as ReturnType<typeof useCanvasStore>)
return render(
<Sidebar
onAddNode={vi.fn()}
onAddGroupRect={vi.fn()}
onScan={vi.fn()}
onSave={vi.fn()}
onNodeApproved={vi.fn()}
/>,
)
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('VersionBadge', () => {
beforeEach(() => {
vi.mocked(useLatestRelease).mockReturnValue({ latest: null, hasUpdate: false })
})
it('displays the current app version', () => {
renderSidebar()
expect(screen.getByText(`v${__APP_VERSION__}`)).toBeInTheDocument()
})
it('links current version to its GitHub release page', () => {
renderSidebar()
const link = screen.getByText(`v${__APP_VERSION__}`).closest('a')
expect(link).toHaveAttribute(
'href',
`https://github.com/Pouzor/homelable/releases/tag/v${__APP_VERSION__}`,
)
expect(link).toHaveAttribute('target', '_blank')
})
it('does not show update badge when on latest version', () => {
renderSidebar()
expect(screen.queryByText(/available/)).not.toBeInTheDocument()
})
it('shows update badge when a newer version is available', async () => {
vi.mocked(useLatestRelease).mockReturnValue({
latest: { version: '9.9.9', url: 'https://github.com/Pouzor/homelable/releases/tag/v9.9.9' },
hasUpdate: true,
})
renderSidebar()
await waitFor(() => expect(screen.getByText('↑ v9.9.9 available')).toBeInTheDocument())
})
it('update badge links to the latest release URL', async () => {
vi.mocked(useLatestRelease).mockReturnValue({
latest: { version: '9.9.9', url: 'https://github.com/Pouzor/homelable/releases/tag/v9.9.9' },
hasUpdate: true,
})
renderSidebar()
await waitFor(() => {
const badge = screen.getByText('↑ v9.9.9 available').closest('a')
expect(badge).toHaveAttribute('href', 'https://github.com/Pouzor/homelable/releases/tag/v9.9.9')
expect(badge).toHaveAttribute('target', '_blank')
})
})
it('does not show update badge when hasUpdate is false even if latest exists', () => {
vi.mocked(useLatestRelease).mockReturnValue({
latest: { version: __APP_VERSION__, url: 'https://github.com' },
hasUpdate: false,
})
renderSidebar()
expect(screen.queryByText(/available/)).not.toBeInTheDocument()
})
})
@@ -0,0 +1,105 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
// Reset module between tests so the module-level cache is cleared
async function freshHook() {
vi.resetModules()
const mod = await import('../useLatestRelease')
return mod.useLatestRelease
}
const CURRENT = '1.8.3'
function mockFetch(payload: unknown, ok = true) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok,
json: () => Promise.resolve(payload),
}),
)
}
describe('useLatestRelease', () => {
beforeEach(() => {
vi.unstubAllGlobals()
})
it('returns no update when latest version matches current', async () => {
mockFetch({ tag_name: 'v1.8.3', html_url: 'https://github.com/Pouzor/homelable/releases/tag/v1.8.3' })
const useLatestRelease = await freshHook()
const { result } = renderHook(() => useLatestRelease(CURRENT))
await waitFor(() => expect(result.current.latest).not.toBeNull())
expect(result.current.hasUpdate).toBe(false)
})
it('returns update when latest version is newer', async () => {
mockFetch({ tag_name: 'v1.9.0', html_url: 'https://github.com/Pouzor/homelable/releases/tag/v1.9.0' })
const useLatestRelease = await freshHook()
const { result } = renderHook(() => useLatestRelease(CURRENT))
await waitFor(() => expect(result.current.hasUpdate).toBe(true))
expect(result.current.latest?.version).toBe('1.9.0')
expect(result.current.latest?.url).toBe('https://github.com/Pouzor/homelable/releases/tag/v1.9.0')
})
it('strips leading v from tag_name', async () => {
mockFetch({ tag_name: 'v2.0.0', html_url: 'https://github.com/example' })
const useLatestRelease = await freshHook()
const { result } = renderHook(() => useLatestRelease(CURRENT))
await waitFor(() => expect(result.current.latest).not.toBeNull())
expect(result.current.latest?.version).toBe('2.0.0')
})
it('does not show update when API returns non-ok response', async () => {
mockFetch({ message: 'Not Found' }, false)
const useLatestRelease = await freshHook()
const { result } = renderHook(() => useLatestRelease(CURRENT))
await new Promise((r) => setTimeout(r, 50))
expect(result.current.hasUpdate).toBe(false)
expect(result.current.latest).toBeNull()
})
it('does not show update when API returns missing tag_name', async () => {
mockFetch({ html_url: 'https://github.com/example' })
const useLatestRelease = await freshHook()
const { result } = renderHook(() => useLatestRelease(CURRENT))
await new Promise((r) => setTimeout(r, 50))
expect(result.current.hasUpdate).toBe(false)
})
it('does not show update when API returns missing html_url', async () => {
mockFetch({ tag_name: 'v2.0.0' })
const useLatestRelease = await freshHook()
const { result } = renderHook(() => useLatestRelease(CURRENT))
await new Promise((r) => setTimeout(r, 50))
expect(result.current.hasUpdate).toBe(false)
})
it('does not show update when fetch throws', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error')))
const useLatestRelease = await freshHook()
const { result } = renderHook(() => useLatestRelease(CURRENT))
await new Promise((r) => setTimeout(r, 50))
expect(result.current.hasUpdate).toBe(false)
})
it('fetches only once when hook is mounted multiple times concurrently', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ tag_name: 'v1.8.3', html_url: 'https://github.com' }),
})
vi.stubGlobal('fetch', fetchMock)
const useLatestRelease = await freshHook()
// Mount all three before the fetch resolves — cache is set to 'pending' after first mount
const a = renderHook(() => useLatestRelease(CURRENT))
const b = renderHook(() => useLatestRelease(CURRENT))
const c = renderHook(() => useLatestRelease(CURRENT))
await waitFor(() => {
expect(a.result.current.latest).not.toBeNull()
})
expect(fetchMock).toHaveBeenCalledTimes(1)
// All hooks see the same result once cache resolves
expect(b.result.current.hasUpdate).toBe(false)
expect(c.result.current.hasUpdate).toBe(false)
})
})
@@ -0,0 +1,170 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useStatusPolling } from '../useStatusPolling'
import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore'
vi.mock('@/stores/canvasStore')
vi.mock('@/stores/authStore')
const mockUpdateNode = vi.fn()
const mockNotifyScanDeviceFound = vi.fn()
class MockWebSocket {
static instances: MockWebSocket[] = []
url: string
onopen: (() => void) | null = null
onmessage: ((e: { data: string }) => void) | null = null
onerror: ((e: unknown) => void) | null = null
send = vi.fn()
close = vi.fn()
constructor(url: string) {
this.url = url
MockWebSocket.instances.push(this)
}
}
describe('useStatusPolling', () => {
beforeEach(() => {
MockWebSocket.instances = []
vi.stubGlobal('WebSocket', MockWebSocket)
vi.mocked(useCanvasStore).mockReturnValue({
updateNode: mockUpdateNode,
notifyScanDeviceFound: mockNotifyScanDeviceFound,
} as ReturnType<typeof useCanvasStore>)
vi.mocked(useAuthStore).mockReturnValue({
isAuthenticated: true,
token: 'test-token',
} as ReturnType<typeof useAuthStore>)
Object.defineProperty(window, 'location', {
value: { protocol: 'http:', host: 'localhost:5173' },
writable: true,
})
})
afterEach(() => {
vi.restoreAllMocks()
mockUpdateNode.mockClear()
mockNotifyScanDeviceFound.mockClear()
})
it('does not open WebSocket when not authenticated', () => {
vi.mocked(useAuthStore).mockReturnValue({
isAuthenticated: false,
token: null,
} as ReturnType<typeof useAuthStore>)
renderHook(() => useStatusPolling())
expect(MockWebSocket.instances).toHaveLength(0)
})
it('does not open WebSocket when token is missing', () => {
vi.mocked(useAuthStore).mockReturnValue({
isAuthenticated: true,
token: null,
} as ReturnType<typeof useAuthStore>)
renderHook(() => useStatusPolling())
expect(MockWebSocket.instances).toHaveLength(0)
})
it('connects to correct ws:// URL', () => {
renderHook(() => useStatusPolling())
expect(MockWebSocket.instances).toHaveLength(1)
expect(MockWebSocket.instances[0].url).toBe('ws://localhost:5173/api/v1/status/ws/status')
})
it('uses wss:// when page is served over https', () => {
Object.defineProperty(window, 'location', {
value: { protocol: 'https:', host: 'mylab.local' },
writable: true,
})
renderHook(() => useStatusPolling())
expect(MockWebSocket.instances[0].url).toMatch(/^wss:\/\//)
})
it('sends token as first message on open', () => {
renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
ws.onopen?.()
expect(ws.send).toHaveBeenCalledWith(JSON.stringify({ token: 'test-token' }))
})
it('calls updateNode with correct data on status message', () => {
renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
ws.onmessage?.({
data: JSON.stringify({
node_id: 'node-1',
status: 'online',
checked_at: '2024-01-01T12:00:00Z',
response_time_ms: 42,
}),
})
expect(mockUpdateNode).toHaveBeenCalledWith('node-1', {
status: 'online',
response_time_ms: 42,
last_seen: '2024-01-01T12:00:00Z',
})
})
it('sets last_seen to undefined when status is offline', () => {
renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
ws.onmessage?.({
data: JSON.stringify({
node_id: 'node-1',
status: 'offline',
checked_at: '2024-01-01T12:00:00Z',
}),
})
expect(mockUpdateNode).toHaveBeenCalledWith('node-1', {
status: 'offline',
response_time_ms: undefined,
last_seen: undefined,
})
})
it('sets response_time_ms to undefined when null in message', () => {
renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
ws.onmessage?.({
data: JSON.stringify({ node_id: 'node-1', status: 'online', response_time_ms: null }),
})
expect(mockUpdateNode).toHaveBeenCalledWith(
'node-1',
expect.objectContaining({ response_time_ms: undefined }),
)
})
it('calls notifyScanDeviceFound on scan_device_found message', () => {
renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
ws.onmessage?.({ data: JSON.stringify({ type: 'scan_device_found' }) })
expect(mockNotifyScanDeviceFound).toHaveBeenCalledOnce()
expect(mockUpdateNode).not.toHaveBeenCalled()
})
it('ignores malformed JSON without throwing', () => {
renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
expect(() => ws.onmessage?.({ data: 'not-valid-json{{' })).not.toThrow()
expect(mockUpdateNode).not.toHaveBeenCalled()
})
it('ignores messages with no node_id or status', () => {
renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
ws.onmessage?.({ data: JSON.stringify({ some: 'unknown-field' }) })
expect(mockUpdateNode).not.toHaveBeenCalled()
})
it('closes WebSocket on unmount', () => {
const { unmount } = renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
unmount()
expect(ws.close).toHaveBeenCalledOnce()
})
})
+42
View File
@@ -0,0 +1,42 @@
import { useEffect, useState } from 'react'
interface ReleaseInfo {
version: string
url: string
}
let cache: ReleaseInfo | null | 'error' | 'pending' = null
export function useLatestRelease(currentVersion: string) {
const [latest, setLatest] = useState<ReleaseInfo | null>(
cache && cache !== 'error' && cache !== 'pending' ? cache : null,
)
useEffect(() => {
if (cache !== null) return
cache = 'pending'
fetch('https://api.github.com/repos/Pouzor/homelable/releases/latest', {
headers: { Accept: 'application/vnd.github+json' },
})
.then((res) => {
if (!res.ok) { cache = 'error'; return }
return res.json()
})
.then((data) => {
if (!data || typeof data.tag_name !== 'string' || !data.html_url) {
cache = 'error'
return
}
const version = data.tag_name.replace(/^v/, '')
const info: ReleaseInfo = { version, url: data.html_url }
cache = info
setLatest(info)
})
.catch(() => {
cache = 'error'
})
}, [])
const hasUpdate = latest !== null && latest.version !== currentVersion
return { latest, hasUpdate }
}
+5
View File
@@ -6,6 +6,11 @@
@custom-variant dark (&:is(.dark *));
@keyframes homelable-basic-dash {
from { stroke-dashoffset: 10; }
to { stroke-dashoffset: 0; }
}
/* Homelable dark theme — always dark */
:root {
--background: #0d1117;
@@ -49,6 +49,21 @@ describe('canvasStore', () => {
expect(hasUnsavedChanges).toBe(true)
})
it('addNode nests under parent only when parent is in container mode', () => {
const parent = { ...makeNode('p1', { container_mode: false }), position: { x: 100, y: 100 } }
const child = { ...makeNode('c1', { parent_id: 'p1' }), position: { x: 150, y: 180 } }
useCanvasStore.getState().addNode(parent)
useCanvasStore.getState().addNode(child)
const childNode = useCanvasStore.getState().nodes.find((n) => n.id === 'c1')
expect(childNode?.parentId).toBeUndefined()
useCanvasStore.getState().updateNode('p1', { container_mode: true })
useCanvasStore.getState().setProxmoxContainerMode('p1', true)
const nested = useCanvasStore.getState().nodes.find((n) => n.id === 'c1')
expect(nested?.parentId).toBe('p1')
expect(nested?.extent).toBe('parent')
})
it('updateNode updates data fields', () => {
useCanvasStore.getState().addNode(makeNode('n1', { label: 'old' }))
useCanvasStore.getState().updateNode('n1', { label: 'new', ip: '10.0.0.1' })
@@ -57,6 +72,57 @@ describe('canvasStore', () => {
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: 130, y: 140 }, 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', () => {
const store = useCanvasStore.getState()
store.addNode(makeNode('n1'))
@@ -163,7 +229,7 @@ describe('canvasStore', () => {
})
it('deleteNode also removes children with matching parentId', () => {
useCanvasStore.getState().addNode(makeNode('parent'))
useCanvasStore.getState().addNode(makeNode('parent', { container_mode: true }))
useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' }))
useCanvasStore.getState().deleteNode('parent')
const { nodes } = useCanvasStore.getState()
@@ -172,7 +238,7 @@ describe('canvasStore', () => {
})
it('addNode with parent_id sets parentId and extent', () => {
useCanvasStore.getState().addNode(makeNode('parent'))
useCanvasStore.getState().addNode(makeNode('parent', { container_mode: true }))
useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' }))
const child = useCanvasStore.getState().nodes.find((n) => n.id === 'child')
expect(child?.parentId).toBe('parent')
@@ -202,11 +268,11 @@ describe('canvasStore', () => {
expect(useCanvasStore.getState().selectedNodeIds).toEqual([])
})
it('setSelectedNode(id) preserves existing selectedNodeIds', () => {
it('setSelectedNode(id) sets selectedNodeIds to [id], clearing multi-selection', () => {
useCanvasStore.setState({ selectedNodeIds: ['n1', 'n2'] })
useCanvasStore.getState().setSelectedNode('n1')
// does NOT wipe selectedNodeIds when setting a specific id
expect(useCanvasStore.getState().selectedNodeIds).toEqual(['n1', 'n2'])
// Single node click resets multi-selection to just the clicked node
expect(useCanvasStore.getState().selectedNodeIds).toEqual(['n1'])
})
// ── createGroup ───────────────────────────────────────────────────────────
@@ -567,4 +633,61 @@ describe('canvasStore', () => {
expect(stored?.width).toBeUndefined()
expect(stored?.height).toBeUndefined()
})
// ── bottom_handles edge remapping ──────────────────────────────────────────
it('remaps source edges to "bottom" when bottom_handles is reduced', () => {
const node = makeNode('n1', { bottom_handles: 4 })
const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom-3' }
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] })
useCanvasStore.getState().updateNode('n1', { bottom_handles: 2 })
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
expect(updated?.sourceHandle).toBe('bottom')
})
it('remaps target edges to "bottom" when bottom_handles is reduced', () => {
const node = makeNode('n2', { bottom_handles: 3 })
const edge = { ...makeEdge('e1', 'n1', 'n2'), targetHandle: 'bottom-3' }
useCanvasStore.setState({ nodes: [makeNode('n1'), node], edges: [edge] })
useCanvasStore.getState().updateNode('n2', { bottom_handles: 1 })
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
expect(updated?.targetHandle).toBe('bottom')
})
it('does not remap edges that are on handles still present after reduction', () => {
const node = makeNode('n1', { bottom_handles: 4 })
const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom-2' }
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] })
useCanvasStore.getState().updateNode('n1', { bottom_handles: 3 })
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
expect(updated?.sourceHandle).toBe('bottom-2')
})
it('does not remap edges when bottom_handles increases', () => {
const node = makeNode('n1', { bottom_handles: 2 })
const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom' }
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] })
useCanvasStore.getState().updateNode('n1', { bottom_handles: 4 })
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
expect(updated?.sourceHandle).toBe('bottom')
})
it('never remaps the "bottom" handle itself', () => {
const node = makeNode('n1', { bottom_handles: 4 })
const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom' }
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] })
useCanvasStore.getState().updateNode('n1', { bottom_handles: 1 })
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
expect(updated?.sourceHandle).toBe('bottom')
})
})
+110 -17
View File
@@ -11,6 +11,7 @@ import {
} from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types'
import { generateUUID } from '@/utils/uuid'
import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
@@ -52,6 +53,8 @@ interface CanvasState {
markSaved: () => void
markUnsaved: () => void
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
fitViewPending: boolean
clearFitViewPending: () => void
notifyScanDeviceFound: () => void
hideIp: boolean
toggleHideIp: () => void
@@ -66,6 +69,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
editingGroupRectId: null,
hideIp: false,
scanEventTs: 0,
fitViewPending: false,
past: [],
future: [],
@@ -149,10 +153,6 @@ export const useCanvasStore = create<CanvasState>((set) => ({
set((state) => {
const extra = connection as Connection & Partial<EdgeData>
const edgeType = extra.type ?? 'ethernet'
// Normalize invisible stub handle IDs so React Flow can locate the handle
// and render the edge immediately (top-t / bottom-t are opacity:0 helpers).
const normalizeHandle = (h: string | null | undefined) =>
h === 'top-t' ? 'top' : h === 'bottom-t' ? 'bottom' : (h ?? null)
return {
edges: addEdge({
...connection,
@@ -165,15 +165,25 @@ export const useCanvasStore = create<CanvasState>((set) => ({
}
}),
setSelectedNode: (id) => set((state) => ({
setSelectedNode: (id) => set({
selectedNodeId: id,
selectedNodeIds: id ? state.selectedNodeIds : [],
})),
selectedNodeIds: id ? [id] : [],
}),
addNode: (node) =>
set((state) => {
const enriched = node.data.parent_id
? { ...node, parentId: node.data.parent_id, extent: 'parent' as const }
const parent = node.data.parent_id ? state.nodes.find((n) => n.id === node.data.parent_id) : null
const shouldNestInParent = !!(parent?.data.container_mode)
const enriched = node.data.parent_id && shouldNestInParent
? {
...node,
parentId: node.data.parent_id,
extent: 'parent' as const,
position: {
x: Math.max(10, node.position.x - parent.position.x),
y: Math.max(10, node.position.y - parent.position.y),
},
}
: node
// Parents must come before children in the array (React Flow requirement)
const withoutNew = state.nodes.filter((n) => n.id !== node.id)
@@ -187,12 +197,69 @@ export const useCanvasStore = create<CanvasState>((set) => ({
}),
updateNode: (id, data) =>
set((state) => ({
nodes: state.nodes.map((n) =>
n.id === id ? { ...n, data: { ...n.data, ...data } } : n
),
hasUnsavedChanges: true,
})),
set((state) => {
let nodes = state.nodes.map((n) => {
if (n.id !== id) return n
const updated: Node<NodeData> = { ...n, data: { ...n.data, ...data } }
// When properties change, clear stored height so the node auto-sizes to fit new content
if ('properties' in data && n.data.type !== 'proxmox' && n.data.type !== 'groupRect') {
updated.height = undefined
}
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]
}
// Remap edges when bottom_handles is reduced so no edge disappears
let edges = state.edges
if ('bottom_handles' in data && data.bottom_handles != null) {
const currentNode = state.nodes.find((n) => n.id === id)
const oldCount = currentNode?.data.bottom_handles ?? 1
const newCount = data.bottom_handles
if (newCount < oldCount) {
const removed = removedBottomHandleIds(oldCount, newCount)
edges = state.edges.map((e) => {
if (e.source === id && e.sourceHandle && removed.has(e.sourceHandle))
return { ...e, sourceHandle: 'bottom' }
if (e.target === id && e.targetHandle && removed.has(e.targetHandle))
return { ...e, targetHandle: 'bottom' }
return e
})
}
}
return { nodes, edges, hasUnsavedChanges: true }
}),
deleteNode: (id) =>
set((state) => {
@@ -226,14 +293,38 @@ export const useCanvasStore = create<CanvasState>((set) => ({
setProxmoxContainerMode: (proxmoxId, enabled) =>
set((state) => {
const parentNode = state.nodes.find((n) => n.id === proxmoxId)
let nodes = state.nodes.map((n) => {
if (n.id === proxmoxId) {
const withMode = { ...n, data: { ...n.data, container_mode: enabled } }
if (n.data.type !== 'proxmox') return withMode
return enabled
? { ...withMode, width: 300, height: 200 }
? { ...withMode, width: n.width ?? 300, height: n.height ?? 200 }
: { ...withMode, width: undefined, height: undefined }
}
if (n.data.parent_id === proxmoxId) {
if (enabled && parentNode) {
return {
...n,
parentId: proxmoxId,
extent: 'parent' as const,
position: {
x: Math.max(10, n.position.x - parentNode.position.x),
y: Math.max(10, n.position.y - parentNode.position.y),
},
}
}
if (!enabled && parentNode) {
return {
...n,
parentId: undefined,
extent: undefined,
position: {
x: parentNode.position.x + n.position.x,
y: parentNode.position.y + n.position.y,
},
}
}
return enabled
? { ...n, parentId: proxmoxId, extent: 'parent' as const }
: { ...n, parentId: undefined, extent: undefined }
@@ -374,6 +465,8 @@ export const useCanvasStore = create<CanvasState>((set) => ({
// React Flow requires parents before children in the array
const parents = nodes.filter((n) => !n.parentId)
const children = nodes.filter((n) => !!n.parentId)
set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], clipboard: [] })
set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], clipboard: [], fitViewPending: true })
},
clearFitViewPending: () => set({ fitViewPending: false }),
}))
+20 -3
View File
@@ -13,7 +13,8 @@ export type NodeType =
| 'printer'
| 'computer'
| 'cpl'
| 'docker'
| 'docker_container'
| 'docker_host'
| 'generic'
| 'groupRect'
| 'group'
@@ -43,6 +44,13 @@ export interface ServiceInfo {
category?: string
}
export interface NodeProperty {
key: string
value: string
icon: string | null
visible: boolean
}
export interface NodeData extends Record<string, unknown> {
label: string
type: NodeType
@@ -62,6 +70,7 @@ export interface NodeData extends Record<string, unknown> {
ram_gb?: number
disk_gb?: number
show_hardware?: boolean
properties?: NodeProperty[]
parent_id?: string
container_mode?: boolean
custom_colors?: {
@@ -82,10 +91,16 @@ export interface NodeData extends Record<string, unknown> {
height?: number
}
custom_icon?: string
bottom_handles?: number
}
export type EdgePathStyle = 'bezier' | 'smooth'
export interface Waypoint {
x: number
y: number
}
export interface EdgeData extends Record<string, unknown> {
type: EdgeType
label?: string
@@ -93,7 +108,8 @@ export interface EdgeData extends Record<string, unknown> {
speed?: string
custom_color?: string
path_style?: EdgePathStyle
animated?: boolean | 'snake' | 'flow' | 'none'
animated?: boolean | 'snake' | 'flow' | 'basic' | 'none'
waypoints?: Waypoint[]
}
export const NODE_TYPE_LABELS: Record<NodeType, string> = {
@@ -111,7 +127,8 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
printer: 'Printer',
computer: 'Computer',
cpl: 'CPL / Powerline',
docker: 'Docker Host',
docker_container: 'Docker Container',
docker_host: 'Docker Host',
generic: 'Generic Device',
groupRect: 'Group Rectangle',
group: 'Node Group',
@@ -236,6 +236,36 @@ describe('serializeEdge', () => {
expect(result.custom_color).toBeNull()
expect(result.path_style).toBeNull()
})
it('serializes waypoints when present', () => {
const edge = makeRfEdge({ data: { type: 'ethernet', waypoints: [{ x: 10, y: 20 }, { x: 30, y: 40 }] } })
const result = serializeEdge(edge)
expect(result.waypoints).toEqual([{ x: 10, y: 20 }, { x: 30, y: 40 }])
})
it('serializes waypoints as null when empty array', () => {
const edge = makeRfEdge({ data: { type: 'ethernet', waypoints: [] } })
const result = serializeEdge(edge)
expect(result.waypoints).toBeNull()
})
it('serializes waypoints as null when absent', () => {
const result = serializeEdge(makeRfEdge())
expect(result.waypoints).toBeNull()
})
})
describe('deserializeApiEdge — waypoints', () => {
it('restores waypoints from API edge', () => {
const edge = makeApiEdge({ waypoints: [{ x: 5, y: 15 }, { x: 25, y: 35 }] })
const result = deserializeApiEdge(edge)
expect((result.data as { waypoints: unknown }).waypoints).toEqual([{ x: 5, y: 15 }, { x: 25, y: 35 }])
})
it('has no waypoints when API edge has none', () => {
const result = deserializeApiEdge(makeApiEdge())
expect((result.data as { waypoints?: unknown }).waypoints).toBeUndefined()
})
})
// ── deserializeApiNode — regular nodes ───────────────────────────────────────
@@ -0,0 +1,111 @@
import { describe, it, expect } from 'vitest'
import {
BOTTOM_HANDLE_IDS,
BOTTOM_HANDLE_POSITIONS,
normalizeHandle,
removedBottomHandleIds,
} from '../handleUtils'
describe('BOTTOM_HANDLE_IDS', () => {
it('first id is always "bottom" for backward compatibility', () => {
expect(BOTTOM_HANDLE_IDS[0]).toBe('bottom')
})
it('has ids for 14 handles', () => {
expect(BOTTOM_HANDLE_IDS).toHaveLength(4)
expect(BOTTOM_HANDLE_IDS).toEqual(['bottom', 'bottom-2', 'bottom-3', 'bottom-4'])
})
})
describe('BOTTOM_HANDLE_POSITIONS', () => {
it('1 handle is centered at 50%', () => {
expect(BOTTOM_HANDLE_POSITIONS[1]).toEqual([50])
})
it('2 handles are symmetric', () => {
const [a, b] = BOTTOM_HANDLE_POSITIONS[2]
expect(a).toBeLessThan(50)
expect(b).toBeGreaterThan(50)
expect(a + b).toBe(100)
})
it('3 handles include a center at 50%', () => {
expect(BOTTOM_HANDLE_POSITIONS[3]).toContain(50)
expect(BOTTOM_HANDLE_POSITIONS[3]).toHaveLength(3)
})
it('4 handles are evenly spaced', () => {
const pos = BOTTOM_HANDLE_POSITIONS[4]
expect(pos).toHaveLength(4)
// All values should be between 0 and 100 exclusive
pos.forEach((p) => {
expect(p).toBeGreaterThan(0)
expect(p).toBeLessThan(100)
})
// Positions should be strictly increasing
for (let i = 1; i < pos.length; i++) {
expect(pos[i]).toBeGreaterThan(pos[i - 1])
}
})
})
describe('normalizeHandle', () => {
it('returns null for null/undefined', () => {
expect(normalizeHandle(null)).toBeNull()
expect(normalizeHandle(undefined)).toBeNull()
})
it('maps top-t → top', () => {
expect(normalizeHandle('top-t')).toBe('top')
})
it('maps bottom-t → bottom', () => {
expect(normalizeHandle('bottom-t')).toBe('bottom')
})
it('maps bottom-2-t → bottom-2', () => {
expect(normalizeHandle('bottom-2-t')).toBe('bottom-2')
})
it('maps bottom-3-t → bottom-3', () => {
expect(normalizeHandle('bottom-3-t')).toBe('bottom-3')
})
it('maps bottom-4-t → bottom-4', () => {
expect(normalizeHandle('bottom-4-t')).toBe('bottom-4')
})
it('passes through non-stub handles unchanged', () => {
expect(normalizeHandle('top')).toBe('top')
expect(normalizeHandle('bottom')).toBe('bottom')
expect(normalizeHandle('bottom-2')).toBe('bottom-2')
expect(normalizeHandle('custom-handle')).toBe('custom-handle')
})
})
describe('removedBottomHandleIds', () => {
it('returns empty set when count does not decrease', () => {
expect(removedBottomHandleIds(2, 2).size).toBe(0)
expect(removedBottomHandleIds(1, 4).size).toBe(0)
})
it('4 → 1 removes bottom-2, bottom-3, bottom-4', () => {
const removed = removedBottomHandleIds(4, 1)
expect(removed).toEqual(new Set(['bottom-2', 'bottom-3', 'bottom-4']))
})
it('4 → 2 removes bottom-3, bottom-4', () => {
const removed = removedBottomHandleIds(4, 2)
expect(removed).toEqual(new Set(['bottom-3', 'bottom-4']))
})
it('3 → 2 removes only bottom-3', () => {
const removed = removedBottomHandleIds(3, 2)
expect(removed).toEqual(new Set(['bottom-3']))
})
it('never removes "bottom" (index 0)', () => {
const removed = removedBottomHandleIds(4, 1)
expect(removed.has('bottom')).toBe(false)
})
})
@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from 'vitest'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { parseYamlToCanvas } from '../importYaml'
import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types'
@@ -73,6 +73,38 @@ describe('parseYamlToCanvas', () => {
expect(nodes[0].data.show_hardware).toBeUndefined()
})
it('links edges have bottom→top-t handles', () => {
const yaml = `
- nodeType: switch
label: "SW"
links:
- label: "SRV"
linkType: ethernet
- nodeType: server
label: "SRV"
`
const { edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
expect(edges).toHaveLength(1)
expect(edges[0].sourceHandle).toBe('bottom')
expect(edges[0].targetHandle).toBe('top-t')
})
it('cluster edges have cluster-right→cluster-left handles', () => {
const yaml = `
- nodeType: proxmox
label: "PVE1"
clusterR:
label: "PVE2"
linkType: ethernet
- nodeType: proxmox
label: "PVE2"
`
const { edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
expect(edges).toHaveLength(1)
expect(edges[0].sourceHandle).toBe('cluster-right')
expect(edges[0].targetHandle).toBe('cluster-left')
})
it('parent relationship sets parentId and creates an edge', () => {
const yaml = `
- nodeType: proxmox
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest'
import { Cpu, HardDrive, MemoryStick } from 'lucide-react'
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '../propertyIcons'
describe('PROPERTY_ICONS', () => {
it('contains the hardware migration icons', () => {
expect(PROPERTY_ICONS['Cpu']).toBe(Cpu)
expect(PROPERTY_ICONS['HardDrive']).toBe(HardDrive)
expect(PROPERTY_ICONS['MemoryStick']).toBe(MemoryStick)
})
it('has at least 10 icons', () => {
expect(Object.keys(PROPERTY_ICONS).length).toBeGreaterThanOrEqual(10)
})
it('every value is a renderable component (function or object)', () => {
for (const [, icon] of Object.entries(PROPERTY_ICONS)) {
// Lucide icons can be functions or forwardRef objects depending on environment
expect(icon).toBeTruthy()
expect(['function', 'object']).toContain(typeof icon)
}
})
})
describe('PROPERTY_ICON_NAMES', () => {
it('matches the keys of PROPERTY_ICONS', () => {
expect(PROPERTY_ICON_NAMES).toEqual(expect.arrayContaining(Object.keys(PROPERTY_ICONS)))
expect(PROPERTY_ICON_NAMES.length).toBe(Object.keys(PROPERTY_ICONS).length)
})
})
describe('resolvePropertyIcon', () => {
it('returns the icon for a known name', () => {
expect(resolvePropertyIcon('Cpu')).toBe(Cpu)
expect(resolvePropertyIcon('HardDrive')).toBe(HardDrive)
})
it('returns null for null input', () => {
expect(resolvePropertyIcon(null)).toBeNull()
})
it('returns null for undefined input', () => {
expect(resolvePropertyIcon(undefined)).toBeNull()
})
it('returns null for unknown icon name', () => {
expect(resolvePropertyIcon('NotARealIcon')).toBeNull()
})
it('returns null for empty string', () => {
expect(resolvePropertyIcon('')).toBeNull()
})
})
@@ -134,6 +134,73 @@ describe('Standalone localStorage save/load cycle', () => {
expect(stored.data?.label).toBe('VLAN 20')
})
it('preserves node properties through the round-trip', () => {
const props = [
{ key: 'RAM', value: '32 GB', icon: 'MemoryStick', visible: true },
{ key: 'CPU', value: 'Intel i9', icon: 'Cpu', visible: false },
]
const nodes = [makeNode('n1', { data: { label: 'n1', type: 'server', status: 'unknown', services: [], properties: props } })]
const raw = standaloneSerialize(nodes, [])
const { nodes: loaded } = standaloneDeserialize(raw)
useCanvasStore.getState().loadCanvas(loaded, [])
const stored = useCanvasStore.getState().nodes[0]
expect(stored.data.properties).toEqual(props)
})
it('preserves empty properties array through the round-trip', () => {
const nodes = [makeNode('n1', { data: { label: 'n1', type: 'server', status: 'unknown', services: [], properties: [] } })]
const raw = standaloneSerialize(nodes, [])
const { nodes: loaded } = standaloneDeserialize(raw)
useCanvasStore.getState().loadCanvas(loaded, [])
expect(useCanvasStore.getState().nodes[0].data.properties).toEqual([])
})
it('preserves edge waypoints through the round-trip', () => {
const waypoints = [{ x: 100, y: 200 }, { x: 300, y: 150 }]
const edges: Edge<EdgeData>[] = [{
id: 'e1', source: 'n1', target: 'n2', type: 'ethernet',
data: { type: 'ethernet', waypoints },
}]
const raw = standaloneSerialize([], edges)
const { edges: loaded } = standaloneDeserialize(raw)
useCanvasStore.getState().loadCanvas([], loaded)
expect(useCanvasStore.getState().edges[0].data?.waypoints).toEqual(waypoints)
})
it('preserves basic animation through the round-trip', () => {
const edges: Edge<EdgeData>[] = [{
id: 'e1', source: 'n1', target: 'n2', type: 'ethernet',
data: { type: 'ethernet', animated: 'basic' },
}]
const raw = standaloneSerialize([], edges)
const { edges: loaded } = standaloneDeserialize(raw)
useCanvasStore.getState().loadCanvas([], loaded)
expect(useCanvasStore.getState().edges[0].data?.animated).toBe('basic')
})
it('preserves all three animation types through the round-trip', () => {
const n1 = makeNode('n1')
const n2 = makeNode('n2')
const n3 = makeNode('n3')
const edges: Edge<EdgeData>[] = [
{ id: 'e1', source: 'n1', target: 'n2', type: 'ethernet', data: { type: 'ethernet', animated: 'snake' } },
{ id: 'e2', source: 'n2', target: 'n3', type: 'ethernet', data: { type: 'ethernet', animated: 'flow' } },
{ id: 'e3', source: 'n1', target: 'n3', type: 'ethernet', data: { type: 'ethernet', animated: 'basic' } },
]
const raw = standaloneSerialize([n1, n2, n3], edges)
const { edges: loaded } = standaloneDeserialize(raw)
useCanvasStore.getState().loadCanvas([n1, n2, n3], loaded)
const stored = useCanvasStore.getState().edges
expect(stored.find((e) => e.id === 'e1')?.data?.animated).toBe('snake')
expect(stored.find((e) => e.id === 'e2')?.data?.animated).toBe('flow')
expect(stored.find((e) => e.id === 'e3')?.data?.animated).toBe('basic')
})
// ── loadCanvas marks clean ────────────────────────────────────────────────
it('loadCanvas sets hasUnsavedChanges to false', () => {
+2 -2
View File
@@ -4,7 +4,7 @@ import type { NodeType, EdgeType, NodeStatus } from '@/types'
const NODE_TYPES: NodeType[] = [
'isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc',
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker', 'generic', 'groupRect',
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker_host', 'docker_container', 'generic', 'groupRect',
]
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown']
@@ -84,7 +84,7 @@ describe('THEMES', () => {
expect(d.nodeAccents.server.border).toBe('#a855f7')
expect(d.nodeAccents.isp.border).toBe('#00d4ff')
expect(d.nodeAccents.proxmox.border).toBe('#ff6e00')
expect(d.nodeAccents.docker.border).toBe('#2496ED')
expect(d.nodeAccents.docker_host.border).toBe('#2496ED')
expect(d.nodeCardBackground).toBe('#21262d')
expect(d.nodeIconBackground).toBe('#161b22')
expect(d.canvasBackground).toBe('#0d1117')
+15 -10
View File
@@ -1,5 +1,6 @@
import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types'
import type { NodeData, EdgeData, Waypoint } from '@/types'
import { normalizeHandle } from '@/utils/handleUtils'
// ── Types ────────────────────────────────────────────────────────────────────
@@ -27,8 +28,10 @@ export interface ApiNode extends Record<string, unknown> {
ram_gb?: number | null
disk_gb?: number | null
show_hardware?: boolean
properties?: unknown[] | null
width?: number | null
height?: number | null
bottom_handles?: number
}
export interface ApiEdge {
@@ -41,9 +44,10 @@ export interface ApiEdge {
speed?: string | null
custom_color?: string | null
path_style?: string | null
animated?: boolean | 'snake' | 'flow' | 'none'
animated?: boolean | 'snake' | 'flow' | 'basic' | 'none'
source_handle?: string | null
target_handle?: string | null
waypoints?: Waypoint[] | null
}
// ── Serialization (RF node → API save payload) ───────────────────────────────
@@ -97,16 +101,15 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
ram_gb: n.data.ram_gb ?? null,
disk_gb: n.data.disk_gb ?? null,
show_hardware: n.data.show_hardware ?? false,
properties: n.data.properties ?? [],
width: n.width ?? null,
height: n.height ?? null,
bottom_handles: n.data.bottom_handles ?? 1,
pos_x: n.position.x,
pos_y: n.position.y,
}
}
const normalizeHandle = (h: string | null | undefined): string | null =>
h === 'top-t' ? 'top' : h === 'bottom-t' ? 'bottom' : (h ?? null)
export function serializeEdge(e: Edge<EdgeData>): Record<string, unknown> {
return {
id: e.id,
@@ -121,6 +124,7 @@ export function serializeEdge(e: Edge<EdgeData>): Record<string, unknown> {
animated: e.data?.animated ?? false,
source_handle: normalizeHandle(e.sourceHandle),
target_handle: normalizeHandle(e.targetHandle),
waypoints: e.data?.waypoints?.length ? e.data.waypoints : null,
}
}
@@ -130,6 +134,7 @@ export function deserializeApiNode(
n: ApiNode,
proxmoxContainerMap: Map<string, boolean>,
): Node<NodeData> {
const normalizedType = n.type === 'docker' ? 'docker_host' : n.type
if (n.type === 'groupRect') {
const w = (n.custom_colors?.width as number | undefined) ?? 360
const h = (n.custom_colors?.height as number | undefined) ?? 240
@@ -148,15 +153,15 @@ export function deserializeApiNode(
const parentIsContainer = n.parent_id ? (proxmoxContainerMap.get(n.parent_id) ?? false) : false
return {
id: n.id,
type: n.type,
type: normalizedType,
position: { x: n.pos_x, y: n.pos_y },
data: n as unknown as NodeData,
data: { ...n, type: normalizedType } as unknown as NodeData,
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
...(n.type === 'proxmox' && n.container_mode !== false
...(normalizedType === 'proxmox' && n.container_mode !== false
? { width: n.width ?? 300, height: n.height ?? 200 }
: {}),
...(n.width && n.type !== 'proxmox' ? { width: n.width } : {}),
...(n.height && n.type !== 'proxmox' ? { height: n.height } : {}),
...(n.width && normalizedType !== 'proxmox' ? { width: n.width } : {}),
...(n.height && normalizedType !== 'proxmox' ? { height: n.height } : {}),
}
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Bottom handle configuration for multi-handle nodes.
*
* Handle IDs: index 0 = 'bottom' (always the default, backward-compatible)
* index 1 = 'bottom-2', index 2 = 'bottom-3', index 3 = 'bottom-4'
*
* Invisible target handles follow the same pattern with a '-t' suffix:
* 'bottom-t', 'bottom-2-t', 'bottom-3-t', 'bottom-4-t'
*/
export const BOTTOM_HANDLE_IDS = ['bottom', 'bottom-2', 'bottom-3', 'bottom-4'] as const
/** Left % position for each handle slot, per count. */
export const BOTTOM_HANDLE_POSITIONS: Record<number, number[]> = {
1: [50],
2: [25, 75],
3: [20, 50, 80],
4: [15, 38, 62, 85],
}
/**
* Normalize a raw handle ID coming from a React Flow connection event.
* Invisible target handles (e.g. 'bottom-2-t') are mapped to their source
* counterpart ('bottom-2') so the stored edge ID is stable and consistent.
*/
export function normalizeHandle(h: string | null | undefined): string | null {
if (!h) return null
if (h === 'top-t') return 'top'
// 'bottom-t' → 'bottom', 'bottom-2-t' → 'bottom-2', etc.
const m = h.match(/^(bottom(?:-\d+)?)-t$/)
if (m) return m[1]
return h
}
/**
* Returns the set of handle IDs that are removed when bottom_handles
* is reduced from `oldCount` to `newCount`.
*/
export function removedBottomHandleIds(oldCount: number, newCount: number): Set<string> {
const removed = new Set<string>()
for (let i = newCount; i < oldCount; i++) {
removed.add(BOTTOM_HANDLE_IDS[i])
}
return removed
}
+13 -9
View File
@@ -34,16 +34,16 @@ export function parseYamlToCanvas(
const yamlNodes: YamlNode[] = []
for (const entry of entries) {
const raw = entry as Record<string, unknown>
const entryRecord = entry as Record<string, unknown>
if (!raw.nodeType || typeof raw.nodeType !== 'string') {
if (!entryRecord.nodeType || typeof entryRecord.nodeType !== 'string') {
throw new Error(`Each YAML entry must have a "nodeType" string field`)
}
if (!raw.label || typeof raw.label !== 'string') {
if (!entryRecord.label || typeof entryRecord.label !== 'string') {
throw new Error(`Each YAML entry must have a "label" string field`)
}
const yn = raw as unknown as YamlNode
const yn = entryRecord as unknown as YamlNode
// Skip if a node with this label already exists on the canvas
if (labelToId.has(yn.label)) {
@@ -95,6 +95,8 @@ export function parseYamlToCanvas(
sourceId: string,
targetId: string,
conn: YamlNodeConnection,
sourceHandle = 'bottom',
targetHandle = 'top-t',
) {
const key = `${sourceId}|${targetId}`
const reverseKey = `${targetId}|${sourceId}`
@@ -105,6 +107,8 @@ export function parseYamlToCanvas(
id: generateUUID(),
source: sourceId,
target: targetId,
sourceHandle,
targetHandle,
type: edgeType,
data: {
type: edgeType,
@@ -126,8 +130,8 @@ export function parseYamlToCanvas(
node.data = { ...node.data, parent_id: parentId }
node.parentId = parentId
node.extent = 'parent'
// Also create an edge
addEdgeIfNew(parentId, node.id, yn.parent)
// Also create an edge (parent bottom → child top)
addEdgeIfNew(parentId, node.id, yn.parent, 'bottom', 'top-t')
}
}
@@ -137,7 +141,7 @@ export function parseYamlToCanvas(
if (!targetId) {
console.warn(`[importYaml] links label not found: "${link.label}" — skipping`)
} else {
addEdgeIfNew(node.id, targetId, link)
addEdgeIfNew(node.id, targetId, link, 'bottom', 'top-t')
}
}
}
@@ -147,7 +151,7 @@ export function parseYamlToCanvas(
if (!targetId) {
console.warn(`[importYaml] clusterR label not found: "${yn.clusterR.label}" — skipping`)
} else {
addEdgeIfNew(node.id, targetId, yn.clusterR)
addEdgeIfNew(node.id, targetId, yn.clusterR, 'cluster-right', 'cluster-left')
}
}
@@ -156,7 +160,7 @@ export function parseYamlToCanvas(
if (!sourceId) {
console.warn(`[importYaml] clusterL label not found: "${yn.clusterL.label}" — skipping`)
} else {
addEdgeIfNew(sourceId, node.id, yn.clusterL)
addEdgeIfNew(sourceId, node.id, yn.clusterL, 'cluster-right', 'cluster-left')
}
}
}
+23
View File
@@ -1,3 +1,4 @@
import type { NodeType } from '@/types'
import {
// Infrastructure (node types)
Globe, Router, Network, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle,
@@ -116,6 +117,28 @@ export const ICON_MAP: Record<string, LucideIcon> = Object.fromEntries(
ICON_REGISTRY.map((e) => [e.key, e.icon]),
)
export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = {
isp: Globe,
router: Router,
switch: Network,
server: Server,
proxmox: Layers,
vm: Box,
lxc: Container,
nas: HardDrive,
iot: Cpu,
ap: Wifi,
camera: Cctv,
printer: Printer,
computer: Monitor,
cpl: PlugZap,
docker_container: Container,
docker_host: Anchor,
generic: Circle,
group: Circle,
groupRect: Circle,
}
/** Resolve the display icon for a node — custom_icon takes priority over type default. */
export function resolveNodeIcon(
typeIcon: LucideIcon,
+53
View File
@@ -0,0 +1,53 @@
import {
Battery,
Box,
Clock,
Cpu,
Database,
Globe,
HardDrive,
Hash,
Key,
Layers,
Link,
MemoryStick,
Monitor,
Network,
Server,
Shield,
Tag,
Thermometer,
Wifi,
Zap,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
export const PROPERTY_ICONS: Record<string, LucideIcon> = {
Battery,
Box,
Clock,
Cpu,
Database,
Globe,
HardDrive,
Hash,
Key,
Layers,
Link,
MemoryStick,
Monitor,
Network,
Server,
Shield,
Tag,
Thermometer,
Wifi,
Zap,
}
export const PROPERTY_ICON_NAMES = Object.keys(PROPERTY_ICONS) as (keyof typeof PROPERTY_ICONS)[]
export function resolvePropertyIcon(name: string | null | undefined): LucideIcon | null {
if (!name) return null
return PROPERTY_ICONS[name] ?? null
}
+10 -5
View File
@@ -56,7 +56,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
printer: { border: '#8b949e', icon: '#8b949e' },
computer: { border: '#a855f7', icon: '#a855f7' },
cpl: { border: '#e3b341', icon: '#e3b341' },
docker: { border: '#2496ED', icon: '#2496ED' },
docker_container: { border: '#38bdf8', icon: '#38bdf8' },
docker_host: { border: '#2496ED', icon: '#2496ED' },
generic: { border: '#8b949e', icon: '#8b949e' },
groupRect:{ border: '#00d4ff', icon: '#00d4ff' },
group: { border: '#00d4ff', icon: '#00d4ff' },
@@ -111,7 +112,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
printer: { border: '#94a3b8', icon: '#94a3b8' },
computer: { border: '#c084fc', icon: '#c084fc' },
cpl: { border: '#fbbf24', icon: '#fbbf24' },
docker: { border: '#2496ED', icon: '#2496ED' },
docker_container: { border: '#38bdf8', icon: '#38bdf8' },
docker_host: { border: '#2496ED', icon: '#2496ED' },
generic: { border: '#94a3b8', icon: '#94a3b8' },
groupRect:{ border: '#22d3ee', icon: '#22d3ee' },
group: { border: '#22d3ee', icon: '#22d3ee' },
@@ -166,7 +168,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
printer: { border: '#6b7280', icon: '#6b7280' },
computer: { border: '#7c3aed', icon: '#7c3aed' },
cpl: { border: '#b45309', icon: '#b45309' },
docker: { border: '#2496ED', icon: '#2496ED' },
docker_container: { border: '#0ea5e9', icon: '#0ea5e9' },
docker_host: { border: '#2496ED', icon: '#2496ED' },
generic: { border: '#6b7280', icon: '#6b7280' },
groupRect:{ border: '#0284c7', icon: '#0284c7' },
group: { border: '#0284c7', icon: '#0284c7' },
@@ -221,7 +224,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
printer: { border: '#8888ff', icon: '#8888ff' },
computer: { border: '#ff00ff', icon: '#ff00ff' },
cpl: { border: '#ffff00', icon: '#ffff00' },
docker: { border: '#00aaff', icon: '#00aaff' },
docker_container: { border: '#00ddff', icon: '#00ddff' },
docker_host: { border: '#00aaff', icon: '#00aaff' },
generic: { border: '#8888ff', icon: '#8888ff' },
groupRect:{ border: '#00ffff', icon: '#00ffff' },
group: { border: '#00ffff', icon: '#00ffff' },
@@ -276,7 +280,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
printer: { border: '#005500', icon: '#005500' },
computer: { border: '#008822', icon: '#008822' },
cpl: { border: '#66ff33', icon: '#66ff33' },
docker: { border: '#00cc88', icon: '#00cc88' },
docker_container: { border: '#00dd99', icon: '#00dd99' },
docker_host: { border: '#00cc88', icon: '#00cc88' },
generic: { border: '#006600', icon: '#006600' },
groupRect:{ border: '#00ff41', icon: '#00ff41' },
group: { border: '#00ff41', icon: '#00ff41' },
+3
View File
@@ -0,0 +1,3 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string
+4
View File
@@ -2,8 +2,12 @@ import path from 'path'
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import pkg from './package.json'
export default defineConfig({
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
},
plugins: [react(), tailwindcss()],
resolve: {
alias: {
-134
View File
@@ -1,134 +0,0 @@
#!/usr/bin/env bash
# Homelable — Proxmox VE LXC creator
# Run this on the Proxmox HOST (not inside a container):
# bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/install-proxmox.sh)
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
info() { echo -e "${GREEN}[homelable]${NC} $*"; }
warn() { echo -e "${YELLOW}[homelable]${NC} $*"; }
error() { echo -e "${RED}[homelable]${NC} $*"; exit 1; }
step() { echo -e "\n${CYAN}$*${NC}"; }
# ── Must run on a Proxmox VE host ─────────────────────────────────────────────
[[ $EUID -ne 0 ]] && error "Run as root on the Proxmox host"
command -v pct &>/dev/null || error "pct not found — run this on a Proxmox VE host, not inside a container"
# ── Detect available storages for LXC rootfs ──────────────────────────────────
mapfile -t STORAGES < <(pvesm status --content rootdir 2>/dev/null | awk 'NR>1 && $3=="active" {print $1}')
[[ ${#STORAGES[@]} -eq 0 ]] && error "No active storage found that supports LXC rootfs (rootdir content type)"
if [[ ${#STORAGES[@]} -eq 1 ]]; then
DEFAULT_STORAGE="${STORAGES[0]}"
else
echo ""
echo "Available storages:"
for i in "${!STORAGES[@]}"; do
echo " $((i+1))) ${STORAGES[$i]}"
done
read -rp "Select storage [1]: " STORAGE_IDX
STORAGE_IDX="${STORAGE_IDX:-1}"
DEFAULT_STORAGE="${STORAGES[$((STORAGE_IDX-1))]}"
fi
# ── Settings (override via env vars) ──────────────────────────────────────────
CT_HOSTNAME="${CT_HOSTNAME:-homelable}"
STORAGE="${STORAGE:-$DEFAULT_STORAGE}"
DISK_SIZE="${DISK_SIZE:-8}" # GB
RAM="${RAM:-1024}" # MB
CORES="${CORES:-2}"
BRIDGE="${BRIDGE:-vmbr0}"
RAW="https://raw.githubusercontent.com/Pouzor/homelable/main"
# ── Interactive prompts ────────────────────────────────────────────────────────
DEFAULT_CTID="$(pvesh get /cluster/nextid 2>/dev/null || echo 200)"
if [[ -z "${CTID:-}" ]]; then
read -rp "Container ID [${DEFAULT_CTID}]: " CTID_INPUT
CTID="${CTID_INPUT:-$DEFAULT_CTID}"
fi
if [[ -z "${ROOT_PASSWORD:-}" ]]; then
while true; do
read -rsp "Root password for LXC container: " ROOT_PASSWORD
echo ""
[[ -z "$ROOT_PASSWORD" ]] && warn "Password cannot be empty, try again." && continue
read -rsp "Confirm root password: " ROOT_PASSWORD_CONFIRM
echo ""
[[ "$ROOT_PASSWORD" == "$ROOT_PASSWORD_CONFIRM" ]] && break
warn "Passwords do not match, try again."
done
fi
step "Creating Homelable LXC (CTID=$CTID, hostname=$CT_HOSTNAME, storage=$STORAGE)"
# ── Download Debian 12 template if needed ─────────────────────────────────────
TEMPLATE_STORAGE=$(pvesm status --content vztmpl | awk 'NR>1 {print $1; exit}')
TEMPLATE=$(pveam list "$TEMPLATE_STORAGE" 2>/dev/null | grep "debian-12" | tail -1 | awk '{print $1}')
if [[ -z "$TEMPLATE" ]]; then
info "Downloading Debian 12 LXC template..."
pveam update
TEMPLATE_NAME=$(pveam available --section system | grep "debian-12" | tail -1 | awk '{print $2}')
[[ -z "$TEMPLATE_NAME" ]] && error "Could not find a Debian 12 template"
pveam download "$TEMPLATE_STORAGE" "$TEMPLATE_NAME"
TEMPLATE="$TEMPLATE_STORAGE:vztmpl/$TEMPLATE_NAME"
fi
info "Using template: $TEMPLATE"
# ── Create the container ───────────────────────────────────────────────────────
pct create "$CTID" "$TEMPLATE" \
--hostname "$CT_HOSTNAME" \
--storage "$STORAGE" \
--rootfs "${STORAGE}:${DISK_SIZE}" \
--memory "$RAM" \
--cores "$CORES" \
--net0 "name=eth0,bridge=${BRIDGE},ip=dhcp${VLAN_TAG:+,tag=${VLAN_TAG}}" \
--ostype debian \
--unprivileged 1 \
--features "nesting=1" \
--password "$ROOT_PASSWORD" \
--start 1
info "Container $CTID created and started"
# ── Wait for container to be ready ────────────────────────────────────────────
info "Waiting for container to be ready..."
for i in $(seq 1 30); do
if pct exec "$CTID" -- test -x /usr/bin/apt-get &>/dev/null; then
break
fi
[[ $i -eq 30 ]] && error "Container did not become ready after 30s"
sleep 1
done
# Wait a bit more for network (DHCP lease)
info "Waiting for network (DHCP)..."
for i in $(seq 1 20); do
if pct exec "$CTID" -- sh -c "ip route | grep -q default" &>/dev/null; then
break
fi
[[ $i -eq 20 ]] && error "Container has no default route after 20s — check bridge $BRIDGE"
sleep 1
done
# ── Grant NET_RAW for nmap (ping-based checks) ─────────────────────────────────
echo "lxc.cap.keep = net_raw net_bind_service" >> "/etc/pve/lxc/${CTID}.conf" 2>/dev/null || true
# ── Bootstrap curl then run the installer ─────────────────────────────────────
step "Running Homelable installer inside container $CTID..."
pct exec "$CTID" -- apt-get install -y -qq curl
pct exec "$CTID" -- bash -c "curl -fsSL ${RAW}/scripts/lxc-install.sh | bash"
# ── Done ──────────────────────────────────────────────────────────────────────
IP=$(pct exec "$CTID" -- hostname -I 2>/dev/null | awk '{print $1}' || echo "<container-ip>")
echo ""
echo -e " ${GREEN}✓ Homelable installed in LXC $CTID${NC}"
echo -e " ${GREEN}✓ Open http://${IP}${NC}"
echo -e " Homelable login: ${YELLOW}admin / admin${NC}"
echo -e " LXC root SSH: ${YELLOW}root / <password you set>${NC}"
echo -e " ${YELLOW}⚠ Change the Homelable password after first login${NC}"
echo -e " ${YELLOW} - edit /opt/homelable/backend/.env (AUTH_PASSWORD_HASH)${NC}"
echo ""
-138
View File
@@ -1,138 +0,0 @@
#!/usr/bin/env bash
# Homelable — in-container installer
# Runs INSIDE a Debian/Ubuntu LXC container (called automatically by install-proxmox.sh)
# Can also be run manually inside any Debian/Ubuntu machine:
# bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/lxc-install.sh)
set -euo pipefail
INSTALL_DIR=/opt/homelable
DATA_DIR=/opt/homelable/data
SERVICE_USER=homelable
REPO_URL="https://github.com/Pouzor/homelable.git"
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
info() { echo -e "${GREEN}[homelable]${NC} $*"; }
warn() { echo -e "${YELLOW}[homelable]${NC} $*"; }
error() { echo -e "${RED}[homelable]${NC} $*"; exit 1; }
[[ $EUID -ne 0 ]] && error "Run as root (sudo bash ...)"
# ── Detect OS ─────────────────────────────────────────────────────────────────
if [[ -f /etc/os-release ]]; then
# shellcheck source=/dev/null
. /etc/os-release
else
error "Cannot detect OS"
fi
info "Detected: $PRETTY_NAME"
[[ "$ID" =~ ^(debian|ubuntu)$ ]] || error "Requires Debian or Ubuntu"
# ── System deps ───────────────────────────────────────────────────────────────
info "Installing system dependencies..."
apt-get update
apt-get install -y --fix-missing python3 python3-pip python3-venv nmap curl git nginx
# ── Node.js 20 ────────────────────────────────────────────────────────────────
if ! command -v node &>/dev/null; then
info "Installing Node.js 20..."
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y -qq nodejs
fi
# ── Service user ──────────────────────────────────────────────────────────────
if ! id "$SERVICE_USER" &>/dev/null; then
useradd --system --shell /sbin/nologin "$SERVICE_USER"
info "Created service user: $SERVICE_USER"
fi
# ── Clone / update repo ───────────────────────────────────────────────────────
if [[ -d "$INSTALL_DIR/.git" ]]; then
info "Updating existing installation..."
git -C "$INSTALL_DIR" pull --quiet
else
info "Cloning repository..."
git clone --quiet "$REPO_URL" "$INSTALL_DIR"
fi
mkdir -p "$DATA_DIR"
# ── Backend ───────────────────────────────────────────────────────────────────
info "Setting up Python backend..."
cd "$INSTALL_DIR/backend"
python3 -m venv .venv
.venv/bin/pip install --quiet -r requirements.txt
# Generate .env if missing
if [[ ! -f .env ]]; then
SECRET=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))")
# Default hash = bcrypt of "admin" (same as .env.example)
cat > .env <<EOF
SECRET_KEY=$SECRET
SQLITE_PATH=$DATA_DIR/homelab.db
CORS_ORIGINS=["http://localhost","http://$(hostname -I | awk '{print $1}')"]
# Auth — default credentials: admin / admin
# Change AUTH_PASSWORD_HASH before exposing on a network.
# Generate: python3 -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"
AUTH_USERNAME=admin
AUTH_PASSWORD_HASH='\$2b\$12\$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG'
SCANNER_RANGES=["192.168.1.0/24"]
STATUS_CHECKER_INTERVAL=60
EOF
warn "Created .env with default admin/admin — change AUTH_PASSWORD_HASH before exposing on a network!"
fi
chown -R "$SERVICE_USER":"$SERVICE_USER" "$DATA_DIR"
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR/backend/.venv"
# ── systemd: backend ──────────────────────────────────────────────────────────
cat > /etc/systemd/system/homelable-backend.service <<EOF
[Unit]
Description=Homelable Backend
After=network.target
[Service]
Type=simple
User=$SERVICE_USER
WorkingDirectory=$INSTALL_DIR/backend
EnvironmentFile=$INSTALL_DIR/backend/.env
ExecStart=$INSTALL_DIR/backend/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
# ── Frontend ──────────────────────────────────────────────────────────────────
info "Building frontend..."
cd "$INSTALL_DIR/frontend"
npm ci --silent
npm run build
# ── nginx ─────────────────────────────────────────────────────────────────────
info "Configuring nginx..."
# Use the project nginx config, adjusted for local backend
sed \
-e 's|http://backend:8000|http://127.0.0.1:8000|g' \
-e "s|/usr/share/nginx/html|$INSTALL_DIR/frontend/dist|g" \
"$INSTALL_DIR/docker/nginx.conf" > /etc/nginx/sites-available/homelable
ln -sf /etc/nginx/sites-available/homelable /etc/nginx/sites-enabled/homelable
rm -f /etc/nginx/sites-enabled/default
nginx -t
systemctl reload nginx || systemctl start nginx
# ── Enable & start ────────────────────────────────────────────────────────────
systemctl daemon-reload
systemctl enable --now homelable-backend
systemctl enable --now nginx
info "Done!"
echo ""
echo -e " ${GREEN}Homelable is running at http://$(hostname -I | awk '{print $1}')${NC}"
echo -e " Default login: admin / admin"
echo -e " ${YELLOW}⚠ Change the password: edit $INSTALL_DIR/backend/.env (AUTH_PASSWORD_HASH)${NC}"
echo ""
-66
View File
@@ -1,66 +0,0 @@
#!/usr/bin/env bash
# Homelable — update to latest version
# Run inside the LXC / any Linux host where lxc-install.sh was used:
# bash /opt/homelable/scripts/update.sh
# Or pull-and-run directly:
# bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/update.sh)
set -euo pipefail
INSTALL_DIR=/opt/homelable
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
info() { echo -e "${GREEN}[homelable]${NC} $*"; }
warn() { echo -e "${YELLOW}[homelable]${NC} $*"; }
error() { echo -e "${RED}[homelable]${NC} $*"; exit 1; }
[[ $EUID -ne 0 ]] && error "Run as root (sudo bash ...)"
[[ -d "$INSTALL_DIR/.git" ]] || error "Homelable not found at $INSTALL_DIR — run lxc-install.sh first"
# ── Pull latest code ──────────────────────────────────────────────────────────
info "Pulling latest code..."
BEFORE=$(git -C "$INSTALL_DIR" rev-parse HEAD)
git -C "$INSTALL_DIR" pull --quiet
AFTER=$(git -C "$INSTALL_DIR" rev-parse HEAD)
if [[ "$BEFORE" == "$AFTER" ]]; then
info "Already up to date."
exit 0
fi
echo ""
info "Changes since last update:"
git -C "$INSTALL_DIR" log --oneline "${BEFORE}..${AFTER}"
echo ""
# ── Stop backend ─────────────────────────────────────────────────────────────
info "Stopping backend service..."
systemctl stop homelable-backend
# ── Backend deps ─────────────────────────────────────────────────────────────
info "Updating Python dependencies..."
cd "$INSTALL_DIR/backend"
.venv/bin/pip install --quiet -r requirements.txt
# ── Frontend build ────────────────────────────────────────────────────────────
info "Rebuilding frontend..."
cd "$INSTALL_DIR/frontend"
npm ci --silent
npm run build
# ── nginx config ─────────────────────────────────────────────────────────────
info "Updating nginx config..."
sed \
-e 's|http://backend:8000|http://127.0.0.1:8000|g' \
-e "s|/usr/share/nginx/html|$INSTALL_DIR/frontend/dist|g" \
"$INSTALL_DIR/docker/nginx.conf" > /etc/nginx/sites-available/homelable
nginx -t && systemctl reload nginx
# ── Restart backend ───────────────────────────────────────────────────────────
info "Starting backend service..."
systemctl start homelable-backend
echo ""
echo -e " ${GREEN}Homelable updated successfully!${NC}"
echo -e " Running at http://$(hostname -I | awk '{print $1}')"
echo ""