Compare commits

...

47 Commits

Author SHA1 Message Date
Remy a9c5c538b4 Merge pull request #82 from Pouzor/1.10.0
1.10.0
2026-04-19 23:49:38 +02:00
Pouzor a816faa0b9 fix: prevent node width expansion when content overflows after resize
Proxmox nodes with container_mode=false fell through both width conditions
in deserializeApiNode and got no explicit width on reload, causing RF to
auto-size to content width and ignoring the user's manual resize.

- canvasSerializer: unified width restore logic — saved width applies to all
  node types; proxmox container_mode defaults (300x200) only kick in when
  no saved width exists
- BaseNode: add overflow-hidden + min-w-0 to properties row so truncate
  actually clips long values instead of expanding the node
2026-04-19 23:43:15 +02:00
Pouzor fbfacec6dc fix: prevent node from expanding beyond resized width on reload 2026-04-19 22:58:10 +02:00
Pouzor b5eb8d1b74 fix: remove duplicate primaryIp export in maskIp.ts after rebase 2026-04-19 22:30:50 +02:00
Pouzor 0193f933ce feat: bulk approve/hide pending devices (#70)
- Backend: POST /scan/pending/bulk-approve and /scan/pending/bulk-hide endpoints (registered before dynamic routes to avoid conflict); bulk-approve response includes device_ids for frontend mapping
- Frontend: PendingDevicesPanel gains per-row checkboxes, select-all, and a bulk action bar (Approve N / Hide N) that appears when ≥1 device is selected
- Tests: 6 new backend API tests + 7 new frontend UI tests for bulk selection flows
2026-04-19 22:13:40 +02:00
Pouzor 5ad5eba58c feat: add connection handles to zone nodes (closes #58)
- GroupRectNode now renders source+target handles on all four sides
  (top, right, bottom, left) using IDs zone-{side} / zone-{side}-t
- Handles are hover-only: opacity 0 by default, fade in on mouse enter
- Handle color matches the zone border color (respects custom_colors)
- Zone↔zone and zone↔node connections both allowed; edge type picker
  (EdgeModal) opens on connect so user chooses ethernet/wifi/vlan/etc.
- Add GroupRectNode.test.tsx: verifies 8 handles rendered (4 source + 4 target)
- Fix @xyflow/react mocks in LiveView and CanvasContainer tests to include Position
2026-04-19 22:13:40 +02:00
Pouzor ef96cafcc8 feat: IPv6 support and multi-IP per node (closes #60)
- maskIp handles IPv6 addresses (masks second and last group)
- maskIp handles comma-separated IP strings (masks each address)
- Add splitIps() helper to parse comma-separated IP field
- Add primaryIp() helper used by status checker (first IP wins)
- BaseNode renders each IP on its own line when comma-separated
- NodeModal placeholder shows comma-separated example
- Backend status_checker uses only first IP for connectivity checks
- Expand maskIp test suite: IPv6, comma-separated, splitIps, primaryIp
2026-04-19 22:13:40 +02:00
Pouzor 6c9974b357 bump version 1.10 2026-04-19 22:13:40 +02:00
Pouzor ce5fc785e1 chore: bump version to 1.10.0 2026-04-19 22:13:40 +02:00
Pouzor 0019c086cf feat: automatic DB backup before migrations using VERSION file
- Add VERSION file at repo root as single source of truth for app version
- frontend/vite.config.ts reads VERSION file instead of package.json
- backend config.py exposes APP_VERSION read from VERSION (dev) or /app/VERSION (Docker)
- database.py backs up DB to homelab.db.back-{version} before running migrations
  (skipped if DB doesn't exist or backup already exists — fully idempotent)
- Dockerfile.backend and Dockerfile.frontend copy VERSION into the image
- Add test_db_backup.py with 4 tests covering create/skip/idempotent/version cases
2026-04-19 22:09:43 +02:00
Remy 0eff7da46e Merge pull request #81 from Pouzor/fix/clickable-ip-multiip
fix: handle multi-IP for clickable IP link
2026-04-19 22:08:19 +02:00
Pouzor 2e6ee9dad2 fix: handle multi-IP and add tests for clickable IP link
Follow-up to #78:
- Use primaryIp() so href targets the first IP when data.ip is comma-separated (e.g. "192.168.1.1, 2001:db8::1")
- Add primaryIp() helper to maskIp.ts
- Add 4 tests covering single IP link, absent IP, multi-IP href, multi-IP display text
2026-04-19 22:04:37 +02:00
findthelorax 81b109f981 feat: make IP Address clickable in detail panel
- Display IP as a clickable link that opens http://<ip> in a new tab
- Match the existing Hostname link styling and behavior
- Add external link icon to indicate it's clickable
2026-04-19 10:08:09 -04:00
Brett Ferrante 73b16a7620 Merge pull request #15 from Pouzor/main
Merge updates from Pouzor main
2026-04-19 09:21:21 -04:00
Remy a37bf101d2 Merge pull request #76 from findthelorax/bug/drag-from-title
Fix/drag from title
2026-04-19 11:41:41 +02:00
Remy 5def6b7fbf Merge pull request #75 from dopp1e/fix-curl-healthcheck
fix: add curl to backend image to support the default healthcheck
2026-04-19 11:35:07 +02:00
Brett Ferrante eb235cb101 Merge branch 'Pouzor:main' into bug/drag-from-title 2026-04-18 22:14:00 -04:00
Brett Ferrante 04a1c63558 Merge pull request #14 from Pouzor/main
Merge with Pouzor main branch
2026-04-18 21:36:53 -04:00
doppie 88f0c03c57 fix: add curl to backend image to support the default healthcheck 2026-04-19 01:08:51 +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
findthelorax b0a67744f5 bug: fixed to allow draging from the titlebar 2026-04-17 22:34:17 -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
68 changed files with 3243 additions and 304 deletions
+1
View File
@@ -45,6 +45,7 @@ htmlcov/
*.db
*.db-shm
*.db-wal
*.db.back
# Docker
.docker/
+3 -2
View File
@@ -2,13 +2,14 @@ FROM python:3.13-slim
WORKDIR /app
# Install nmap for network scanning + iputils-ping for ping-based status checks
RUN apt-get update && apt-get install -y --no-install-recommends nmap iputils-ping && rm -rf /var/lib/apt/lists/*
# Install nmap for network scanning + iputils-ping for ping-based status checks + curl for the health check
RUN apt-get update && apt-get install -y --no-install-recommends nmap iputils-ping curl && rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/ .
COPY VERSION /app/VERSION
# Create data directory (volume mount point)
RUN mkdir -p /app/data
+1
View File
@@ -12,6 +12,7 @@ COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ .
COPY VERSION ../VERSION
RUN npm run build
# Stage 2: serve
+1
View File
@@ -0,0 +1 @@
1.10.0
+59
View File
@@ -17,6 +17,10 @@ from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
from app.services.scanner import request_cancel, run_scan
class BulkActionRequest(BaseModel):
device_ids: list[str]
class ScanConfig(BaseModel):
ranges: list[str]
@@ -99,6 +103,61 @@ async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_c
return list(result.scalars().all())
@router.post("/pending/bulk-approve", response_model=dict)
async def bulk_approve_devices(
payload: BulkActionRequest,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, Any]:
result = await db.execute(
select(PendingDevice).where(
PendingDevice.id.in_(payload.device_ids),
PendingDevice.status == "pending",
)
)
devices = result.scalars().all()
node_ids: list[str] = []
for device in devices:
device.status = "approved"
node = Node(
label=device.hostname or device.ip,
type=device.suggested_type or "generic",
ip=device.ip,
hostname=device.hostname,
status="unknown",
services=device.services or [],
)
db.add(node)
node_ids.append(node.id)
await db.commit()
approved_device_ids = [d.id for d in devices]
return {
"approved": len(node_ids),
"node_ids": node_ids,
"device_ids": approved_device_ids,
"skipped": len(payload.device_ids) - len(node_ids),
}
@router.post("/pending/bulk-hide", response_model=dict)
async def bulk_hide_devices(
payload: BulkActionRequest,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, Any]:
result = await db.execute(
select(PendingDevice).where(
PendingDevice.id.in_(payload.device_ids),
PendingDevice.status == "pending",
)
)
devices = result.scalars().all()
for device in devices:
device.status = "hidden"
await db.commit()
return {"hidden": len(devices), "skipped": len(payload.device_ids) - len(devices)}
@router.post("/pending/{device_id}/approve", response_model=dict)
async def approve_device(
device_id: str,
+11
View File
@@ -7,6 +7,17 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
logger = logging.getLogger(__name__)
def _read_version() -> str:
for candidate in [
Path(__file__).parent.parent.parent.parent / "VERSION", # repo root (dev)
Path("/app/VERSION"), # Docker image
]:
if candidate.exists():
return candidate.read_text().strip()
return "unknown"
APP_VERSION = _read_version()
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
+47 -1
View File
@@ -1,3 +1,5 @@
import logging
import shutil
from collections.abc import AsyncGenerator
from contextlib import suppress
from pathlib import Path
@@ -6,7 +8,9 @@ from sqlalchemy.exc import OperationalError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
from app.core.config import APP_VERSION, settings
logger = logging.getLogger(__name__)
# Ensure the data directory exists before SQLite tries to open the file
Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True)
@@ -23,7 +27,22 @@ class Base(DeclarativeBase):
pass
def _backup_db() -> None:
db_path = Path(settings.sqlite_path)
if not db_path.exists():
return
backup_path = db_path.with_suffix(f".db.back-{APP_VERSION}")
if backup_path.exists():
return
try:
shutil.copy2(db_path, backup_path)
logger.info("DB backup created: %s", backup_path.name)
except OSError:
logger.warning("Could not create DB backup at %s", backup_path)
async def init_db() -> None:
_backup_db()
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Add columns introduced after initial schema (idempotent)
@@ -61,6 +80,33 @@ async def init_db() -> None:
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")
+2 -1
View File
@@ -42,6 +42,7 @@ 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)
@@ -49,7 +50,6 @@ class Node(Base):
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])
@@ -69,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)
+1 -1
View File
@@ -35,7 +35,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
app = FastAPI(
title="Homelable API",
version="1.8.3",
version="1.9.0",
lifespan=lifespan,
)
+2
View File
@@ -29,6 +29,7 @@ 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
@@ -49,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
+2
View File
@@ -27,6 +27,7 @@ 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
@@ -59,6 +60,7 @@ 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
+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'
+3 -1
View File
@@ -19,7 +19,9 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
if check_method == "none":
return {"status": "online", "response_time_ms": None}
host = target or ip
# Use only the first IP when the field contains comma-separated addresses
raw_ip = ip.split(",")[0].strip() if ip else None
host = target or raw_ip
if not host:
return {"status": "unknown", "response_time_ms": None}
+1
View File
@@ -2,3 +2,4 @@
*.db-shm
*.db-wal
scan_config.json
homelab.db.*
Binary file not shown.
+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
+3 -3
View File
@@ -9,7 +9,7 @@ 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
@@ -21,6 +21,6 @@ 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"
+57
View File
@@ -0,0 +1,57 @@
"""
Tests for automatic DB backup before migrations.
"""
import os
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
from pathlib import Path
from unittest.mock import patch
import pytest
from app.db.database import _backup_db
@pytest.fixture()
def tmp_db(tmp_path: Path):
db = tmp_path / "homelab.db"
db.write_bytes(b"SQLite placeholder")
return db
def test_backup_created_when_db_exists(tmp_db: Path):
with patch("app.db.database.settings") as mock_settings, \
patch("app.db.database.APP_VERSION", "1.9"):
mock_settings.sqlite_path = str(tmp_db)
_backup_db()
backup = tmp_db.parent / "homelab.db.back-1.9"
assert backup.exists()
assert backup.read_bytes() == b"SQLite placeholder"
def test_backup_skipped_when_db_missing(tmp_path: Path):
with patch("app.db.database.settings") as mock_settings, \
patch("app.db.database.APP_VERSION", "1.9"):
mock_settings.sqlite_path = str(tmp_path / "nonexistent.db")
_backup_db()
assert not any(tmp_path.glob("*.back-*"))
def test_backup_idempotent_second_call_no_overwrite(tmp_db: Path):
with patch("app.db.database.settings") as mock_settings, \
patch("app.db.database.APP_VERSION", "1.9"):
mock_settings.sqlite_path = str(tmp_db)
_backup_db()
backup = tmp_db.parent / "homelab.db.back-1.9"
backup.write_bytes(b"original backup")
_backup_db()
assert backup.read_bytes() == b"original backup"
def test_backup_version_in_filename(tmp_db: Path):
with patch("app.db.database.settings") as mock_settings, \
patch("app.db.database.APP_VERSION", "2.0"):
mock_settings.sqlite_path = str(tmp_db)
_backup_db()
assert (tmp_db.parent / "homelab.db.back-2.0").exists()
+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()
+98
View File
@@ -444,3 +444,101 @@ async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession
# Services and hostname should be updated
assert device.hostname == "myhost.lan"
assert any(s["port"] == 8096 for s in device.services)
# --- Bulk approve ---
@pytest.fixture
async def two_pending_devices(db_session):
devices = []
for i in range(2):
d = PendingDevice(
id=str(uuid.uuid4()),
ip=f"192.168.1.{10 + i}",
mac=None,
hostname=f"host-{i}",
os=None,
services=[],
suggested_type="generic",
status="pending",
)
db_session.add(d)
devices.append(d)
await db_session.commit()
for d in devices:
await db_session.refresh(d)
return devices
@pytest.mark.asyncio
async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_pending_devices):
ids = [d.id for d in two_pending_devices]
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
assert res.status_code == 200
data = res.json()
assert data["approved"] == 2
assert len(data["node_ids"]) == 2
assert len(data["device_ids"]) == 2
assert data["skipped"] == 0
# Pending list should now be empty
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
assert pending_res.json() == []
@pytest.mark.asyncio
async def test_bulk_approve_skips_already_approved(client: AsyncClient, headers, two_pending_devices):
ids = [d.id for d in two_pending_devices]
# Approve first device individually first
await client.post(
f"/api/v1/scan/pending/{ids[0]}/approve",
json={"label": "h", "type": "generic", "ip": "192.168.1.10", "status": "unknown", "services": []},
headers=headers,
)
# Bulk approve both — first one is already approved (not pending), should be skipped
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
assert res.status_code == 200
data = res.json()
assert data["approved"] == 1
assert data["skipped"] == 1
@pytest.mark.asyncio
async def test_bulk_approve_requires_auth(client: AsyncClient, two_pending_devices):
ids = [d.id for d in two_pending_devices]
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids})
assert res.status_code == 401
# --- Bulk hide ---
@pytest.mark.asyncio
async def test_bulk_hide_hides_devices(client: AsyncClient, headers, two_pending_devices):
ids = [d.id for d in two_pending_devices]
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers)
assert res.status_code == 200
data = res.json()
assert data["hidden"] == 2
assert data["skipped"] == 0
# Should appear in hidden list
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
assert len(hidden_res.json()) == 2
@pytest.mark.asyncio
async def test_bulk_hide_skips_non_pending(client: AsyncClient, headers, two_pending_devices):
ids = [d.id for d in two_pending_devices]
# Hide first device individually first
await client.post(f"/api/v1/scan/pending/{ids[0]}/hide", headers=headers)
# Bulk hide both — first is already hidden (not pending anymore)
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers)
assert res.status_code == 200
data = res.json()
assert data["hidden"] == 1
assert data["skipped"] == 1
@pytest.mark.asyncio
async def test_bulk_hide_requires_auth(client: AsyncClient, two_pending_devices):
ids = [d.id for d in two_pending_devices]
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids})
assert res.status_code == 401
+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

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.8.3",
"version": "1.10.0",
"type": "module",
"scripts": {
"dev": "vite",
+13
View File
@@ -343,6 +343,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()
@@ -357,6 +361,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
@@ -393,6 +404,7 @@ export default function App() {
<CanvasContainer
onConnect={handleEdgeConnect}
onEdgeDoubleClick={handleEdgeDoubleClick}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeDragStart={snapshotHistory}
onOpenPending={(deviceId) => {
setHighlightPendingId(undefined)
@@ -446,6 +458,7 @@ export default function App() {
onClose={() => setEditEdgeId(null)}
onSubmit={handleEdgeUpdate}
onDelete={handleEdgeDelete}
onClearWaypoints={handleClearWaypoints}
initial={editEdge?.data}
title="Edit Link"
/>
+2
View File
@@ -60,6 +60,8 @@ export const scanApi = {
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`),
bulkApprove: (ids: string[]) => api.post<{ approved: number; node_ids: string[]; device_ids: string[]; skipped: number }>('/scan/pending/bulk-approve', { device_ids: ids }),
bulkHide: (ids: string[]) => api.post<{ hidden: number; skipped: number }>('/scan/pending/bulk-hide', { device_ids: ids }),
stop: (runId: string) => api.post(`/scan/${runId}/stop`),
getConfig: () => api.get<{ ranges: string[] }>('/scan/config'),
saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
@@ -11,6 +11,7 @@ vi.mock('@xyflow/react', () => ({
Controls: () => null,
BackgroundVariant: { Dots: 'dots' },
ConnectionMode: { Loose: 'loose' },
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
useReactFlow: () => ({ fitView: vi.fn() }),
}))
vi.mock('@xyflow/react/dist/style.css', () => ({}))
@@ -143,6 +144,7 @@ const XYFLOW_MOCK = {
Controls: () => null,
BackgroundVariant: { Dots: 'dots' },
ConnectionMode: { Loose: 'loose' },
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
useReactFlow: () => ({ fitView: vi.fn() }),
}
@@ -25,11 +25,12 @@ 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, onOpenPending }: CanvasContainerProps) {
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onOpenPending }: CanvasContainerProps) {
const [lassoMode, setLassoMode] = useState(true)
const {
nodes, edges,
@@ -68,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
@@ -79,26 +94,29 @@ 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={[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}
/>
@@ -0,0 +1,200 @@
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,
splitIps: (ip: string) => ip ? ip.split(',').map((s: string) => s.trim()).filter(Boolean) : [],
}))
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' },
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
useReactFlow: () => ({ fitView: vi.fn() }),
}))
@@ -104,6 +105,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', () => {
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { GroupNode } from '../nodes/GroupNode'
import * as canvasStore from '@/stores/canvasStore'
import type { Node } from '@xyflow/react'
@@ -102,6 +102,19 @@ describe('GroupNode', () => {
expect(screen.getByTestId('node-resizer').getAttribute('data-visible')).toBe('true')
})
it('allows dragging from the header while keeping rename controls nodrag', () => {
renderGroupNode({ selected: true })
expect(screen.getByText('My Group').closest('div')).not.toHaveClass('nodrag')
const renameButton = screen.getByTitle('Rename group')
expect(renameButton).toHaveClass('nodrag')
fireEvent.click(renameButton)
expect(screen.getByDisplayValue('My Group')).toHaveClass('nodrag')
})
it('shows online/offline status summary from children', () => {
const storeNodes = [
{ id: 'c1', parentId: 'g1', data: { status: 'online' } },
@@ -0,0 +1,77 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { GroupRectNode } from '../nodes/GroupRectNode'
import type { NodeData } from '@/types'
import type { Node } from '@xyflow/react'
vi.mock('@xyflow/react', () => ({
Handle: ({ id, type }: { id: string; type: string }) => <div data-testid={`handle-${id}`} data-type={type} />,
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
NodeResizer: () => null,
}))
vi.mock('@/stores/canvasStore', () => ({
useCanvasStore: (sel: (s: { setEditingGroupRectId: () => void }) => unknown) =>
sel({ setEditingGroupRectId: vi.fn() }),
}))
function makeNode(overrides: Partial<NodeData> = {}): Node<NodeData> {
return {
id: 'zone1',
type: 'groupRect',
position: { x: 0, y: 0 },
data: { label: 'My Zone', type: 'groupRect', status: 'unknown', services: [], ...overrides },
}
}
function renderZone(overrides: Partial<NodeData> = {}) {
const node = makeNode(overrides)
return render(
<GroupRectNode
id={node.id}
data={node.data}
selected={false}
type="groupRect"
dragging={false}
zIndex={0}
isConnectable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
)
}
describe('GroupRectNode — handles', () => {
it('renders source handles on all four sides', () => {
renderZone()
expect(screen.getByTestId('handle-zone-top')).toBeDefined()
expect(screen.getByTestId('handle-zone-right')).toBeDefined()
expect(screen.getByTestId('handle-zone-bottom')).toBeDefined()
expect(screen.getByTestId('handle-zone-left')).toBeDefined()
})
it('renders target handles on all four sides', () => {
renderZone()
expect(screen.getByTestId('handle-zone-top-t')).toBeDefined()
expect(screen.getByTestId('handle-zone-right-t')).toBeDefined()
expect(screen.getByTestId('handle-zone-bottom-t')).toBeDefined()
expect(screen.getByTestId('handle-zone-left-t')).toBeDefined()
})
it('renders 8 handles total (4 source + 4 target)', () => {
renderZone()
expect(screen.getAllByTestId(/^handle-zone-/).length).toBe(8)
})
})
describe('GroupRectNode — label', () => {
it('renders inside label by default', () => {
renderZone({ label: 'DMZ' })
expect(screen.getByText('DMZ')).toBeDefined()
})
it('renders no label when label is empty', () => {
renderZone({ label: '' })
expect(screen.queryByText('DMZ')).toBeNull()
})
})
@@ -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)
})
})
+253 -11
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] = 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.5 py-0.5 rounded"
style={{
transform: `translate(-50%, -50%) translate(${labelX}px, ${(sourceY + targetY) / 2}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,14 @@
import { createElement, useEffect } from 'react'
import { Handle, Position, NodeResizer, useUpdateNodeInternals, 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 { maskIp, splitIps } from '@/utils/maskIp'
import { BOTTOM_HANDLE_IDS, BOTTOM_HANDLE_POSITIONS } from '@/utils/handleUtils'
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
@@ -23,6 +24,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
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]
@@ -31,19 +35,25 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
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
className="relative flex flex-col rounded-lg border transition-all duration-200"
className="relative flex flex-col rounded-lg border transition-all duration-200 overflow-hidden"
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,
@@ -55,7 +65,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
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
@@ -67,7 +77,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
{/* Main row */}
<div className="flex flex-row items-center gap-2.5 px-2.5 py-2">
<div className="flex flex-row items-center gap-2.5 px-2.5 py-2 min-w-0 overflow-hidden">
{/* Icon */}
<div
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
@@ -88,24 +98,43 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
>
{data.label}
</div>
{data.ip && (
{data.ip && splitIps(data.ip).map((ip) => (
<div
key={ip}
className="font-mono text-[10px] truncate"
style={{ color: theme.colors.nodeSubtextColor }}
title={data.ip}
title={ip}
>
{hideIp ? maskIp(data.ip) : data.ip}
{hideIp ? maskIp(ip) : ip}
</div>
)}
))}
</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 overflow-hidden">
{visibleProperties.map((prop) => {
const Icon = resolvePropertyIcon(prop.icon)
return (
<div key={prop.key} className="flex items-center gap-1 font-mono text-[10px] min-w-0 overflow-hidden" 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 min-w-0" 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" />
@@ -117,7 +146,6 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
)}
</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 && (
@@ -66,13 +66,13 @@ export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
borderBottom: isVisible ? `1px solid ${borderColor}40` : 'none',
pointerEvents: 'auto',
}}
className="nodrag"
>
<Layers size={12} style={{ color: '#00d4ff', flexShrink: 0 }} />
{editing ? (
<input
autoFocus
className="nodrag"
value={labelDraft}
onChange={(e) => setLabelDraft(e.target.value)}
onKeyDown={(e) => {
@@ -97,11 +97,12 @@ export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
{editing ? (
<>
<button onClick={handleRename} style={{ color: '#39d353', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><Check size={11} /></button>
<button onClick={() => { setLabelDraft(data.label); setEditing(false) }} style={{ color: '#f85149', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><X size={11} /></button>
<button className="nodrag" onClick={handleRename} style={{ color: '#39d353', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><Check size={11} /></button>
<button className="nodrag" onClick={() => { setLabelDraft(data.label); setEditing(false) }} style={{ color: '#f85149', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><X size={11} /></button>
</>
) : (
<button
className="nodrag"
onClick={() => { setLabelDraft(data.label); setEditing(true) }}
style={{ color: '#8b949e', background: 'none', border: 'none', cursor: 'pointer', padding: 1, opacity: selected ? 1 : 0 }}
title="Rename group"
@@ -1,4 +1,5 @@
import { NodeResizer, type NodeProps, type Node } from '@xyflow/react'
import { useState } from 'react'
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
import { useCanvasStore } from '@/stores/canvasStore'
import type { NodeData, TextPosition } from '@/types'
@@ -26,8 +27,16 @@ const POSITION_STYLES: Record<TextPosition, AlignStyle> = {
'bottom-right': { alignItems: 'flex-end', justifyContent: 'flex-end', textAlign: 'right' },
}
const HANDLE_SIDES = [
{ id: 'zone-top', position: Position.Top },
{ id: 'zone-right', position: Position.Right },
{ id: 'zone-bottom', position: Position.Bottom },
{ id: 'zone-left', position: Position.Left },
] as const
export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
const setEditingGroupRectId = useCanvasStore((s) => s.setEditingGroupRectId)
const [hovered, setHovered] = useState(false)
const rc = data.custom_colors ?? {}
const borderColor = rc.border ?? '#00d4ff'
@@ -60,6 +69,16 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
whiteSpace: 'pre-wrap',
}
const handleStyle: React.CSSProperties = {
width: 10,
height: 10,
background: borderColor,
border: '2px solid #0d1117',
borderRadius: '50%',
opacity: hovered ? 1 : 0,
transition: 'opacity 0.15s',
}
return (
<>
<NodeResizer
@@ -73,8 +92,16 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
background: '#00d4ff',
border: '1px solid #0d1117',
}}
lineStyle={{ borderColor: '#00d4ff55', borderWidth: 1 }}
lineStyle={{ borderColor: 'transparent' }}
/>
{HANDLE_SIDES.map(({ id: hid, position }) => (
<span key={hid}>
<Handle type="source" id={hid} position={position} style={handleStyle} />
<Handle type="target" id={`${hid}-t`} position={position} style={{ ...handleStyle, opacity: 0, width: 14, height: 14 }} />
</span>
))}
<div
style={{
position: 'relative',
@@ -86,11 +113,14 @@ 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',
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onDoubleClick={(e) => {
e.stopPropagation()
setEditingGroupRectId(id)
+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}>
+2 -86
View File
@@ -49,8 +49,6 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
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 }))
@@ -211,11 +209,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
{/* IP */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">IP Address</Label>
<Label className="text-xs text-muted-foreground">IP Address <span className="text-muted-foreground/50">(comma-separated)</span></Label>
<Input
value={form.ip ?? ''}
onChange={(e) => set('ip', e.target.value)}
placeholder="192.168.1.x"
placeholder="192.168.1.x, 2001:db8::1"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
</div>
@@ -334,88 +332,6 @@ 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>
)}
</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">
@@ -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()
})
})
@@ -72,7 +72,7 @@ describe('NodeModal', () => {
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')
expect((screen.getByPlaceholderText('192.168.1.x, 2001:db8::1') as HTMLInputElement).value).toBe('192.168.1.10')
})
// ── Cancel ────────────────────────────────────────────────────────────
@@ -121,7 +121,7 @@ describe('NodeModal', () => {
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('192.168.1.x, 2001:db8::1'), { 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>
@@ -293,91 +293,6 @@ describe('NodeModal', () => {
expect(screen.getByText(/Using default colors for/)).toBeDefined()
})
// ── Hardware section ──────────────────────────────────────────────────
it('renders Hardware toggle button', () => {
renderModal()
expect(screen.getByText('Hardware')).toBeDefined()
})
it('hardware fields are hidden by default', () => {
renderModal()
expect(screen.queryByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeNull()
})
it('expands hardware fields on toggle click', () => {
renderModal()
fireEvent.click(screen.getByText('Hardware'))
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
expect(screen.getByPlaceholderText('e.g. 8')).toBeDefined()
expect(screen.getByPlaceholderText('e.g. 32')).toBeDefined()
expect(screen.getByPlaceholderText('e.g. 500')).toBeDefined()
})
it('auto-expands when initial has hardware data', () => {
renderModal({ initial: { ...BASE, cpu_count: 8, ram_gb: 32 } })
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
})
it('pre-fills hardware fields from initial', () => {
renderModal({ initial: { ...BASE, cpu_model: 'Intel i5', cpu_count: 4, ram_gb: 16, disk_gb: 500 } })
expect((screen.getByPlaceholderText('e.g. Intel Xeon E5-2680') as HTMLInputElement).value).toBe('Intel i5')
})
it('submits hardware fields when filled', () => {
const { onSubmit } = renderModal()
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Homelab' } })
fireEvent.click(screen.getByText('Hardware'))
fireEvent.change(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680'), { target: { value: 'Intel i7-12700K' } })
fireEvent.change(screen.getByPlaceholderText('e.g. 8'), { target: { value: '12' } })
fireEvent.change(screen.getByPlaceholderText('e.g. 32'), { target: { value: '64' } })
fireEvent.change(screen.getByPlaceholderText('e.g. 500'), { target: { value: '2000' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
expect(data.cpu_model).toBe('Intel i7-12700K')
expect(data.cpu_count).toBe(12)
expect(data.ram_gb).toBe(64)
expect(data.disk_gb).toBe(2000)
})
it('hides Hardware section for groupRect type', () => {
renderModal({ initial: { type: 'groupRect' } })
expect(screen.queryByText('Hardware')).toBeNull()
})
it('show_hardware toggle hidden when section is collapsed', () => {
renderModal()
expect(screen.queryByText('Show on node')).toBeNull()
})
it('show_hardware toggle appears when section is expanded', () => {
renderModal()
fireEvent.click(screen.getByText('Hardware'))
expect(screen.getByText('Show on node')).toBeDefined()
})
it('show_hardware defaults to falsy', () => {
const { onSubmit } = renderModal()
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect(onSubmit.mock.calls[0][0].show_hardware).toBeFalsy()
})
it('toggling show_hardware sets it to true', () => {
const { onSubmit } = renderModal()
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
fireEvent.click(screen.getByText('Hardware'))
fireEvent.click(screen.getByRole('switch'))
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
})
it('pre-fills show_hardware from initial', () => {
const { onSubmit } = renderModal({ initial: { label: 'Node', show_hardware: true, cpu_count: 8 } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
})
// ── Bottom connection points ───────────────────────────────────────────
it('shows Bottom Connection Points for server type', () => {
+221 -16
View File
@@ -1,10 +1,12 @@
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 { primaryIp } from '@/utils/maskIp'
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
import type { Node } from '@xyflow/react'
interface DetailPanelProps {
@@ -14,6 +16,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 +29,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))
@@ -119,6 +130,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">
@@ -146,22 +203,68 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
</a>
</div>
)}
{data.ip && <DetailRow label="IP Address" value={data.ip} mono />}
{data.ip && (
<div className="flex justify-between gap-2 items-baseline">
<span className="text-muted-foreground text-xs shrink-0">IP Address</span>
<a href={`http://${primaryIp(data.ip)}`} target="_blank" rel="noopener noreferrer" className="text-xs font-mono text-[#00d4ff] hover:underline truncate flex items-center gap-1" title={data.ip}>
{data.ip}<ExternalLink size={10} className="shrink-0" />
</a>
</div>
)}
{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.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">
@@ -365,11 +468,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">
@@ -407,6 +505,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',
}
+100 -3
View File
@@ -165,9 +165,26 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
const [devices, setDevices] = useState<PendingDevice[]>([])
const [loading, setLoading] = useState(false)
const [selected, setSelected] = useState<PendingDevice | null>(null)
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set())
const { addNode, scanEventTs } = useCanvasStore()
const highlightRef = useRef<HTMLButtonElement>(null)
const allChecked = devices.length > 0 && checkedIds.size === devices.length
const someChecked = checkedIds.size > 0
const toggleCheck = (id: string, e: React.MouseEvent) => {
e.stopPropagation()
setCheckedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
const toggleAll = () => {
setCheckedIds(allChecked ? new Set() : new Set(devices.map((d) => d.id)))
}
const load = useCallback(async () => {
setLoading(true)
try {
@@ -184,12 +201,58 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
try {
await scanApi.clearPending()
setDevices([])
setCheckedIds(new Set())
toast.success('Pending devices cleared')
} catch {
toast.error('Failed to clear pending devices')
}
}
const handleBulkApprove = async () => {
const ids = [...checkedIds]
try {
const res = await scanApi.bulkApprove(ids)
const deviceToNode: Record<string, string> = {}
res.data.device_ids.forEach((did, i) => { deviceToNode[did] = res.data.node_ids[i] })
const approvedDevices = devices.filter((d) => ids.includes(d.id))
approvedDevices.forEach((d, i) => {
const nodeId = deviceToNode[d.id]
if (!nodeId) return
addNode({
id: nodeId,
type: (d.suggested_type ?? 'generic') as import('@/types').NodeType,
position: { x: 400 + (i % 4) * 160, y: 300 + Math.floor(i / 4) * 100 },
data: {
label: d.hostname ?? d.ip,
type: (d.suggested_type ?? 'generic') as import('@/types').NodeType,
ip: d.ip,
hostname: d.hostname ?? undefined,
status: 'unknown' as const,
services: (d.services ?? []) as import('@/types').ServiceInfo[],
},
})
onNodeApproved(nodeId)
})
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
setCheckedIds(new Set())
toast.success(`Approved ${res.data.approved} device${res.data.approved !== 1 ? 's' : ''}`)
} catch {
toast.error('Failed to bulk approve devices')
}
}
const handleBulkHide = async () => {
const ids = [...checkedIds]
try {
const res = await scanApi.bulkHide(ids)
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
setCheckedIds(new Set())
toast.success(`Hidden ${res.data.hidden} device${res.data.hidden !== 1 ? 's' : ''}`)
} catch {
toast.error('Failed to bulk hide devices')
}
}
useEffect(() => { load() }, [load])
useEffect(() => {
@@ -251,7 +314,19 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
<>
<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>
<div className="flex items-center gap-1.5">
{devices.length > 0 && (
<input
type="checkbox"
checked={allChecked}
ref={(el) => { if (el) el.indeterminate = someChecked && !allChecked }}
onChange={toggleAll}
className="w-3 h-3 accent-[#00d4ff] cursor-pointer"
title="Select all"
/>
)}
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Pending</span>
</div>
<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} />
@@ -263,6 +338,22 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
)}
</div>
</div>
{someChecked && (
<div className="flex items-center gap-1 mb-2">
<button
onClick={handleBulkApprove}
className="flex-1 text-[10px] py-1 px-2 rounded bg-[#39d353]/20 text-[#39d353] hover:bg-[#39d353]/30 transition-colors font-medium"
>
Approve ({checkedIds.size})
</button>
<button
onClick={handleBulkHide}
className="flex-1 text-[10px] py-1 px-2 rounded bg-[#8b949e]/20 text-[#8b949e] hover:bg-[#8b949e]/30 transition-colors font-medium"
>
Hide ({checkedIds.size})
</button>
</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>
@@ -288,10 +379,16 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
key={d.id}
ref={isHighlighted ? highlightRef : null}
onClick={() => setSelected(d)}
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]'}`}
className={`w-full mb-1.5 p-2 rounded-md text-xs text-left transition-colors border ${isHighlighted ? 'bg-[#2d3748] border-[#e3b341]' : checkedIds.has(d.id) ? 'bg-[#21262d] border-[#00d4ff]/40' : '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" />
<input
type="checkbox"
checked={checkedIds.has(d.id)}
onClick={(e) => toggleCheck(d.id, e)}
onChange={() => {}}
className="w-3 h-3 accent-[#00d4ff] cursor-pointer shrink-0"
/>
<span className="text-foreground truncate font-medium">{title}</span>
</div>
{showIpBelow && (
@@ -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' })
})
@@ -301,4 +385,34 @@ describe('DetailPanel', () => {
expect(screen.getByText('nginx')).toBeDefined()
})
})
describe('IP Address — clickable link', () => {
it('renders a link for a single IP', () => {
setupStore({ ip: '192.168.1.10' })
render(<DetailPanel onEdit={vi.fn()} />)
const link = screen.getByRole('link', { name: /192\.168\.1\.10/ })
expect(link).toBeDefined()
expect(link.getAttribute('href')).toBe('http://192.168.1.10')
expect(link.getAttribute('target')).toBe('_blank')
})
it('renders no IP link when ip is absent', () => {
setupStore({ ip: undefined })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.queryByText('IP Address')).toBeNull()
})
it('uses primary IP as href for comma-separated IPs', () => {
setupStore({ ip: '192.168.1.10, 192.168.1.11' })
render(<DetailPanel onEdit={vi.fn()} />)
const link = screen.getByRole('link', { name: /192\.168\.1\.10/ })
expect(link.getAttribute('href')).toBe('http://192.168.1.10')
})
it('displays full comma-separated IP string as link text', () => {
setupStore({ ip: '192.168.1.10, 192.168.1.11' })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText(/192\.168\.1\.10, 192\.168\.1\.11/)).toBeDefined()
})
})
})
@@ -9,6 +9,9 @@ import type { NodeData } from '@/types'
vi.mock('@/stores/canvasStore')
const mockBulkApprove = vi.fn()
const mockBulkHide = vi.fn()
vi.mock('@/api/client', () => ({
scanApi: {
trigger: vi.fn().mockResolvedValue({}),
@@ -16,6 +19,12 @@ vi.mock('@/api/client', () => ({
hidden: vi.fn().mockResolvedValue({ data: [] }),
runs: vi.fn().mockResolvedValue({ data: [] }),
stop: vi.fn().mockResolvedValue({}),
clearPending: vi.fn().mockResolvedValue({}),
approve: vi.fn().mockResolvedValue({ data: { approved: true, node_id: 'new-node-1' } }),
hide: vi.fn().mockResolvedValue({ data: { hidden: true } }),
ignore: vi.fn().mockResolvedValue({ data: { ignored: true } }),
bulkApprove: (...args: unknown[]) => mockBulkApprove(...args),
bulkHide: (...args: unknown[]) => mockBulkHide(...args),
},
settingsApi: {
get: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }),
@@ -259,3 +268,100 @@ describe('Sidebar', () => {
expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument()
})
})
// ── PendingDevicesPanel — bulk select ─────────────────────────────────────────
const DEVICE_A = {
id: 'dev-a',
ip: '192.168.1.10',
hostname: 'host-a',
mac: null,
os: null,
services: [],
suggested_type: 'generic',
status: 'pending',
discovery_source: 'arp',
}
const DEVICE_B = {
id: 'dev-b',
ip: '192.168.1.11',
hostname: 'host-b',
mac: null,
os: null,
services: [],
suggested_type: 'generic',
status: 'pending',
discovery_source: 'arp',
}
describe('PendingDevicesPanel — bulk select', () => {
beforeEach(() => {
mockStore()
vi.clearAllMocks()
mockBulkApprove.mockResolvedValue({
data: { approved: 2, node_ids: ['n1', 'n2'], device_ids: ['dev-a', 'dev-b'], skipped: 0 },
})
mockBulkHide.mockResolvedValue({ data: { hidden: 2, skipped: 0 } })
})
async function renderWithDevices() {
const { scanApi } = await import('@/api/client')
vi.mocked(scanApi.pending).mockResolvedValue({ data: [DEVICE_A, DEVICE_B] } as never)
render(<Sidebar {...defaultProps} forceView="pending" />)
await waitFor(() => expect(screen.getByText('host-a')).toBeInTheDocument())
}
it('renders checkboxes for each device', async () => {
await renderWithDevices()
const checkboxes = screen.getAllByRole('checkbox')
// select-all + 2 device checkboxes
expect(checkboxes.length).toBe(3)
})
it('shows bulk action bar when a device is checked', async () => {
await renderWithDevices()
const [, firstDeviceCheckbox] = screen.getAllByRole('checkbox')
fireEvent.click(firstDeviceCheckbox)
await waitFor(() => expect(screen.getByText(/Approve \(1\)/)).toBeInTheDocument())
expect(screen.getByText(/Hide \(1\)/)).toBeInTheDocument()
})
it('hides bulk action bar when no device is checked', async () => {
await renderWithDevices()
expect(screen.queryByText(/Approve \(/)).not.toBeInTheDocument()
})
it('select-all checks all devices', async () => {
await renderWithDevices()
const [selectAll] = screen.getAllByRole('checkbox')
fireEvent.click(selectAll)
await waitFor(() => expect(screen.getByText(/Approve \(2\)/)).toBeInTheDocument())
})
it('select-all unchecks all when all are selected', async () => {
await renderWithDevices()
const [selectAll] = screen.getAllByRole('checkbox')
fireEvent.click(selectAll) // select all
fireEvent.click(selectAll) // deselect all
await waitFor(() => expect(screen.queryByText(/Approve \(/)).not.toBeInTheDocument())
})
it('calls bulkApprove with checked ids and removes devices from list', async () => {
await renderWithDevices()
const [selectAll] = screen.getAllByRole('checkbox')
fireEvent.click(selectAll)
fireEvent.click(screen.getByText(/Approve \(2\)/))
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b']))
await waitFor(() => expect(screen.queryByText('host-a')).not.toBeInTheDocument())
})
it('calls bulkHide with checked ids and removes devices from list', async () => {
await renderWithDevices()
const [selectAll] = screen.getAllByRole('checkbox')
fireEvent.click(selectAll)
fireEvent.click(screen.getByText(/Hide \(2\)/))
await waitFor(() => expect(mockBulkHide).toHaveBeenCalledWith(['dev-a', 'dev-b']))
await waitFor(() => expect(screen.queryByText('host-b')).not.toBeInTheDocument())
})
})
+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;
+4
View File
@@ -191,6 +191,10 @@ export const useCanvasStore = create<CanvasState>((set) => ({
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) {
+15 -1
View File
@@ -43,6 +43,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 +69,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?: {
@@ -87,6 +95,11 @@ export interface NodeData extends Record<string, unknown> {
export type EdgePathStyle = 'bezier' | 'smooth'
export interface Waypoint {
x: number
y: number
}
export interface EdgeData extends Record<string, unknown> {
type: EdgeType
label?: string
@@ -94,7 +107,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> = {
@@ -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 ───────────────────────────────────────
+56 -3
View File
@@ -1,7 +1,8 @@
import { describe, it, expect } from 'vitest'
import { maskIp } from '../maskIp'
import { maskIp, splitIps, primaryIp } from '../maskIp'
describe('maskIp', () => {
// IPv4
it('masks last two octets of a standard IPv4', () => {
expect(maskIp('192.168.1.115')).toBe('192.168.XX.XX')
})
@@ -11,9 +12,61 @@ describe('maskIp', () => {
expect(maskIp('172.16.254.1')).toBe('172.16.XX.XX')
})
it('passes through non-IPv4 strings unchanged', () => {
// IPv6
it('masks second group and last group of an IPv6 address', () => {
expect(maskIp('2001:db8::1')).toBe('2001:XX::XX')
})
it('masks a full IPv6 address', () => {
expect(maskIp('fe80:0000:0000:0000:0202:b3ff:fe1e:8329')).toBe('fe80:XX:0000:0000:0202:b3ff:fe1e:XX')
})
it('masks loopback IPv6', () => {
// ::1 splits into ['', '', '1'] — groups[1] and last are masked
expect(maskIp('::1')).toBe(':XX:XX')
})
// Comma-separated
it('masks all IPs in a comma-separated string', () => {
expect(maskIp('192.168.1.1, 2001:db8::1')).toBe('192.168.XX.XX, 2001:XX::XX')
})
it('handles comma-separated without spaces', () => {
expect(maskIp('10.0.0.1,10.0.0.2')).toBe('10.0.XX.XX, 10.0.XX.XX')
})
// Edge cases
it('passes through non-IP strings unchanged', () => {
expect(maskIp('hostname')).toBe('hostname')
expect(maskIp('fe80::1')).toBe('fe80::1')
expect(maskIp('')).toBe('')
})
})
describe('splitIps', () => {
it('returns array of trimmed IPs', () => {
expect(splitIps('192.168.1.1, 2001:db8::1')).toEqual(['192.168.1.1', '2001:db8::1'])
})
it('returns single-element array for single IP', () => {
expect(splitIps('10.0.0.1')).toEqual(['10.0.0.1'])
})
it('returns empty array for empty string', () => {
expect(splitIps('')).toEqual([])
expect(splitIps(' ')).toEqual([])
})
})
describe('primaryIp', () => {
it('returns first IP from comma-separated string', () => {
expect(primaryIp('192.168.1.1, 2001:db8::1')).toBe('192.168.1.1')
})
it('returns the only IP when single', () => {
expect(primaryIp('10.0.0.1')).toBe('10.0.0.1')
})
it('returns empty string for empty input', () => {
expect(primaryIp('')).toBe('')
})
})
@@ -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', () => {
+10 -9
View File
@@ -1,5 +1,5 @@
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 ────────────────────────────────────────────────────────────────────
@@ -28,6 +28,7 @@ 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
@@ -43,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) ───────────────────────────────
@@ -99,8 +101,9 @@ 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,
width: n.width ?? null,
height: n.height ?? null,
properties: n.data.properties ?? [],
width: n.measured?.width ?? n.width ?? null,
height: n.measured?.height ?? n.height ?? null,
bottom_handles: n.data.bottom_handles ?? 1,
pos_x: n.position.x,
pos_y: n.position.y,
@@ -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,
}
}
@@ -152,11 +156,8 @@ export function deserializeApiNode(
position: { x: n.pos_x, y: n.pos_y },
data: n as unknown as NodeData,
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
...(n.type === '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 ? { width: n.width } : n.type === 'proxmox' && n.container_mode !== false ? { width: 300 } : {}),
...(n.height ? { height: n.height } : n.type === 'proxmox' && n.container_mode !== false ? { height: 200 } : {}),
}
}
+41 -6
View File
@@ -1,10 +1,45 @@
/**
* Mask the last two octets of an IPv4 address.
* e.g. "192.168.1.115" → "192.168.XX.XX"
* Non-IPv4 strings are returned unchanged.
* Mask a single IP address:
* - IPv4 "192.168.1.115" → "192.168.XX.XX"
* - IPv6 "2001:db8::1" → "2001:XX::XX"
* - Other strings returned unchanged.
*/
function maskSingle(ip: string): string {
const trimmed = ip.trim()
if (/^[\da-fA-F:]+$/.test(trimmed) && trimmed.includes(':')) {
const groups = trimmed.split(':')
if (groups.length >= 2) {
groups[1] = 'XX'
groups[groups.length - 1] = 'XX'
return groups.join(':')
}
}
const parts = trimmed.split('.')
if (parts.length === 4) return `${parts[0]}.${parts[1]}.XX.XX`
return trimmed
}
/**
* Mask all IPs in a comma-separated string.
* e.g. "192.168.1.1, 2001:db8::1" → "192.168.XX.XX, 2001:XX::XX"
*/
export function maskIp(ip: string): string {
const parts = ip.split('.')
if (parts.length === 4) return `${parts[0]}.${parts[1]}.XX.XX`
return ip
if (!ip) return ip
return ip.split(',').map(maskSingle).join(', ')
}
/**
* Split a comma-separated IP string into an array of trimmed values.
* Empty string returns [].
*/
export function splitIps(ip: string): string[] {
if (!ip?.trim()) return []
return ip.split(',').map((s) => s.trim()).filter(Boolean)
}
/**
* Return the first IP from a comma-separated string (used for status checks).
*/
export function primaryIp(ip: string): string {
return splitIps(ip)[0] ?? ''
}
+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
}
+4 -2
View File
@@ -1,12 +1,14 @@
import fs from 'fs'
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'
const appVersion = fs.readFileSync(path.resolve(__dirname, '../VERSION'), 'utf-8').trim()
export default defineConfig({
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
__APP_VERSION__: JSON.stringify(appVersion),
},
plugins: [react(), tailwindcss()],
resolve: {