Compare commits

...

19 Commits

Author SHA1 Message Date
Pouzor a8dd41a156 fix: add group NodeType to all theme definitions 2026-04-02 00:20:32 +02:00
Pouzor bdf3b6ea40 feat: add Ctrl+F search bar to canvas
- Ctrl+F / Cmd+F opens floating search bar at top-center of canvas
- Filters nodes by label, IP, hostname and service name (case-insensitive)
- Shows match count and no-results message
- Click result selects node and flies camera to it with animation
- Escape or × closes the bar
- groupRect nodes excluded from results
- Handles grouped nodes with correct absolute position for navigation
2026-04-01 23:32:28 +02:00
Pouzor 0b89244317 feat: lasso selection, multi-select panel, and named groups
- Add lasso/box selection via selectionOnDrag (Space to pan, lasso by default)
- Add lasso/pan toggle button in canvas controls (bottom-left)
- Multi-select panel in right panel when 2+ nodes selected (including zones)
- Create named Group node from selected nodes with bounding box math
- Group node: resizable, inline rename, show/hide border toggle, status summary
- GroupDetailPanel: lists members, online/offline count, ungroup action
- Fix group persistence after save/reload (extend proxmox container map to include group nodes)
- Fix groupRect serialization to preserve parent_id
- Remove background color from group and proxmox container node wrappers
- Add tests for all new store actions, GroupNode, MultiSelectPanel, GroupDetailPanel
2026-04-01 23:15:52 +02:00
Pouzor 45a17b0254 fix: use node:20-slim in build stage to fix lightningcss musl binary error 2026-04-01 14:22:45 +02:00
Pouzor 057891f7d5 feat: add stop scan button in UI with backend cancellation support
- POST /scan/{run_id}/stop endpoint signals running scan to cancel
- Scanner checks cancellation flag between CIDR ranges and hosts, exits early
- Cancelled scans get status 'cancelled' instead of 'done'
- Stop button (red StopCircle) shown in Scan History panel for running scans
- 6 new backend tests, 5 new frontend tests
2026-04-01 14:18:44 +02:00
Remy 5321070720 Update INSTALLATION.md 2026-03-31 14:17:14 +02:00
Pouzor 59e5a95912 docs: clarify bcrypt dollar-sign escaping for .env vs docker-compose.yml
Fixes #31
2026-03-31 14:13:22 +02:00
Pouzor 985ced6bf5 chore: bump version to 1.6.0 2026-03-31 00:33:28 +02:00
Remy 05a647aac7 Merge pull request #30 from Pouzor/fix/delete-key-and-undo
fix: DEL key deletes nodes and deletion is undoable
2026-03-31 00:29:16 +02:00
Pouzor 1444a81150 fix: DEL key deletes nodes and deletion is now undoable
- Add 'Delete' to deleteKeyCode so both Backspace and Delete remove nodes
- Call snapshotHistory() in onBeforeDelete (keyboard) and in DetailPanel
  handleDelete (button) so deletions can be undone with Ctrl+Z
2026-03-31 00:19:27 +02:00
Pouzor 1f884fd1db fix: guard scheduler against double-start and unguarded reschedule 2026-03-31 00:03:01 +02:00
Pouzor e9152df17a fix: remove stale reschedule call from scan config after settings endpoint split 2026-03-31 00:01:31 +02:00
Pouzor e7fc091701 fix: reschedule APScheduler job immediately when status check interval is updated
Interval was read once at startup — changing it via API had no effect
until server restart. Now calls reschedule_status_checks() after saving.
2026-03-30 23:59:47 +02:00
Remy 7071f8ef5a Merge pull request #29 from Pouzor/feat/scan-dedup-skip-canvas
feat: scan dedup, skip canvas/hidden nodes, settings endpoint
2026-03-30 23:37:18 +02:00
Pouzor 350dc14a16 test: add SettingsPanel tests covering settingsApi integration
- Opens panel and calls settingsApi.get
- Displays interval loaded from API
- Saves updated interval via settingsApi.save
- Shows error toast on save failure
- Toggles panel closed on second Settings click
2026-03-30 23:14:42 +02:00
Pouzor 68c7672cea feat: split scan config and app settings into separate endpoints
- New GET/POST /api/v1/settings for status check interval
- Scan /api/v1/scan/config now handles ranges only
- Frontend: settingsApi client, SettingsPanel uses settingsApi
- ScanConfigModal no longer reads/writes interval
- 4 new backend tests for settings endpoint
2026-03-30 23:06:51 +02:00
Pouzor 381f870bb5 fix: update ScanConfigModal tests after removing interval field
- Replace interval display test with interval-preservation test
- Reset saveConfig mock call history in beforeEach to prevent test bleed
2026-03-30 22:24:30 +02:00
Pouzor 58381b97d2 feat: move status check interval to sidebar Settings panel
- Add Settings item in sidebar actions section (below Save Canvas)
- Settings panel shows status check interval with save button
- Remove interval field from ScanConfigModal (now belongs in Settings)
- Scan modal shows a hint pointing to sidebar Settings
2026-03-30 22:21:36 +02:00
Pouzor ce4af14ee6 feat: deduplicate pending devices and skip canvas/hidden nodes on scan
- At scan start, purge any pending entries whose IPs already exist in canvas
- Skip canvas nodes (approved) during scan — don't re-add to pending
- Skip hidden devices during scan — respect user's hide decision
- Add 4 tests covering all new behaviors
2026-03-30 22:01:47 +02:00
34 changed files with 2248 additions and 244 deletions
+2 -1
View File
@@ -1,7 +1,8 @@
# Stage 1: build
# Use the native build platform so npm ci never runs under QEMU emulation.
# The build output (static HTML/JS/CSS) is platform-independent.
FROM --platform=$BUILDPLATFORM node:20-alpine AS builder
# node:20-slim (Debian/glibc) avoids lightningcss musl binary resolution issues on Alpine.
FROM --platform=$BUILDPLATFORM node:20-slim AS builder
ARG VITE_STANDALONE=false
ENV VITE_STANDALONE=$VITE_STANDALONE
+12 -3
View File
@@ -11,9 +11,18 @@ Open **http://localhost:3000** — login with `admin` / `admin`.
> Change the password before exposing to a network: edit `.env` and update `AUTH_USERNAME` / `AUTH_PASSWORD_HASH`.
>
> Generate a new hash: `docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"`
>
> ⚠️ Keep the single quotes around the hash value in `.env` — bcrypt hashes contain `$` characters that Docker Compose would otherwise misinterpret.
Generate a new hash:
```bash
docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"
```
⚠️ **bcrypt hashes contain `$` characters** — how to handle them depends on where you set the value:
- **`.env` file** (recommended): wrap the hash in single quotes → `AUTH_PASSWORD_HASH='$2b$12$...'`
- **`docker-compose.yml` `environment:` block**: escape every `$` as `$$` — use this command to generate a pre-escaped hash:
```bash
docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword').replace('\$', '\$\$'))"
```
## Quick Start — Frontend only
+17 -7
View File
@@ -12,12 +12,11 @@ from app.db.database import AsyncSessionLocal, get_db
from app.db.models import Node, PendingDevice, ScanRun
from app.schemas.nodes import NodeCreate
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
from app.services.scanner import run_scan
from app.services.scanner import request_cancel, run_scan
class ScanConfig(BaseModel):
ranges: list[str]
interval_seconds: int
logger = logging.getLogger(__name__)
@@ -44,6 +43,21 @@ async def trigger_scan(
return run
@router.post("/{run_id}/stop", response_model=dict)
async def stop_scan(
run_id: str,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, bool]:
run = await db.get(ScanRun, run_id)
if not run:
raise HTTPException(status_code=404, detail="Scan run not found")
if run.status != "running":
raise HTTPException(status_code=409, detail="Scan is not running")
request_cancel(run_id)
return {"stopping": True}
@router.get("/pending", response_model=list[PendingDeviceResponse])
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending"))
@@ -103,17 +117,13 @@ async def list_runs(db: AsyncSession = Depends(get_db), _: str = Depends(get_cur
@router.get("/config", response_model=ScanConfig)
async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
return ScanConfig(
ranges=settings.scanner_ranges,
interval_seconds=settings.status_checker_interval,
)
return ScanConfig(ranges=settings.scanner_ranges)
@router.post("/config", response_model=ScanConfig)
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig:
try:
settings.scanner_ranges = payload.ranges
settings.status_checker_interval = payload.interval_seconds
settings.save_overrides()
return payload
except Exception as exc:
+29
View File
@@ -0,0 +1,29 @@
"""App-level settings (status checker interval, etc.)."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from app.api.deps import get_current_user
from app.core.config import settings
router = APIRouter()
class AppSettings(BaseModel):
interval_seconds: int
@router.get("", response_model=AppSettings)
async def get_settings(_: str = Depends(get_current_user)) -> AppSettings:
return AppSettings(interval_seconds=settings.status_checker_interval)
@router.post("", response_model=AppSettings)
async def update_settings(
payload: AppSettings, _: str = Depends(get_current_user)
) -> AppSettings:
try:
settings.status_checker_interval = payload.interval_seconds
settings.save_overrides()
return payload
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
+13 -1
View File
@@ -47,11 +47,23 @@ async def _run_status_checks() -> None:
def start_scheduler() -> None:
global scheduler
if scheduler.running:
scheduler.shutdown(wait=False)
scheduler = AsyncIOScheduler()
scheduler.add_job(_run_status_checks, "interval", seconds=settings.status_checker_interval, id="status_checks")
scheduler.start()
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
def reschedule_status_checks(interval_seconds: int) -> None:
"""Update the status check interval on the running scheduler."""
if not scheduler.running:
logger.warning("Scheduler not running, skipping reschedule")
return
scheduler.reschedule_job("status_checks", trigger="interval", seconds=interval_seconds)
logger.info("Status checks rescheduled to every %ds", interval_seconds)
def stop_scheduler() -> None:
scheduler.shutdown(wait=False)
if scheduler.running:
scheduler.shutdown(wait=False)
+2
View File
@@ -6,6 +6,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import auth, canvas, edges, liveview, nodes, scan, status
from app.api.routes import settings as settings_routes
from app.core.config import settings
from app.core.scheduler import start_scheduler, stop_scheduler
from app.db.database import init_db
@@ -40,6 +41,7 @@ app.include_router(edges.router, prefix="/api/v1/edges", tags=["edges"])
app.include_router(canvas.router, prefix="/api/v1/canvas", tags=["canvas"])
app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"])
app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
app.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"])
+61 -5
View File
@@ -7,11 +7,24 @@ from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import PendingDevice, ScanRun
from app.db.models import Node, PendingDevice, ScanRun
from app.services.fingerprint import fingerprint_ports, suggest_node_type
logger = logging.getLogger(__name__)
# Run IDs that have been requested to cancel
_cancelled_runs: set[str] = set()
def request_cancel(run_id: str) -> None:
"""Signal a running scan to stop early."""
_cancelled_runs.add(run_id)
def _is_cancelled(run_id: str) -> bool:
return run_id in _cancelled_runs
try:
import nmap
_NMAP_AVAILABLE = True
@@ -107,18 +120,59 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
devices_found = 0
try:
# Clean up stale pending devices whose IPs are already in the canvas
# (covers devices approved between scans, or pre-existing canvas nodes)
canvas_ips_result = await db.execute(select(Node.ip).where(Node.ip.isnot(None)))
canvas_ips = {row[0] for row in canvas_ips_result.fetchall()}
if canvas_ips:
stale_result = await db.execute(
select(PendingDevice).where(
PendingDevice.status == "pending",
PendingDevice.ip.in_(canvas_ips),
)
)
for stale in stale_result.scalars().all():
await db.delete(stale)
await db.commit()
for cidr in ranges:
if _is_cancelled(run_id):
break
# Run nmap in a thread pool — does not block the event loop
hosts = await asyncio.to_thread(_nmap_scan, cidr)
for host in hosts:
if _is_cancelled(run_id):
break
ip = host["ip"]
# Skip if device is already in the canvas (approved node)
canvas_result = await db.execute(
select(Node).where(Node.ip == ip)
)
if canvas_result.scalar_one_or_none() is not None:
logger.debug("Skipping %s — already in canvas", ip)
continue
# Skip if device was explicitly hidden by the user
hidden_result = await db.execute(
select(PendingDevice).where(
PendingDevice.ip == ip,
PendingDevice.status == "hidden",
)
)
if hidden_result.scalar_one_or_none() is not None:
logger.debug("Skipping %s — hidden by user", ip)
continue
services = fingerprint_ports(host["open_ports"])
suggested_type = suggest_node_type(host["open_ports"], host.get("mac"))
# Update existing pending device or create a new one
existing_result = await db.execute(
select(PendingDevice).where(
PendingDevice.ip == host["ip"],
PendingDevice.ip == ip,
PendingDevice.status == "pending",
)
)
@@ -131,7 +185,7 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
existing.suggested_type = suggested_type
else:
device = PendingDevice(
ip=host["ip"],
ip=ip,
mac=host.get("mac"),
hostname=host.get("hostname"),
os=host.get("os"),
@@ -154,10 +208,10 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
# Push WS event so the frontend refreshes pending panel
await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
# Mark scan as done
# Mark scan as done or cancelled
run = await db.get(ScanRun, run_id)
if run:
run.status = "done"
run.status = "cancelled" if _is_cancelled(run_id) else "done"
run.devices_found = devices_found
run.finished_at = datetime.now(timezone.utc)
await db.commit()
@@ -170,3 +224,5 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
run.error = str(exc)
run.finished_at = datetime.now(timezone.utc)
await db.commit()
finally:
_cancelled_runs.discard(run_id)
+209 -3
View File
@@ -1,4 +1,4 @@
"""Tests for scan routes: trigger, pending devices, approve/hide/ignore."""
"""Tests for scan routes: trigger, pending devices, approve/hide/ignore, stop."""
import uuid
from unittest.mock import AsyncMock, patch
@@ -7,8 +7,8 @@ from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import PendingDevice, ScanRun
from app.services.scanner import run_scan
from app.db.models import Node, PendingDevice, ScanRun
from app.services.scanner import _cancelled_runs, request_cancel, run_scan
@pytest.fixture
@@ -199,6 +199,212 @@ async def test_run_scan_creates_new_pending_device(db_session: AsyncSession):
assert device.suggested_type == "server"
@pytest.mark.asyncio
async def test_run_scan_purges_stale_pending_for_canvas_nodes(db_session: AsyncSession):
"""Pending devices that were already in canvas before scan starts must be removed."""
node = Node(
id=str(uuid.uuid4()),
label="Existing Server",
type="server",
ip="192.168.1.50",
status="online",
services=[],
pos_x=0.0,
pos_y=0.0,
)
stale = PendingDevice(
id=str(uuid.uuid4()),
ip="192.168.1.50",
mac=None,
hostname=None,
os=None,
services=[],
suggested_type="generic",
status="pending",
)
db_session.add(node)
db_session.add(stale)
await db_session.commit()
run_id = str(uuid.uuid4())
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
db_session.add(run)
await db_session.commit()
with (
patch("app.services.scanner._nmap_scan", return_value=[]),
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
):
await run_scan(["192.168.1.0/24"], db_session, run_id)
result = await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
)
assert result.scalar_one_or_none() is None
@pytest.mark.asyncio
async def test_run_scan_skips_ip_already_in_canvas(db_session: AsyncSession):
"""Devices whose IP already exists as a canvas Node must not appear in pending."""
node = Node(
id=str(uuid.uuid4()),
label="Existing Server",
type="server",
ip="192.168.1.50",
status="online",
services=[],
pos_x=0.0,
pos_y=0.0,
)
db_session.add(node)
await db_session.commit()
run_id = str(uuid.uuid4())
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
db_session.add(run)
await db_session.commit()
with (
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
):
await run_scan(["192.168.1.0/24"], db_session, run_id)
result = await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
)
assert result.scalar_one_or_none() is None
@pytest.mark.asyncio
async def test_run_scan_skips_hidden_device(db_session: AsyncSession):
"""Devices previously hidden by the user must not re-appear in pending on re-scan."""
hidden = PendingDevice(
id=str(uuid.uuid4()),
ip="192.168.1.50",
mac=None,
hostname=None,
os=None,
services=[],
suggested_type="generic",
status="hidden",
)
db_session.add(hidden)
await db_session.commit()
run_id = str(uuid.uuid4())
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
db_session.add(run)
await db_session.commit()
with (
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
):
await run_scan(["192.168.1.0/24"], db_session, run_id)
result = await db_session.execute(
select(PendingDevice).where(
PendingDevice.ip == "192.168.1.50",
PendingDevice.status == "pending",
)
)
assert result.scalar_one_or_none() is None
# --- Stop scan ---
@pytest.mark.asyncio
async def test_stop_scan_requires_auth(client: AsyncClient):
res = await client.post("/api/v1/scan/fake-id/stop")
assert res.status_code == 401
@pytest.mark.asyncio
async def test_stop_scan_not_found(client: AsyncClient, headers):
res = await client.post("/api/v1/scan/nonexistent-id/stop", headers=headers)
assert res.status_code == 404
@pytest.mark.asyncio
async def test_stop_scan_not_running(client: AsyncClient, headers, db_session: AsyncSession):
run = ScanRun(id=str(uuid.uuid4()), status="done", ranges=["192.168.1.0/24"])
db_session.add(run)
await db_session.commit()
res = await client.post(f"/api/v1/scan/{run.id}/stop", headers=headers)
assert res.status_code == 409
@pytest.mark.asyncio
async def test_stop_scan_success(client: AsyncClient, headers, db_session: AsyncSession):
run = ScanRun(id=str(uuid.uuid4()), status="running", ranges=["192.168.1.0/24"])
db_session.add(run)
await db_session.commit()
res = await client.post(f"/api/v1/scan/{run.id}/stop", headers=headers)
assert res.status_code == 200
assert res.json() == {"stopping": True}
# run_id added to cancel set
assert run.id in _cancelled_runs
# cleanup for other tests
_cancelled_runs.discard(run.id)
# --- run_scan cancellation ---
@pytest.mark.asyncio
async def test_run_scan_cancelled_marks_status(db_session: AsyncSession):
"""When cancel is requested before the scan starts, status becomes 'cancelled'."""
run_id = str(uuid.uuid4())
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
db_session.add(run)
await db_session.commit()
request_cancel(run_id)
with (
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]) as mock_nmap,
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
):
await run_scan(["192.168.1.0/24"], db_session, run_id)
# nmap should not have been called — cancelled before first range
mock_nmap.assert_not_called()
await db_session.refresh(run)
assert run.status == "cancelled"
assert run.finished_at is not None
@pytest.mark.asyncio
async def test_run_scan_cancelled_mid_scan_skips_remaining_cidrs(db_session: AsyncSession):
"""Cancel flag set after first CIDR is started prevents processing of the second CIDR."""
run_id = str(uuid.uuid4())
run = ScanRun(id=run_id, status="running", ranges=["10.0.0.0/24", "10.0.1.0/24"])
db_session.add(run)
await db_session.commit()
call_count = 0
def nmap_side_effect(target: str):
nonlocal call_count
call_count += 1
# Signal cancellation after the first CIDR scan completes
if call_count == 1:
request_cancel(run_id)
return []
with (
patch("app.services.scanner._nmap_scan", side_effect=nmap_side_effect),
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
):
await run_scan(["10.0.0.0/24", "10.0.1.0/24"], db_session, run_id)
assert call_count == 1 # second CIDR was skipped
await db_session.refresh(run)
assert run.status == "cancelled"
@pytest.mark.asyncio
async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession):
"""Re-scanning the same IP updates services instead of creating a duplicate."""
+47
View File
@@ -0,0 +1,47 @@
"""Tests for GET/POST /api/v1/settings."""
from unittest.mock import patch
import pytest
from httpx import AsyncClient
@pytest.fixture
async def headers(client: AsyncClient):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
token = res.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
@pytest.mark.asyncio
async def test_get_settings_requires_auth(client: AsyncClient):
res = await client.get("/api/v1/settings")
assert res.status_code == 401
@pytest.mark.asyncio
async def test_get_settings_returns_interval(client: AsyncClient, headers):
res = await client.get("/api/v1/settings", headers=headers)
assert res.status_code == 200
data = res.json()
assert "interval_seconds" in data
assert isinstance(data["interval_seconds"], int)
@pytest.mark.asyncio
async def test_update_settings_saves_interval(client: AsyncClient, headers):
with patch("app.api.routes.settings.settings") as mock_settings:
mock_settings.status_checker_interval = 60
mock_settings.save_overrides = lambda: None
res = await client.post(
"/api/v1/settings",
json={"interval_seconds": 120},
headers=headers,
)
assert res.status_code == 200
assert res.json()["interval_seconds"] == 120
@pytest.mark.asyncio
async def test_update_settings_requires_auth(client: AsyncClient):
res = await client.post("/api/v1/settings", json={"interval_seconds": 30})
assert res.status_code == 401
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.5.0",
"version": "1.6.0",
"type": "module",
"scripts": {
"dev": "vite",
+4 -4
View File
@@ -35,7 +35,7 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
export default function App() {
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
const canvasRef = useRef<HTMLDivElement>(null)
const { isAuthenticated } = useAuthStore()
const { activeTheme, setTheme } = useThemeStore()
@@ -100,8 +100,8 @@ export default function App() {
// Build a map of proxmox container mode to know if children should be nested
const proxmoxContainerMap = new Map<string, boolean>(
(apiNodes as ApiNode[])
.filter((n) => n.type === 'proxmox')
.map((n) => [n.id, n.container_mode !== false])
.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)
@@ -385,7 +385,7 @@ export default function App() {
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
<CanvasContainer onConnect={handleEdgeConnect} onEdgeDoubleClick={handleEdgeDoubleClick} onNodeDragStart={snapshotHistory} />
</div>
{selectedNodeId && <DetailPanel onEdit={handleEditNode} />}
{(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
</div>
</div>
</div>
+8 -2
View File
@@ -59,6 +59,12 @@ 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`),
getConfig: () => api.get<{ ranges: string[]; interval_seconds: number }>('/scan/config'),
saveConfig: (data: { ranges: string[]; interval_seconds: number }) => api.post('/scan/config', data),
stop: (runId: string) => api.post(`/scan/${runId}/stop`),
getConfig: () => api.get<{ ranges: string[] }>('/scan/config'),
saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
}
export const settingsApi = {
get: () => api.get<{ interval_seconds: number }>('/settings'),
save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data),
}
+2 -2
View File
@@ -71,8 +71,8 @@ function LiveViewCanvas() {
const { nodes: apiNodes, edges: apiEdges } = res.data
const proxmoxMap = new Map<string, boolean>(
(apiNodes as ApiNode[])
.filter((n: ApiNode) => n.type === 'proxmox')
.map((n: ApiNode) => [n.id, n.container_mode !== false])
.filter((n: ApiNode) => n.type === 'proxmox' || n.type === 'group')
.map((n: ApiNode) => [n.id, n.type === 'group' ? true : n.container_mode !== false])
)
loadCanvas(
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
@@ -1,20 +1,24 @@
import { useCallback } from 'react'
import { useCallback, useState } from 'react'
import {
ReactFlow,
Background,
Controls,
ControlButton,
BackgroundVariant,
ConnectionMode,
SelectionMode,
type Node,
type Edge,
type Connection,
} from '@xyflow/react'
import { MousePointer2, Hand } from 'lucide-react'
import '@xyflow/react/dist/style.css'
import { useCanvasStore } from '@/stores/canvasStore'
import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
import { nodeTypes } from './nodes/nodeTypes'
import { edgeTypes } from './edges/edgeTypes'
import { SearchBar } from './SearchBar'
import type { NodeData, EdgeData } from '@/types'
interface CanvasContainerProps {
@@ -24,17 +28,22 @@ interface CanvasContainerProps {
}
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart }: CanvasContainerProps) {
const [lassoMode, setLassoMode] = useState(true)
const {
nodes, edges,
onNodesChange, onEdgesChange,
setSelectedNode,
setSelectedNode, snapshotHistory,
} = useCanvasStore()
const activeTheme = useThemeStore((s) => s.activeTheme)
const theme = THEMES[activeTheme]
const onNodeClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => {
setSelectedNode(node.id)
const onNodeClick = useCallback((e: React.MouseEvent, node: Node<NodeData>) => {
if (e.ctrlKey || e.metaKey) {
setSelectedNode(null)
} else {
setSelectedNode(node.id)
}
}, [setSelectedNode])
const onPaneClick = useCallback(() => {
@@ -59,6 +68,13 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
onNodeDragStart={onNodeDragStart}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
deleteKeyCode={['Backspace', 'Delete']}
onBeforeDelete={async () => { snapshotHistory(); return true }}
selectionOnDrag={lassoMode}
panOnDrag={lassoMode ? [1, 2] : true}
panActivationKeyCode="Space"
selectionMode={SelectionMode.Partial}
multiSelectionKeyCode={['Meta', 'Control']}
snapToGrid
snapGrid={[16, 16]}
fitView
@@ -73,7 +89,15 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
size={1}
color={theme.colors.canvasDotColor}
/>
<Controls />
<SearchBar />
<Controls>
<ControlButton
onClick={() => setLassoMode((m) => !m)}
title={lassoMode ? 'Switch to pan mode (Space to pan)' : 'Switch to lasso mode'}
>
{lassoMode ? <MousePointer2 size={12} /> : <Hand size={12} />}
</ControlButton>
</Controls>
</ReactFlow>
</div>
)
@@ -0,0 +1,160 @@
import { useState, useEffect, useRef } from 'react'
import { useReactFlow } from '@xyflow/react'
import { Search, X } from 'lucide-react'
import { useCanvasStore } from '@/stores/canvasStore'
import { NODE_TYPE_LABELS } from '@/types'
export function SearchBar() {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const { nodes, setSelectedNode } = useCanvasStore()
const { setCenter } = useReactFlow()
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault()
setOpen(true)
}
if (e.key === 'Escape') {
setOpen(false)
setQuery('')
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [])
useEffect(() => {
if (open) inputRef.current?.focus()
}, [open])
const q = query.toLowerCase().trim()
const results = q
? nodes.filter((n) => {
if (n.data.type === 'groupRect') return false
return (
n.data.label?.toLowerCase().includes(q) ||
n.data.ip?.toLowerCase().includes(q) ||
n.data.hostname?.toLowerCase().includes(q) ||
(n.data.services ?? []).some((s) => s.service_name?.toLowerCase().includes(q))
)
})
: []
const goToNode = (id: string) => {
const node = nodes.find((n) => n.id === id)
if (!node) return
setSelectedNode(id)
// For grouped nodes, add parent's absolute position
let absX = node.position.x
let absY = node.position.y
if (node.parentId) {
const parent = nodes.find((n) => n.id === node.parentId)
if (parent) { absX += parent.position.x; absY += parent.position.y }
}
const w = node.measured?.width ?? node.width ?? 200
const h = node.measured?.height ?? node.height ?? 80
setCenter(absX + w / 2, absY + h / 2, { zoom: 1.5, duration: 500 })
setOpen(false)
setQuery('')
}
if (!open) return null
return (
<div
className="nodrag nowheel"
style={{
position: 'absolute',
top: 16,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 1000,
width: 360,
pointerEvents: 'all',
}}
>
<div style={{
background: '#161b22',
border: '1px solid #30363d',
borderRadius: 8,
boxShadow: '0 8px 24px rgba(0,0,0,0.6)',
overflow: 'hidden',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px' }}>
<Search size={14} style={{ color: '#8b949e', flexShrink: 0 }} />
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by name, IP, hostname or service…"
style={{
flex: 1,
background: 'transparent',
border: 'none',
outline: 'none',
color: '#e6edf3',
fontSize: 13,
}}
/>
{query && (
<span style={{ fontSize: 11, color: '#6e7681', flexShrink: 0 }}>
{results.length} result{results.length !== 1 ? 's' : ''}
</span>
)}
<button
onClick={() => { setOpen(false); setQuery('') }}
aria-label="Close search"
style={{ color: '#8b949e', background: 'none', border: 'none', cursor: 'pointer', padding: 2 }}
>
<X size={14} />
</button>
</div>
{results.length > 0 && (
<div style={{ borderTop: '1px solid #30363d', maxHeight: 260, overflowY: 'auto' }}>
{results.map((n) => (
<button
key={n.id}
onClick={() => goToNode(n.id)}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '7px 12px',
background: 'none',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = '#21262d')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}
>
<span style={{ fontSize: 12, fontWeight: 600, color: '#e6edf3', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{n.data.label}
</span>
{n.data.ip && (
<span style={{ fontSize: 11, color: '#8b949e', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>
{n.data.ip}
</span>
)}
<span style={{ fontSize: 10, color: '#6e7681', flexShrink: 0 }}>
{NODE_TYPE_LABELS[n.data.type] ?? n.data.type}
</span>
</button>
))}
</div>
)}
{q && results.length === 0 && (
<div style={{ borderTop: '1px solid #30363d', padding: '10px 12px', fontSize: 12, color: '#6e7681', textAlign: 'center' }}>
No results for &ldquo;{query}&rdquo;
</div>
)}
</div>
</div>
)
}
@@ -16,8 +16,10 @@ vi.mock('@xyflow/react', () => ({
},
Background: () => null,
Controls: () => null,
ControlButton: () => null,
BackgroundVariant: { Dots: 'dots' },
ConnectionMode: { Loose: 'loose' },
SelectionMode: { Partial: 'partial' },
}))
vi.mock('@xyflow/react/dist/style.css', () => ({}))
@@ -143,4 +145,71 @@ describe('CanvasContainer', () => {
render(<CanvasContainer />)
expect(rfProps.snapGrid).toEqual([16, 16])
})
// ── Delete key ────────────────────────────────────────────────────────────
it('sets deleteKeyCode to include both Backspace and Delete', () => {
render(<CanvasContainer />)
expect(rfProps.deleteKeyCode).toEqual(['Backspace', 'Delete'])
})
// ── Lasso / multi-select ──────────────────────────────────────────────────
it('enables selectionOnDrag for lasso selection', () => {
render(<CanvasContainer />)
expect(rfProps.selectionOnDrag).toBe(true)
})
it('sets panActivationKeyCode to Space', () => {
render(<CanvasContainer />)
expect(rfProps.panActivationKeyCode).toBe('Space')
})
it('sets panOnDrag to [1, 2]', () => {
render(<CanvasContainer />)
expect(rfProps.panOnDrag).toEqual([1, 2])
})
it('sets selectionMode to Partial', () => {
render(<CanvasContainer />)
expect(rfProps.selectionMode).toBe('partial')
})
it('sets multiSelectionKeyCode to Meta and Control', () => {
render(<CanvasContainer />)
expect(rfProps.multiSelectionKeyCode).toEqual(['Meta', 'Control'])
})
it('clears selectedNode (sets null) on Ctrl+click instead of selecting', () => {
const node = makeNode('n1')
useCanvasStore.setState({ nodes: [node], selectedNodeId: 'n1' })
render(<CanvasContainer />)
;(rfProps.onNodeClick as (...args: unknown[]) => unknown)(
{ ctrlKey: true, metaKey: false } as unknown as MouseEvent,
node,
)
expect(useCanvasStore.getState().selectedNodeId).toBeNull()
})
it('clears selectedNode (sets null) on Cmd+click', () => {
const node = makeNode('n1')
useCanvasStore.setState({ nodes: [node], selectedNodeId: 'n1' })
render(<CanvasContainer />)
;(rfProps.onNodeClick as (...args: unknown[]) => unknown)(
{ ctrlKey: false, metaKey: true } as unknown as MouseEvent,
node,
)
expect(useCanvasStore.getState().selectedNodeId).toBeNull()
})
// ── onBeforeDelete snapshot ───────────────────────────────────────────────
it('onBeforeDelete calls snapshotHistory and returns true', async () => {
const snapshotHistory = vi.fn()
useCanvasStore.setState({ snapshotHistory } as unknown as Parameters<typeof useCanvasStore.setState>[0])
render(<CanvasContainer />)
const result = await (rfProps.onBeforeDelete as () => Promise<boolean>)()
expect(snapshotHistory).toHaveBeenCalledOnce()
expect(result).toBe(true)
})
})
@@ -0,0 +1,122 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { GroupNode } from '../nodes/GroupNode'
import * as canvasStore from '@/stores/canvasStore'
import type { Node } from '@xyflow/react'
import type { NodeData } from '@/types'
vi.mock('@/stores/canvasStore')
vi.mock('@xyflow/react', () => ({
NodeResizer: ({ isVisible }: { isVisible: boolean }) => (
<div data-testid="node-resizer" data-visible={isVisible} />
),
useReactFlow: () => ({}),
}))
vi.mock('@xyflow/react/dist/style.css', () => ({}))
function makeGroupNode(overrides: Partial<NodeData> = {}): Node<NodeData> {
return {
id: 'g1',
type: 'group',
position: { x: 0, y: 0 },
width: 400,
height: 250,
data: {
label: 'My Group',
type: 'group',
status: 'unknown',
services: [],
custom_colors: { show_border: true },
...overrides,
},
}
}
function renderGroupNode(props: Partial<Parameters<typeof GroupNode>[0]> = {}, storeNodes: unknown[] = []) {
const node = makeGroupNode(props.data)
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: storeNodes,
updateNode: vi.fn(),
snapshotHistory: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
return render(
<GroupNode
id="g1"
data={node.data}
selected={false}
dragging={false}
zIndex={1}
isConnectable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
{...props}
/>,
)
}
describe('GroupNode', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders the group label when show_border is true', () => {
renderGroupNode()
expect(screen.getByText('My Group')).toBeDefined()
})
it('hides the header when show_border is false and not selected', () => {
renderGroupNode({ data: makeGroupNode({ custom_colors: { show_border: false } }).data, selected: false })
expect(screen.queryByText('My Group')).toBeNull()
})
it('shows header when show_border is false but node is selected', () => {
renderGroupNode({ data: makeGroupNode({ custom_colors: { show_border: false } }).data, selected: true })
expect(screen.getByText('My Group')).toBeDefined()
})
it('shows NodeResizer only when selected', () => {
const { rerender } = renderGroupNode({ selected: false })
expect(screen.getByTestId('node-resizer').getAttribute('data-visible')).toBe('false')
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [],
updateNode: vi.fn(),
snapshotHistory: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
rerender(
<GroupNode
id="g1"
data={makeGroupNode().data}
selected={true}
dragging={false}
zIndex={1}
isConnectable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>,
)
expect(screen.getByTestId('node-resizer').getAttribute('data-visible')).toBe('true')
})
it('shows online/offline status summary from children', () => {
const storeNodes = [
{ id: 'c1', parentId: 'g1', data: { status: 'online' } },
{ id: 'c2', parentId: 'g1', data: { status: 'offline' } },
{ id: 'c3', parentId: 'other', data: { status: 'online' } }, // different group — excluded
]
renderGroupNode({}, storeNodes)
// Two status indicators: one online, one offline (c3 excluded — wrong parent)
const statusSpans = screen.getAllByText(/● \d+/)
expect(statusSpans).toHaveLength(2)
})
it('does not show status summary when group has no children', () => {
renderGroupNode()
expect(screen.queryByText(/●/)).toBeNull()
})
})
@@ -0,0 +1,146 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { SearchBar } from '../SearchBar'
import * as canvasStore from '@/stores/canvasStore'
vi.mock('@/stores/canvasStore')
vi.mock('@xyflow/react', () => ({
useReactFlow: () => ({ setCenter: vi.fn() }),
}))
function makeNode(id: string, overrides = {}) {
return {
id,
type: 'server',
position: { x: 0, y: 0 },
data: { label: id, type: 'server', status: 'online', services: [], ip: null, hostname: null },
...overrides,
}
}
function setupStore(nodes: unknown[] = []) {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes,
setSelectedNode: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
}
function openSearch() {
fireEvent.keyDown(window, { key: 'f', ctrlKey: true })
}
describe('SearchBar', () => {
beforeEach(() => {
setupStore([])
vi.clearAllMocks()
})
it('is hidden by default', () => {
render(<SearchBar />)
expect(screen.queryByPlaceholderText(/search/i)).toBeNull()
})
it('opens on Ctrl+F', () => {
render(<SearchBar />)
openSearch()
expect(screen.getByPlaceholderText(/search/i)).toBeDefined()
})
it('opens on Cmd+F', () => {
render(<SearchBar />)
fireEvent.keyDown(window, { key: 'f', metaKey: true })
expect(screen.getByPlaceholderText(/search/i)).toBeDefined()
})
it('closes on Escape', () => {
render(<SearchBar />)
openSearch()
fireEvent.keyDown(window, { key: 'Escape' })
expect(screen.queryByPlaceholderText(/search/i)).toBeNull()
})
it('closes when X button is clicked', () => {
render(<SearchBar />)
openSearch()
fireEvent.click(screen.getByLabelText('Close search'))
expect(screen.queryByPlaceholderText(/search/i)).toBeNull()
})
it('filters by label', () => {
setupStore([
makeNode('n1', { data: { label: 'My Router', type: 'router', status: 'online', services: [], ip: null, hostname: null } }),
makeNode('n2', { data: { label: 'My NAS', type: 'nas', status: 'online', services: [], ip: null, hostname: null } }),
])
render(<SearchBar />)
openSearch()
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'router' } })
expect(screen.getByText('My Router')).toBeDefined()
expect(screen.queryByText('My NAS')).toBeNull()
})
it('filters by IP', () => {
setupStore([
makeNode('n1', { data: { label: 'Server A', type: 'server', status: 'online', services: [], ip: '192.168.1.10', hostname: null } }),
makeNode('n2', { data: { label: 'Server B', type: 'server', status: 'online', services: [], ip: '10.0.0.1', hostname: null } }),
])
render(<SearchBar />)
openSearch()
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: '192.168' } })
expect(screen.getByText('Server A')).toBeDefined()
expect(screen.queryByText('Server B')).toBeNull()
})
it('filters by service name', () => {
setupStore([
makeNode('n1', { data: { label: 'Web Server', type: 'server', status: 'online', services: [{ service_name: 'nginx', port: 80, protocol: 'tcp' }], ip: null, hostname: null } }),
makeNode('n2', { data: { label: 'DB Server', type: 'server', status: 'online', services: [{ service_name: 'mysql', port: 3306, protocol: 'tcp' }], ip: null, hostname: null } }),
])
render(<SearchBar />)
openSearch()
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'nginx' } })
expect(screen.getByText('Web Server')).toBeDefined()
expect(screen.queryByText('DB Server')).toBeNull()
})
it('excludes groupRect nodes from results', () => {
setupStore([
makeNode('gr1', { data: { label: 'DMZ Zone', type: 'groupRect', status: 'unknown', services: [], ip: null, hostname: null } }),
])
render(<SearchBar />)
openSearch()
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'dmz' } })
expect(screen.queryByText('DMZ Zone')).toBeNull()
})
it('shows no-results message when query has no matches', () => {
render(<SearchBar />)
openSearch()
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'zzznomatch' } })
expect(screen.getByText(/no results/i)).toBeDefined()
})
it('calls setSelectedNode when a result is clicked', () => {
const setSelectedNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode('n1', { data: { label: 'My Server', type: 'server', status: 'online', services: [], ip: null, hostname: null } })],
setSelectedNode,
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<SearchBar />)
openSearch()
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'my server' } })
fireEvent.click(screen.getByText('My Server'))
expect(setSelectedNode).toHaveBeenCalledWith('n1')
})
it('shows result count', () => {
setupStore([
makeNode('n1', { data: { label: 'Alpha', type: 'server', status: 'online', services: [], ip: null, hostname: null } }),
makeNode('n2', { data: { label: 'Beta', type: 'server', status: 'online', services: [], ip: null, hostname: null } }),
])
render(<SearchBar />)
openSearch()
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'a' } })
expect(screen.getByText(/2 results/i)).toBeDefined()
})
})
@@ -0,0 +1,125 @@
import { useState } from 'react'
import { type NodeProps, type Node, NodeResizer } from '@xyflow/react'
import { Layers, Pencil, Check, X } from 'lucide-react'
import { useCanvasStore } from '@/stores/canvasStore'
import { STATUS_COLORS, type NodeData } from '@/types'
export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
const { nodes, updateNode, snapshotHistory } = useCanvasStore()
const showBorder = data.custom_colors?.show_border !== false
const isVisible = showBorder || selected
const [editing, setEditing] = useState(false)
const [labelDraft, setLabelDraft] = useState(data.label)
const children = nodes.filter((n) => n.parentId === id)
const onlineCount = children.filter((n) => n.data.status === 'online').length
const offlineCount = children.filter((n) => n.data.status === 'offline').length
const unknownCount = children.length - onlineCount - offlineCount
const handleRename = () => {
if (labelDraft.trim()) {
snapshotHistory()
updateNode(id, { label: labelDraft.trim() })
}
setEditing(false)
}
const borderColor = selected ? '#00d4ff' : '#30363d'
const borderStyle = selected ? 'solid' : 'dashed'
return (
<div
style={{
width: '100%',
height: '100%',
position: 'relative',
borderRadius: 8,
border: isVisible ? `2px ${borderStyle} ${borderColor}` : '2px solid transparent',
background: 'transparent',
transition: 'border-color 0.15s, background 0.15s',
boxSizing: 'border-box',
}}
>
<NodeResizer
isVisible={selected}
minWidth={120}
minHeight={80}
lineStyle={{ stroke: '#00d4ff', strokeWidth: 1 }}
handleStyle={{ fill: '#00d4ff', stroke: '#0d1117', width: 8, height: 8, borderRadius: 2 }}
/>
{/* Header */}
{isVisible && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
padding: '5px 10px',
display: 'flex',
alignItems: 'center',
gap: 6,
background: selected ? 'rgba(0,212,255,0.08)' : 'rgba(22,27,34,0.8)',
borderRadius: '6px 6px 0 0',
borderBottom: isVisible ? `1px solid ${borderColor}40` : 'none',
pointerEvents: 'auto',
}}
className="nodrag"
>
<Layers size={12} style={{ color: '#00d4ff', flexShrink: 0 }} />
{editing ? (
<input
autoFocus
value={labelDraft}
onChange={(e) => setLabelDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRename()
if (e.key === 'Escape') { setLabelDraft(data.label); setEditing(false) }
}}
style={{
flex: 1,
background: 'transparent',
border: 'none',
outline: 'none',
color: '#e6edf3',
fontSize: 11,
fontWeight: 600,
}}
/>
) : (
<span style={{ flex: 1, fontSize: 11, fontWeight: 600, color: '#e6edf3', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{data.label}
</span>
)}
{editing ? (
<>
<button onClick={handleRename} style={{ color: '#39d353', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><Check size={11} /></button>
<button onClick={() => { setLabelDraft(data.label); setEditing(false) }} style={{ color: '#f85149', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><X size={11} /></button>
</>
) : (
<button
onClick={() => { setLabelDraft(data.label); setEditing(true) }}
style={{ color: '#8b949e', background: 'none', border: 'none', cursor: 'pointer', padding: 1, opacity: selected ? 1 : 0 }}
title="Rename group"
>
<Pencil size={10} />
</button>
)}
{/* Status summary */}
{children.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 10, flexShrink: 0, marginLeft: 4 }}>
{onlineCount > 0 && <span style={{ color: STATUS_COLORS.online }}> {onlineCount}</span>}
{offlineCount > 0 && <span style={{ color: STATUS_COLORS.offline }}> {offlineCount}</span>}
{unknownCount > 0 && <span style={{ color: STATUS_COLORS.unknown }}> {unknownCount}</span>}
</div>
)}
</div>
)}
</div>
)
}
@@ -1,6 +1,7 @@
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerNode, GenericNode } from './index'
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
import { GroupRectNode } from './GroupRectNode'
import { GroupNode } from './GroupNode'
export const nodeTypes = {
isp: IspNode,
@@ -20,4 +21,5 @@ export const nodeTypes = {
docker: DockerNode,
generic: GenericNode,
groupRect: GroupRectNode,
group: GroupNode,
}
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { Plus, Trash2, Settings } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -15,16 +15,12 @@ interface ScanConfigModalProps {
export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalProps) {
const [ranges, setRanges] = useState<string[]>([''])
const [interval, setInterval] = useState(60)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!open) return
scanApi.getConfig()
.then((res) => {
setRanges(res.data.ranges.length > 0 ? res.data.ranges : [''])
setInterval(res.data.interval_seconds)
})
.then((res) => setRanges(res.data.ranges.length > 0 ? res.data.ranges : ['']))
.catch(() => {/* use defaults */})
}, [open])
@@ -33,7 +29,7 @@ export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalPro
if (cleaned.length === 0) { toast.error('Add at least one IP range'); return }
setSaving(true)
try {
await scanApi.saveConfig({ ranges: cleaned, interval_seconds: interval })
await scanApi.saveConfig({ ranges: cleaned })
toast.success('Scan config saved')
onClose()
} catch {
@@ -95,18 +91,10 @@ export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalPro
</Button>
</div>
{/* Status check interval */}
<div className="space-y-1.5">
<Label className="text-sm text-muted-foreground">Status check interval (seconds)</Label>
<Input
type="number"
min={10}
max={3600}
value={interval}
onChange={(e) => setInterval(Number(e.target.value))}
className="font-mono text-sm bg-[#0d1117] border-border w-32"
/>
</div>
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
<Settings size={11} />
Status check interval can be configured in the sidebar Settings.
</p>
</div>
<DialogFooter className="gap-2">
@@ -13,11 +13,12 @@ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.f
import { scanApi } from '@/api/client'
import { toast } from 'sonner'
const defaultConfig = { data: { ranges: ['192.168.1.0/24'], interval_seconds: 60 } }
const defaultConfig = { data: { ranges: ['192.168.1.0/24'] } }
describe('ScanConfigModal', () => {
beforeEach(() => {
vi.mocked(scanApi.getConfig).mockResolvedValue(defaultConfig as never)
vi.mocked(scanApi.saveConfig).mockReset()
vi.mocked(scanApi.saveConfig).mockResolvedValue({} as never)
vi.mocked(toast.success).mockReset()
vi.mocked(toast.error).mockReset()
@@ -37,11 +38,14 @@ describe('ScanConfigModal', () => {
expect(input).toBeDefined()
})
it('loads interval from API on open', async () => {
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['10.0.0.0/8'], interval_seconds: 120 } } as never)
it('saves only ranges (interval managed by settings endpoint)', async () => {
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['10.0.0.0/8'] } } as never)
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
const input = await screen.findByDisplayValue('120')
expect(input).toBeDefined()
await screen.findByDisplayValue('10.0.0.0/8')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['10.0.0.0/8'] })
})
})
it('adds a new empty range on "Add range" click', async () => {
@@ -61,7 +65,7 @@ describe('ScanConfigModal', () => {
})
it('can remove a range when more than one exist', async () => {
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['192.168.1.0/24', '10.0.0.0/8'], interval_seconds: 60 } } as never)
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['192.168.1.0/24', '10.0.0.0/8'], } } as never)
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
// Both trash buttons should be enabled
@@ -70,7 +74,7 @@ describe('ScanConfigModal', () => {
})
it('shows error toast and does not save when all ranges are empty', async () => {
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: [''], interval_seconds: 60 } } as never)
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: [''], } } as never)
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await waitFor(() => expect(scanApi.getConfig).toHaveBeenCalled())
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
@@ -86,7 +90,7 @@ describe('ScanConfigModal', () => {
await screen.findByDisplayValue('192.168.1.0/24')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'], interval_seconds: 60 })
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'] })
expect(toast.success).toHaveBeenCalledWith('Scan config saved')
expect(onClose).toHaveBeenCalledOnce()
})
+235 -171
View File
@@ -1,33 +1,75 @@
import { useState } from 'react'
import { X, Edit, Trash2, ExternalLink, Plus, Pencil } from 'lucide-react'
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useCanvasStore } from '@/stores/canvasStore'
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo } from '@/types'
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData } from '@/types'
import { getServiceUrl } from '@/utils/serviceUrl'
import type { Node } from '@xyflow/react'
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: '' }
export function DetailPanel({ onEdit }: DetailPanelProps) {
const { nodes, selectedNodeId, setSelectedNode, deleteNode, updateNode } = useCanvasStore()
const node = nodes.find((n) => n.id === selectedNodeId)
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup } = useCanvasStore()
const [addingForNode, setAddingForNode] = useState<string | null>(null)
const [newSvc, setNewSvc] = useState<SvcForm>(EMPTY_FORM)
const [editingFor, setEditingFor] = useState<{ nodeId: string; index: number } | null>(null)
const [editSvc, setEditSvc] = useState<SvcForm>(EMPTY_FORM)
const [groupName, setGroupName] = useState('')
const [creatingGroup, setCreatingGroup] = useState(false)
// Multi-select panel
const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id))
if (multiSelected.length > 1) {
return (
<MultiSelectPanel
nodeIds={multiSelected}
nodes={nodes}
groupName={groupName}
setGroupName={setGroupName}
creatingGroup={creatingGroup}
setCreatingGroup={setCreatingGroup}
onCreateGroup={(name) => { createGroup(multiSelected, name); setGroupName(''); setCreatingGroup(false) }}
onClose={() => setSelectedNode(null)}
/>
)
}
const node = nodes.find((n) => n.id === selectedNodeId)
if (!node || node.data.type === 'groupRect') return null
// Group detail panel
if (node.data.type === 'group') {
return (
<GroupDetailPanel
node={node}
nodes={nodes}
onUngroup={() => { ungroup(node.id) }}
onToggleBorder={() => {
snapshotHistory()
updateNode(node.id, {
custom_colors: {
...node.data.custom_colors,
show_border: !(node.data.custom_colors?.show_border !== false),
},
})
}}
onClose={() => setSelectedNode(null)}
onSelectChild={(id) => setSelectedNode(id)}
/>
)
}
// Normal single-node panel
const addingService = addingForNode === node.id
const editingIndex = editingFor?.nodeId === node.id ? editingFor.index : null
const { data } = node
const services = data.services ?? []
const statusColor = STATUS_COLORS[data.status]
@@ -35,6 +77,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
const handleDelete = () => {
if (confirm(`Delete "${data.label}"?`)) {
snapshotHistory()
deleteNode(node.id)
}
}
@@ -42,11 +85,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
const handleAddService = () => {
const port = parseInt(newSvc.port, 10)
if (!newSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
const svc: ServiceInfo = {
port,
protocol: newSvc.protocol,
service_name: newSvc.service_name.trim(),
}
const svc: ServiceInfo = { port, protocol: newSvc.protocol, service_name: newSvc.service_name.trim() }
updateNode(node.id, { services: [...services, svc] })
setNewSvc(EMPTY_FORM)
setAddingForNode(null)
@@ -71,9 +110,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
const port = parseInt(editSvc.port, 10)
if (!editSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
const updated = services.map((svc, i) =>
i === editingIndex
? { ...svc, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() }
: svc
i === editingIndex ? { ...svc, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() } : svc
)
updateNode(node.id, { services: updated })
setEditingFor(null)
@@ -81,19 +118,13 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
return (
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<span className="font-semibold text-sm text-foreground truncate">{data.label}</span>
<button
aria-label="Close panel"
onClick={() => setSelectedNode(null)}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<button aria-label="Close panel" onClick={() => setSelectedNode(null)} className="text-muted-foreground hover:text-foreground transition-colors">
<X size={16} />
</button>
</div>
{/* Status */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: statusColor }} />
<span className="text-sm capitalize" style={{ color: statusColor }}>{data.status}</span>
@@ -102,21 +133,13 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
)}
</div>
{/* Details */}
<div className="flex flex-col gap-3 px-4 py-3 text-sm">
<DetailRow label="Type" value={NODE_TYPE_LABELS[data.type]} />
{data.hostname && (
<div className="flex justify-between gap-2 items-baseline">
<span className="text-muted-foreground text-xs shrink-0">Hostname</span>
<a
href={`http://${data.hostname}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-mono text-[#00d4ff] hover:underline truncate flex items-center gap-1"
title={data.hostname}
>
{data.hostname}
<ExternalLink size={10} className="shrink-0" />
<a href={`http://${data.hostname}`} target="_blank" rel="noopener noreferrer" className="text-xs font-mono text-[#00d4ff] hover:underline truncate flex items-center gap-1" title={data.hostname}>
{data.hostname}<ExternalLink size={10} className="shrink-0" />
</a>
</div>
)}
@@ -124,12 +147,9 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
{data.os && <DetailRow label="OS" value={data.os} />}
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
{data.last_seen && (
<DetailRow label="Last Seen" value={new Date(data.last_seen).toLocaleString()} />
)}
{data.last_seen && <DetailRow label="Last Seen" value={new Date(data.last_seen).toLocaleString()} />}
</div>
{/* Hardware */}
{(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null) && (
<div className="flex flex-col gap-3 px-4 py-3 text-sm border-t border-border">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Hardware</span>
@@ -140,64 +160,28 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
</div>
)}
{/* Services */}
<div className="px-4 py-3 border-t border-border">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground">
Services{services.length > 0 ? ` (${services.length})` : ''}
</span>
<button
onClick={() => { setAddingForNode((v) => v === node.id ? null : node.id); setEditingFor(null) }}
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors"
>
<span className="text-xs text-muted-foreground">Services{services.length > 0 ? ` (${services.length})` : ''}</span>
<button onClick={() => { setAddingForNode((v) => v === node.id ? null : node.id); setEditingFor(null) }} className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors">
<Plus size={10} /> Add
</button>
</div>
{/* Add service form */}
{addingService && (
<ServiceForm
form={newSvc}
onChange={setNewSvc}
onConfirm={handleAddService}
onCancel={() => setAddingForNode(null)}
confirmLabel="Add"
autoFocus
/>
)}
{addingService && <ServiceForm form={newSvc} onChange={setNewSvc} onConfirm={handleAddService} onCancel={() => setAddingForNode(null)} confirmLabel="Add" autoFocus />}
{services.length > 0 && (
<div className="flex flex-col gap-1.5">
{services.map((svc, i) =>
editingIndex === i ? (
<ServiceForm
key={`edit-${i}`}
form={editSvc}
onChange={setEditSvc}
onConfirm={handleSaveEdit}
onCancel={() => setEditingFor(null)}
confirmLabel="Save"
autoFocus
/>
<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}-${svc.protocol}-${i}`} svc={svc} host={host} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
)
)}
</div>
)}
{services.length === 0 && !addingService && (
<p className="text-[10px] text-muted-foreground/50">No services click Add to register one.</p>
)}
{services.length === 0 && !addingService && <p className="text-[10px] text-muted-foreground/50">No services click Add to register one.</p>}
</div>
{/* Notes */}
{data.notes && (
<div className="px-4 py-3 border-t border-border">
<div className="text-xs text-muted-foreground mb-1">Notes</div>
@@ -205,7 +189,6 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
</div>
)}
{/* Actions */}
<div className="mt-auto flex gap-2 px-4 py-3 border-t border-border">
<Button size="sm" variant="secondary" className="flex-1 gap-1.5" onClick={() => onEdit(node.id)}>
<Edit size={14} /> Edit
@@ -218,6 +201,167 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
)
}
// --- Multi-select panel ---
interface MultiSelectPanelProps {
nodeIds: string[]
nodes: Node<NodeData>[]
groupName: string
setGroupName: (v: string) => void
creatingGroup: boolean
setCreatingGroup: (v: boolean) => void
onCreateGroup: (name: string) => void
onClose: () => void
}
function MultiSelectPanel({ nodeIds, nodes, groupName, setGroupName, creatingGroup, setCreatingGroup, onCreateGroup, onClose }: MultiSelectPanelProps) {
const selectedNodes = nodeIds.map((id) => nodes.find((n) => n.id === id)).filter(Boolean) as Node<NodeData>[]
const handleCreate = () => {
const name = groupName.trim() || 'Group'
onCreateGroup(name)
}
return (
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<div className="flex items-center gap-2">
<Layers size={14} className="text-[#00d4ff]" />
<span className="font-semibold text-sm text-foreground">{nodeIds.length} nodes selected</span>
</div>
<button aria-label="Close panel" onClick={onClose} className="text-muted-foreground hover:text-foreground transition-colors">
<X size={16} />
</button>
</div>
<div className="flex-1 px-4 py-3 space-y-1.5 overflow-y-auto">
{selectedNodes.map((n) => (
<div key={n.id} className="flex items-center gap-2 px-2 py-1.5 rounded-md bg-[#21262d] text-xs">
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: STATUS_COLORS[n.data.status] }} />
<span className="truncate text-foreground font-medium">{n.data.label}</span>
<span className="ml-auto text-muted-foreground shrink-0">{NODE_TYPE_LABELS[n.data.type] ?? n.data.type}</span>
</div>
))}
</div>
<div className="px-4 py-3 border-t border-border space-y-2">
{creatingGroup ? (
<>
<Input
autoFocus
placeholder="Group name…"
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); if (e.key === 'Escape') setCreatingGroup(false) }}
className="bg-[#21262d] border-[#30363d] text-xs h-7"
/>
<div className="flex gap-2">
<Button size="sm" className="flex-1 h-7 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={handleCreate}>
Create Group
</Button>
<Button size="sm" variant="ghost" className="h-7 text-[10px]" onClick={() => setCreatingGroup(false)}>
Cancel
</Button>
</div>
</>
) : (
<Button
size="sm"
className="w-full gap-2 bg-[#00d4ff]/10 text-[#00d4ff] border border-[#00d4ff]/30 hover:bg-[#00d4ff]/20"
variant="ghost"
onClick={() => setCreatingGroup(true)}
>
<Layers size={13} /> Create Group
</Button>
)}
</div>
</aside>
)
}
// --- Group detail panel ---
interface GroupDetailPanelProps {
node: Node<NodeData>
nodes: Node<NodeData>[]
onUngroup: () => void
onToggleBorder: () => void
onClose: () => void
onSelectChild: (id: string) => void
}
function GroupDetailPanel({ node, nodes, onUngroup, onToggleBorder, onClose, onSelectChild }: GroupDetailPanelProps) {
const children = nodes.filter((n) => n.parentId === node.id)
const onlineCount = children.filter((n) => n.data.status === 'online').length
const offlineCount = children.filter((n) => n.data.status === 'offline').length
const showBorder = node.data.custom_colors?.show_border !== false
const handleUngroup = () => {
if (confirm(`Ungroup "${node.data.label}"? Nodes will be released to the canvas.`)) {
onUngroup()
}
}
return (
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<div className="flex items-center gap-2 min-w-0">
<Layers size={14} className="text-[#00d4ff] shrink-0" />
<span className="font-semibold text-sm text-foreground truncate">{node.data.label}</span>
</div>
<button aria-label="Close panel" onClick={onClose} className="text-muted-foreground hover:text-foreground transition-colors shrink-0">
<X size={16} />
</button>
</div>
{/* Status summary */}
<div className="flex items-center gap-4 px-4 py-3 border-b border-border text-xs">
<span className="text-muted-foreground">{children.length} node{children.length !== 1 ? 's' : ''}</span>
{onlineCount > 0 && <span style={{ color: STATUS_COLORS.online }}> {onlineCount} online</span>}
{offlineCount > 0 && <span style={{ color: STATUS_COLORS.offline }}> {offlineCount} offline</span>}
</div>
{/* Children list */}
<div className="flex-1 px-4 py-3 space-y-1.5 overflow-y-auto">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Members</span>
{children.length === 0 && <p className="text-xs text-muted-foreground/50">No nodes in this group.</p>}
{children.map((child) => (
<button
key={child.id}
onClick={() => onSelectChild(child.id)}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md bg-[#21262d] text-xs hover:bg-[#30363d] transition-colors text-left"
>
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: STATUS_COLORS[child.data.status] }} />
<span className="truncate text-foreground font-medium">{child.data.label}</span>
<span className="ml-auto text-muted-foreground shrink-0">{NODE_TYPE_LABELS[child.data.type] ?? child.data.type}</span>
</button>
))}
</div>
{/* Actions */}
<div className="px-4 py-3 border-t border-border space-y-2">
<button
onClick={onToggleBorder}
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-xs text-muted-foreground hover:text-foreground hover:bg-[#21262d] transition-colors"
>
{showBorder ? <Eye size={13} /> : <EyeOff size={13} />}
{showBorder ? 'Hide border & title' : 'Show border & title'}
</button>
<Button
size="sm"
variant="destructive"
className="w-full gap-2"
onClick={handleUngroup}
>
<Ungroup size={13} /> Ungroup
</Button>
</div>
</aside>
)
}
// --- Helpers ---
function formatStorage(gb: number): string {
if (gb >= 1024) return `${(gb / 1024).toFixed(1).replace(/\.0$/, '')} TB`
return `${gb} GB`
@@ -227,24 +371,14 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono?
return (
<div className="flex justify-between gap-2 items-baseline">
<span className="text-muted-foreground text-xs shrink-0">{label}</span>
<span
className={`text-xs text-right truncate ${mono ? 'font-mono text-[#00d4ff]' : 'text-foreground'}`}
title={value}
>
<span className={`text-xs text-right truncate ${mono ? 'font-mono text-[#00d4ff]' : 'text-foreground'}`} title={value}>
{value}
</span>
</div>
)
}
function ServiceForm({
form,
onChange,
onConfirm,
onCancel,
confirmLabel,
autoFocus,
}: {
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
onConfirm: () => void
@@ -254,82 +388,31 @@ function ServiceForm({
}) {
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()}
/>
<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()}
/>
<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"
>
<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()} />
<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>
<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>
<Button size="sm" className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={onConfirm}>{confirmLabel}</Button>
<Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}>Cancel</Button>
</div>
</div>
)
}
const CATEGORY_COLORS: Record<string, string> = {
web: '#00d4ff',
database: '#a855f7',
monitoring: '#39d353',
storage: '#e3b341',
security: '#f85149',
remote: '#8b949e',
web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
}
function ServiceBadge({
svc,
host,
onEdit,
onRemove,
}: {
svc: ServiceInfo
host?: string
onEdit: () => void
onRemove: () => void
}) {
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 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="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>
@@ -337,30 +420,11 @@ function ServiceBadge({
<div className="flex items-center gap-1.5 shrink-0">
<span className="font-mono text-[#8b949e]">{svc.port}/{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>
<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>
</div>
</div>
)
if (url) {
return (
<a href={url} target="_blank" rel="noopener noreferrer" className="block hover:opacity-80 transition-opacity">
{inner}
</a>
)
}
if (url) return <a href={url} target="_blank" rel="noopener noreferrer" className="block hover:opacity-80 transition-opacity">{inner}</a>
return inner
}
+105 -4
View File
@@ -1,15 +1,15 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye } from 'lucide-react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle } from 'lucide-react'
import { Logo } from '@/components/ui/Logo'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvasStore'
import { scanApi } from '@/api/client'
import { scanApi, settingsApi } from '@/api/client'
import { toast } from 'sonner'
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history'
type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history' | 'settings'
const ALL_VIEWS = [
{ id: 'canvas' as SidebarView, icon: LayoutDashboard, label: 'Canvas' },
@@ -95,6 +95,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
{activeView === 'pending' && <PendingDevicesPanel onNodeApproved={onNodeApproved} />}
{activeView === 'hidden' && <HiddenDevicesPanel />}
{activeView === 'history' && <ScanHistoryPanel />}
{activeView === 'settings' && <SettingsPanel />}
</div>
)}
@@ -141,6 +142,15 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
badge={hasUnsavedChanges}
accent
/>
{!STANDALONE && (
<SidebarItem
icon={Settings}
label="Settings"
collapsed={collapsed}
active={activeView === 'settings'}
onClick={() => setActiveView((v) => v === 'settings' ? 'canvas' : 'settings')}
/>
)}
</div>
</aside>
)
@@ -377,8 +387,26 @@ function ScanHistoryPanel() {
return () => clearInterval(id)
}, [runs, load])
const [stopping, setStopping] = useState<string | null>(null)
const handleStop = async (runId: string) => {
setStopping(runId)
try {
await scanApi.stop(runId)
toast.success('Scan stop requested')
} catch {
toast.error('Failed to stop scan')
} finally {
setStopping(null)
}
}
const statusColor = (s: string) =>
s === 'done' ? '#39d353' : s === 'running' ? '#e3b341' : s === 'error' ? '#f85149' : '#8b949e'
s === 'done' ? '#39d353'
: s === 'running' ? '#e3b341'
: s === 'error' ? '#f85149'
: s === 'cancelled' ? '#8b949e'
: '#8b949e'
return (
<div className="p-2">
@@ -399,6 +427,24 @@ function ScanHistoryPanel() {
<span className="font-mono text-foreground capitalize">{r.status}</span>
{r.status === 'running' && <Loader2 size={10} className="animate-spin text-[#e3b341]" />}
<span className="ml-auto text-muted-foreground font-mono">{r.devices_found} found</span>
{r.status === 'running' && (
<Tooltip>
<TooltipTrigger>
<button
aria-label="Stop scan"
onClick={() => handleStop(r.id)}
disabled={stopping === r.id}
className="p-0.5 text-[#f85149] hover:bg-[#f85149]/10 rounded transition-colors disabled:opacity-50"
>
{stopping === r.id
? <Loader2 size={11} className="animate-spin" />
: <StopCircle size={11} />
}
</button>
</TooltipTrigger>
<TooltipContent side="left">Stop scan</TooltipContent>
</Tooltip>
)}
</div>
<div className="text-muted-foreground text-[10px] mt-0.5">
{new Date(r.started_at).toLocaleString()}
@@ -417,6 +463,61 @@ function ScanHistoryPanel() {
)
}
function SettingsPanel() {
const [interval, setIntervalValue] = useState(60)
const [saving, setSaving] = useState(false)
useEffect(() => {
settingsApi.get()
.then((res) => setIntervalValue(res.data.interval_seconds))
.catch(() => {/* use default */})
}, [])
const handleSave = async () => {
setSaving(true)
try {
await settingsApi.save({ interval_seconds: interval })
toast.success('Settings saved')
} catch {
toast.error('Failed to save settings')
} finally {
setSaving(false)
}
}
return (
<div className="p-3 space-y-4">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Settings</span>
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">Status check interval (s)</label>
<div className="flex items-center gap-2">
<input
type="number"
min={10}
max={3600}
value={interval}
onChange={(e) => setIntervalValue(Number(e.target.value))}
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>
</div>
<p className="text-[10px] text-muted-foreground leading-tight">
How often node health is polled (ping, HTTP, SSH)
</p>
</div>
<button
onClick={handleSave}
disabled={saving}
className="w-full py-1.5 rounded-md text-xs font-medium bg-[#00d4ff]/10 text-[#00d4ff] border border-[#00d4ff]/30 hover:bg-[#00d4ff]/20 transition-colors disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
)
}
const MAC_OUI: Record<string, { label: string; title: string }> = {
'52:54:00': { label: 'QEMU', title: 'QEMU/KVM Virtual Machine' },
'bc:24:11': { label: 'PVE', title: 'Proxmox Virtual Machine or LXC' },
@@ -26,9 +26,13 @@ function setupStore(nodeData: Partial<NodeData> = {}) {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode(nodeData)],
selectedNodeId: 'n1',
selectedNodeIds: [],
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode: vi.fn(),
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
}
@@ -37,9 +41,13 @@ describe('DetailPanel', () => {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [],
selectedNodeId: null,
selectedNodeIds: [],
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode: vi.fn(),
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
})
@@ -125,6 +133,7 @@ describe('DetailPanel', () => {
setSelectedNode,
deleteNode: vi.fn(),
updateNode: vi.fn(),
snapshotHistory: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
fireEvent.click(screen.getByLabelText('Close panel'))
@@ -139,33 +148,39 @@ describe('DetailPanel', () => {
expect(onEdit).toHaveBeenCalledWith('n1')
})
it('calls deleteNode when delete confirmed', () => {
it('calls snapshotHistory then deleteNode when delete confirmed', () => {
const deleteNode = vi.fn()
const snapshotHistory = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ label: 'My Server' })],
selectedNodeId: 'n1',
setSelectedNode: vi.fn(),
deleteNode,
updateNode: vi.fn(),
snapshotHistory,
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
vi.spyOn(window, 'confirm').mockReturnValue(true)
render(<DetailPanel onEdit={vi.fn()} />)
fireEvent.click(screen.getByLabelText('Delete node'))
expect(snapshotHistory).toHaveBeenCalledOnce()
expect(deleteNode).toHaveBeenCalledWith('n1')
})
it('does not call deleteNode when delete is cancelled', () => {
it('does not call deleteNode or snapshotHistory when delete is cancelled', () => {
const deleteNode = vi.fn()
const snapshotHistory = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({})],
selectedNodeId: 'n1',
setSelectedNode: vi.fn(),
deleteNode,
updateNode: vi.fn(),
snapshotHistory,
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
vi.spyOn(window, 'confirm').mockReturnValue(false)
render(<DetailPanel onEdit={vi.fn()} />)
fireEvent.click(screen.getByLabelText('Delete node'))
expect(snapshotHistory).not.toHaveBeenCalled()
expect(deleteNode).not.toHaveBeenCalled()
})
})
@@ -186,6 +201,7 @@ describe('DetailPanel', () => {
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode,
snapshotHistory: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
fireEvent.click(screen.getByText('Add'))
@@ -207,6 +223,7 @@ describe('DetailPanel', () => {
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode,
snapshotHistory: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
fireEvent.click(screen.getByTitle('Remove service'))
@@ -221,6 +238,7 @@ describe('DetailPanel', () => {
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode: vi.fn(),
snapshotHistory: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
expect(() => render(<DetailPanel onEdit={vi.fn()} />)).not.toThrow()
})
@@ -249,6 +267,7 @@ describe('DetailPanel', () => {
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode,
snapshotHistory: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
@@ -271,6 +290,7 @@ describe('DetailPanel', () => {
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode,
snapshotHistory: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
@@ -0,0 +1,233 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { DetailPanel } from '../DetailPanel'
import * as canvasStore from '@/stores/canvasStore'
import { TooltipProvider } from '@/components/ui/tooltip'
vi.mock('@/stores/canvasStore')
vi.mock('@/utils/serviceUrl', () => ({ getServiceUrl: () => null }))
function makeNode(id: string, overrides = {}) {
return {
id,
type: 'server',
position: { x: 0, y: 0 },
data: { label: id, type: 'server', status: 'online', services: [] },
...overrides,
}
}
function makeGroupNode(id = 'g1', label = 'My Group', showBorder = true) {
return {
id,
type: 'group',
position: { x: 76, y: 52 },
data: {
label,
type: 'group',
status: 'unknown',
services: [],
custom_colors: { show_border: showBorder },
},
}
}
const mockStore = {
nodes: [],
selectedNodeId: null,
selectedNodeIds: [],
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode: vi.fn(),
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
}
function setupStore(overrides = {}) {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
...mockStore,
...overrides,
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
}
function renderPanel() {
return render(
<TooltipProvider>
<DetailPanel onEdit={vi.fn()} />
</TooltipProvider>,
)
}
describe('MultiSelectPanel', () => {
beforeEach(() => vi.clearAllMocks())
it('renders multi-select panel when 2+ nodes selected', () => {
const n1 = makeNode('n1', { data: { label: 'Router', type: 'router', status: 'online', services: [] } })
const n2 = makeNode('n2', { data: { label: 'Switch', type: 'switch', status: 'offline', services: [] } })
setupStore({
nodes: [n1, n2],
selectedNodeId: null,
selectedNodeIds: ['n1', 'n2'],
})
renderPanel()
expect(screen.getByText('2 nodes selected')).toBeDefined()
})
it('lists selected node labels in multi-select panel', () => {
const n1 = makeNode('n1', { data: { label: 'My Router', type: 'router', status: 'online', services: [] } })
const n2 = makeNode('n2', { data: { label: 'My NAS', type: 'nas', status: 'unknown', services: [] } })
setupStore({ nodes: [n1, n2], selectedNodeId: null, selectedNodeIds: ['n1', 'n2'] })
renderPanel()
expect(screen.getByText('My Router')).toBeDefined()
expect(screen.getByText('My NAS')).toBeDefined()
})
it('shows Create Group button', () => {
const n1 = makeNode('n1')
const n2 = makeNode('n2')
setupStore({ nodes: [n1, n2], selectedNodeId: null, selectedNodeIds: ['n1', 'n2'] })
renderPanel()
expect(screen.getByRole('button', { name: /create group/i })).toBeDefined()
})
it('shows name input when Create Group is clicked', async () => {
const n1 = makeNode('n1')
const n2 = makeNode('n2')
setupStore({ nodes: [n1, n2], selectedNodeId: null, selectedNodeIds: ['n1', 'n2'] })
renderPanel()
fireEvent.click(screen.getByRole('button', { name: /create group/i }))
await waitFor(() => {
expect(screen.getByPlaceholderText(/group name/i)).toBeDefined()
})
})
it('calls createGroup with selected ids and entered name', async () => {
const createGroup = vi.fn()
const n1 = makeNode('n1')
const n2 = makeNode('n2')
setupStore({ nodes: [n1, n2], selectedNodeId: null, selectedNodeIds: ['n1', 'n2'], createGroup })
renderPanel()
fireEvent.click(screen.getByRole('button', { name: /create group/i }))
const input = await screen.findByPlaceholderText(/group name/i)
fireEvent.change(input, { target: { value: 'DMZ' } })
fireEvent.click(screen.getByRole('button', { name: /^create group$/i }))
expect(createGroup).toHaveBeenCalledWith(['n1', 'n2'], 'DMZ')
})
it('uses default name "Group" when input is empty', async () => {
const createGroup = vi.fn()
const n1 = makeNode('n1')
const n2 = makeNode('n2')
setupStore({ nodes: [n1, n2], selectedNodeId: null, selectedNodeIds: ['n1', 'n2'], createGroup })
renderPanel()
fireEvent.click(screen.getByRole('button', { name: /create group/i }))
await screen.findByPlaceholderText(/group name/i)
fireEvent.click(screen.getByRole('button', { name: /^create group$/i }))
expect(createGroup).toHaveBeenCalledWith(['n1', 'n2'], 'Group')
})
it('includes groupRect (zone) nodes in multi-select count', () => {
const n1 = makeNode('n1')
const gr = makeNode('gr1', { data: { label: 'Zone', type: 'groupRect', status: 'unknown', services: [] } })
setupStore({ nodes: [n1, gr], selectedNodeId: null, selectedNodeIds: ['n1', 'gr1'] })
renderPanel()
// groupRect included → 2 nodes selected → multi-select panel shown
expect(screen.getByText('2 nodes selected')).toBeDefined()
})
})
describe('GroupDetailPanel', () => {
beforeEach(() => vi.clearAllMocks())
it('renders group name and members heading', () => {
const group = makeGroupNode()
const child = makeNode('c1', { parentId: 'g1', data: { label: 'Router', type: 'router', status: 'online', services: [] } })
setupStore({ nodes: [group, child], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
renderPanel()
expect(screen.getByText('My Group')).toBeDefined()
expect(screen.getByText('Members')).toBeDefined()
})
it('lists children with their labels', () => {
const group = makeGroupNode()
const c1 = makeNode('c1', { parentId: 'g1', data: { label: 'My Router', type: 'router', status: 'online', services: [] } })
const c2 = makeNode('c2', { parentId: 'g1', data: { label: 'My NAS', type: 'nas', status: 'offline', services: [] } })
setupStore({ nodes: [group, c1, c2], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
renderPanel()
expect(screen.getByText('My Router')).toBeDefined()
expect(screen.getByText('My NAS')).toBeDefined()
})
it('shows online/offline count in status summary', () => {
const group = makeGroupNode()
const c1 = makeNode('c1', { parentId: 'g1', data: { label: 'A', type: 'server', status: 'online', services: [] } })
const c2 = makeNode('c2', { parentId: 'g1', data: { label: 'B', type: 'server', status: 'offline', services: [] } })
setupStore({ nodes: [group, c1, c2], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
renderPanel()
expect(screen.getByText(/1 online/)).toBeDefined()
expect(screen.getByText(/1 offline/)).toBeDefined()
})
it('shows Ungroup button', () => {
const group = makeGroupNode()
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
renderPanel()
expect(screen.getByRole('button', { name: /ungroup/i })).toBeDefined()
})
it('calls ungroup after confirm', () => {
const ungroup = vi.fn()
vi.spyOn(window, 'confirm').mockReturnValue(true)
const group = makeGroupNode()
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], ungroup })
renderPanel()
fireEvent.click(screen.getByRole('button', { name: /ungroup/i }))
expect(ungroup).toHaveBeenCalledWith('g1')
})
it('does not call ungroup when confirm is cancelled', () => {
const ungroup = vi.fn()
vi.spyOn(window, 'confirm').mockReturnValue(false)
const group = makeGroupNode()
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], ungroup })
renderPanel()
fireEvent.click(screen.getByRole('button', { name: /ungroup/i }))
expect(ungroup).not.toHaveBeenCalled()
})
it('shows "Hide border & title" when show_border is true', () => {
const group = makeGroupNode('g1', 'G', true)
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
renderPanel()
expect(screen.getByText(/hide border/i)).toBeDefined()
})
it('shows "Show border & title" when show_border is false', () => {
const group = makeGroupNode('g1', 'G', false)
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
renderPanel()
expect(screen.getByText(/show border/i)).toBeDefined()
})
it('calls updateNode to toggle show_border off', () => {
const updateNode = vi.fn()
const group = makeGroupNode('g1', 'G', true)
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], updateNode })
renderPanel()
fireEvent.click(screen.getByText(/hide border/i))
expect(updateNode).toHaveBeenCalledWith('g1', expect.objectContaining({
custom_colors: expect.objectContaining({ show_border: false }),
}))
})
it('calls setSelectedNode when a child node is clicked', () => {
const setSelectedNode = vi.fn()
const group = makeGroupNode()
const child = makeNode('c1', { parentId: 'g1', data: { label: 'Child Node Alpha', type: 'server', status: 'online', services: [] } })
setupStore({ nodes: [group, child], selectedNodeId: 'g1', selectedNodeIds: ['g1'], setSelectedNode })
renderPanel()
fireEvent.click(screen.getByText('Child Node Alpha'))
expect(setSelectedNode).toHaveBeenCalledWith('c1')
})
})
@@ -0,0 +1,155 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { Sidebar } from '../Sidebar'
import * as canvasStore from '@/stores/canvasStore'
import { TooltipProvider } from '@/components/ui/tooltip'
vi.mock('@/stores/canvasStore')
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('@/api/client', () => ({
scanApi: {
trigger: vi.fn(),
pending: vi.fn().mockResolvedValue({ data: [] }),
hidden: vi.fn().mockResolvedValue({ data: [] }),
runs: vi.fn().mockResolvedValue({ data: [] }),
stop: vi.fn(),
getConfig: vi.fn().mockResolvedValue({ data: { ranges: [] } }),
},
settingsApi: { get: vi.fn(), save: vi.fn() },
}))
import { scanApi } from '@/api/client'
import { toast } from 'sonner'
const RUNNING_RUN = {
id: 'run-1',
status: 'running',
ranges: ['192.168.1.0/24'],
devices_found: 2,
started_at: new Date().toISOString(),
finished_at: null,
error: null,
}
const DONE_RUN = {
id: 'run-2',
status: 'done',
ranges: ['192.168.1.0/24'],
devices_found: 3,
started_at: new Date().toISOString(),
finished_at: new Date().toISOString(),
error: null,
}
const CANCELLED_RUN = {
id: 'run-3',
status: 'cancelled',
ranges: ['192.168.1.0/24'],
devices_found: 1,
started_at: new Date().toISOString(),
finished_at: new Date().toISOString(),
error: null,
}
function renderSidebar() {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [],
hasUnsavedChanges: false,
hideIp: false,
toggleHideIp: vi.fn(),
addNode: vi.fn(),
scanEventTs: 0,
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
return render(
<TooltipProvider>
<Sidebar
onAddNode={vi.fn()}
onAddGroupRect={vi.fn()}
onScan={vi.fn()}
onSave={vi.fn()}
onNodeApproved={vi.fn()}
/>
</TooltipProvider>
)
}
async function openHistory() {
fireEvent.click(screen.getByRole('button', { name: 'Scan History' }))
// Wait for runs to load
await waitFor(() => expect(scanApi.runs).toHaveBeenCalled())
}
describe('ScanHistoryPanel — stop scan', () => {
beforeEach(() => {
vi.mocked(toast.success).mockReset()
vi.mocked(toast.error).mockReset()
vi.mocked(scanApi.stop).mockReset()
vi.mocked(scanApi.runs).mockResolvedValue({ data: [] } as never)
})
it('shows stop button only for running scans', async () => {
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN, DONE_RUN] } as never)
renderSidebar()
await openHistory()
await waitFor(() => expect(screen.getByText('running')).toBeDefined())
// Exactly one stop button rendered (for the running scan only)
const stopButtons = screen.getAllByRole('button', { name: 'Stop scan' })
expect(stopButtons).toHaveLength(1)
})
it('calls scanApi.stop with the correct run ID on click', async () => {
vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never)
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
renderSidebar()
await openHistory()
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
fireEvent.click(stopBtn)
await waitFor(() => {
expect(scanApi.stop).toHaveBeenCalledWith('run-1')
})
})
it('shows success toast when stop succeeds', async () => {
vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never)
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
renderSidebar()
await openHistory()
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
fireEvent.click(stopBtn)
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith('Scan stop requested')
})
})
it('shows error toast when stop fails', async () => {
vi.mocked(scanApi.stop).mockRejectedValue(new Error('network'))
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
renderSidebar()
await openHistory()
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
fireEvent.click(stopBtn)
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Failed to stop scan')
})
})
it('renders cancelled status without stop button or spinner', async () => {
vi.mocked(scanApi.runs).mockResolvedValue({ data: [CANCELLED_RUN] } as never)
renderSidebar()
await openHistory()
await waitFor(() => expect(screen.getByText('cancelled')).toBeDefined())
// No stop button
expect(screen.queryByRole('button', { name: 'Stop scan' })).toBeNull()
})
})
@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { Sidebar } from '../Sidebar'
import * as canvasStore from '@/stores/canvasStore'
import { TooltipProvider } from '@/components/ui/tooltip'
vi.mock('@/stores/canvasStore')
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('@/api/client', () => ({
scanApi: {
trigger: vi.fn(),
pending: vi.fn().mockResolvedValue({ data: [] }),
hidden: vi.fn().mockResolvedValue({ data: [] }),
runs: vi.fn().mockResolvedValue({ data: [] }),
getConfig: vi.fn().mockResolvedValue({ data: { ranges: [] } }),
},
settingsApi: {
get: vi.fn(),
save: vi.fn(),
},
}))
import { settingsApi } from '@/api/client'
import { toast } from 'sonner'
function renderSidebar() {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [],
hasUnsavedChanges: false,
hideIp: false,
toggleHideIp: vi.fn(),
addNode: vi.fn(),
scanEventTs: 0,
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
return render(
<TooltipProvider>
<Sidebar
onAddNode={vi.fn()}
onAddGroupRect={vi.fn()}
onScan={vi.fn()}
onSave={vi.fn()}
onNodeApproved={vi.fn()}
/>
</TooltipProvider>
)
}
describe('SettingsPanel', () => {
beforeEach(() => {
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60 } } as never)
vi.mocked(settingsApi.save).mockResolvedValue({ data: { interval_seconds: 60 } } as never)
vi.mocked(toast.success).mockReset()
vi.mocked(toast.error).mockReset()
})
it('opens when Settings item is clicked', async () => {
renderSidebar()
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
await waitFor(() => {
expect(settingsApi.get).toHaveBeenCalledOnce()
})
expect(screen.getByText('Status check interval (s)')).toBeDefined()
})
it('displays interval loaded from API', async () => {
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 120 } } as never)
renderSidebar()
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
const input = await screen.findByDisplayValue('120')
expect(input).toBeDefined()
})
it('saves interval via settingsApi on Save click', async () => {
renderSidebar()
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
const input = await screen.findByDisplayValue('60')
fireEvent.change(input, { target: { value: '180' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(settingsApi.save).toHaveBeenCalledWith({ interval_seconds: 180 })
expect(toast.success).toHaveBeenCalledWith('Settings saved')
})
})
it('shows error toast when save fails', async () => {
vi.mocked(settingsApi.save).mockRejectedValue(new Error('network'))
renderSidebar()
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
await screen.findByDisplayValue('60')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Failed to save settings')
})
})
it('closes panel when Settings is clicked again', async () => {
renderSidebar()
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
await screen.findByText('Status check interval (s)')
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
expect(screen.queryByText('Status check interval (s)')).toBeNull()
})
})
+9
View File
@@ -111,6 +111,15 @@
background-color: var(--surface-card) !important;
}
/* Transparent wrapper for container node types */
.react-flow__node-proxmox,
.react-flow__node-group {
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
}
/* Mono font utility */
.font-mono {
font-family: 'JetBrains Mono', monospace;
@@ -25,6 +25,7 @@ describe('canvasStore', () => {
edges: [],
hasUnsavedChanges: false,
selectedNodeId: null,
selectedNodeIds: [],
editingGroupRectId: null,
past: [],
future: [],
@@ -178,6 +179,178 @@ describe('canvasStore', () => {
expect(child?.extent).toBe('parent')
})
// ── selectedNodeIds ───────────────────────────────────────────────────────
it('selectedNodeIds starts empty', () => {
expect(useCanvasStore.getState().selectedNodeIds).toEqual([])
})
it('onNodesChange syncs selectedNodeIds from select changes', () => {
useCanvasStore.getState().addNode(makeNode('n1'))
useCanvasStore.getState().addNode(makeNode('n2'))
useCanvasStore.getState().onNodesChange([
{ type: 'select', id: 'n1', selected: true },
{ type: 'select', id: 'n2', selected: true },
])
expect(useCanvasStore.getState().selectedNodeIds).toEqual(expect.arrayContaining(['n1', 'n2']))
expect(useCanvasStore.getState().selectedNodeIds).toHaveLength(2)
})
it('setSelectedNode(null) resets selectedNodeIds to empty', () => {
useCanvasStore.setState({ selectedNodeIds: ['n1', 'n2'] })
useCanvasStore.getState().setSelectedNode(null)
expect(useCanvasStore.getState().selectedNodeIds).toEqual([])
})
it('setSelectedNode(id) preserves existing selectedNodeIds', () => {
useCanvasStore.setState({ selectedNodeIds: ['n1', 'n2'] })
useCanvasStore.getState().setSelectedNode('n1')
// does NOT wipe selectedNodeIds when setting a specific id
expect(useCanvasStore.getState().selectedNodeIds).toEqual(['n1', 'n2'])
})
// ── createGroup ───────────────────────────────────────────────────────────
it('createGroup creates a group node at the bounding box of selected nodes', () => {
// n1 at (100,100), n2 at (300,200); both default to 200x80
const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 }, width: 200, height: 80 }
const n2 = { ...makeNode('n2'), position: { x: 300, y: 200 }, width: 200, height: 80 }
useCanvasStore.setState({ nodes: [n1, n2] })
useCanvasStore.getState().createGroup(['n1', 'n2'], 'My Group')
const { nodes } = useCanvasStore.getState()
const group = nodes.find((n) => n.data.type === 'group')
expect(group).toBeDefined()
expect(group?.data.label).toBe('My Group')
// groupX = 100-24=76, groupY = 100-48=52
expect(group?.position.x).toBe(76)
expect(group?.position.y).toBe(52)
// groupW = (500-100)+48=448, groupH = (280-100)+48+24=252
expect(group?.width).toBe(448)
expect(group?.height).toBe(252)
})
it('createGroup converts children to relative positions', () => {
const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 }, width: 200, height: 80 }
const n2 = { ...makeNode('n2'), position: { x: 300, y: 200 }, width: 200, height: 80 }
useCanvasStore.setState({ nodes: [n1, n2] })
useCanvasStore.getState().createGroup(['n1', 'n2'], 'G')
const { nodes } = useCanvasStore.getState()
const c1 = nodes.find((n) => n.id === 'n1')
const c2 = nodes.find((n) => n.id === 'n2')
// groupX=76, groupY=52 → relative: n1=(24,48), n2=(224,148)
expect(c1?.position).toEqual({ x: 24, y: 48 })
expect(c2?.position).toEqual({ x: 224, y: 148 })
})
it('createGroup sets parentId and extent on children', () => {
const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 } }
const n2 = { ...makeNode('n2'), position: { x: 200, y: 100 } }
useCanvasStore.setState({ nodes: [n1, n2] })
useCanvasStore.getState().createGroup(['n1', 'n2'], 'G')
const { nodes } = useCanvasStore.getState()
const group = nodes.find((n) => n.data.type === 'group')!
const c1 = nodes.find((n) => n.id === 'n1')
const c2 = nodes.find((n) => n.id === 'n2')
expect(c1?.parentId).toBe(group.id)
expect(c1?.extent).toBe('parent')
expect(c2?.parentId).toBe(group.id)
})
it('createGroup places the group node before its children in the array', () => {
const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 } }
const n2 = { ...makeNode('n2'), position: { x: 200, y: 100 } }
useCanvasStore.setState({ nodes: [n1, n2] })
useCanvasStore.getState().createGroup(['n1', 'n2'], 'G')
const { nodes } = useCanvasStore.getState()
const groupIdx = nodes.findIndex((n) => n.data.type === 'group')
const c1Idx = nodes.findIndex((n) => n.id === 'n1')
const c2Idx = nodes.findIndex((n) => n.id === 'n2')
expect(groupIdx).toBeLessThan(c1Idx)
expect(groupIdx).toBeLessThan(c2Idx)
})
it('createGroup snapshots history and marks unsaved', () => {
const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 } }
useCanvasStore.setState({ nodes: [n1] })
useCanvasStore.getState().markSaved()
useCanvasStore.getState().createGroup(['n1'], 'G')
expect(useCanvasStore.getState().past).toHaveLength(1)
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
it('createGroup clears selection', () => {
const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 } }
useCanvasStore.setState({ nodes: [n1], selectedNodeId: 'n1', selectedNodeIds: ['n1'] })
useCanvasStore.getState().createGroup(['n1'], 'G')
expect(useCanvasStore.getState().selectedNodeId).toBeNull()
expect(useCanvasStore.getState().selectedNodeIds).toEqual([])
})
// ── ungroup ───────────────────────────────────────────────────────────────
it('ungroup restores children to absolute positions', () => {
const group = {
...makeNode('g1', { type: 'group', label: 'G' }),
position: { x: 76, y: 52 },
}
const c1 = { ...makeNode('n1'), position: { x: 24, y: 48 }, parentId: 'g1', extent: 'parent' as const }
const c2 = { ...makeNode('n2'), position: { x: 224, y: 148 }, parentId: 'g1', extent: 'parent' as const }
useCanvasStore.setState({ nodes: [group, c1, c2] })
useCanvasStore.getState().ungroup('g1')
const { nodes } = useCanvasStore.getState()
const r1 = nodes.find((n) => n.id === 'n1')
const r2 = nodes.find((n) => n.id === 'n2')
expect(r1?.position).toEqual({ x: 100, y: 100 })
expect(r2?.position).toEqual({ x: 300, y: 200 })
})
it('ungroup removes parentId and extent from children', () => {
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
const child = { ...makeNode('n1'), position: { x: 50, y: 50 }, parentId: 'g1', extent: 'parent' as const }
useCanvasStore.setState({ nodes: [group, child] })
useCanvasStore.getState().ungroup('g1')
const { nodes } = useCanvasStore.getState()
const released = nodes.find((n) => n.id === 'n1')
expect(released?.parentId).toBeUndefined()
expect(released?.extent).toBeUndefined()
})
it('ungroup deletes the group node', () => {
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
useCanvasStore.setState({ nodes: [group] })
useCanvasStore.getState().ungroup('g1')
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'g1')).toBeUndefined()
})
it('ungroup snapshots history and marks unsaved', () => {
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
useCanvasStore.setState({ nodes: [group] })
useCanvasStore.getState().markSaved()
useCanvasStore.getState().ungroup('g1')
expect(useCanvasStore.getState().past).toHaveLength(1)
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
it('updateEdge updates edge data and marks unsaved', () => {
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] }))
useCanvasStore.getState().markSaved()
+123 -5
View File
@@ -19,6 +19,7 @@ interface CanvasState {
edges: Edge<EdgeData>[]
hasUnsavedChanges: boolean
selectedNodeId: string | null
selectedNodeIds: string[]
scanEventTs: number
// History
@@ -46,6 +47,8 @@ interface CanvasState {
setNodeZIndex: (id: string, zIndex: number) => void
editingGroupRectId: string | null
setEditingGroupRectId: (id: string | null) => void
createGroup: (nodeIds: string[], name: string) => void
ungroup: (groupId: string) => void
markSaved: () => void
markUnsaved: () => void
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
@@ -59,6 +62,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
edges: [],
hasUnsavedChanges: false,
selectedNodeId: null,
selectedNodeIds: [],
editingGroupRectId: null,
hideIp: false,
scanEventTs: 0,
@@ -125,10 +129,15 @@ export const useCanvasStore = create<CanvasState>((set) => ({
}),
onNodesChange: (changes) =>
set((state) => ({
nodes: applyNodeChanges(changes, state.nodes),
hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'),
})),
set((state) => {
const nodes = applyNodeChanges(changes, state.nodes)
const selectedNodeIds = nodes.filter((n) => n.selected).map((n) => n.id)
return {
nodes,
selectedNodeIds,
hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'),
}
}),
onEdgesChange: (changes) =>
set((state) => ({
@@ -156,7 +165,10 @@ export const useCanvasStore = create<CanvasState>((set) => ({
}
}),
setSelectedNode: (id) => set({ selectedNodeId: id }),
setSelectedNode: (id) => set((state) => ({
selectedNodeId: id,
selectedNodeIds: id ? state.selectedNodeIds : [],
})),
addNode: (node) =>
set((state) => {
@@ -244,6 +256,112 @@ export const useCanvasStore = create<CanvasState>((set) => ({
setEditingGroupRectId: (id) => set({ editingGroupRectId: id }),
createGroup: (nodeIds, name) =>
set((state) => {
const PADDING_H = 24
const PADDING_TOP = 48
const PADDING_BOTTOM = 24
const targets = state.nodes.filter((n) => nodeIds.includes(n.id))
if (targets.length === 0) return state
// Bounding box in absolute coordinates
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
for (const n of targets) {
const w = n.width ?? 200
const h = n.height ?? 80
minX = Math.min(minX, n.position.x)
minY = Math.min(minY, n.position.y)
maxX = Math.max(maxX, n.position.x + w)
maxY = Math.max(maxY, n.position.y + h)
}
const groupX = minX - PADDING_H
const groupY = minY - PADDING_TOP
const groupW = maxX - minX + PADDING_H * 2
const groupH = maxY - minY + PADDING_TOP + PADDING_BOTTOM
const groupId = generateUUID()
const groupNode: Node<NodeData> = {
id: groupId,
type: 'group',
position: { x: groupX, y: groupY },
width: groupW,
height: groupH,
data: {
label: name,
type: 'group',
status: 'unknown',
services: [],
custom_colors: { show_border: true },
},
selected: false,
}
// Convert children to relative positions and assign parentId
const updatedNodes = state.nodes.map((n) => {
if (!nodeIds.includes(n.id)) return n
return {
...n,
parentId: groupId,
extent: 'parent' as const,
position: {
x: n.position.x - groupX,
y: n.position.y - groupY,
},
selected: false,
data: { ...n.data, parent_id: groupId },
}
})
// Group node must come before its children
const withoutTargets = updatedNodes.filter((n) => !nodeIds.includes(n.id))
const children = updatedNodes.filter((n) => nodeIds.includes(n.id))
const nodes = [...withoutTargets, groupNode, ...children]
return {
nodes,
selectedNodeIds: [],
selectedNodeId: null,
hasUnsavedChanges: true,
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
future: [],
}
}),
ungroup: (groupId) =>
set((state) => {
const group = state.nodes.find((n) => n.id === groupId)
if (!group) return state
const groupAbsX = group.position.x
const groupAbsY = group.position.y
const nodes = state.nodes
.filter((n) => n.id !== groupId)
.map((n) => {
if (n.parentId !== groupId) return n
return {
...n,
parentId: undefined,
extent: undefined,
position: {
x: n.position.x + groupAbsX,
y: n.position.y + groupAbsY,
},
data: { ...n.data, parent_id: undefined },
}
})
return {
nodes,
selectedNodeId: null,
selectedNodeIds: [],
hasUnsavedChanges: true,
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
future: [],
}
}),
markSaved: () => set({ hasUnsavedChanges: false }),
markUnsaved: () => set({ hasUnsavedChanges: true }),
+3
View File
@@ -16,6 +16,7 @@ export type NodeType =
| 'docker'
| 'generic'
| 'groupRect'
| 'group'
export type TextPosition =
| 'top-left'
@@ -76,6 +77,7 @@ export interface NodeData extends Record<string, unknown> {
label_position?: 'inside' | 'outside'
text_size?: number
z_order?: number
show_border?: boolean
width?: number
height?: number
}
@@ -112,6 +114,7 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
docker: 'Docker Host',
generic: 'Generic Device',
groupRect: 'Group Rectangle',
group: 'Node Group',
}
export const STATUS_COLORS: Record<NodeStatus, string> = {
+2 -1
View File
@@ -63,7 +63,7 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
check_target: null,
services: [],
notes: null,
parent_id: null,
parent_id: n.data.parent_id ?? null,
container_mode: false,
custom_icon: null,
pos_x: n.position.x,
@@ -142,6 +142,7 @@ export function deserializeApiNode(
width: w,
height: h,
zIndex: z - 10,
...(n.parent_id ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
}
}
const parentIsContainer = n.parent_id ? (proxmoxContainerMap.get(n.parent_id) ?? false) : false
+5
View File
@@ -59,6 +59,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
docker: { border: '#2496ED', icon: '#2496ED' },
generic: { border: '#8b949e', icon: '#8b949e' },
groupRect:{ border: '#00d4ff', icon: '#00d4ff' },
group: { border: '#00d4ff', icon: '#00d4ff' },
},
nodeCardBackground: '#21262d',
nodeIconBackground: '#161b22',
@@ -113,6 +114,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
docker: { border: '#2496ED', icon: '#2496ED' },
generic: { border: '#94a3b8', icon: '#94a3b8' },
groupRect:{ border: '#22d3ee', icon: '#22d3ee' },
group: { border: '#22d3ee', icon: '#22d3ee' },
},
nodeCardBackground: '#0a0a0a',
nodeIconBackground: '#111111',
@@ -167,6 +169,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
docker: { border: '#2496ED', icon: '#2496ED' },
generic: { border: '#6b7280', icon: '#6b7280' },
groupRect:{ border: '#0284c7', icon: '#0284c7' },
group: { border: '#0284c7', icon: '#0284c7' },
},
nodeCardBackground: '#ffffff',
nodeIconBackground: '#f0f6ff',
@@ -221,6 +224,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
docker: { border: '#00aaff', icon: '#00aaff' },
generic: { border: '#8888ff', icon: '#8888ff' },
groupRect:{ border: '#00ffff', icon: '#00ffff' },
group: { border: '#00ffff', icon: '#00ffff' },
},
nodeCardBackground: '#0f0f2a',
nodeIconBackground: '#0a0a1a',
@@ -275,6 +279,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
docker: { border: '#00cc88', icon: '#00cc88' },
generic: { border: '#006600', icon: '#006600' },
groupRect:{ border: '#00ff41', icon: '#00ff41' },
group: { border: '#00ff41', icon: '#00ff41' },
},
nodeCardBackground: '#001100',
nodeIconBackground: '#002200',