Compare commits

..

42 Commits

Author SHA1 Message Date
Pouzor daa78a036a bump: version 1.10.2 2026-04-21 11:52:43 +02:00
Pouzor 3deb750441 fix: NaN guard on settings interval input and validate release URL scheme 2026-04-21 11:43:15 +02:00
Pouzor 88554ef952 fix: stop click propagation on pending device checkbox to prevent modal opening 2026-04-21 11:37:43 +02:00
Pouzor 110592f89e fix: checkbox onChange anti-pattern in PendingDevicesPanel 2026-04-21 11:28:55 +02:00
Pouzor 8b8da5584c feat: add logout button to sidebar 2026-04-21 11:23:52 +02:00
Pouzor 4260a6582c Bimp version 1.10.1 2026-04-20 15:05:12 +02:00
Pouzor a0f18dd237 fix: use custom icon in proxmox container mode header 2026-04-20 14:57:45 +02:00
Pouzor 00edc32aeb fix: show visible properties on proxmox container mode node header 2026-04-20 14:45:37 +02:00
findthelorax cd6a788f77 fix: test was missing leading / 2026-04-20 14:37:08 +02:00
findthelorax 9cf6a48b04 fix: port input to text numeric and removed up/down arrows 2026-04-20 14:37:08 +02:00
findthelorax 2c94616afa cleanup path examples 2026-04-20 14:37:08 +02:00
findthelorax c7be851c34 feature: add support for services to use a path 2026-04-20 14:37:08 +02:00
Remy fddfd0a769 Merge pull request #90 from Pouzor/feat/zone-color-opacity
feat: add opacity slider to zone color pickers (fixes #72)
2026-04-20 14:13:35 +02:00
Pouzor 074b49358b feat: add opacity slider to zone color pickers (fixes #72)
The native <input type="color"> only supports 6-digit hex, stripping alpha
and forcing background/border/text colors to be fully opaque on edit.

Each color field now shows an opacity slider (0–100%) below the swatch.
Values are stored as 8-digit hex (#rrggbbaa). Existing zones with 6-digit
colors are handled transparently (alpha defaults to 100%).

- colorUtils.ts: hexToRgba / rgbaToHex8 helpers
- GroupRectModal: opacity sliders for all three color fields
- 26 new tests across colorUtils and GroupRectModal
2026-04-20 14:05:06 +02:00
Remy a47b7649f0 Merge pull request #89 from Pouzor/feat/export-quality
feat: PNG export quality selector (standard / high / ultra)
2026-04-20 13:50:42 +02:00
Pouzor 7e08a85f73 feat: add quality selector to PNG export (standard / high / ultra)
Clicking Export PNG now opens a modal with three quality presets:
- Standard (1× pixel ratio) — small file
- High (2×, default) — recommended for sharing
- Ultra (4×) — print quality

Adds ExportModal component, updates exportToPng() to accept a quality
param, and wires the modal into App.tsx replacing the direct export call.
2026-04-20 11:40:16 +02:00
Remy c7c5183356 Merge pull request #87 from findthelorax/fix/reset-form-data
fix: resets form data after submission
2026-04-20 10:48:02 +02:00
Pouzor 7608d07255 fix: flush before reading node IDs in bulk/single approve; 404/409 guards; catch scan errors
- db.flush() ensures node.id is populated before reading — fixes bulk approve
  where node_ids were null, causing frontend to skip addNode for every device
- approve_device raises 404 on missing device, 409 on already-processed device
- _background_scan rollbacks dirty session then marks run as "failed"
- Explicit Node() field mapping instead of **model_dump() to prevent injection
- update_scan_config rolls back in-memory change if save_overrides() fails
- clear_pending uses bulk DELETE instead of N individual row deletes
2026-04-20 10:41:02 +02:00
Remy 1bc6798d76 Merge pull request #84 from findthelorax/feature/property-icons
feature: added new icons for properties
2026-04-20 10:07:49 +02:00
findthelorax f6de7d1770 fix: removed setState within an effect, responsibility moved to parent key 2026-04-20 00:13:07 -04:00
findthelorax 9dddd00858 fix: resets form data after submission 2026-04-20 00:06:29 -04:00
findthelorax a5bf9c9db6 feature: added new icons for properties 2026-04-19 21:00:20 -04:00
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
findthelorax b0a67744f5 bug: fixed to allow draging from the titlebar 2026-04-17 22:34:17 -04:00
56 changed files with 1605 additions and 373 deletions
+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.2
+94 -14
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]
@@ -37,7 +41,15 @@ router = APIRouter()
async def _background_scan(run_id: str, ranges: list[str]) -> None:
async with AsyncSessionLocal() as db:
await run_scan(ranges, db, run_id)
try:
await run_scan(ranges, db, run_id)
except Exception:
logger.exception("Scan run %s failed unexpectedly", run_id)
await db.rollback()
run = await db.get(ScanRun, run_id)
if run and run.status == "running":
run.status = "failed"
await db.commit()
@router.post("/trigger", response_model=ScanRunResponse)
@@ -85,12 +97,10 @@ async def clear_pending(
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, int]:
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending"))
devices = result.scalars().all()
for device in devices:
await db.delete(device)
from sqlalchemy import delete as sa_delete
result = await db.execute(sa_delete(PendingDevice).where(PendingDevice.status == "pending"))
await db.commit()
return {"deleted": len(devices)}
return {"deleted": result.rowcount}
@router.get("/hidden", response_model=list[PendingDeviceResponse])
@@ -99,6 +109,63 @@ 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()
created_nodes: list[Node] = []
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)
created_nodes.append(node)
await db.flush() # populates node.id from Python-side default before reading
node_ids = [n.id for n in created_nodes]
approved_device_ids = [d.id for d in devices]
await db.commit()
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,
@@ -107,13 +174,24 @@ async def approve_device(
_: str = Depends(get_current_user),
) -> dict[str, Any]:
device = await db.get(PendingDevice, device_id)
if device:
device.status = "approved"
node = Node(**node_data.model_dump())
db.add(node)
await db.commit()
return {"approved": True, "node_id": node.id}
return {"approved": False}
if not device:
raise HTTPException(status_code=404, detail="Device not found")
if device.status != "pending":
raise HTTPException(status_code=409, detail="Device already processed")
device.status = "approved"
node = Node(
label=node_data.label,
type=node_data.type,
ip=node_data.ip,
hostname=node_data.hostname,
status=node_data.status,
services=node_data.services or [],
)
db.add(node)
await db.flush()
node_id = node.id
await db.commit()
return {"approved": True, "node_id": node_id}
@router.post("/pending/{device_id}/hide")
@@ -153,10 +231,12 @@ async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
@router.post("/config", response_model=ScanConfig)
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig:
previous = settings.scanner_ranges
settings.scanner_ranges = payload.ranges
try:
settings.scanner_ranges = payload.ranges
settings.save_overrides()
return payload
except Exception as exc:
settings.scanner_ranges = previous
logger.error("Failed to save scan config: %s", exc)
raise HTTPException(status_code=500, detail="Failed to save scan config") from exc
+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")
+20 -11
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)
@@ -94,16 +113,6 @@ async def init_db() -> None:
with suppress(OperationalError):
sql = "UPDATE edges SET animated = 'none' WHERE animated = '0' OR animated = 0 OR animated IS NULL"
await conn.exec_driver_sql(sql)
# Ensure existing proxmox nodes have container_mode=1 (they were always containers before the flag existed)
with suppress(OperationalError):
await conn.exec_driver_sql(
"UPDATE nodes SET container_mode = 1 WHERE type = 'proxmox' AND container_mode = 0"
)
# Rename legacy 'docker' type → 'docker_container'
with suppress(OperationalError):
await conn.exec_driver_sql(
"UPDATE nodes SET type = 'docker_container' WHERE type = 'docker'"
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
+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.*
+9
View File
@@ -453,6 +453,15 @@ async def test_save_canvas_persists_services_and_notes(client: AsyncClient, head
assert node["notes"] == "My NAS device"
async def test_save_canvas_persists_service_paths(client: AsyncClient, headers: dict):
services = [{"service_name": "Grafana", "protocol": "tcp", "port": 3000, "path": "/login"}]
n1 = node_payload(ip="192.168.1.50:8080", services=services)
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]["services"] == services
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)
+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()
-96
View File
@@ -1,96 +0,0 @@
"""
Tests for docker type rename and proxmox container_mode migrations.
"""
import os
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
TEST_DB_URL = "sqlite+aiosqlite:///:memory:"
async def _setup_table(conn):
await conn.exec_driver_sql("""
CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY,
type TEXT NOT NULL DEFAULT 'generic',
label TEXT NOT NULL DEFAULT '',
container_mode BOOLEAN NOT NULL DEFAULT 0
)
""")
async def _run_migrations(conn):
await conn.exec_driver_sql(
"UPDATE nodes SET container_mode = 1 WHERE type = 'proxmox' AND container_mode = 0"
)
await conn.exec_driver_sql(
"UPDATE nodes SET type = 'docker_container' WHERE type = 'docker'"
)
@pytest.mark.asyncio
async def test_proxmox_container_mode_set_to_true():
engine = create_async_engine(TEST_DB_URL)
async with engine.begin() as conn:
await _setup_table(conn)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, type, label, container_mode) VALUES ('p1', 'proxmox', 'PVE', 0)"
)
await _run_migrations(conn)
row = (await conn.exec_driver_sql("SELECT container_mode FROM nodes WHERE id = 'p1'")).fetchone()
assert row[0] == 1
@pytest.mark.asyncio
async def test_proxmox_already_true_unchanged():
engine = create_async_engine(TEST_DB_URL)
async with engine.begin() as conn:
await _setup_table(conn)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, type, label, container_mode) VALUES ('p2', 'proxmox', 'PVE', 1)"
)
await _run_migrations(conn)
row = (await conn.exec_driver_sql("SELECT container_mode FROM nodes WHERE id = 'p2'")).fetchone()
assert row[0] == 1
@pytest.mark.asyncio
async def test_non_proxmox_container_mode_untouched():
engine = create_async_engine(TEST_DB_URL)
async with engine.begin() as conn:
await _setup_table(conn)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, type, label, container_mode) VALUES ('s1', 'server', 'Srv', 0)"
)
await _run_migrations(conn)
row = (await conn.exec_driver_sql("SELECT container_mode FROM nodes WHERE id = 's1'")).fetchone()
assert row[0] == 0
@pytest.mark.asyncio
async def test_docker_type_renamed_to_docker_container():
engine = create_async_engine(TEST_DB_URL)
async with engine.begin() as conn:
await _setup_table(conn)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, type, label) VALUES ('d1', 'docker', 'My Docker')"
)
await _run_migrations(conn)
row = (await conn.exec_driver_sql("SELECT type FROM nodes WHERE id = 'd1'")).fetchone()
assert row[0] == 'docker_container'
@pytest.mark.asyncio
async def test_docker_host_type_untouched():
engine = create_async_engine(TEST_DB_URL)
async with engine.begin() as conn:
await _setup_table(conn)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, type, label) VALUES ('d2', 'docker_host', 'Docker Host')"
)
await _run_migrations(conn)
row = (await conn.exec_driver_sql("SELECT type FROM nodes WHERE id = 'd2'")).fetchone()
assert row[0] == 'docker_host'
+100 -2
View File
@@ -120,8 +120,7 @@ async def test_approve_nonexistent_device(client: AsyncClient, headers):
json=node_payload,
headers=headers,
)
assert res.status_code == 200
assert res.json()["approved"] is False
assert res.status_code == 404
# --- Hide device ---
@@ -444,3 +443,102 @@ 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 all(nid is not None for nid in data["node_ids"]), "node_ids must be non-null UUIDs"
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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.9.0",
"version": "1.10.2",
"type": "module",
"scripts": {
"dev": "vite",
+17 -19
View File
@@ -5,7 +5,7 @@ import { applyDagreLayout } from '@/utils/layout'
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
import { generateUUID } from '@/utils/uuid'
import { generateMarkdownTable } from '@/utils/exportMarkdown'
import { exportToPng } from '@/utils/export'
import { ExportModal } from '@/components/modals/ExportModal'
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
import { parseYamlToCanvas } from '@/utils/importYaml'
import { TooltipProvider } from '@/components/ui/tooltip'
@@ -33,7 +33,6 @@ import type { NodeData, EdgeData } from '@/types'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
const CONTAINER_MODE_TYPES = new Set<NodeData['type']>(['proxmox', 'docker_host'])
export default function App() {
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
@@ -54,6 +53,7 @@ export default function App() {
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
const [scanConfigOpen, setScanConfigOpen] = useState(false)
const [exportModalOpen, setExportModalOpen] = useState(false)
// Declare handleSave before the Ctrl+S effect so it is in scope
const handleSave = useCallback(async () => {
@@ -103,8 +103,8 @@ export default function App() {
// Build a map of proxmox container mode to know if children should be nested
const proxmoxContainerMap = new Map<string, boolean>(
(apiNodes as ApiNode[])
.filter((n) => n.type === 'group' || n.container_mode === true)
.map((n) => [n.id, true])
.filter((n) => n.type === 'proxmox' || n.type === 'group')
.map((n) => [n.id, n.type === 'group' ? true : n.container_mode !== false])
)
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
@@ -242,8 +242,8 @@ export default function App() {
snapshotHistory()
const existingNode = nodes.find((n) => n.id === editNodeId)
updateNode(editNodeId, data)
// If container_mode changed, apply structural changes (children parentId, node dimensions)
if (typeof data.container_mode === 'boolean') {
// If proxmox container_mode changed, apply structural changes (children parentId, node dimensions)
if (data.type === 'proxmox' && typeof data.container_mode === 'boolean') {
setProxmoxContainerMode(editNodeId, data.container_mode)
}
// Sync virtual edge when parent_id changes on an LXC/VM node
@@ -306,15 +306,10 @@ export default function App() {
}
}, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved])
const handleExport = useCallback(async () => {
const handleExport = useCallback(() => {
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
if (!el) { toast.error('Canvas not ready'); return }
try {
await exportToPng(el)
toast.success('Exported as PNG')
} catch {
toast.error('Export failed')
}
setExportModalOpen(true)
}, [])
const handleEdgeConnect = useCallback((connection: Connection) => {
@@ -423,13 +418,12 @@ export default function App() {
</div>
<NodeModal
key={addNodeOpen ? 'add-open' : 'add-closed'}
open={addNodeOpen}
onClose={() => setAddNodeOpen(false)}
onSubmit={handleAddNode}
title="Add Node"
parentContainerNodes={nodes
.filter((n) => CONTAINER_MODE_TYPES.has(n.data.type) && n.data.container_mode)
.map((n) => ({ id: n.id, label: n.data.label }))}
proxmoxNodes={nodes.filter((n) => n.type === 'proxmox').map((n) => ({ id: n.id, label: n.data.label }))}
/>
{/* key forces re-mount when editing a different node, resetting form state */}
@@ -440,9 +434,7 @@ export default function App() {
onSubmit={handleUpdateNode}
initial={editNode?.data}
title="Edit Node"
parentContainerNodes={nodes
.filter((n) => n.id !== editNodeId && CONTAINER_MODE_TYPES.has(n.data.type) && n.data.container_mode)
.map((n) => ({ id: n.id, label: n.data.label }))}
proxmoxNodes={nodes.filter((n) => n.type === 'proxmox').map((n) => ({ id: n.id, label: n.data.label }))}
/>
<EdgeModal
@@ -536,6 +528,12 @@ export default function App() {
/>
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
<ExportModal
open={exportModalOpen}
onClose={() => setExportModalOpen(false)}
getElement={() => canvasRef.current?.querySelector<HTMLElement>('.react-flow') ?? null}
/>
<Toaster theme="dark" position="bottom-right" />
</ReactFlowProvider>
</TooltipProvider>
+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() }),
}
@@ -48,6 +48,7 @@ vi.mock('@/utils/nodeIcons', () => ({
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', () => ({
@@ -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() }),
}))
@@ -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()
})
})
@@ -8,7 +8,7 @@ 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>> {
@@ -43,7 +43,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
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,
@@ -77,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"
@@ -98,15 +98,16 @@ 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>
@@ -114,14 +115,14 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
{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">
<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]" style={{ color: theme.colors.nodeSubtextColor }}>
<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" title={prop.value}>· {prop.value}</span>
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
</div>
)
})}
@@ -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
@@ -75,6 +94,14 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
}}
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',
@@ -92,6 +119,8 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
boxSizing: 'border-box',
cursor: 'default',
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onDoubleClick={(e) => {
e.stopPropagation()
setEditingGroupRectId(id)
@@ -1,7 +1,10 @@
import { createElement } from 'react'
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
import { Layers } 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 { BaseNode } from './BaseNode'
@@ -41,6 +44,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
const isOnline = data.status === 'online'
const glow = colors.border
const proxmoxAccent = theme.colors.nodeAccents.proxmox.border
const resolvedIcon = resolveNodeIcon(Layers, data.custom_icon)
return (
<>
@@ -80,7 +84,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
background: theme.colors.nodeIconBackground,
}}
>
<Layers size={12} />
{createElement(resolvedIcon, { size: 12 })}
</div>
<div className="flex flex-col min-w-0 flex-1">
<span
@@ -106,6 +110,27 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
/>
</div>
{/* Properties */}
{data.properties?.filter((p) => p.visible).map((prop, i, arr) => {
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 px-2.5 shrink-0"
style={{
color: theme.colors.nodeSubtextColor,
paddingTop: i === 0 ? 4 : 2,
paddingBottom: i === arr.length - 1 ? 4 : 2,
borderTop: i === 0 ? `1px solid ${glow}22` : undefined,
}}
>
{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>
)
})}
{/* Inner area — React Flow places children here */}
<div className="flex-1 relative" />
</div>
@@ -23,5 +23,4 @@ export const PrinterNode = (props: N) => <BaseNode {...props} icon={Printer} />
export const ComputerNode = (props: N) => <BaseNode {...props} icon={Monitor} />
export const CplNode = (props: N) => <BaseNode {...props} icon={PlugZap} />
export const DockerNode = (props: N) => <BaseNode {...props} icon={Anchor} />
export const DockerContainerNode = (props: N) => <BaseNode {...props} icon={Container} />
export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
@@ -1,4 +1,4 @@
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerNode, DockerContainerNode, GenericNode } from './index'
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerNode, GenericNode } from './index'
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
import { GroupRectNode } from './GroupRectNode'
import { GroupNode } from './GroupNode'
@@ -18,8 +18,7 @@ export const nodeTypes = {
printer: PrinterNode,
computer: ComputerNode,
cpl: CplNode,
docker_container: DockerContainerNode,
docker_host: DockerNode,
docker: DockerNode,
generic: GenericNode,
groupRect: GroupRectNode,
group: GroupNode,
@@ -0,0 +1,71 @@
import { useState } from 'react'
import { Download, Loader2 } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { exportToPng, EXPORT_QUALITY_OPTIONS, type ExportQuality } from '@/utils/export'
interface ExportModalProps {
open: boolean
onClose: () => void
getElement: () => HTMLElement | null
}
export function ExportModal({ open, onClose, getElement }: ExportModalProps) {
const [quality, setQuality] = useState<ExportQuality>('high')
const [exporting, setExporting] = useState(false)
const handleExport = async () => {
const el = getElement()
if (!el) return
setExporting(true)
try {
await exportToPng(el, quality)
onClose()
} finally {
setExporting(false)
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="bg-[#161b22] border-border max-w-sm">
<DialogHeader>
<DialogTitle className="text-foreground">Export as PNG</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
{EXPORT_QUALITY_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setQuality(opt.value)}
className={[
'w-full flex items-center justify-between px-3 py-2.5 rounded-md border text-sm transition-colors',
quality === opt.value
? 'border-[#00d4ff] bg-[#00d4ff10] text-foreground'
: 'border-border bg-[#0d1117] text-muted-foreground hover:border-muted-foreground',
].join(' ')}
>
<span className="font-medium">{opt.label}</span>
<span className="text-xs opacity-70">{opt.hint}</span>
</button>
))}
</div>
<DialogFooter className="gap-2">
<Button variant="ghost" onClick={onClose} disabled={exporting}>Cancel</Button>
<Button
onClick={handleExport}
disabled={exporting}
style={{ background: '#00d4ff', color: '#0d1117' }}
>
{exporting
? <><Loader2 size={14} className="animate-spin mr-1.5" />Exporting</>
: <><Download size={14} className="mr-1.5" />Download</>
}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import type { TextPosition } from '@/types'
import { hexToRgba, rgbaToHex8 } from '@/utils/colorUtils'
export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
@@ -204,23 +205,35 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Colors</Label>
<div className="grid grid-cols-3 gap-2">
{colorFields.map(({ key, label }) => (
<div key={key} className="flex flex-col gap-1 items-center">
<label
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden"
style={{ borderColor: '#30363d' }}
>
{colorFields.map(({ key, label }) => {
const { hex6, alpha } = hexToRgba(form[key])
return (
<div key={key} className="flex flex-col gap-1 items-center">
<label
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden"
style={{ borderColor: '#30363d' }}
>
<input
type="color"
value={hex6}
onChange={(e) => set(key, rgbaToHex8(e.target.value, alpha))}
className="absolute inset-0 w-full h-full cursor-pointer opacity-0"
/>
<div className="w-full h-full rounded-sm" style={{ background: form[key] }} />
</label>
<input
type="color"
value={form[key]}
onChange={(e) => set(key, e.target.value)}
className="absolute inset-0 w-full h-full cursor-pointer opacity-0"
type="range"
min={0}
max={100}
value={alpha}
onChange={(e) => set(key, rgbaToHex8(hex6, Number(e.target.value)))}
className="w-full h-1 accent-[#00d4ff] cursor-pointer"
title={`Opacity: ${alpha}%`}
/>
<div className="w-full h-full rounded-sm" style={{ background: form[key] }} />
</label>
<span className="text-[9px] text-muted-foreground/60">{label}</span>
</div>
))}
<span className="text-[9px] text-muted-foreground/60">{label} {alpha}%</span>
</div>
)
})}
</div>
</div>
+27 -33
View File
@@ -1,4 +1,4 @@
import { Fragment, createElement, useState } from 'react'
import { createElement, useState } from 'react'
import { RotateCcw, ChevronDown } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
@@ -11,13 +11,12 @@ import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS } from '@/utils
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
{ label: 'Hardware', types: ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer'] },
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker'] },
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
{ label: 'Generic', types: ['computer', 'generic', 'groupRect'] },
]
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
const CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host']
const DEFAULT_DATA: Partial<NodeData> = {
type: 'server',
@@ -27,7 +26,7 @@ const DEFAULT_DATA: Partial<NodeData> = {
status: 'unknown',
check_method: 'ping',
services: [],
container_mode: false,
container_mode: true,
custom_colors: undefined,
custom_icon: undefined,
}
@@ -38,12 +37,12 @@ interface NodeModalProps {
onSubmit: (data: Partial<NodeData>) => void
initial?: Partial<NodeData>
title?: string
parentContainerNodes?: { id: string; label: string }[]
proxmoxNodes?: { id: string; label: string }[]
}
// NodeModal is always mounted with a key that changes on open/edit, so useState
// initial value is enough - no need for a reset effect.
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentContainerNodes = [] }: NodeModalProps) {
const CHILD_TYPES: NodeType[] = ['vm', 'lxc']
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', proxmoxNodes = [] }: NodeModalProps) {
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
const [iconSearch, setIconSearch] = useState('')
const [iconPickerOpen, setIconPickerOpen] = useState(false)
@@ -59,12 +58,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
return
}
setLabelError(false)
const selectedType = (form.type ?? 'generic') as NodeType
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
onSubmit({
...form,
container_mode: canUseContainerMode ? !!form.container_mode : false,
})
onSubmit(form)
onClose()
}
@@ -82,13 +76,13 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
<Label className="text-xs text-muted-foreground">Type</Label>
<Select value={form.type} onValueChange={(v) => set('type', v as NodeType)}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8 w-full">
<SelectValue>{NODE_TYPE_LABELS[(form.type ?? 'server') as NodeType]}</SelectValue>
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
{NODE_TYPE_GROUPS.map((group, i) => (
<Fragment key={group.label}>
{i > 0 && <SelectSeparator className="bg-[#30363d]" />}
<SelectGroup>
<>
{i > 0 && <SelectSeparator key={`sep-${group.label}`} className="bg-[#30363d]" />}
<SelectGroup key={group.label}>
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50 px-2 py-1">
{group.label}
</SelectLabel>
@@ -98,7 +92,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
</SelectItem>
))}
</SelectGroup>
</Fragment>
</>
))}
</SelectContent>
</Select>
@@ -138,7 +132,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
</button>
</div>
{/* Inline icon picker - full width, shown below the type+icon row */}
{/* Inline icon picker full width, shown below the type+icon row */}
{iconPickerOpen && (
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d] col-span-2">
<Input
@@ -213,11 +207,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>
@@ -248,10 +242,10 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
/>
</div>
{/* Parent container */}
{form.type !== 'groupRect' && form.type !== 'group' && parentContainerNodes.length > 0 && (
{/* Parent Proxmox (VM / LXC only) */}
{CHILD_TYPES.includes(form.type as NodeType) && proxmoxNodes.length > 0 && (
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Parent Container</Label>
<Label className="text-xs text-muted-foreground">Parent Proxmox</Label>
<Select
value={form.parent_id ?? 'none'}
onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)}
@@ -261,7 +255,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
<SelectItem value="none" className="text-sm">None (standalone)</SelectItem>
{parentContainerNodes.map((n) => (
{proxmoxNodes.map((n) => (
<SelectItem key={n.id} value={n.id} className="text-sm">{n.label}</SelectItem>
))}
</SelectContent>
@@ -269,12 +263,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
</div>
)}
{/* Container mode */}
{CONTAINER_MODE_TYPES.includes((form.type ?? 'generic') as NodeType) && (
{/* Container mode (proxmox only) */}
{form.type === 'proxmox' && (
<div className="flex items-center justify-between col-span-2 py-1">
<div className="flex flex-col gap-0.5">
<Label className="text-xs text-muted-foreground">Container Mode</Label>
<span className="text-[10px] text-muted-foreground/60">Allow other nodes to nest inside this node</span>
<span className="text-[10px] text-muted-foreground/60">Show VM/LXC nodes nested inside</span>
</div>
<button
type="button"
@@ -348,10 +342,10 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
<SelectItem value="1" className="text-sm">1 - center</SelectItem>
<SelectItem value="2" className="text-sm">2 - left / right</SelectItem>
<SelectItem value="3" className="text-sm">3 - left / center / right</SelectItem>
<SelectItem value="4" className="text-sm">4 - evenly spaced</SelectItem>
<SelectItem value="1" className="text-sm">1 center</SelectItem>
<SelectItem value="2" className="text-sm">2 left / right</SelectItem>
<SelectItem value="3" className="text-sm">3 left / center / right</SelectItem>
<SelectItem value="4" className="text-sm">4 evenly spaced</SelectItem>
</SelectContent>
</Select>
</div>
@@ -4,7 +4,6 @@ import { Search } from 'lucide-react'
import { useCanvasStore } from '@/stores/canvasStore'
import { scanApi } from '@/api/client'
import type { PendingDevice } from '@/components/modals/PendingDeviceModal'
import { NODE_TYPE_LABELS } from '@/types'
interface SearchModalProps {
open: boolean
@@ -90,7 +89,7 @@ export function SearchModal({ open, onClose, onOpenPending }: SearchModalProps)
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
onClick={() => handleSelectNode(node.id)}
>
<span className="text-xs font-mono text-[#00d4ff] w-16 shrink-0">{NODE_TYPE_LABELS[node.data.type] ?? node.data.type}</span>
<span className="text-xs font-mono text-[#00d4ff] w-16 shrink-0">{node.data.type}</span>
<span className="text-sm text-foreground font-medium flex-1 truncate">{node.data.label}</span>
{node.data.ip && (
<span className="text-xs font-mono text-muted-foreground shrink-0">{node.data.ip}</span>
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { ExportModal } from '../ExportModal'
const mockExportToPng = vi.fn()
vi.mock('@/utils/export', () => ({
exportToPng: (...args: unknown[]) => mockExportToPng(...args),
EXPORT_QUALITY_OPTIONS: [
{ value: 'standard', label: 'Standard', pixelRatio: 1, hint: '1× — small file' },
{ value: 'high', label: 'High', pixelRatio: 2, hint: '2× — recommended' },
{ value: 'ultra', label: 'Ultra', pixelRatio: 4, hint: '4× — print quality, large file' },
],
}))
const el = document.createElement('div')
const getElement = () => el
const onClose = vi.fn()
describe('ExportModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockExportToPng.mockResolvedValue(undefined)
})
it('renders all three quality options', () => {
render(<ExportModal open onClose={onClose} getElement={getElement} />)
expect(screen.getByText('Standard')).toBeInTheDocument()
expect(screen.getByText('High')).toBeInTheDocument()
expect(screen.getByText('Ultra')).toBeInTheDocument()
})
it('selects High by default', () => {
render(<ExportModal open onClose={onClose} getElement={getElement} />)
const highBtn = screen.getByText('High').closest('button')!
expect(highBtn.className).toContain('border-[#00d4ff]')
})
it('changes selection when another option is clicked', () => {
render(<ExportModal open onClose={onClose} getElement={getElement} />)
fireEvent.click(screen.getByText('Ultra').closest('button')!)
expect(screen.getByText('Ultra').closest('button')!.className).toContain('border-[#00d4ff]')
expect(screen.getByText('High').closest('button')!.className).not.toContain('border-[#00d4ff]')
})
it('calls exportToPng with selected quality on Download click', async () => {
render(<ExportModal open onClose={onClose} getElement={getElement} />)
fireEvent.click(screen.getByText('Standard').closest('button')!)
fireEvent.click(screen.getByRole('button', { name: /download/i }))
await waitFor(() => expect(mockExportToPng).toHaveBeenCalledWith(el, 'standard'))
})
it('closes after successful export', async () => {
render(<ExportModal open onClose={onClose} getElement={getElement} />)
fireEvent.click(screen.getByRole('button', { name: /download/i }))
await waitFor(() => expect(onClose).toHaveBeenCalled())
})
it('calls onClose when Cancel is clicked', () => {
render(<ExportModal open onClose={onClose} getElement={getElement} />)
fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
expect(onClose).toHaveBeenCalled()
})
it('does not call exportToPng when getElement returns null', async () => {
render(<ExportModal open onClose={onClose} getElement={() => null} />)
fireEvent.click(screen.getByRole('button', { name: /download/i }))
await waitFor(() => expect(mockExportToPng).not.toHaveBeenCalled())
})
it('does not render when closed', () => {
render(<ExportModal open={false} onClose={onClose} getElement={getElement} />)
expect(screen.queryByText('Export as PNG')).not.toBeInTheDocument()
})
})
@@ -251,4 +251,60 @@ describe('GroupRectModal', () => {
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.border_style).toBe('solid')
})
it('shows opacity sliders for all three color fields', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
const sliders = screen.getAllByRole('slider')
expect(sliders).toHaveLength(3)
})
it('default background_color is 8-digit hex with low alpha', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.background_color).toBe('#00d4ff0d')
expect(submitted.background_color.length).toBe(9)
})
it('moving background opacity slider updates background_color alpha', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
// background slider is the third one (Text, Border, Background)
const sliders = screen.getAllByRole('slider')
fireEvent.change(sliders[2], { target: { value: '50' } })
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
// alpha 50% → 0x80 = 128
expect(submitted.background_color).toBe('#00d4ff80')
})
it('moving border opacity slider to 0 makes border fully transparent', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
const sliders = screen.getAllByRole('slider')
fireEvent.change(sliders[1], { target: { value: '0' } })
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.border_color).toBe('#00d4ff00')
})
it('pre-fills opacity from 8-digit initial background_color', () => {
render(
<GroupRectModal
open
onClose={vi.fn()}
onSubmit={vi.fn()}
initial={{ background_color: '#ff6e0080' }}
/>
)
const sliders = screen.getAllByRole('slider')
expect((sliders[2] as HTMLInputElement).value).toBe('50')
})
it('shows opacity percentage in label', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
// Background default is 5% opacity
expect(screen.getByText(/Background 5%/)).toBeInTheDocument()
})
})
@@ -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>
@@ -130,6 +130,21 @@ describe('NodeModal', () => {
expect(data.notes).toBe('rack A')
})
it('resets form values when reopened in Add mode', () => {
const onClose = vi.fn()
const onSubmit = vi.fn()
const { rerender } = render(<NodeModal key="open-1" open onClose={onClose} onSubmit={onSubmit} />)
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Temp Node' } })
fireEvent.change(screen.getByPlaceholderText('server.lan'), { target: { value: 'temp.local' } })
rerender(<NodeModal key="closed" open={false} onClose={onClose} onSubmit={onSubmit} />)
rerender(<NodeModal key="open-2" open onClose={onClose} onSubmit={onSubmit} />)
expect((screen.getByPlaceholderText('My Server') as HTMLInputElement).value).toBe('')
expect((screen.getByPlaceholderText('server.lan') as HTMLInputElement).value).toBe('')
})
it('submits check_target', () => {
const { onSubmit } = renderModal({ initial: BASE })
fireEvent.change(screen.getByPlaceholderText('http://...'), { target: { value: 'http://192.168.1.10:8080' } })
@@ -219,20 +234,15 @@ describe('NodeModal', () => {
expect(screen.queryByTitle('Router')).toBeNull()
})
// ── Container mode ─────────────────────────────────────────────────────
// ── Container mode (proxmox only) ─────────────────────────────────────
it('shows Container Mode toggle for proxmox type', () => {
renderModal({ initial: { ...BASE, type: 'proxmox' } })
expect(screen.getByText('Container Mode')).toBeDefined()
})
it('hides Container Mode for server type', () => {
renderModal({ initial: { ...BASE, type: 'server' } })
expect(screen.queryByText('Container Mode')).toBeNull()
})
it('hides Container Mode for groupRect type', () => {
renderModal({ initial: { ...BASE, type: 'groupRect' } })
it('hides Container Mode for non-proxmox types', () => {
renderModal({ initial: BASE })
expect(screen.queryByText('Container Mode')).toBeNull()
})
@@ -243,25 +253,33 @@ describe('NodeModal', () => {
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).container_mode).toBe(false)
})
// ── Parent container ──────────────────────────────────────────────────
// ── Parent Proxmox (vm / lxc only) ───────────────────────────────────
it('shows Parent Container when options are provided', () => {
it('shows Parent Proxmox for vm with proxmoxNodes', () => {
renderModal({
initial: { ...BASE, type: 'server' },
parentContainerNodes: [{ id: 'c1', label: 'Container 01' }],
initial: { ...BASE, type: 'vm' },
proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }],
})
expect(screen.getByText('Parent Container')).toBeDefined()
expect(screen.getByText('Container 01')).toBeDefined()
expect(screen.getByText('Parent Proxmox')).toBeDefined()
expect(screen.getByText('PVE-01')).toBeDefined()
})
it('hides Parent Container for groupRect type', () => {
renderModal({ initial: { ...BASE, type: 'groupRect' }, parentContainerNodes: [{ id: 'c1', label: 'Container 01' }] })
expect(screen.queryByText('Parent Container')).toBeNull()
it('shows Parent Proxmox for lxc with proxmoxNodes', () => {
renderModal({
initial: { ...BASE, type: 'lxc' },
proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }],
})
expect(screen.getByText('Parent Proxmox')).toBeDefined()
})
it('hides Parent Container when no container options are available', () => {
renderModal({ initial: { ...BASE, type: 'server' } })
expect(screen.queryByText('Parent Container')).toBeNull()
it('hides Parent Proxmox for server type', () => {
renderModal({ initial: BASE, proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }] })
expect(screen.queryByText('Parent Proxmox')).toBeNull()
})
it('hides Parent Proxmox for vm when no proxmoxNodes', () => {
renderModal({ initial: { ...BASE, type: 'vm' } })
expect(screen.queryByText('Parent Proxmox')).toBeNull()
})
// ── Appearance ────────────────────────────────────────────────────────
+69 -16
View File
@@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input'
import { useCanvasStore } from '@/stores/canvasStore'
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'
@@ -12,8 +13,8 @@ interface DetailPanelProps {
onEdit: (id: string) => void
}
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string }
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '' }
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '', path: '' }
type PropForm = { key: string; value: string; icon: string | null; visible: boolean }
const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true }
@@ -93,10 +94,18 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
}
const handleAddService = () => {
const port = parseInt(newSvc.port, 10)
if (!newSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
const trimmedPort = newSvc.port.trim()
const port = trimmedPort === '' ? undefined : parseInt(trimmedPort, 10)
if (!newSvc.service_name.trim()) return
if (trimmedPort !== '' && (port == null || Number.isNaN(port) || port < 1 || port > 65535)) return
snapshotHistory()
const svc: ServiceInfo = { port, protocol: newSvc.protocol, service_name: newSvc.service_name.trim() }
const path = newSvc.path.trim()
const svc: ServiceInfo = {
...(port != null ? { port } : {}),
protocol: newSvc.protocol,
service_name: newSvc.service_name.trim(),
...(path ? { path } : {}),
}
updateNode(node.id, { services: [...services, svc] })
setNewSvc(EMPTY_FORM)
setAddingForNode(null)
@@ -112,18 +121,29 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
const handleStartEdit = (index: number) => {
const svc = services[index]
if (!svc) return
setEditSvc({ port: String(svc.port), protocol: svc.protocol, service_name: svc.service_name })
setEditSvc({ port: svc.port != null ? String(svc.port) : '', protocol: svc.protocol, service_name: svc.service_name, path: svc.path ?? '' })
setEditingFor({ nodeId: node.id, index })
setAddingForNode(null)
}
const handleSaveEdit = () => {
if (editingIndex === null) return
const port = parseInt(editSvc.port, 10)
if (!editSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
const trimmedPort = editSvc.port.trim()
const port = trimmedPort === '' ? undefined : parseInt(trimmedPort, 10)
if (!editSvc.service_name.trim()) return
if (trimmedPort !== '' && (port == null || Number.isNaN(port) || port < 1 || port > 65535)) return
snapshotHistory()
const path = editSvc.path.trim()
const updated = services.map((svc, i) =>
i === editingIndex ? { ...svc, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() } : svc
i === editingIndex
? {
...svc,
protocol: editSvc.protocol,
service_name: editSvc.service_name.trim(),
...(port != null ? { port } : { port: undefined }),
...(path ? { path } : { path: undefined }),
}
: svc
)
updateNode(node.id, { services: updated })
setEditingFor(null)
@@ -202,7 +222,14 @@ 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 />}
@@ -272,7 +299,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
editingIndex === i ? (
<ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus />
) : (
<ServiceBadge key={`${svc.port}-${svc.protocol}-${i}`} svc={svc} host={host} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
<ServiceBadge key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`} svc={svc} host={host} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
)
)}
</div>
@@ -472,23 +499,46 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono?
}
function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFocus }: {
form: { port: string; protocol: 'tcp' | 'udp'; service_name: string }
onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string }) => void
form: { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }
onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }) => void
onConfirm: () => void
onCancel: () => void
confirmLabel: string
autoFocus?: boolean
}) {
const setPort = (value: string) => {
const digitsOnly = value.replace(/\D/g, '').slice(0, 5)
onChange({ ...form, port: digitsOnly })
}
const clampPort = (value: string) => {
if (!value) return ''
const parsed = Number.parseInt(value, 10)
if (!Number.isFinite(parsed)) return ''
return String(Math.max(1, Math.min(65535, parsed)))
}
return (
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
<Input value={form.service_name} onChange={(e) => onChange({ ...form, service_name: e.target.value })} placeholder="Service name" className="bg-[#21262d] border-[#30363d] text-xs h-7" autoFocus={autoFocus} onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
<div className="flex gap-1.5">
<Input type="number" value={form.port} onChange={(e) => onChange({ ...form, port: e.target.value })} placeholder="Port" min={1} max={65535} className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-20 shrink-0" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
<Input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.port}
onChange={(e) => setPort(e.target.value)}
onBlur={() => onChange({ ...form, port: clampPort(form.port) })}
placeholder="Port"
className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-28 shrink-0"
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
/>
<select value={form.protocol} onChange={(e) => onChange({ ...form, protocol: e.target.value as 'tcp' | 'udp' })} className="flex-1 bg-[#21262d] border border-[#30363d] rounded-md text-xs h-7 px-1.5 text-foreground">
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
</div>
<Input value={form.path} onChange={(e) => onChange({ ...form, path: e.target.value })} placeholder="Path (/admin)" className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
<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>
@@ -611,14 +661,17 @@ const CATEGORY_COLORS: Record<string, string> = {
function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) {
const url = getServiceUrl(svc, host)
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
const portLabel = svc.port != null ? String(svc.port) : 'host'
const pathLabel = svc.path?.trim() ? svc.path.trim() : null
const inner = (
<div className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors" style={{ background: '#21262d', borderColor: '#30363d', cursor: url ? 'pointer' : 'default' }}>
<div className="flex items-center gap-1.5 min-w-0">
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
<span className="font-medium truncate" style={{ color }}>{svc.service_name}</span>
<span className="font-medium truncate" style={{ color }} title={svc.service_name}>{svc.service_name}</span>
{pathLabel && <span className="truncate text-[#8b949e]" title={pathLabel}>{pathLabel}</span>}
</div>
<div className="flex items-center gap-1.5 shrink-0">
<span className="font-mono text-[#8b949e]">{svc.port}/{svc.protocol}</span>
<span className="font-mono text-[#8b949e]">{portLabel}/{svc.protocol}</span>
{url && <ExternalLink size={10} className="text-muted-foreground" />}
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5" title="Edit service"><Pencil size={10} /></button>
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5" title="Remove service"><X size={10} /></button>
+114 -7
View File
@@ -1,8 +1,9 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, X } from 'lucide-react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, X, LogOut } from 'lucide-react'
import { Logo } from '@/components/ui/Logo'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore'
import { scanApi, settingsApi } from '@/api/client'
import { toast } from 'sonner'
import { useLatestRelease } from '@/hooks/useLatestRelease'
@@ -43,6 +44,7 @@ interface SidebarProps {
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) {
const [_collapsed, setCollapsed] = useState(false)
const [_activeView, setActiveView] = useState<SidebarView>('canvas')
const logout = useAuthStore((s) => s.logout)
// When forceView is set, override local state without useEffect
const collapsed = forceView ? false : _collapsed
@@ -152,6 +154,14 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
onClick={() => setActiveView((v) => v === 'settings' ? 'canvas' : 'settings')}
/>
)}
{!STANDALONE && (
<SidebarItem
icon={LogOut}
label="Logout"
collapsed={collapsed}
onClick={logout}
/>
)}
</div>
{!collapsed && <VersionBadge />}
@@ -165,9 +175,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 +211,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 +324,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,12 +348,28 @@ 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>
)}
{devices.map((d) => {
const namedService = d.services.find((s) => s.category != null && !COMMON_PORTS.has(s.port))
const namedService = d.services.find((s) => s.category != null && s.port != null && !COMMON_PORTS.has(s.port))
const titleService = namedService
?? d.services.find((s) => s.port === 80)
?? d.services.find((s) => s.port === 443)
@@ -288,10 +389,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) => e.stopPropagation()}
onChange={(e) => { e.stopPropagation(); toggleCheck(d.id, e as unknown as React.MouseEvent) }}
className="w-3 h-3 accent-[#00d4ff] cursor-pointer shrink-0"
/>
<span className="text-foreground truncate font-medium">{title}</span>
</div>
{showIpBelow && (
@@ -530,7 +637,7 @@ function SettingsPanel() {
min={10}
max={3600}
value={interval}
onChange={(e) => setIntervalValue(Number(e.target.value))}
onChange={(e) => { const v = Number(e.target.value); if (!isNaN(v)) setIntervalValue(v) }}
className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]"
/>
<span className="text-xs text-muted-foreground">seconds</span>
@@ -567,7 +674,7 @@ function VersionBadge() {
</a>
{hasUpdate && latest && (
<a
href={latest.url}
href={latest.url.startsWith('https://') ? latest.url : '#'}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-[#e3b341]/15 text-[#e3b341] border border-[#e3b341]/30 hover:bg-[#e3b341]/25 transition-colors self-start"
@@ -2,10 +2,11 @@ import { describe, it, expect } from 'vitest'
import { getServiceUrl } from '@/utils/serviceUrl'
import type { ServiceInfo } from '@/types'
const svc = (port: number, protocol: 'tcp' | 'udp' = 'tcp', service_name = 'test'): ServiceInfo => ({
port,
const svc = (port?: number, protocol: 'tcp' | 'udp' = 'tcp', service_name = 'test', path?: string): ServiceInfo => ({
...(port != null ? { port } : {}),
protocol,
service_name,
...(path ? { path } : {}),
})
describe('getServiceUrl', () => {
@@ -63,4 +64,20 @@ describe('getServiceUrl', () => {
it('uses host string directly (works with both IP and hostname)', () => {
expect(getServiceUrl(svc(80), 'myserver.lan')).toBe('http://myserver.lan:80')
})
it('uses the node port when the host already includes one', () => {
expect(getServiceUrl(svc(undefined, 'tcp', 'app'), '192.168.1.10:8080')).toBe('http://192.168.1.10:8080')
})
it('lets the service port override the node port', () => {
expect(getServiceUrl(svc(3000, 'tcp', 'app'), '192.168.1.10:8080')).toBe('http://192.168.1.10:3000')
})
it('appends a normalized path to the final URL', () => {
expect(getServiceUrl(svc(3000, 'tcp', 'app', 'admin/login'), '192.168.1.10')).toBe('http://192.168.1.10:3000/admin/login')
})
it('supports path-only services inheriting the node port', () => {
expect(getServiceUrl(svc(undefined, 'tcp', 'app', '/metrics'), '192.168.1.10:9090')).toBe('http://192.168.1.10:9090/metrics')
})
})
@@ -293,9 +293,35 @@ describe('DetailPanel', () => {
fireEvent.click(addHeaders[addHeaders.length - 1])
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } })
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } })
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '/admin' } })
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' })
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp', path: '/admin' })
})
it('allows adding a service without a port', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ ip: '192.168.1.10:8080' })],
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()} />)
const addHeaders = screen.getAllByText('Add')
fireEvent.click(addHeaders[addHeaders.length - 1])
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'health' } })
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: 'healthz' } })
fireEvent.click(screen.getAllByRole('button', { name: 'Add' }).at(-1) as HTMLButtonElement)
expect(updateNode).toHaveBeenCalledOnce()
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'health', protocol: 'tcp', path: 'healthz' })
expect(updateNode.mock.calls[0][1].services[0].port).toBeUndefined()
})
it('calls updateNode without the removed service when X is clicked', () => {
@@ -332,7 +358,7 @@ describe('DetailPanel', () => {
const svc = { port: 80, protocol: 'tcp' as const, service_name: 'nginx' }
it('shows edit form pre-filled when pencil is clicked', () => {
setupStore({ services: [svc] })
setupStore({ services: [{ ...svc, path: '/admin' }] })
render(<DetailPanel onEdit={vi.fn()} />)
// Hover to reveal edit button (fireEvent.mouseOver isn't needed — opacity is CSS only)
const editBtn = screen.getByTitle('Edit service')
@@ -341,6 +367,8 @@ describe('DetailPanel', () => {
expect(nameInput.value).toBe('nginx')
const portInput = screen.getByPlaceholderText('Port') as HTMLInputElement
expect(portInput.value).toBe('80')
const pathInput = screen.getByPlaceholderText('Path (/admin)') as HTMLInputElement
expect(pathInput.value).toBe('/admin')
})
it('calls updateNode with updated values on Save', () => {
@@ -359,11 +387,13 @@ describe('DetailPanel', () => {
const nameInput = screen.getByPlaceholderText('Service name')
fireEvent.change(nameInput, { target: { value: 'apache' } })
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '/admin' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(updateNode).toHaveBeenCalledOnce()
expect(updateNode.mock.calls[0][1].services[0].service_name).toBe('apache')
expect(updateNode.mock.calls[0][1].services[0].port).toBe(80)
expect(updateNode.mock.calls[0][1].services[0].path).toBe('/admin')
})
it('cancels edit without updating', () => {
@@ -385,4 +415,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()
})
})
})
@@ -2,12 +2,17 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { Sidebar } from '../Sidebar'
import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore'
import type { Node } from '@xyflow/react'
import type { NodeData } from '@/types'
// ── Mocks ────────────────────────────────────────────────────────────────────
vi.mock('@/stores/canvasStore')
vi.mock('@/stores/authStore')
const mockBulkApprove = vi.fn()
const mockBulkHide = vi.fn()
vi.mock('@/api/client', () => ({
scanApi: {
@@ -16,6 +21,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 } }),
@@ -51,6 +62,7 @@ const makeNode = (id: string, status: NodeData['status'], type: NodeData['type']
})
const mockToggleHideIp = vi.fn()
const mockLogout = vi.fn()
function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
vi.mocked(useCanvasStore).mockReturnValue({
@@ -64,6 +76,12 @@ function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
} as ReturnType<typeof useCanvasStore>)
}
function mockAuth() {
vi.mocked(useAuthStore).mockImplementation((selector: (s: { logout: () => void }) => unknown) =>
selector({ logout: mockLogout }) as ReturnType<typeof useAuthStore>
)
}
const defaultProps = {
onAddNode: vi.fn(),
onAddGroupRect: vi.fn(),
@@ -77,6 +95,7 @@ const defaultProps = {
describe('Sidebar', () => {
beforeEach(() => {
mockStore()
mockAuth()
vi.clearAllMocks()
})
@@ -258,4 +277,115 @@ describe('Sidebar', () => {
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument()
})
// ── Logout ─────────────────────────────────────────────────────────────────
it('shows Logout button in normal mode', () => {
render(<Sidebar {...defaultProps} />)
expect(screen.getByText('Logout')).toBeInTheDocument()
})
it('calls logout when Logout is clicked', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Logout'))
expect(mockLogout).toHaveBeenCalledOnce()
})
})
// ── 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()
mockAuth()
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())
})
})
@@ -49,21 +49,6 @@ describe('canvasStore', () => {
expect(hasUnsavedChanges).toBe(true)
})
it('addNode nests under parent only when parent is in container mode', () => {
const parent = { ...makeNode('p1', { container_mode: false }), position: { x: 100, y: 100 } }
const child = { ...makeNode('c1', { parent_id: 'p1' }), position: { x: 150, y: 180 } }
useCanvasStore.getState().addNode(parent)
useCanvasStore.getState().addNode(child)
const childNode = useCanvasStore.getState().nodes.find((n) => n.id === 'c1')
expect(childNode?.parentId).toBeUndefined()
useCanvasStore.getState().updateNode('p1', { container_mode: true })
useCanvasStore.getState().setProxmoxContainerMode('p1', true)
const nested = useCanvasStore.getState().nodes.find((n) => n.id === 'c1')
expect(nested?.parentId).toBe('p1')
expect(nested?.extent).toBe('parent')
})
it('updateNode updates data fields', () => {
useCanvasStore.getState().addNode(makeNode('n1', { label: 'old' }))
useCanvasStore.getState().updateNode('n1', { label: 'new', ip: '10.0.0.1' })
@@ -99,7 +84,7 @@ describe('canvasStore', () => {
it('updateNode clearing parent_id converts position to absolute and clears parentId', () => {
const proxmox = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 100, y: 100 } }
const lxc = { ...makeNode('lxc1', { type: 'lxc', parent_id: 'px1' }), position: { x: 130, y: 140 }, parentId: 'px1', extent: 'parent' as const }
const lxc = { ...makeNode('lxc1', { type: 'lxc', parent_id: 'px1' }), position: { x: 30, y: 40 }, parentId: 'px1', extent: 'parent' as const }
useCanvasStore.getState().addNode(proxmox)
useCanvasStore.getState().addNode(lxc)
useCanvasStore.getState().updateNode('lxc1', { parent_id: undefined })
@@ -229,7 +214,7 @@ describe('canvasStore', () => {
})
it('deleteNode also removes children with matching parentId', () => {
useCanvasStore.getState().addNode(makeNode('parent', { container_mode: true }))
useCanvasStore.getState().addNode(makeNode('parent'))
useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' }))
useCanvasStore.getState().deleteNode('parent')
const { nodes } = useCanvasStore.getState()
@@ -238,7 +223,7 @@ describe('canvasStore', () => {
})
it('addNode with parent_id sets parentId and extent', () => {
useCanvasStore.getState().addNode(makeNode('parent', { container_mode: true }))
useCanvasStore.getState().addNode(makeNode('parent'))
useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' }))
const child = useCanvasStore.getState().nodes.find((n) => n.id === 'child')
expect(child?.parentId).toBe('parent')
+3 -37
View File
@@ -172,18 +172,8 @@ export const useCanvasStore = create<CanvasState>((set) => ({
addNode: (node) =>
set((state) => {
const parent = node.data.parent_id ? state.nodes.find((n) => n.id === node.data.parent_id) : null
const shouldNestInParent = !!(parent?.data.container_mode)
const enriched = node.data.parent_id && shouldNestInParent
? {
...node,
parentId: node.data.parent_id,
extent: 'parent' as const,
position: {
x: Math.max(10, node.position.x - parent.position.x),
y: Math.max(10, node.position.y - parent.position.y),
},
}
const enriched = node.data.parent_id
? { ...node, parentId: node.data.parent_id, extent: 'parent' as const }
: node
// Parents must come before children in the array (React Flow requirement)
const withoutNew = state.nodes.filter((n) => n.id !== node.id)
@@ -293,38 +283,14 @@ export const useCanvasStore = create<CanvasState>((set) => ({
setProxmoxContainerMode: (proxmoxId, enabled) =>
set((state) => {
const parentNode = state.nodes.find((n) => n.id === proxmoxId)
let nodes = state.nodes.map((n) => {
if (n.id === proxmoxId) {
const withMode = { ...n, data: { ...n.data, container_mode: enabled } }
if (n.data.type !== 'proxmox') return withMode
return enabled
? { ...withMode, width: n.width ?? 300, height: n.height ?? 200 }
? { ...withMode, width: 300, height: 200 }
: { ...withMode, width: undefined, height: undefined }
}
if (n.data.parent_id === proxmoxId) {
if (enabled && parentNode) {
return {
...n,
parentId: proxmoxId,
extent: 'parent' as const,
position: {
x: Math.max(10, n.position.x - parentNode.position.x),
y: Math.max(10, n.position.y - parentNode.position.y),
},
}
}
if (!enabled && parentNode) {
return {
...n,
parentId: undefined,
extent: undefined,
position: {
x: parentNode.position.x + n.position.x,
y: parentNode.position.y + n.position.y,
},
}
}
return enabled
? { ...n, parentId: proxmoxId, extent: 'parent' as const }
: { ...n, parentId: undefined, extent: undefined }
+4 -5
View File
@@ -13,8 +13,7 @@ export type NodeType =
| 'printer'
| 'computer'
| 'cpl'
| 'docker_container'
| 'docker_host'
| 'docker'
| 'generic'
| 'groupRect'
| 'group'
@@ -37,9 +36,10 @@ export type NodeStatus = 'online' | 'offline' | 'pending' | 'unknown'
export type CheckMethod = 'ping' | 'http' | 'https' | 'tcp' | 'ssh' | 'prometheus' | 'health' | 'none'
export interface ServiceInfo {
port: number
port?: number
protocol: 'tcp' | 'udp'
service_name: string
path?: string
icon?: string
category?: string
}
@@ -127,8 +127,7 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
printer: 'Printer',
computer: 'Computer',
cpl: 'CPL / Powerline',
docker_container: 'Docker Container',
docker_host: 'Docker Host',
docker: 'Docker Host',
generic: 'Generic Device',
groupRect: 'Group Rectangle',
group: 'Node Group',
@@ -0,0 +1,97 @@
import { describe, it, expect } from 'vitest'
import { hexToRgba, rgbaToHex8 } from '../colorUtils'
describe('hexToRgba', () => {
it('splits 8-digit hex into hex6 and alpha', () => {
const { hex6, alpha } = hexToRgba('#00d4ff0d')
expect(hex6).toBe('#00d4ff')
expect(alpha).toBe(5)
})
it('handles fully opaque 8-digit hex (ff)', () => {
const { hex6, alpha } = hexToRgba('#00d4ffff')
expect(hex6).toBe('#00d4ff')
expect(alpha).toBe(100)
})
it('handles fully transparent 8-digit hex (00)', () => {
const { hex6, alpha } = hexToRgba('#00d4ff00')
expect(hex6).toBe('#00d4ff')
expect(alpha).toBe(0)
})
it('defaults alpha to 100 for 6-digit hex', () => {
const { hex6, alpha } = hexToRgba('#00d4ff')
expect(hex6).toBe('#00d4ff')
expect(alpha).toBe(100)
})
it('handles 6-digit hex without leading #', () => {
const { hex6, alpha } = hexToRgba('ff6e00')
expect(hex6).toBe('#ff6e00')
expect(alpha).toBe(100)
})
it('handles 8-digit hex without leading #', () => {
const { hex6, alpha } = hexToRgba('ff6e0080')
expect(hex6).toBe('#ff6e00')
expect(alpha).toBe(50)
})
it('returns fallback for invalid input', () => {
const { hex6, alpha } = hexToRgba('invalid')
expect(hex6).toBe('#000000')
expect(alpha).toBe(100)
})
it('is case-insensitive', () => {
const { hex6 } = hexToRgba('#00D4FF0D')
expect(hex6).toBe('#00D4FF')
})
})
describe('rgbaToHex8', () => {
it('combines hex6 and alpha into 8-digit hex', () => {
expect(rgbaToHex8('#00d4ff', 5)).toBe('#00d4ff0d')
})
it('produces ff for alpha 100', () => {
expect(rgbaToHex8('#00d4ff', 100)).toBe('#00d4ffff')
})
it('produces 00 for alpha 0', () => {
expect(rgbaToHex8('#00d4ff', 0)).toBe('#00d4ff00')
})
it('produces 80 for alpha 50', () => {
expect(rgbaToHex8('#ff6e00', 50)).toBe('#ff6e0080')
})
it('clamps alpha below 0 to 0', () => {
expect(rgbaToHex8('#ffffff', -10)).toBe('#ffffff00')
})
it('clamps alpha above 100 to 100', () => {
expect(rgbaToHex8('#ffffff', 150)).toBe('#ffffffff')
})
it('pads single-digit alpha hex with leading zero', () => {
const result = rgbaToHex8('#000000', 1)
const alphaPart = result.slice(7)
expect(alphaPart.length).toBe(2)
})
})
describe('round-trip', () => {
it('hexToRgba → rgbaToHex8 round-trips correctly', () => {
const original = '#00d4ff0d'
const { hex6, alpha } = hexToRgba(original)
expect(rgbaToHex8(hex6, alpha)).toBe(original)
})
it('round-trips fully opaque color', () => {
const original = '#a855f7ff'
const { hex6, alpha } = hexToRgba(original)
expect(rgbaToHex8(hex6, alpha)).toBe(original)
})
})
@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { exportToPng, EXPORT_QUALITY_OPTIONS } from '../export'
const mockToPng = vi.fn()
vi.mock('html-to-image', () => ({ toPng: (...args: unknown[]) => mockToPng(...args) }))
describe('exportToPng', () => {
let el: HTMLElement
let clickSpy: ReturnType<typeof vi.fn>
let appendSpy: ReturnType<typeof vi.spyOn>
let createSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
el = document.createElement('div')
clickSpy = vi.fn()
createSpy = vi.spyOn(document, 'createElement').mockReturnValue(
Object.assign(document.createElement('a'), { click: clickSpy }) as HTMLAnchorElement
)
appendSpy = vi.spyOn(document.body, 'appendChild').mockImplementation((n) => n)
mockToPng.mockResolvedValue('data:image/png;base64,abc')
})
afterEach(() => {
createSpy.mockRestore()
appendSpy.mockRestore()
})
it('calls toPng with pixelRatio 1 for standard quality', async () => {
await exportToPng(el, 'standard')
expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ pixelRatio: 1 }))
})
it('calls toPng with pixelRatio 2 for high quality', async () => {
await exportToPng(el, 'high')
expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ pixelRatio: 2 }))
})
it('calls toPng with pixelRatio 4 for ultra quality', async () => {
await exportToPng(el, 'ultra')
expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ pixelRatio: 4 }))
})
it('defaults to high quality when no quality arg given', async () => {
await exportToPng(el)
expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ pixelRatio: 2 }))
})
it('triggers a download with the correct filename', async () => {
await exportToPng(el, 'high')
expect(clickSpy).toHaveBeenCalled()
})
it('passes dark background color', async () => {
await exportToPng(el, 'standard')
expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ backgroundColor: '#0d1117' }))
})
})
describe('EXPORT_QUALITY_OPTIONS', () => {
it('has exactly three options', () => {
expect(EXPORT_QUALITY_OPTIONS).toHaveLength(3)
})
it('options are standard, high, ultra in order', () => {
expect(EXPORT_QUALITY_OPTIONS.map((o) => o.value)).toEqual(['standard', 'high', 'ultra'])
})
it('pixel ratios are 1, 2, 4', () => {
expect(EXPORT_QUALITY_OPTIONS.map((o) => o.pixelRatio)).toEqual([1, 2, 4])
})
})
+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('')
})
})
@@ -1,12 +1,17 @@
import { describe, it, expect } from 'vitest'
import { Cpu, HardDrive, MemoryStick } from 'lucide-react'
import { CircuitBoard, Cpu, EthernetPort, Gpu, HardDrive, HdmiPort, MemoryStick, Usb } from 'lucide-react'
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '../propertyIcons'
describe('PROPERTY_ICONS', () => {
it('contains the hardware migration icons', () => {
expect(PROPERTY_ICONS['CircuitBoard']).toBe(CircuitBoard)
expect(PROPERTY_ICONS['Cpu']).toBe(Cpu)
expect(PROPERTY_ICONS['EthernetPort']).toBe(EthernetPort)
expect(PROPERTY_ICONS['Gpu']).toBe(Gpu)
expect(PROPERTY_ICONS['HardDrive']).toBe(HardDrive)
expect(PROPERTY_ICONS['HdmiPort']).toBe(HdmiPort)
expect(PROPERTY_ICONS['MemoryStick']).toBe(MemoryStick)
expect(PROPERTY_ICONS['Usb']).toBe(Usb)
})
it('has at least 10 icons', () => {
+2 -2
View File
@@ -4,7 +4,7 @@ import type { NodeType, EdgeType, NodeStatus } from '@/types'
const NODE_TYPES: NodeType[] = [
'isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc',
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker_host', 'docker_container', 'generic', 'groupRect',
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker', 'generic', 'groupRect',
]
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown']
@@ -84,7 +84,7 @@ describe('THEMES', () => {
expect(d.nodeAccents.server.border).toBe('#a855f7')
expect(d.nodeAccents.isp.border).toBe('#00d4ff')
expect(d.nodeAccents.proxmox.border).toBe('#ff6e00')
expect(d.nodeAccents.docker_host.border).toBe('#2496ED')
expect(d.nodeAccents.docker.border).toBe('#2496ED')
expect(d.nodeCardBackground).toBe('#21262d')
expect(d.nodeIconBackground).toBe('#161b22')
expect(d.canvasBackground).toBe('#0d1117')
+6 -10
View File
@@ -102,8 +102,8 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
disk_gb: n.data.disk_gb ?? null,
show_hardware: n.data.show_hardware ?? false,
properties: n.data.properties ?? [],
width: n.width ?? null,
height: n.height ?? null,
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,
@@ -134,7 +134,6 @@ export function deserializeApiNode(
n: ApiNode,
proxmoxContainerMap: Map<string, boolean>,
): Node<NodeData> {
const normalizedType = n.type === 'docker' ? 'docker_host' : n.type
if (n.type === 'groupRect') {
const w = (n.custom_colors?.width as number | undefined) ?? 360
const h = (n.custom_colors?.height as number | undefined) ?? 240
@@ -153,15 +152,12 @@ export function deserializeApiNode(
const parentIsContainer = n.parent_id ? (proxmoxContainerMap.get(n.parent_id) ?? false) : false
return {
id: n.id,
type: normalizedType,
type: n.type,
position: { x: n.pos_x, y: n.pos_y },
data: { ...n, type: normalizedType } as unknown as NodeData,
data: n as unknown as NodeData,
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
...(normalizedType === 'proxmox' && n.container_mode !== false
? { width: n.width ?? 300, height: n.height ?? 200 }
: {}),
...(n.width && normalizedType !== 'proxmox' ? { width: n.width } : {}),
...(n.height && normalizedType !== '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 } : {}),
}
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Split a 6- or 8-digit hex color into its RGB hex and alpha (0100).
* 6-digit input returns alpha 100.
* Invalid input returns { hex6: '#000000', alpha: 100 }.
*/
export function hexToRgba(hex: string): { hex6: string; alpha: number } {
const clean = hex.replace('#', '')
if (clean.length === 8) {
const alphaByte = parseInt(clean.slice(6, 8), 16)
return {
hex6: `#${clean.slice(0, 6)}`,
alpha: Math.round((alphaByte / 255) * 100),
}
}
if (clean.length === 6) {
return { hex6: `#${clean}`, alpha: 100 }
}
return { hex6: '#000000', alpha: 100 }
}
/**
* Combine a 6-digit hex color and an alpha (0100) into an 8-digit hex.
*/
export function rgbaToHex8(hex6: string, alpha: number): string {
const clamped = Math.max(0, Math.min(100, alpha))
const alphaByte = Math.round((clamped / 100) * 255)
const alphaHex = alphaByte.toString(16).padStart(2, '0')
return `${hex6}${alphaHex}`
}
+11 -6
View File
@@ -1,14 +1,19 @@
import { toPng } from 'html-to-image'
/**
* Export the React Flow canvas as a PNG and trigger a browser download.
* Pass the `.react-flow` wrapper element.
*/
export async function exportToPng(element: HTMLElement): Promise<void> {
export type ExportQuality = 'standard' | 'high' | 'ultra'
export const EXPORT_QUALITY_OPTIONS: { value: ExportQuality; label: string; pixelRatio: number; hint: string }[] = [
{ value: 'standard', label: 'Standard', pixelRatio: 1, hint: '1× — small file' },
{ value: 'high', label: 'High', pixelRatio: 2, hint: '2× — recommended' },
{ value: 'ultra', label: 'Ultra', pixelRatio: 4, hint: '4× — print quality, large file' },
]
export async function exportToPng(element: HTMLElement, quality: ExportQuality = 'high'): Promise<void> {
const option = EXPORT_QUALITY_OPTIONS.find((o) => o.value === quality) ?? EXPORT_QUALITY_OPTIONS[1]
const dataUrl = await toPng(element, {
backgroundColor: '#0d1117',
pixelRatio: option.pixelRatio,
style: {
// Exclude controls from the export
'--xy-controls-display': 'none',
} as Partial<CSSStyleDeclaration>,
})
+5 -1
View File
@@ -15,7 +15,11 @@ export function generateMarkdownTable(nodes: Node<NodeData>[]): string {
.map((n) => {
const d = n.data
const services = d.services?.length
? d.services.map((s) => `${s.service_name}:${s.port}`).join(', ')
? d.services.map((s) => {
const port = s.port != null ? `:${s.port}` : ''
const path = s.path?.trim() ? s.path.trim() : ''
return `${s.service_name}${port}${path}`
}).join(', ')
: EMPTY
return [
cell(d.label),
+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] ?? ''
}
+1 -2
View File
@@ -132,8 +132,7 @@ export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = {
printer: Printer,
computer: Monitor,
cpl: PlugZap,
docker_container: Container,
docker_host: Anchor,
docker: Anchor,
generic: Circle,
group: Circle,
groupRect: Circle,
+10
View File
@@ -1,11 +1,15 @@
import {
Battery,
Box,
CircuitBoard,
Clock,
Cpu,
Database,
EthernetPort,
Globe,
Gpu,
HardDrive,
HdmiPort,
Hash,
Key,
Layers,
@@ -17,6 +21,7 @@ import {
Shield,
Tag,
Thermometer,
Usb,
Wifi,
Zap,
} from 'lucide-react'
@@ -25,11 +30,15 @@ import type { LucideIcon } from 'lucide-react'
export const PROPERTY_ICONS: Record<string, LucideIcon> = {
Battery,
Box,
CircuitBoard,
Clock,
Cpu,
Database,
EthernetPort,
Globe,
Gpu,
HardDrive,
HdmiPort,
Hash,
Key,
Layers,
@@ -41,6 +50,7 @@ export const PROPERTY_ICONS: Record<string, LucideIcon> = {
Shield,
Tag,
Thermometer,
Usb,
Wifi,
Zap,
}
+72 -5
View File
@@ -20,15 +20,82 @@ const NON_HTTP_PORTS = new Set([
27017, 27018, // MongoDB
])
function splitFirstHost(host: string): string {
return host.split(',')[0]?.trim() ?? ''
}
function parsePort(port: string): number | undefined {
if (!/^\d+$/.test(port)) return undefined
const parsed = Number.parseInt(port, 10)
return parsed >= 1 && parsed <= 65535 ? parsed : undefined
}
function parseHostParts(host: string): { protocol?: 'http' | 'https'; hostname: string; port?: number } | null {
const firstHost = splitFirstHost(host)
if (!firstHost) return null
if (firstHost.startsWith('http://') || firstHost.startsWith('https://')) {
const url = new URL(firstHost)
return {
protocol: url.protocol === 'https:' ? 'https' : 'http',
hostname: url.hostname,
port: parsePort(url.port),
}
}
if (firstHost.startsWith('[')) {
const bracketIndex = firstHost.indexOf(']')
if (bracketIndex === -1) return { hostname: firstHost }
const hostname = firstHost.slice(1, bracketIndex)
const remainder = firstHost.slice(bracketIndex + 1)
return {
hostname,
port: remainder.startsWith(':') ? parsePort(remainder.slice(1)) : undefined,
}
}
const colonCount = (firstHost.match(/:/g) ?? []).length
if (colonCount === 1) {
const [hostname, rawPort] = firstHost.split(':')
const parsedPort = parsePort(rawPort)
if (hostname && parsedPort != null) {
return { hostname, port: parsedPort }
}
}
return { hostname: firstHost }
}
function normalizePath(path?: string): string {
const trimmed = path?.trim()
if (!trimmed) return ''
if (trimmed === '/') return '/'
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`
}
function formatHostname(hostname: string): string {
return hostname.includes(':') && !hostname.startsWith('[') ? `[${hostname}]` : hostname
}
export function getServiceUrl(svc: ServiceInfo, host?: string): string | null {
if (!host) return null
if (svc.port === 22) return null // SSH — no browser
if (svc.protocol === 'udp') return null // UDP — not HTTP
if (NON_HTTP_PORTS.has(svc.port)) return null
const parts = parseHostParts(host)
if (!parts?.hostname) return null
const effectivePort = svc.port ?? parts.port
if (effectivePort === 22) return null // SSH — no browser
if (effectivePort != null && NON_HTTP_PORTS.has(effectivePort)) return null
const name = svc.service_name.toLowerCase()
const isHttps =
const protocol = parts.protocol ?? (
name.includes('https') || name.includes('ssl') || name.includes('tls') ||
svc.port === 443 || svc.port === 8443
return `${isHttps ? 'https' : 'http'}://${host}:${svc.port}`
effectivePort === 443 || effectivePort === 8443
? 'https'
: 'http'
)
const base = `${protocol}://${formatHostname(parts.hostname)}`
const port = effectivePort != null ? `:${effectivePort}` : ''
return `${base}${port}${normalizePath(svc.path)}`
}
+5 -10
View File
@@ -56,8 +56,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
printer: { border: '#8b949e', icon: '#8b949e' },
computer: { border: '#a855f7', icon: '#a855f7' },
cpl: { border: '#e3b341', icon: '#e3b341' },
docker_container: { border: '#38bdf8', icon: '#38bdf8' },
docker_host: { border: '#2496ED', icon: '#2496ED' },
docker: { border: '#2496ED', icon: '#2496ED' },
generic: { border: '#8b949e', icon: '#8b949e' },
groupRect:{ border: '#00d4ff', icon: '#00d4ff' },
group: { border: '#00d4ff', icon: '#00d4ff' },
@@ -112,8 +111,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
printer: { border: '#94a3b8', icon: '#94a3b8' },
computer: { border: '#c084fc', icon: '#c084fc' },
cpl: { border: '#fbbf24', icon: '#fbbf24' },
docker_container: { border: '#38bdf8', icon: '#38bdf8' },
docker_host: { border: '#2496ED', icon: '#2496ED' },
docker: { border: '#2496ED', icon: '#2496ED' },
generic: { border: '#94a3b8', icon: '#94a3b8' },
groupRect:{ border: '#22d3ee', icon: '#22d3ee' },
group: { border: '#22d3ee', icon: '#22d3ee' },
@@ -168,8 +166,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
printer: { border: '#6b7280', icon: '#6b7280' },
computer: { border: '#7c3aed', icon: '#7c3aed' },
cpl: { border: '#b45309', icon: '#b45309' },
docker_container: { border: '#0ea5e9', icon: '#0ea5e9' },
docker_host: { border: '#2496ED', icon: '#2496ED' },
docker: { border: '#2496ED', icon: '#2496ED' },
generic: { border: '#6b7280', icon: '#6b7280' },
groupRect:{ border: '#0284c7', icon: '#0284c7' },
group: { border: '#0284c7', icon: '#0284c7' },
@@ -224,8 +221,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
printer: { border: '#8888ff', icon: '#8888ff' },
computer: { border: '#ff00ff', icon: '#ff00ff' },
cpl: { border: '#ffff00', icon: '#ffff00' },
docker_container: { border: '#00ddff', icon: '#00ddff' },
docker_host: { border: '#00aaff', icon: '#00aaff' },
docker: { border: '#00aaff', icon: '#00aaff' },
generic: { border: '#8888ff', icon: '#8888ff' },
groupRect:{ border: '#00ffff', icon: '#00ffff' },
group: { border: '#00ffff', icon: '#00ffff' },
@@ -280,8 +276,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
printer: { border: '#005500', icon: '#005500' },
computer: { border: '#008822', icon: '#008822' },
cpl: { border: '#66ff33', icon: '#66ff33' },
docker_container: { border: '#00dd99', icon: '#00dd99' },
docker_host: { border: '#00cc88', icon: '#00cc88' },
docker: { border: '#00cc88', icon: '#00cc88' },
generic: { border: '#006600', icon: '#006600' },
groupRect:{ border: '#00ff41', icon: '#00ff41' },
group: { border: '#00ff41', icon: '#00ff41' },
+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: {