Compare commits

..

1 Commits

Author SHA1 Message Date
Pouzor 701c9c5bb9 fix: force frontend builder stage to native platform, fixes QEMU arm64 npm crash 2026-03-28 14:23:21 +01:00
62 changed files with 488 additions and 3605 deletions
-6
View File
@@ -1,7 +1,6 @@
# Backend - server-side only (NEVER commit .env) # Backend - server-side only (NEVER commit .env)
SECRET_KEY=change_me_in_production SECRET_KEY=change_me_in_production
SQLITE_PATH=./data/homelab.db SQLITE_PATH=./data/homelab.db
# Set this to the URL(s) you use to access Homelable in your browser.
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"] CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
# Auth — default credentials: admin / admin # Auth — default credentials: admin / admin
@@ -23,8 +22,3 @@ STATUS_CHECKER_INTERVAL=60
# Generate keys: python3 -c "import secrets; print(secrets.token_hex(32))" # Generate keys: python3 -c "import secrets; print(secrets.token_hex(32))"
MCP_API_KEY=mcp_sk_changeme MCP_API_KEY=mcp_sk_changeme
MCP_SERVICE_KEY=svc_changeme MCP_SERVICE_KEY=svc_changeme
# Live view — read-only public canvas at /view?key=<value>
# Off by default. Set to a random secret to enable.
# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
# LIVEVIEW_KEY=
+1 -2
View File
@@ -1,8 +1,7 @@
# Stage 1: build # Stage 1: build
# Use the native build platform so npm ci never runs under QEMU emulation. # Use the native build platform so npm ci never runs under QEMU emulation.
# The build output (static HTML/JS/CSS) is platform-independent. # The build output (static HTML/JS/CSS) is platform-independent.
# node:20-slim (Debian/glibc) avoids lightningcss musl binary resolution issues on Alpine. FROM --platform=$BUILDPLATFORM node:20-alpine AS builder
FROM --platform=$BUILDPLATFORM node:20-slim AS builder
ARG VITE_STANDALONE=false ARG VITE_STANDALONE=false
ENV VITE_STANDALONE=$VITE_STANDALONE ENV VITE_STANDALONE=$VITE_STANDALONE
+3 -12
View File
@@ -11,18 +11,9 @@ Open **http://localhost:3000** — login with `admin` / `admin`.
> Change the password before exposing to a network: edit `.env` and update `AUTH_USERNAME` / `AUTH_PASSWORD_HASH`. > Change the password before exposing to a network: edit `.env` and update `AUTH_USERNAME` / `AUTH_PASSWORD_HASH`.
> >
Generate a new hash: > Generate a new hash: `docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"`
```bash >
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.
```
⚠️ **bcrypt hashes contain `$` characters** — how to handle them depends on where you set the value:
- **`.env` file** (recommended): wrap the hash in single quotes → `AUTH_PASSWORD_HASH='$2b$12$...'`
- **`docker-compose.yml` `environment:` block**: escape every `$` as `$$` — use this command to generate a pre-escaped hash:
```bash
docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword').replace('\$', '\$\$'))"
```
## Quick Start — Frontend only ## Quick Start — Frontend only
+2 -27
View File
@@ -16,8 +16,8 @@ If you just like the design, you can only run the frontend and export your desig
<p align="center"> <p align="center">
<img src="docs/homelable1.png" alt="Homelable canvas overview" width="100%" /> <img src="docs/homelable1.png" alt="Homelable canvas overview" width="100%" />
<img src="docs/homelable2.png" alt="Homelable node detail" width="100%" /> <img src="docs/homelable2.png" alt="Homelable node detail" width="100%" />
<img src="docs/homelable3.png" alt="Homelable sidebar and scan" width="48%" /> <img src="docs/homelable3.png" alt="Homelable sidebar and scan" width="40%" />
<img src="docs/homelable4.png" alt="Homelable edit pannel" width="48%" /> <img src="docs/homelable4.png" alt="Homelable edit pannel" width="40%" />
</p> </p>
--- ---
@@ -74,31 +74,6 @@ Homelable continuously monitors your nodes and displays their live status (onlin
--- ---
## Live View (read-only public canvas)
Live View lets you share a read-only snapshot of your canvas with anyone on your network — no login required. It is disabled by default.
### Activation
Add LIVEVIEW_KEY to your .env:
`LIVEVIEW_KEY=your-secret-key`
Then restart the backend:
`docker compose restart backend`
### Usage
Use this URL to view your canvas:
http://<your-homelab-ip>/view?key=your-secret-key
The page shows your canvas in pan/zoom-only mode — no editing, no credentials needed. Clicking a node that has an IP opens it in a new tab.
---
## MCP Server (AI Integration) (optionnal) ## MCP Server (AI Integration) (optionnal)
Homelable can exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so any MCP-compatible AI client (Claude Code, Claude Desktop, Open WebUI…) can read your homelab topology and act on it. Homelable can exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so any MCP-compatible AI client (Claude Code, Claude Desktop, Open WebUI…) can read your homelab topology and act on it.
-41
View File
@@ -1,41 +0,0 @@
import hmac
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.db.database import get_db
from app.db.models import CanvasState, Edge, Node
from app.schemas.canvas import CanvasStateResponse
from app.schemas.edges import EdgeResponse
from app.schemas.nodes import NodeResponse
router = APIRouter()
@router.get("", response_model=CanvasStateResponse)
async def liveview_canvas(
key: str | None = Query(default=None),
db: AsyncSession = Depends(get_db),
) -> CanvasStateResponse:
"""Read-only public canvas endpoint.
Disabled by default — requires LIVEVIEW_KEY to be set in .env.
Always returns 403 when disabled, regardless of the key provided.
"""
if not settings.liveview_key:
raise HTTPException(status_code=403, detail="Live view is disabled")
if not key or not hmac.compare_digest(key, settings.liveview_key):
raise HTTPException(status_code=403, detail="Invalid live view key")
nodes = (await db.execute(select(Node))).scalars().all()
edges = (await db.execute(select(Edge))).scalars().all()
state = await db.get(CanvasState, 1)
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
return CanvasStateResponse(
nodes=[NodeResponse.model_validate(n) for n in nodes],
edges=[EdgeResponse.model_validate(e) for e in edges],
viewport=viewport,
)
+7 -17
View File
@@ -12,11 +12,12 @@ from app.db.database import AsyncSessionLocal, get_db
from app.db.models import Node, PendingDevice, ScanRun from app.db.models import Node, PendingDevice, ScanRun
from app.schemas.nodes import NodeCreate from app.schemas.nodes import NodeCreate
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
from app.services.scanner import request_cancel, run_scan from app.services.scanner import run_scan
class ScanConfig(BaseModel): class ScanConfig(BaseModel):
ranges: list[str] ranges: list[str]
interval_seconds: int
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -43,21 +44,6 @@ async def trigger_scan(
return run return run
@router.post("/{run_id}/stop", response_model=dict)
async def stop_scan(
run_id: str,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, bool]:
run = await db.get(ScanRun, run_id)
if not run:
raise HTTPException(status_code=404, detail="Scan run not found")
if run.status != "running":
raise HTTPException(status_code=409, detail="Scan is not running")
request_cancel(run_id)
return {"stopping": True}
@router.get("/pending", response_model=list[PendingDeviceResponse]) @router.get("/pending", response_model=list[PendingDeviceResponse])
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]: async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending")) result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending"))
@@ -117,13 +103,17 @@ async def list_runs(db: AsyncSession = Depends(get_db), _: str = Depends(get_cur
@router.get("/config", response_model=ScanConfig) @router.get("/config", response_model=ScanConfig)
async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig: async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
return ScanConfig(ranges=settings.scanner_ranges) return ScanConfig(
ranges=settings.scanner_ranges,
interval_seconds=settings.status_checker_interval,
)
@router.post("/config", response_model=ScanConfig) @router.post("/config", response_model=ScanConfig)
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig: async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig:
try: try:
settings.scanner_ranges = payload.ranges settings.scanner_ranges = payload.ranges
settings.status_checker_interval = payload.interval_seconds
settings.save_overrides() settings.save_overrides()
return payload return payload
except Exception as exc: except Exception as exc:
-29
View File
@@ -1,29 +0,0 @@
"""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
-20
View File
@@ -1,12 +1,8 @@
import json import json
import logging
from pathlib import Path from pathlib import Path
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
logger = logging.getLogger(__name__)
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
@@ -23,17 +19,6 @@ class Settings(BaseSettings):
auth_username: str = "admin" auth_username: str = "admin"
auth_password_hash: str = "" auth_password_hash: str = ""
@model_validator(mode="after")
def check_password_hash(self) -> "Settings":
h = self.auth_password_hash
if h and not h.startswith("$2"):
logger.error(
"AUTH_PASSWORD_HASH looks invalid (does not start with '$2b$'). "
"bcrypt hashes contain '$' signs — wrap the value in single quotes "
"in your .env file: AUTH_PASSWORD_HASH='$2b$12$...'"
)
return self
# Scanner # Scanner
scanner_ranges: list[str] = ["192.168.1.0/24"] scanner_ranges: list[str] = ["192.168.1.0/24"]
@@ -45,11 +30,6 @@ class Settings(BaseSettings):
# Leave empty to disable MCP service key auth. # Leave empty to disable MCP service key auth.
mcp_service_key: str = "" mcp_service_key: str = ""
# Live view — optional read-only public canvas endpoint.
# Set to a random secret string to enable /api/v1/liveview?key=<value>.
# Leave unset (or empty) to keep the feature disabled (default).
liveview_key: str | None = None
def _override_path(self) -> Path: def _override_path(self) -> Path:
return Path(self.sqlite_path).parent / "scan_config.json" return Path(self.sqlite_path).parent / "scan_config.json"
+1 -13
View File
@@ -47,23 +47,11 @@ async def _run_status_checks() -> None:
def start_scheduler() -> None: def start_scheduler() -> None:
global scheduler global scheduler
if scheduler.running:
scheduler.shutdown(wait=False)
scheduler = AsyncIOScheduler() scheduler = AsyncIOScheduler()
scheduler.add_job(_run_status_checks, "interval", seconds=settings.status_checker_interval, id="status_checks") scheduler.add_job(_run_status_checks, "interval", seconds=settings.status_checker_interval, id="status_checks")
scheduler.start() scheduler.start()
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval) logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
def reschedule_status_checks(interval_seconds: int) -> None:
"""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: def stop_scheduler() -> None:
if scheduler.running: scheduler.shutdown(wait=False)
scheduler.shutdown(wait=False)
+1 -4
View File
@@ -9,10 +9,7 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain: str, hashed: str) -> bool: def verify_password(plain: str, hashed: str) -> bool:
try: return bool(pwd_context.verify(plain, hashed))
return bool(pwd_context.verify(plain, hashed))
except ValueError:
return False
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
+15 -22
View File
@@ -2,7 +2,6 @@ from collections.abc import AsyncGenerator
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
from sqlalchemy.exc import OperationalError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
@@ -27,42 +26,36 @@ async def init_db() -> None:
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
# Add columns introduced after initial schema (idempotent) # Add columns introduced after initial schema (idempotent)
with suppress(OperationalError): with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN container_mode BOOLEAN NOT NULL DEFAULT 0") await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN container_mode BOOLEAN NOT NULL DEFAULT 0")
with suppress(OperationalError): with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN custom_colors JSON") await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN custom_colors JSON")
with suppress(OperationalError): with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN custom_color TEXT") await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN custom_color TEXT")
with suppress(OperationalError): with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN path_style TEXT") await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN path_style TEXT")
with suppress(OperationalError): with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN custom_icon TEXT") await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN custom_icon TEXT")
with suppress(OperationalError): with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN source_handle TEXT") await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN source_handle TEXT")
with suppress(OperationalError): with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT") await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT")
with suppress(OperationalError): with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0") await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0")
with suppress(OperationalError): with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_count INTEGER") await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_count INTEGER")
with suppress(OperationalError): with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_model TEXT") await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_model TEXT")
with suppress(OperationalError): with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN ram_gb REAL") await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN ram_gb REAL")
with suppress(OperationalError): with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN disk_gb REAL") await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN disk_gb REAL")
with suppress(OperationalError): with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_hardware BOOLEAN NOT NULL DEFAULT 0") await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_hardware BOOLEAN NOT NULL DEFAULT 0")
with suppress(OperationalError): with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN width REAL") await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN width REAL")
with suppress(OperationalError): with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN height REAL") await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN height REAL")
# Migrate animated column from boolean (0/1) to string ('none'/'snake')
with suppress(OperationalError):
await conn.exec_driver_sql("UPDATE edges SET animated = 'snake' WHERE animated = '1' OR animated = 1")
with suppress(OperationalError):
sql = "UPDATE edges SET animated = 'none' WHERE animated = '0' OR animated = 0 OR animated IS NULL"
await conn.exec_driver_sql(sql)
async def get_db() -> AsyncGenerator[AsyncSession, None]: async def get_db() -> AsyncGenerator[AsyncSession, None]:
+2 -2
View File
@@ -33,7 +33,7 @@ class Node(Base):
notes: Mapped[str | None] = mapped_column(Text) notes: Mapped[str | None] = mapped_column(Text)
pos_x: Mapped[float] = mapped_column(Float, default=0) pos_x: Mapped[float] = mapped_column(Float, default=0)
pos_y: Mapped[float] = mapped_column(Float, default=0) pos_y: Mapped[float] = mapped_column(Float, default=0)
parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE")) parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id"))
container_mode: Mapped[bool] = mapped_column(Boolean, default=False) container_mode: Mapped[bool] = mapped_column(Boolean, default=False)
custom_colors: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) custom_colors: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
custom_icon: Mapped[str | None] = mapped_column(String, nullable=True) custom_icon: Mapped[str | None] = mapped_column(String, nullable=True)
@@ -65,7 +65,7 @@ class Edge(Base):
speed: Mapped[str | None] = mapped_column(String) speed: Mapped[str | None] = mapped_column(String)
custom_color: Mapped[str | None] = mapped_column(String) custom_color: Mapped[str | None] = mapped_column(String)
path_style: Mapped[str | None] = mapped_column(String) path_style: Mapped[str | None] = mapped_column(String)
animated: Mapped[str] = mapped_column(String, nullable=False, default='none') animated: Mapped[bool] = mapped_column(Boolean, default=False)
source_handle: Mapped[str | None] = mapped_column(String) source_handle: Mapped[str | None] = mapped_column(String)
target_handle: Mapped[str | None] = mapped_column(String) target_handle: Mapped[str | None] = mapped_column(String)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+2 -5
View File
@@ -5,8 +5,7 @@ from typing import Any
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import auth, canvas, edges, liveview, nodes, scan, status from app.api.routes import auth, canvas, edges, nodes, scan, status
from app.api.routes import settings as settings_routes
from app.core.config import settings from app.core.config import settings
from app.core.scheduler import start_scheduler, stop_scheduler from app.core.scheduler import start_scheduler, stop_scheduler
from app.db.database import init_db from app.db.database import init_db
@@ -23,7 +22,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
app = FastAPI( app = FastAPI(
title="Homelable API", title="Homelable API",
version="1.4.0", version="1.3.3",
lifespan=lifespan, lifespan=lifespan,
) )
@@ -41,8 +40,6 @@ 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(canvas.router, prefix="/api/v1/canvas", tags=["canvas"])
app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"]) 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(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"])
@app.get("/api/v1/health") @app.get("/api/v1/health")
+2 -8
View File
@@ -1,10 +1,9 @@
from typing import Any from typing import Any
from pydantic import BaseModel, field_validator from pydantic import BaseModel
from app.schemas.edges import EdgeResponse from app.schemas.edges import EdgeResponse
from app.schemas.nodes import NodeResponse from app.schemas.nodes import NodeResponse
from app.schemas.utils import normalize_animated
class NodeSave(BaseModel): class NodeSave(BaseModel):
@@ -45,15 +44,10 @@ class EdgeSave(BaseModel):
speed: str | None = None speed: str | None = None
custom_color: str | None = None custom_color: str | None = None
path_style: str | None = None path_style: str | None = None
animated: str = 'none' animated: bool = False
source_handle: str | None = None source_handle: str | None = None
target_handle: str | None = None target_handle: str | None = None
@field_validator('animated', mode='before')
@classmethod
def validate_animated(cls, v: object) -> str:
return normalize_animated(v)
class CanvasSaveRequest(BaseModel): class CanvasSaveRequest(BaseModel):
nodes: list[NodeSave] = [] nodes: list[NodeSave] = []
+3 -17
View File
@@ -1,8 +1,6 @@
from datetime import datetime from datetime import datetime
from pydantic import BaseModel, field_validator from pydantic import BaseModel
from app.schemas.utils import normalize_animated
class EdgeBase(BaseModel): class EdgeBase(BaseModel):
@@ -14,15 +12,10 @@ class EdgeBase(BaseModel):
speed: str | None = None speed: str | None = None
custom_color: str | None = None custom_color: str | None = None
path_style: str | None = None path_style: str | None = None
animated: str = 'none' animated: bool = False
source_handle: str | None = None source_handle: str | None = None
target_handle: str | None = None target_handle: str | None = None
@field_validator('animated', mode='before')
@classmethod
def validate_animated(cls, v: object) -> str:
return normalize_animated(v)
class EdgeCreate(EdgeBase): class EdgeCreate(EdgeBase):
pass pass
@@ -35,17 +28,10 @@ class EdgeUpdate(BaseModel):
speed: str | None = None speed: str | None = None
custom_color: str | None = None custom_color: str | None = None
path_style: str | None = None path_style: str | None = None
animated: str | None = None animated: bool | None = None
source_handle: str | None = None source_handle: str | None = None
target_handle: str | None = None target_handle: str | None = None
@field_validator('animated', mode='before')
@classmethod
def validate_animated(cls, v: object) -> str | None:
if v is None:
return None
return normalize_animated(v)
class EdgeResponse(EdgeBase): class EdgeResponse(EdgeBase):
id: str id: str
-9
View File
@@ -1,9 +0,0 @@
def normalize_animated(v: object) -> str:
"""Normalize legacy bool/int animated values to string mode ('none'/'snake'/'flow')."""
if v is True or v == 1 or v == '1':
return 'snake'
if v is False or v == 0 or v == '0' or v is None or v == 'none':
return 'none'
if v in ('snake', 'flow'):
return str(v)
return 'none'
+5 -61
View File
@@ -7,24 +7,11 @@ from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Node, PendingDevice, ScanRun from app.db.models import PendingDevice, ScanRun
from app.services.fingerprint import fingerprint_ports, suggest_node_type from app.services.fingerprint import fingerprint_ports, suggest_node_type
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Run IDs that have been requested to cancel
_cancelled_runs: set[str] = set()
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: try:
import nmap import nmap
_NMAP_AVAILABLE = True _NMAP_AVAILABLE = True
@@ -120,59 +107,18 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
devices_found = 0 devices_found = 0
try: 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: for cidr in ranges:
if _is_cancelled(run_id):
break
# Run nmap in a thread pool — does not block the event loop # Run nmap in a thread pool — does not block the event loop
hosts = await asyncio.to_thread(_nmap_scan, cidr) hosts = await asyncio.to_thread(_nmap_scan, cidr)
for host in hosts: 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"]) services = fingerprint_ports(host["open_ports"])
suggested_type = suggest_node_type(host["open_ports"], host.get("mac")) suggested_type = suggest_node_type(host["open_ports"], host.get("mac"))
# Update existing pending device or create a new one # Update existing pending device or create a new one
existing_result = await db.execute( existing_result = await db.execute(
select(PendingDevice).where( select(PendingDevice).where(
PendingDevice.ip == ip, PendingDevice.ip == host["ip"],
PendingDevice.status == "pending", PendingDevice.status == "pending",
) )
) )
@@ -185,7 +131,7 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
existing.suggested_type = suggested_type existing.suggested_type = suggested_type
else: else:
device = PendingDevice( device = PendingDevice(
ip=ip, ip=host["ip"],
mac=host.get("mac"), mac=host.get("mac"),
hostname=host.get("hostname"), hostname=host.get("hostname"),
os=host.get("os"), os=host.get("os"),
@@ -208,10 +154,10 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
# Push WS event so the frontend refreshes pending panel # Push WS event so the frontend refreshes pending panel
await broadcast_scan_update(run_id=run_id, devices_found=devices_found) await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
# Mark scan as done or cancelled # Mark scan as done
run = await db.get(ScanRun, run_id) run = await db.get(ScanRun, run_id)
if run: if run:
run.status = "cancelled" if _is_cancelled(run_id) else "done" run.status = "done"
run.devices_found = devices_found run.devices_found = devices_found
run.finished_at = datetime.now(timezone.utc) run.finished_at = datetime.now(timezone.utc)
await db.commit() await db.commit()
@@ -224,5 +170,3 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
run.error = str(exc) run.error = str(exc)
run.finished_at = datetime.now(timezone.utc) run.finished_at = datetime.now(timezone.utc)
await db.commit() await db.commit()
finally:
_cancelled_runs.discard(run_id)
+146
View File
@@ -0,0 +1,146 @@
[
{"port": 8006, "protocol": "tcp", "banner_regex": null, "service_name": "Proxmox VE", "icon": "layers", "category": "hypervisor", "suggested_node_type": "proxmox"},
{"port": 5000, "protocol": "tcp", "banner_regex": "synology|DSM", "service_name": "Synology DSM", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
{"port": 5001, "protocol": "tcp", "banner_regex": null, "service_name": "Synology DSM HTTPS", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
{"port": 5006, "protocol": "tcp", "banner_regex": null, "service_name": "Synology DSM Mobile", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
{"port": 8080, "protocol": "tcp", "banner_regex": "QNAP|qnap|QTS", "service_name": "QNAP NAS", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
{"port": 5005, "protocol": "tcp", "banner_regex": null, "service_name": "TrueNAS", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
{"port": 445, "protocol": "tcp", "banner_regex": null, "service_name": "SMB / CIFS", "icon": "share-2", "category": "storage", "suggested_node_type": "nas"},
{"port": 2049, "protocol": "tcp", "banner_regex": null, "service_name": "NFS", "icon": "share-2", "category": "storage", "suggested_node_type": "nas"},
{"port": 548, "protocol": "tcp", "banner_regex": null, "service_name": "AFP (Apple Filing)", "icon": "share-2", "category": "storage", "suggested_node_type": "nas"},
{"port": 873, "protocol": "tcp", "banner_regex": null, "service_name": "rsync", "icon": "refresh-cw", "category": "storage", "suggested_node_type": "nas"},
{"port": 32400, "protocol": "tcp", "banner_regex": null, "service_name": "Plex Media Server", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
{"port": 32469, "protocol": "tcp", "banner_regex": null, "service_name": "Plex DLNA", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
{"port": 8096, "protocol": "tcp", "banner_regex": "Jellyfin", "service_name": "Jellyfin", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
{"port": 8096, "protocol": "tcp", "banner_regex": "Emby", "service_name": "Emby", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
{"port": 8096, "protocol": "tcp", "banner_regex": null, "service_name": "Jellyfin / Emby", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
{"port": 8920, "protocol": "tcp", "banner_regex": null, "service_name": "Jellyfin HTTPS", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
{"port": 8181, "protocol": "tcp", "banner_regex": null, "service_name": "Tautulli", "icon": "bar-chart", "category": "media", "suggested_node_type": "server"},
{"port": 8013, "protocol": "tcp", "banner_regex": null, "service_name": "Komga", "icon": "book-open", "category": "media", "suggested_node_type": "server"},
{"port": 1935, "protocol": "tcp", "banner_regex": null, "service_name": "RTMP (Stream)", "icon": "video", "category": "media", "suggested_node_type": "server"},
{"port": 8989, "protocol": "tcp", "banner_regex": null, "service_name": "Sonarr", "icon": "tv", "category": "media", "suggested_node_type": "server"},
{"port": 7878, "protocol": "tcp", "banner_regex": null, "service_name": "Radarr", "icon": "film", "category": "media", "suggested_node_type": "server"},
{"port": 8686, "protocol": "tcp", "banner_regex": null, "service_name": "Lidarr", "icon": "music", "category": "media", "suggested_node_type": "server"},
{"port": 9696, "protocol": "tcp", "banner_regex": null, "service_name": "Prowlarr", "icon": "search", "category": "media", "suggested_node_type": "server"},
{"port": 8787, "protocol": "tcp", "banner_regex": null, "service_name": "Readarr", "icon": "book", "category": "media", "suggested_node_type": "server"},
{"port": 6767, "protocol": "tcp", "banner_regex": null, "service_name": "Bazarr", "icon": "subtitles", "category": "media", "suggested_node_type": "server"},
{"port": 5055, "protocol": "tcp", "banner_regex": null, "service_name": "Overseerr / Jellyseerr", "icon": "search", "category": "media", "suggested_node_type": "server"},
{"port": 9117, "protocol": "tcp", "banner_regex": null, "service_name": "Jackett", "icon": "search", "category": "media", "suggested_node_type": "server"},
{"port": 6969, "protocol": "tcp", "banner_regex": null, "service_name": "Whisparr", "icon": "film", "category": "media", "suggested_node_type": "server"},
{"port": 5454, "protocol": "tcp", "banner_regex": null, "service_name": "Notifiarr", "icon": "bell", "category": "media", "suggested_node_type": "server"},
{"port": 8191, "protocol": "tcp", "banner_regex": null, "service_name": "FlareSolverr", "icon": "shield", "category": "network", "suggested_node_type": "server"},
{"port": 9091, "protocol": "tcp", "banner_regex": "Transmission", "service_name": "Transmission", "icon": "download", "category": "download", "suggested_node_type": "server"},
{"port": 8112, "protocol": "tcp", "banner_regex": null, "service_name": "Deluge", "icon": "download", "category": "download", "suggested_node_type": "server"},
{"port": 6789, "protocol": "tcp", "banner_regex": null, "service_name": "NZBGet", "icon": "download", "category": "download", "suggested_node_type": "server"},
{"port": 6800, "protocol": "tcp", "banner_regex": null, "service_name": "Aria2 RPC", "icon": "download", "category": "download", "suggested_node_type": "server"},
{"port": 51413, "protocol": "tcp", "banner_regex": null, "service_name": "Transmission BitTorrent", "icon": "download", "category": "download", "suggested_node_type": "server"},
{"port": 6881, "protocol": "tcp", "banner_regex": null, "service_name": "BitTorrent Peer", "icon": "download", "category": "download", "suggested_node_type": "server"},
{"port": 8123, "protocol": "tcp", "banner_regex": null, "service_name": "Home Assistant", "icon": "home", "category": "automation", "suggested_node_type": "iot"},
{"port": 1883, "protocol": "tcp", "banner_regex": null, "service_name": "MQTT Broker", "icon": "radio", "category": "iot", "suggested_node_type": "iot"},
{"port": 8883, "protocol": "tcp", "banner_regex": null, "service_name": "MQTT Broker TLS", "icon": "radio", "category": "iot", "suggested_node_type": "iot"},
{"port": 6052, "protocol": "tcp", "banner_regex": null, "service_name": "ESPHome", "icon": "cpu", "category": "iot", "suggested_node_type": "iot"},
{"port": 1880, "protocol": "tcp", "banner_regex": null, "service_name": "Node-RED", "icon": "git-branch", "category": "automation", "suggested_node_type": "iot"},
{"port": 8971, "protocol": "tcp", "banner_regex": null, "service_name": "Frigate NVR", "icon": "camera", "category": "nvr", "suggested_node_type": "camera"},
{"port": 10443, "protocol": "tcp", "banner_regex": null, "service_name": "Scrypted", "icon": "camera", "category": "nvr", "suggested_node_type": "camera"},
{"port": 5000, "protocol": "tcp", "banner_regex": "frigate", "service_name": "Frigate NVR", "icon": "camera", "category": "nvr", "suggested_node_type": "camera"},
{"port": 8081, "protocol": "tcp", "banner_regex": "iobroker|ioBroker", "service_name": "ioBroker", "icon": "cpu", "category": "automation", "suggested_node_type": "iot"},
{"port": 8080, "protocol": "tcp", "banner_regex": "Domoticz|domoticz", "service_name": "Domoticz", "icon": "home", "category": "automation", "suggested_node_type": "iot"},
{"port": 5683, "protocol": "udp", "banner_regex": null, "service_name": "CoAP (IoT)", "icon": "radio", "category": "iot", "suggested_node_type": "iot"},
{"port": 554, "protocol": "tcp", "banner_regex": null, "service_name": "RTSP (Camera)", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
{"port": 8554, "protocol": "tcp", "banner_regex": null, "service_name": "RTSP Alt (Camera)", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
{"port": 37777, "protocol": "tcp", "banner_regex": null, "service_name": "Dahua Camera SDK", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
{"port": 34567, "protocol": "tcp", "banner_regex": null, "service_name": "Amcrest / Dahua Camera", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
{"port": 8000, "protocol": "tcp", "banner_regex": "[Hh]ikvision|[Dd]ahua", "service_name": "IP Camera SDK", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
{"port": 2020, "protocol": "tcp", "banner_regex": null, "service_name": "TP-Link Tapo Camera", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
{"port": 9000, "protocol": "tcp", "banner_regex": "[Rr]eolink", "service_name": "Reolink Camera", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
{"port": 8291, "protocol": "tcp", "banner_regex": null, "service_name": "MikroTik Winbox", "icon": "router", "category": "network", "suggested_node_type": "router"},
{"port": 8880, "protocol": "tcp", "banner_regex": null, "service_name": "UniFi HTTP Portal", "icon": "wifi", "category": "network", "suggested_node_type": "ap"},
{"port": 8443, "protocol": "tcp", "banner_regex": "[Uu]ni[Ff]i", "service_name": "UniFi Controller", "icon": "wifi", "category": "network", "suggested_node_type": "ap"},
{"port": 4711, "protocol": "tcp", "banner_regex": null, "service_name": "Pi-hole API", "icon": "shield", "category": "network", "suggested_node_type": "router"},
{"port": 3000, "protocol": "tcp", "banner_regex": "[Aa]d[Gg]uard", "service_name": "AdGuard Home", "icon": "shield", "category": "network", "suggested_node_type": "router"},
{"port": 81, "protocol": "tcp", "banner_regex": null, "service_name": "Nginx Proxy Manager", "icon": "arrow-right", "category": "network", "suggested_node_type": "router"},
{"port": 23, "protocol": "tcp", "banner_regex": null, "service_name": "Telnet", "icon": "terminal", "category": "network", "suggested_node_type": "switch"},
{"port": 161, "protocol": "udp", "banner_regex": null, "service_name": "SNMP", "icon": "activity", "category": "network", "suggested_node_type": "switch"},
{"port": 8200, "protocol": "tcp", "banner_regex": null, "service_name": "HashiCorp Vault", "icon": "lock", "category": "security", "suggested_node_type": "server"},
{"port": 389, "protocol": "tcp", "banner_regex": null, "service_name": "LDAP", "icon": "users", "category": "auth", "suggested_node_type": "server"},
{"port": 636, "protocol": "tcp", "banner_regex": null, "service_name": "LDAPS", "icon": "users", "category": "auth", "suggested_node_type": "server"},
{"port": 9091, "protocol": "tcp", "banner_regex": "[Aa]uthelia", "service_name": "Authelia", "icon": "shield", "category": "security", "suggested_node_type": "server"},
{"port": 9000, "protocol": "tcp", "banner_regex": "[Aa]uthentik", "service_name": "Authentik", "icon": "shield", "category": "security", "suggested_node_type": "server"},
{"port": 8080, "protocol": "tcp", "banner_regex": "[Kk]eycloak", "service_name": "Keycloak", "icon": "shield", "category": "auth", "suggested_node_type": "server"},
{"port": 3000, "protocol": "tcp", "banner_regex": "[Gg]rafana", "service_name": "Grafana", "icon": "bar-chart-2", "category": "monitoring", "suggested_node_type": "server"},
{"port": 9090, "protocol": "tcp", "banner_regex": null, "service_name": "Prometheus", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
{"port": 9093, "protocol": "tcp", "banner_regex": null, "service_name": "Alertmanager", "icon": "bell", "category": "monitoring", "suggested_node_type": "server"},
{"port": 9100, "protocol": "tcp", "banner_regex": null, "service_name": "Node Exporter", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
{"port": 8086, "protocol": "tcp", "banner_regex": null, "service_name": "InfluxDB", "icon": "database", "category": "monitoring", "suggested_node_type": "server"},
{"port": 3100, "protocol": "tcp", "banner_regex": null, "service_name": "Grafana Loki", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
{"port": 8428, "protocol": "tcp", "banner_regex": null, "service_name": "VictoriaMetrics", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
{"port": 19999, "protocol": "tcp", "banner_regex": null, "service_name": "Netdata", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
{"port": 3001, "protocol": "tcp", "banner_regex": null, "service_name": "Uptime Kuma", "icon": "heart", "category": "monitoring", "suggested_node_type": "server"},
{"port": 8581, "protocol": "tcp", "banner_regex": null, "service_name": "Uptime Kuma", "icon": "heart", "category": "monitoring", "suggested_node_type": "server"},
{"port": 10051, "protocol": "tcp", "banner_regex": null, "service_name": "Zabbix Server", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
{"port": 9411, "protocol": "tcp", "banner_regex": null, "service_name": "Zipkin", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
{"port": 16686, "protocol": "tcp", "banner_regex": null, "service_name": "Jaeger UI", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
{"port": 5601, "protocol": "tcp", "banner_regex": null, "service_name": "Kibana", "icon": "bar-chart-2", "category": "monitoring", "suggested_node_type": "server"},
{"port": 9443, "protocol": "tcp", "banner_regex": "[Pp]ortainer", "service_name": "Portainer HTTPS", "icon": "box", "category": "containers", "suggested_node_type": "lxc"},
{"port": 9000, "protocol": "tcp", "banner_regex": "[Pp]ortainer", "service_name": "Portainer", "icon": "box", "category": "containers", "suggested_node_type": "lxc"},
{"port": 2375, "protocol": "tcp", "banner_regex": null, "service_name": "Docker API", "icon": "box", "category": "containers", "suggested_node_type": "server"},
{"port": 2376, "protocol": "tcp", "banner_regex": null, "service_name": "Docker API TLS", "icon": "box", "category": "containers", "suggested_node_type": "server"},
{"port": 6443, "protocol": "tcp", "banner_regex": null, "service_name": "Kubernetes API", "icon": "layers", "category": "containers", "suggested_node_type": "server"},
{"port": 3306, "protocol": "tcp", "banner_regex": null, "service_name": "MySQL / MariaDB", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 5432, "protocol": "tcp", "banner_regex": null, "service_name": "PostgreSQL", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 6379, "protocol": "tcp", "banner_regex": null, "service_name": "Redis", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 27017, "protocol": "tcp", "banner_regex": null, "service_name": "MongoDB", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 9200, "protocol": "tcp", "banner_regex": null, "service_name": "Elasticsearch", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 9300, "protocol": "tcp", "banner_regex": null, "service_name": "Elasticsearch Transport", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 5984, "protocol": "tcp", "banner_regex": null, "service_name": "CouchDB", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 1521, "protocol": "tcp", "banner_regex": null, "service_name": "Oracle DB", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 6432, "protocol": "tcp", "banner_regex": null, "service_name": "PgBouncer", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 22, "protocol": "tcp", "banner_regex": null, "service_name": "SSH", "icon": "terminal", "category": "remote", "suggested_node_type": "server"},
{"port": 21, "protocol": "tcp", "banner_regex": null, "service_name": "FTP", "icon": "upload", "category": "storage", "suggested_node_type": "server"},
{"port": 25, "protocol": "tcp", "banner_regex": null, "service_name": "SMTP", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
{"port": 110, "protocol": "tcp", "banner_regex": null, "service_name": "POP3", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
{"port": 143, "protocol": "tcp", "banner_regex": null, "service_name": "IMAP", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
{"port": 465, "protocol": "tcp", "banner_regex": null, "service_name": "SMTPS", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
{"port": 587, "protocol": "tcp", "banner_regex": null, "service_name": "SMTP Submission", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
{"port": 993, "protocol": "tcp", "banner_regex": null, "service_name": "IMAPS", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
{"port": 995, "protocol": "tcp", "banner_regex": null, "service_name": "POP3S", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
{"port": 3389, "protocol": "tcp", "banner_regex": null, "service_name": "RDP", "icon": "monitor", "category": "remote", "suggested_node_type": "server"},
{"port": 5900, "protocol": "tcp", "banner_regex": null, "service_name": "VNC", "icon": "monitor", "category": "remote", "suggested_node_type": "server"},
{"port": 5800, "protocol": "tcp", "banner_regex": null, "service_name": "VNC (HTTP)", "icon": "monitor", "category": "remote", "suggested_node_type": "server"},
{"port": 8888, "protocol": "tcp", "banner_regex": null, "service_name": "Jupyter Notebook", "icon": "code", "category": "dev", "suggested_node_type": "server"},
{"port": 3000, "protocol": "tcp", "banner_regex": "[Gg]itea", "service_name": "Gitea", "icon": "git-branch", "category": "dev", "suggested_node_type": "server"},
{"port": 80, "protocol": "tcp", "banner_regex": null, "service_name": "HTTP", "icon": "globe", "category": "web", "suggested_node_type": "server"},
{"port": 443, "protocol": "tcp", "banner_regex": null, "service_name": "HTTPS", "icon": "lock", "category": "web", "suggested_node_type": "server"},
{"port": 8080, "protocol": "tcp", "banner_regex": null, "service_name": "HTTP Alt", "icon": "globe", "category": "web", "suggested_node_type": "server"},
{"port": 8443, "protocol": "tcp", "banner_regex": null, "service_name": "HTTPS Alt", "icon": "lock", "category": "web", "suggested_node_type": "server"},
{"port": 8008, "protocol": "tcp", "banner_regex": null, "service_name": "HTTP Alt", "icon": "globe", "category": "web", "suggested_node_type": "server"},
{"port": 3000, "protocol": "tcp", "banner_regex": null, "service_name": "Web service", "icon": "globe", "category": "web", "suggested_node_type": "server"},
{"port": 9091, "protocol": "tcp", "banner_regex": null, "service_name": "Transmission", "icon": "download", "category": "download", "suggested_node_type": "server"},
{"port": 9000, "protocol": "tcp", "banner_regex": null, "service_name": "Web service", "icon": "globe", "category": "web", "suggested_node_type": "server"},
{"port": 9443, "protocol": "tcp", "banner_regex": null, "service_name": "HTTPS Alt", "icon": "lock", "category": "web", "suggested_node_type": "server"},
{"port": 5000, "protocol": "tcp", "banner_regex": null, "service_name": "Web service", "icon": "globe", "category": "web", "suggested_node_type": "server"},
{"port": 8448, "protocol": "tcp", "banner_regex": null, "service_name": "Matrix (Synapse)", "icon": "message-square", "category": "communication", "suggested_node_type": "server"},
{"port": 64738, "protocol": "tcp", "banner_regex": null, "service_name": "Mumble", "icon": "mic", "category": "communication", "suggested_node_type": "server"},
{"port": 25565, "protocol": "tcp", "banner_regex": null, "service_name": "Minecraft Server", "icon": "cpu", "category": "gaming", "suggested_node_type": "server"},
{"port": 51820, "protocol": "udp", "banner_regex": null, "service_name": "WireGuard", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
{"port": 1194, "protocol": "udp", "banner_regex": null, "service_name": "OpenVPN", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
{"port": 500, "protocol": "udp", "banner_regex": null, "service_name": "IPsec IKE", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
{"port": 53, "protocol": "udp", "banner_regex": null, "service_name": "DNS", "icon": "search", "category": "network", "suggested_node_type": "router"},
{"port": 67, "protocol": "udp", "banner_regex": null, "service_name": "DHCP", "icon": "wifi", "category": "network", "suggested_node_type": "router"}
]
-12
View File
@@ -56,15 +56,3 @@ async def test_service_key_disabled_when_not_configured(client: AsyncClient):
settings.mcp_service_key = "" settings.mcp_service_key = ""
res = await client.get("/api/v1/nodes", headers={"X-MCP-Service-Key": "any-key"}) res = await client.get("/api/v1/nodes", headers={"X-MCP-Service-Key": "any-key"})
assert res.status_code == 401 assert res.status_code == 401
async def test_login_with_malformed_hash_returns_401_not_500(client: AsyncClient):
"""Malformed hash (e.g. $ stripped by shell) must not crash with 500."""
from app.core.config import settings
original = settings.auth_password_hash
settings.auth_password_hash = "2b12RtMbyw17l4N5UGzeXMNAWu" # $ signs stripped
try:
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
assert res.status_code == 401
finally:
settings.auth_password_hash = original
-19
View File
@@ -104,25 +104,6 @@ async def test_save_canvas_persists_custom_colors(client: AsyncClient, headers:
assert canvas["nodes"][0]["custom_colors"] == {"border": "#ff0000", "icon": "#00ff00"} assert canvas["nodes"][0]["custom_colors"] == {"border": "#ff0000", "icon": "#00ff00"}
async def test_save_canvas_persists_zone_label_position_and_text_size(client: AsyncClient, headers: dict):
"""label_position and text_size are stored in custom_colors and returned unchanged."""
n1 = node_payload(custom_colors={
"border": "#00d4ff",
"border_style": "solid",
"border_width": 3,
"label_position": "outside",
"text_size": 16,
"text_color": "#e6edf3",
})
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
cc = canvas["nodes"][0]["custom_colors"]
assert cc["label_position"] == "outside"
assert cc["text_size"] == 16
assert cc["border_width"] == 3
async def test_save_canvas_persists_edge_custom_color_and_path_style(client: AsyncClient, headers: dict): async def test_save_canvas_persists_edge_custom_color_and_path_style(client: AsyncClient, headers: dict):
n1 = node_payload() n1 = node_payload()
n2 = node_payload() n2 = node_payload()
-126
View File
@@ -1,126 +0,0 @@
"""
Tests for the /api/v1/liveview read-only canvas endpoint.
The endpoint is:
- Disabled by default (LIVEVIEW_KEY not set) → 403
- Returns 403 for missing or wrong key even when enabled
- Returns canvas data for a valid key (no JWT required)
"""
import pytest
from httpx import AsyncClient
from app.core.config import settings
@pytest.fixture(autouse=True)
def reset_liveview_key():
"""Restore liveview_key after each test so tests are isolated."""
original = settings.liveview_key
yield
settings.liveview_key = original
# ── Disabled (no key configured) ─────────────────────────────────────────────
@pytest.mark.asyncio
async def test_liveview_disabled_by_default(client: AsyncClient):
settings.liveview_key = None
res = await client.get("/api/v1/liveview?key=anything")
assert res.status_code == 403
assert res.json()["detail"] == "Live view is disabled"
@pytest.mark.asyncio
async def test_liveview_disabled_when_key_empty(client: AsyncClient):
settings.liveview_key = ""
res = await client.get("/api/v1/liveview?key=anything")
assert res.status_code == 403
assert res.json()["detail"] == "Live view is disabled"
# ── Enabled but wrong / missing key ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_liveview_wrong_key(client: AsyncClient):
settings.liveview_key = "correct-secret"
res = await client.get("/api/v1/liveview?key=wrong-key")
assert res.status_code == 403
assert res.json()["detail"] == "Invalid live view key"
@pytest.mark.asyncio
async def test_liveview_missing_key_param(client: AsyncClient):
settings.liveview_key = "correct-secret"
res = await client.get("/api/v1/liveview")
assert res.status_code == 403
assert res.json()["detail"] == "Invalid live view key"
# ── Valid key — no JWT needed ────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_liveview_valid_key_returns_canvas(client: AsyncClient):
settings.liveview_key = "my-secret-key"
res = await client.get("/api/v1/liveview?key=my-secret-key")
assert res.status_code == 200
data = res.json()
assert "nodes" in data
assert "edges" in data
assert "viewport" in data
assert isinstance(data["nodes"], list)
assert isinstance(data["edges"], list)
@pytest.mark.asyncio
async def test_liveview_does_not_require_jwt(client: AsyncClient):
"""Accessing without Authorization header must work when key is correct."""
settings.liveview_key = "open-sesame"
# client has no auth headers set here
res = await client.get("/api/v1/liveview?key=open-sesame")
assert res.status_code == 200
@pytest.mark.asyncio
async def test_liveview_returns_saved_canvas(client: AsyncClient, auth_headers):
"""Canvas saved via POST /canvas/save appears in liveview response."""
settings.liveview_key = "test-key"
headers = await auth_headers()
# Save a canvas with one node
payload = {
"nodes": [{
"id": "lv-node-1",
"type": "server",
"label": "Live Node",
"status": "online",
"services": [],
"pos_x": 10,
"pos_y": 20,
}],
"edges": [],
"viewport": {"x": 0, "y": 0, "zoom": 1},
}
await client.post("/api/v1/canvas/save", json=payload, headers=headers)
# Liveview should return the same node
res = await client.get("/api/v1/liveview?key=test-key")
assert res.status_code == 200
nodes = res.json()["nodes"]
assert len(nodes) == 1
assert nodes[0]["id"] == "lv-node-1"
assert nodes[0]["label"] == "Live Node"
# ── Re-disable after enabling ─────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_liveview_disabled_after_key_cleared(client: AsyncClient):
settings.liveview_key = "was-enabled"
res = await client.get("/api/v1/liveview?key=was-enabled")
assert res.status_code == 200
settings.liveview_key = None
res = await client.get("/api/v1/liveview?key=was-enabled")
assert res.status_code == 403
assert res.json()["detail"] == "Live view is disabled"
+3 -209
View File
@@ -1,4 +1,4 @@
"""Tests for scan routes: trigger, pending devices, approve/hide/ignore, stop.""" """Tests for scan routes: trigger, pending devices, approve/hide/ignore."""
import uuid import uuid
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
@@ -7,8 +7,8 @@ from httpx import AsyncClient
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Node, PendingDevice, ScanRun from app.db.models import PendingDevice, ScanRun
from app.services.scanner import _cancelled_runs, request_cancel, run_scan from app.services.scanner import run_scan
@pytest.fixture @pytest.fixture
@@ -199,212 +199,6 @@ async def test_run_scan_creates_new_pending_device(db_session: AsyncSession):
assert device.suggested_type == "server" 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 @pytest.mark.asyncio
async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession): async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession):
"""Re-scanning the same IP updates services instead of creating a duplicate.""" """Re-scanning the same IP updates services instead of creating a duplicate."""
-47
View File
@@ -1,47 +0,0 @@
"""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
+2 -1
View File
@@ -7,8 +7,9 @@ services:
env_file: env_file:
- .env - .env
environment: environment:
# Override env_file: SQLite path must point inside the container volume # Override env_file values that differ in Docker
SQLITE_PATH: /app/data/homelab.db SQLITE_PATH: /app/data/homelab.db
CORS_ORIGINS: '["http://localhost:3000"]'
volumes: volumes:
- backend_data:/app/data - backend_data:/app/data
networks: networks:
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "frontend", "name": "frontend",
"version": "1.4.0", "version": "1.3.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "frontend", "name": "frontend",
"version": "1.4.0", "version": "1.3.3",
"dependencies": { "dependencies": {
"@base-ui/react": "^1.2.0", "@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4", "@dagrejs/dagre": "^2.0.4",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "1.7.0", "version": "1.3.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+7 -17
View File
@@ -35,7 +35,7 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const STANDALONE_STORAGE_KEY = 'homelable_canvas' const STANDALONE_STORAGE_KEY = 'homelable_canvas'
export default function App() { export default function App() {
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore() const { loadCanvas, markSaved, markUnsaved, selectedNodeId, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
const canvasRef = useRef<HTMLDivElement>(null) const canvasRef = useRef<HTMLDivElement>(null)
const { isAuthenticated } = useAuthStore() const { isAuthenticated } = useAuthStore()
const { activeTheme, setTheme } = useThemeStore() const { activeTheme, setTheme } = useThemeStore()
@@ -100,8 +100,8 @@ export default function App() {
// Build a map of proxmox container mode to know if children should be nested // Build a map of proxmox container mode to know if children should be nested
const proxmoxContainerMap = new Map<string, boolean>( const proxmoxContainerMap = new Map<string, boolean>(
(apiNodes as ApiNode[]) (apiNodes as ApiNode[])
.filter((n) => n.type === 'proxmox' || n.type === 'group') .filter((n) => n.type === 'proxmox')
.map((n) => [n.id, n.type === 'group' ? true : n.container_mode !== false]) .map((n) => [n.id, n.container_mode !== false])
) )
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)) const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge) const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
@@ -182,12 +182,9 @@ export default function App() {
custom_colors: { custom_colors: {
border: data.border_color, border: data.border_color,
border_style: data.border_style, border_style: data.border_style,
border_width: data.border_width,
background: data.background_color, background: data.background_color,
text_color: data.text_color, text_color: data.text_color,
text_position: data.text_position, text_position: data.text_position,
text_size: data.text_size,
label_position: data.label_position,
font: data.font, font: data.font,
z_order: data.z_order, z_order: data.z_order,
}, },
@@ -201,7 +198,6 @@ export default function App() {
const handleUpdateGroupRect = useCallback((data: GroupRectFormData) => { const handleUpdateGroupRect = useCallback((data: GroupRectFormData) => {
if (!editingGroupRectId) return if (!editingGroupRectId) return
snapshotHistory()
const existing = nodes.find((n) => n.id === editingGroupRectId) const existing = nodes.find((n) => n.id === editingGroupRectId)
updateNode(editingGroupRectId, { updateNode(editingGroupRectId, {
label: data.label, label: data.label,
@@ -209,19 +205,16 @@ export default function App() {
...existing?.data.custom_colors, ...existing?.data.custom_colors,
border: data.border_color, border: data.border_color,
border_style: data.border_style, border_style: data.border_style,
border_width: data.border_width,
background: data.background_color, background: data.background_color,
text_color: data.text_color, text_color: data.text_color,
text_position: data.text_position, text_position: data.text_position,
text_size: data.text_size,
label_position: data.label_position,
font: data.font, font: data.font,
z_order: data.z_order, z_order: data.z_order,
}, },
}) })
setNodeZIndex(editingGroupRectId, data.z_order - 10) setNodeZIndex(editingGroupRectId, data.z_order - 10)
setEditingGroupRectId(null) setEditingGroupRectId(null)
}, [editingGroupRectId, nodes, updateNode, setNodeZIndex, setEditingGroupRectId, snapshotHistory]) }, [editingGroupRectId, nodes, updateNode, setNodeZIndex, setEditingGroupRectId])
const handleDeleteGroupRect = useCallback(() => { const handleDeleteGroupRect = useCallback(() => {
if (!editingGroupRectId) return if (!editingGroupRectId) return
@@ -385,7 +378,7 @@ export default function App() {
<div ref={canvasRef} className="flex-1 min-w-0 h-full"> <div ref={canvasRef} className="flex-1 min-w-0 h-full">
<CanvasContainer onConnect={handleEdgeConnect} onEdgeDoubleClick={handleEdgeDoubleClick} onNodeDragStart={snapshotHistory} /> <CanvasContainer onConnect={handleEdgeConnect} onEdgeDoubleClick={handleEdgeDoubleClick} onNodeDragStart={snapshotHistory} />
</div> </div>
{(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />} {selectedNodeId && <DetailPanel onEdit={handleEditNode} />}
</div> </div>
</div> </div>
</div> </div>
@@ -443,7 +436,7 @@ export default function App() {
open={addGroupRectOpen} open={addGroupRectOpen}
onClose={() => setAddGroupRectOpen(false)} onClose={() => setAddGroupRectOpen(false)}
onSubmit={handleAddGroupRect} onSubmit={handleAddGroupRect}
title="Add Zone" title="Add Rectangle"
/> />
{/* key forces re-mount when editing a different rect */} {/* key forces re-mount when editing a different rect */}
@@ -464,14 +457,11 @@ export default function App() {
text_position: rc.text_position ?? 'top-left', text_position: rc.text_position ?? 'top-left',
border_color: rc.border ?? '#00d4ff', border_color: rc.border ?? '#00d4ff',
border_style: rc.border_style ?? 'solid', border_style: rc.border_style ?? 'solid',
border_width: rc.border_width ?? 2,
background_color: rc.background ?? '#00d4ff0d', background_color: rc.background ?? '#00d4ff0d',
text_size: rc.text_size ?? 12,
label_position: rc.label_position ?? 'inside',
z_order: rc.z_order ?? 1, z_order: rc.z_order ?? 1,
} }
})()} })()}
title="Edit Zone" title="Edit Rectangle"
/> />
{/* key forces re-mount on open so useState captures current theme as original */} {/* key forces re-mount on open so useState captures current theme as original */}
+2 -15
View File
@@ -5,9 +5,6 @@ export const api = axios.create({
baseURL: '/api/v1', baseURL: '/api/v1',
}) })
// Unauthenticated axios instance — no JWT, no 401 redirect (used for public endpoints)
const publicApi = axios.create({ baseURL: '/api/v1' })
api.interceptors.request.use((config) => { api.interceptors.request.use((config) => {
const token = useAuthStore.getState().token const token = useAuthStore.getState().token
if (token) config.headers.Authorization = `Bearer ${token}` if (token) config.headers.Authorization = `Bearer ${token}`
@@ -47,10 +44,6 @@ export const edgesApi = {
delete: (id: string) => api.delete(`/edges/${id}`), delete: (id: string) => api.delete(`/edges/${id}`),
} }
export const liveviewApi = {
load: (key: string) => publicApi.get('/liveview', { params: { key } }),
}
export const scanApi = { export const scanApi = {
trigger: () => api.post('/scan/trigger'), trigger: () => api.post('/scan/trigger'),
pending: () => api.get('/scan/pending'), pending: () => api.get('/scan/pending'),
@@ -59,12 +52,6 @@ export const scanApi = {
approve: (id: string, nodeData: object) => api.post(`/scan/pending/${id}/approve`, nodeData), approve: (id: string, nodeData: object) => api.post(`/scan/pending/${id}/approve`, nodeData),
hide: (id: string) => api.post(`/scan/pending/${id}/hide`), hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`), ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
stop: (runId: string) => api.post(`/scan/${runId}/stop`), getConfig: () => api.get<{ ranges: string[]; interval_seconds: number }>('/scan/config'),
getConfig: () => api.get<{ ranges: string[] }>('/scan/config'), saveConfig: (data: { ranges: string[]; interval_seconds: number }) => api.post('/scan/config', data),
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),
} }
-155
View File
@@ -1,155 +0,0 @@
/**
* LiveView — read-only canvas accessible at /view?key=<LIVEVIEW_KEY>.
*
* - Non-standalone: fetches canvas from /api/v1/liveview?key=... (no JWT needed).
* Returns 403 when the feature is disabled or the key is wrong.
* - Standalone: loads canvas from localStorage directly (no key required,
* since there is no backend to validate against).
*
* Pan and zoom work. Editing is fully disabled.
* Clicking a node with an IP opens http://<ip> in a new tab.
*/
import { useCallback, useEffect, useState } from 'react'
import {
ReactFlowProvider,
ReactFlow,
Background,
BackgroundVariant,
Controls,
ConnectionMode,
type Node,
} from '@xyflow/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 '@/components/canvas/nodes/nodeTypes'
import { edgeTypes } from '@/components/canvas/edges/edgeTypes'
import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
import { liveviewApi } from '@/api/client'
import type { NodeData } from '@/types'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const STORAGE_KEY = 'homelable_canvas'
type ViewState = 'loading' | 'disabled' | 'invalid-key' | 'no-key' | 'network-error' | 'ready'
function LiveViewCanvas() {
const { nodes, edges, loadCanvas } = useCanvasStore()
const activeTheme = useThemeStore((s) => s.activeTheme)
const theme = THEMES[activeTheme]
// Derive initial view state synchronously (avoids calling setState inside an effect):
// - standalone → always ready (localStorage, no key required)
// - non-standalone, no ?key= → no-key error immediately
// - non-standalone, key present → loading (API call below)
const [viewState, setViewState] = useState<ViewState>(() => {
if (STANDALONE) return 'ready'
return new URLSearchParams(window.location.search).get('key') ? 'loading' : 'no-key'
})
useEffect(() => {
if (STANDALONE) {
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
const { nodes: savedNodes, edges: savedEdges } = JSON.parse(saved)
loadCanvas(savedNodes, savedEdges)
}
} catch {
// empty canvas on parse error — show empty canvas
}
return
}
// Already handled synchronously in useState initializer
const key = new URLSearchParams(window.location.search).get('key')
if (!key) return
liveviewApi.load(key)
.then((res) => {
const { nodes: apiNodes, edges: apiEdges } = res.data
const proxmoxMap = new Map<string, boolean>(
(apiNodes as ApiNode[])
.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)),
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
)
setViewState('ready')
})
.catch((err) => {
if (!err.response) { setViewState('network-error'); return }
const detail: string = err.response.data?.detail ?? ''
setViewState(detail === 'Live view is disabled' ? 'disabled' : 'invalid-key')
})
}, [loadCanvas])
const onNodeClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => {
const ip = node.data.ip
if (ip) window.open(`http://${ip}`, '_blank', 'noopener,noreferrer')
}, [])
if (viewState === 'loading') {
return (
<div className="flex h-screen w-screen items-center justify-center bg-[#0d1117] text-[#8b949e]">
Loading
</div>
)
}
if (viewState !== 'ready') {
const messages: Record<Exclude<ViewState, 'loading' | 'ready'>, string> = {
disabled: 'Live view is disabled on this instance.',
'invalid-key': 'Invalid or expired live view key.',
'no-key': 'Missing key — use ?key=your-secret in the URL.',
'network-error': 'Could not reach the server. Check your connection.',
}
return (
<div className="flex h-screen w-screen items-center justify-center bg-[#0d1117]">
<div className="text-center space-y-2">
<p className="text-[#f85149] text-lg font-medium">Access Denied</p>
<p className="text-[#8b949e] text-sm">{messages[viewState]}</p>
</div>
</div>
)
}
return (
<div className="w-full h-screen" style={{ background: theme.colors.canvasBackground }}>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
panOnDrag
zoomOnScroll
fitView
colorMode={theme.colors.reactFlowColorMode}
connectionMode={ConnectionMode.Loose}
onNodeClick={onNodeClick}
>
<Background
variant={BackgroundVariant.Dots}
gap={24}
size={1}
color={theme.colors.canvasDotColor}
/>
<Controls showInteractive={false} />
</ReactFlow>
</div>
)
}
export default function LiveView() {
return (
<ReactFlowProvider>
<LiveViewCanvas />
</ReactFlowProvider>
)
}
+3 -4
View File
@@ -20,9 +20,8 @@ export function LoginPage() {
try { try {
const res = await authApi.login(username, password) const res = await authApi.login(username, password)
login(res.data.access_token) login(res.data.access_token)
} catch (err: unknown) { } catch {
const hasResponse = err && typeof err === 'object' && 'response' in err setError('Invalid username or password')
setError(hasResponse ? 'Invalid username or password' : 'Could not reach the server — check your CORS_ORIGINS setting')
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -96,7 +95,7 @@ export function LoginPage() {
</form> </form>
<p className="text-center text-[10px] text-muted-foreground/40 mt-4"> <p className="text-center text-[10px] text-muted-foreground/40 mt-4">
Credentials configured in <span className="font-mono">.env</span> Credentials configured in <span className="font-mono">config.yml</span>
</p> </p>
</div> </div>
</div> </div>
@@ -1,185 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { useCanvasStore } from '@/stores/canvasStore'
// ── Mock heavy dependencies ────────────────────────────────────────────────
vi.mock('@xyflow/react', () => ({
ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
ReactFlow: () => <div data-testid="react-flow" />,
Background: () => null,
Controls: () => null,
BackgroundVariant: { Dots: 'dots' },
ConnectionMode: { Loose: 'loose' },
}))
vi.mock('@xyflow/react/dist/style.css', () => ({}))
vi.mock('@/api/client', () => ({
liveviewApi: { load: vi.fn() },
}))
import { liveviewApi } from '@/api/client'
import LiveView from '../LiveView'
// ── Helpers ────────────────────────────────────────────────────────────────
function setSearch(params: string) {
Object.defineProperty(window, 'location', {
writable: true,
value: { ...window.location, search: params, pathname: '/view' },
})
}
const canvasPayload = {
data: {
nodes: [{
id: 'n1', type: 'server', label: 'CI Node', status: 'online',
services: [], pos_x: 0, pos_y: 0,
created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
}],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
}
// ── Tests ──────────────────────────────────────────────────────────────────
describe('LiveView (non-standalone)', () => {
beforeEach(() => {
vi.mocked(liveviewApi.load).mockReset()
useCanvasStore.setState({ nodes: [], edges: [] })
})
// ── No key ────────────────────────────────────────────────────────────────
it('shows no-key error when ?key= is missing', async () => {
setSearch('')
render(<LiveView />)
await waitFor(() => {
expect(screen.getByText('Access Denied')).toBeDefined()
expect(screen.getByText(/Missing key/)).toBeDefined()
})
expect(liveviewApi.load).not.toHaveBeenCalled()
})
// ── Disabled ──────────────────────────────────────────────────────────────
it('shows disabled error when backend returns "Live view is disabled"', async () => {
setSearch('?key=anything')
vi.mocked(liveviewApi.load).mockRejectedValue({
response: { data: { detail: 'Live view is disabled' } },
})
render(<LiveView />)
await waitFor(() => {
expect(screen.getByText(/disabled on this instance/)).toBeDefined()
})
})
// ── Invalid key ───────────────────────────────────────────────────────────
it('shows invalid-key error when backend returns "Invalid live view key"', async () => {
setSearch('?key=wrong')
vi.mocked(liveviewApi.load).mockRejectedValue({
response: { data: { detail: 'Invalid live view key' } },
})
render(<LiveView />)
await waitFor(() => {
expect(screen.getByText(/Invalid or expired/)).toBeDefined()
})
})
it('shows network-error for non-response errors (offline, CORS, 500)', async () => {
setSearch('?key=anything')
vi.mocked(liveviewApi.load).mockRejectedValue(new Error('network'))
render(<LiveView />)
await waitFor(() => {
expect(screen.getByText(/Could not reach the server/)).toBeDefined()
})
})
// ── Valid key → canvas rendered ───────────────────────────────────────────
it('renders the canvas on valid key', async () => {
setSearch('?key=correct-key')
vi.mocked(liveviewApi.load).mockResolvedValue(canvasPayload as never)
render(<LiveView />)
await waitFor(() => {
expect(screen.getByTestId('react-flow')).toBeDefined()
})
expect(liveviewApi.load).toHaveBeenCalledWith('correct-key')
})
it('loads nodes into the canvas store on success', async () => {
setSearch('?key=secret')
vi.mocked(liveviewApi.load).mockResolvedValue(canvasPayload as never)
render(<LiveView />)
await waitFor(() => {
expect(screen.getByTestId('react-flow')).toBeDefined()
})
const { nodes } = useCanvasStore.getState()
expect(nodes.find((n) => n.id === 'n1')).toBeDefined()
})
// ── No editing props passed ───────────────────────────────────────────────
it('does not show any Access Denied when key is valid', async () => {
setSearch('?key=valid')
vi.mocked(liveviewApi.load).mockResolvedValue(canvasPayload as never)
render(<LiveView />)
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
expect(screen.queryByText('Access Denied')).toBeNull()
})
})
// ── Standalone mode ────────────────────────────────────────────────────────
describe('LiveView (standalone — localStorage)', () => {
beforeEach(() => {
localStorage.clear()
useCanvasStore.setState({ nodes: [], edges: [] })
vi.mocked(liveviewApi.load).mockReset()
})
it('loads canvas from localStorage without calling the API', async () => {
const stored = {
nodes: [{
id: 'ls-node', type: 'router',
position: { x: 10, y: 20 },
data: { label: 'Router', type: 'router', status: 'unknown', services: [] },
}],
edges: [],
}
localStorage.setItem('homelable_canvas', JSON.stringify(stored))
// Stub VITE_STANDALONE before re-importing
vi.stubEnv('VITE_STANDALONE', 'true')
vi.resetModules()
const { default: LiveViewStandalone } = await import('../LiveView')
setSearch('') // no key needed in standalone
render(<LiveViewStandalone />)
await waitFor(() => {
expect(screen.getByTestId('react-flow')).toBeDefined()
})
expect(liveviewApi.load).not.toHaveBeenCalled()
vi.unstubAllEnvs()
})
it('shows canvas (empty) when localStorage has no saved data', async () => {
vi.stubEnv('VITE_STANDALONE', 'true')
vi.resetModules()
const { default: LiveViewStandalone } = await import('../LiveView')
setSearch('')
render(<LiveViewStandalone />)
await waitFor(() => {
expect(screen.getByTestId('react-flow')).toBeDefined()
})
expect(liveviewApi.load).not.toHaveBeenCalled()
vi.unstubAllEnvs()
})
})
@@ -51,7 +51,7 @@ describe('LoginPage', () => {
}) })
it('shows a generic error message — no credential enumeration', async () => { it('shows a generic error message — no credential enumeration', async () => {
vi.mocked(authApi.login).mockRejectedValue({ response: { status: 401 } }) vi.mocked(authApi.login).mockRejectedValue(new Error('401'))
render(<LoginPage />) render(<LoginPage />)
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } }) fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } })
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'wrongpass' } }) fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'wrongpass' } })
@@ -65,21 +65,10 @@ describe('LoginPage', () => {
expect(errors[0].textContent).toBe('Invalid username or password') expect(errors[0].textContent).toBe('Invalid username or password')
}) })
it('shows a network error message when no response (e.g. CORS misconfiguration)', async () => {
vi.mocked(authApi.login).mockRejectedValue(new Error('Network Error'))
render(<LoginPage />)
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } })
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'admin' } })
fireEvent.submit(screen.getByRole('button', { name: /sign in/i }).closest('form')!)
await waitFor(() => {
expect(screen.getByText(/Could not reach the server/)).toBeDefined()
})
})
it('clears previous error before each new attempt', async () => { it('clears previous error before each new attempt', async () => {
vi.mocked(authApi.login) vi.mocked(authApi.login)
.mockRejectedValueOnce({ response: { status: 401 } }) .mockRejectedValueOnce(new Error('401'))
.mockRejectedValueOnce({ response: { status: 401 } }) .mockRejectedValueOnce(new Error('401'))
render(<LoginPage />) render(<LoginPage />)
const form = screen.getByRole('button', { name: /sign in/i }).closest('form')! const form = screen.getByRole('button', { name: /sign in/i }).closest('form')!
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } }) fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } })
@@ -1,24 +1,20 @@
import { useCallback, useState } from 'react' import { useCallback } from 'react'
import { import {
ReactFlow, ReactFlow,
Background, Background,
Controls, Controls,
ControlButton,
BackgroundVariant, BackgroundVariant,
ConnectionMode, ConnectionMode,
SelectionMode,
type Node, type Node,
type Edge, type Edge,
type Connection, type Connection,
} from '@xyflow/react' } from '@xyflow/react'
import { MousePointer2, Hand } from 'lucide-react'
import '@xyflow/react/dist/style.css' import '@xyflow/react/dist/style.css'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { useThemeStore } from '@/stores/themeStore' import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes' import { THEMES } from '@/utils/themes'
import { nodeTypes } from './nodes/nodeTypes' import { nodeTypes } from './nodes/nodeTypes'
import { edgeTypes } from './edges/edgeTypes' import { edgeTypes } from './edges/edgeTypes'
import { SearchBar } from './SearchBar'
import type { NodeData, EdgeData } from '@/types' import type { NodeData, EdgeData } from '@/types'
interface CanvasContainerProps { interface CanvasContainerProps {
@@ -28,22 +24,17 @@ interface CanvasContainerProps {
} }
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart }: CanvasContainerProps) { export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart }: CanvasContainerProps) {
const [lassoMode, setLassoMode] = useState(true)
const { const {
nodes, edges, nodes, edges,
onNodesChange, onEdgesChange, onNodesChange, onEdgesChange,
setSelectedNode, snapshotHistory, setSelectedNode,
} = useCanvasStore() } = useCanvasStore()
const activeTheme = useThemeStore((s) => s.activeTheme) const activeTheme = useThemeStore((s) => s.activeTheme)
const theme = THEMES[activeTheme] const theme = THEMES[activeTheme]
const onNodeClick = useCallback((e: React.MouseEvent, node: Node<NodeData>) => { const onNodeClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => {
if (e.ctrlKey || e.metaKey) { setSelectedNode(node.id)
setSelectedNode(null)
} else {
setSelectedNode(node.id)
}
}, [setSelectedNode]) }, [setSelectedNode])
const onPaneClick = useCallback(() => { const onPaneClick = useCallback(() => {
@@ -68,13 +59,6 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
onNodeDragStart={onNodeDragStart} onNodeDragStart={onNodeDragStart}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
edgeTypes={edgeTypes} 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 snapToGrid
snapGrid={[16, 16]} snapGrid={[16, 16]}
fitView fitView
@@ -89,15 +73,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
size={1} size={1}
color={theme.colors.canvasDotColor} color={theme.colors.canvasDotColor}
/> />
<SearchBar /> <Controls />
<Controls>
<ControlButton
onClick={() => setLassoMode((m) => !m)}
title={lassoMode ? 'Switch to pan mode (Space to pan)' : 'Switch to lasso mode'}
>
{lassoMode ? <MousePointer2 size={12} /> : <Hand size={12} />}
</ControlButton>
</Controls>
</ReactFlow> </ReactFlow>
</div> </div>
) )
@@ -1,160 +0,0 @@
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,10 +16,8 @@ vi.mock('@xyflow/react', () => ({
}, },
Background: () => null, Background: () => null,
Controls: () => null, Controls: () => null,
ControlButton: () => null,
BackgroundVariant: { Dots: 'dots' }, BackgroundVariant: { Dots: 'dots' },
ConnectionMode: { Loose: 'loose' }, ConnectionMode: { Loose: 'loose' },
SelectionMode: { Partial: 'partial' },
})) }))
vi.mock('@xyflow/react/dist/style.css', () => ({})) vi.mock('@xyflow/react/dist/style.css', () => ({}))
@@ -145,71 +143,4 @@ describe('CanvasContainer', () => {
render(<CanvasContainer />) render(<CanvasContainer />)
expect(rfProps.snapGrid).toEqual([16, 16]) 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)
})
}) })
@@ -1,122 +0,0 @@
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()
})
})
@@ -1,146 +0,0 @@
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()
})
})
+20 -26
View File
@@ -50,48 +50,42 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}), ...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}),
} }
// Normalize animated value — supports legacy boolean (true → 'snake') // Animated dot: slightly brighter + thicker than the base edge, travels source→target
const animMode: 'none' | 'snake' | 'flow' = const dotColor = customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : edgeColors[edgeType as keyof typeof edgeColors] as string)
data?.animated === true || data?.animated === 'snake' ? 'snake' : const dotWidth = ((style.strokeWidth as number ?? 2) + 1.5) * 2
data?.animated === 'flow' ? 'flow' : 'none'
const animColor = customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : edgeColors[edgeType as keyof typeof edgeColors] as string)
return ( return (
<> <>
<BaseEdge id={id} path={edgePath} style={style} /> <BaseEdge id={id} path={edgePath} style={style} />
{animMode === 'snake' && ( {data?.animated && (
<path <path
d={edgePath} d={edgePath}
fill="none" fill="none"
stroke={animColor} stroke={dotColor}
strokeWidth={((style.strokeWidth as number ?? 2) + 1.5) * 2} strokeWidth={dotWidth}
strokeDasharray="20 10000" strokeDasharray="20 10000"
strokeLinecap="round" strokeLinecap="round"
style={{ pointerEvents: 'none' }} style={{ pointerEvents: 'none' }}
> >
{isBidirectional ? ( {isBidirectional ? (
<animate attributeName="stroke-dashoffset" values="-10000;0;-10000" keyTimes="0;0.5;1" dur="20s" repeatCount="indefinite" /> <animate
attributeName="stroke-dashoffset"
values="-10000;0;-10000"
keyTimes="0;0.5;1"
dur="20s"
repeatCount="indefinite"
/>
) : ( ) : (
<animate attributeName="stroke-dashoffset" from="-10000" to="0" dur="10s" repeatCount="indefinite" /> <animate
attributeName="stroke-dashoffset"
from="-10000"
to="0"
dur="10s"
repeatCount="indefinite"
/>
)} )}
</path> </path>
)} )}
{animMode === 'flow' && (
<path
d={edgePath}
fill="none"
stroke={animColor}
strokeWidth={Math.max(3, (style.strokeWidth as number ?? 2) * 1.8)}
strokeDasharray="6 12"
strokeLinecap="round"
strokeOpacity={0.85}
style={{ pointerEvents: 'none' }}
>
<animate attributeName="stroke-dashoffset" from="0" to="18" dur="1.2s" repeatCount="indefinite" />
</path>
)}
{data?.label && ( {data?.label && (
<EdgeLabelRenderer> <EdgeLabelRenderer>
<div <div
@@ -1,125 +0,0 @@
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>
)
}
@@ -32,34 +32,12 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
const rc = data.custom_colors ?? {} const rc = data.custom_colors ?? {}
const borderColor = rc.border ?? '#00d4ff' const borderColor = rc.border ?? '#00d4ff'
const borderStyle = rc.border_style ?? 'solid' const borderStyle = rc.border_style ?? 'solid'
const borderWidth = rc.border_width ?? 2
const backgroundColor = rc.background ?? 'rgba(0,212,255,0.05)' const backgroundColor = rc.background ?? 'rgba(0,212,255,0.05)'
const textColor = rc.text_color ?? '#e6edf3' const textColor = rc.text_color ?? '#e6edf3'
const textSize: number = rc.text_size ?? 12
const labelPosition: string = rc.label_position ?? 'inside'
const fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter const fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter
const textPos = (rc.text_position ?? 'top-left') as TextPosition const textPos = (rc.text_position ?? 'top-left') as TextPosition
const posStyle = POSITION_STYLES[textPos] const posStyle = POSITION_STYLES[textPos]
const outsideJustify = textPos.includes('right') ? 'flex-end'
: (textPos.includes('center') || textPos === 'center') ? 'center'
: 'flex-start'
const isOutsideBottom = textPos.startsWith('bottom')
const outsideOffset = textSize + 16
const outsideVertical: React.CSSProperties = isOutsideBottom
? { bottom: -outsideOffset }
: { top: -outsideOffset }
const sharedTextStyle: React.CSSProperties = {
color: textColor,
fontFamily,
fontSize: textSize,
fontWeight: 500,
userSelect: 'none',
whiteSpace: 'pre-wrap',
}
return ( return (
<> <>
<NodeResizer <NodeResizer
@@ -77,8 +55,6 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
/> />
<div <div
style={{ style={{
position: 'relative',
overflow: 'visible',
width: '100%', width: '100%',
height: '100%', height: '100%',
display: 'flex', display: 'flex',
@@ -86,8 +62,12 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
justifyContent: posStyle.justifyContent, justifyContent: posStyle.justifyContent,
padding: 12, padding: 12,
background: backgroundColor, background: backgroundColor,
border: `${selected ? borderWidth + 1 : borderWidth}px ${selected ? 'solid' : borderStyle} ${selected ? '#00d4ff' : borderColor}`, border: `${selected ? 2 : 1}px ${selected ? 'solid' : borderStyle} ${selected ? '#00d4ff' : borderColor}`,
borderRadius: 10, borderRadius: 10,
fontFamily,
color: textColor,
fontSize: 12,
fontWeight: 500,
boxSizing: 'border-box', boxSizing: 'border-box',
cursor: 'default', cursor: 'default',
}} }}
@@ -96,24 +76,8 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
setEditingGroupRectId(id) setEditingGroupRectId(id)
}} }}
> >
{labelPosition === 'outside' && data.label && ( {data.label && (
<span <span style={{ textAlign: posStyle.textAlign, userSelect: 'none', whiteSpace: 'pre-wrap' }}>
style={{
position: 'absolute',
...outsideVertical,
left: 0,
right: 0,
display: 'flex',
justifyContent: outsideJustify,
pointerEvents: 'none',
...sharedTextStyle,
}}
>
{data.label}
</span>
)}
{labelPosition === 'inside' && data.label && (
<span style={{ textAlign: posStyle.textAlign, ...sharedTextStyle }}>
{data.label} {data.label}
</span> </span>
)} )}
@@ -1,7 +1,6 @@
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerNode, GenericNode } from './index' import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerNode, GenericNode } from './index'
import { ProxmoxGroupNode } from './ProxmoxGroupNode' import { ProxmoxGroupNode } from './ProxmoxGroupNode'
import { GroupRectNode } from './GroupRectNode' import { GroupRectNode } from './GroupRectNode'
import { GroupNode } from './GroupNode'
export const nodeTypes = { export const nodeTypes = {
isp: IspNode, isp: IspNode,
@@ -21,5 +20,4 @@ export const nodeTypes = {
docker: DockerNode, docker: DockerNode,
generic: GenericNode, generic: GenericNode,
groupRect: GroupRectNode, groupRect: GroupRectNode,
group: GroupNode,
} }
+16 -29
View File
@@ -10,14 +10,6 @@ import { EDGE_DEFAULT_COLORS } from '@/utils/edgeColors'
const EDGE_TYPES = Object.entries(EDGE_TYPE_LABELS) as [EdgeType, string][] const EDGE_TYPES = Object.entries(EDGE_TYPE_LABELS) as [EdgeType, string][]
type AnimMode = 'none' | 'snake' | 'flow'
function toAnimMode(v: EdgeData['animated']): AnimMode {
if (v === true || v === 'snake') return 'snake'
if (v === 'flow') return 'flow'
return 'none'
}
interface EdgeModalProps { interface EdgeModalProps {
open: boolean open: boolean
onClose: () => void onClose: () => void
@@ -33,7 +25,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
const [vlanId, setVlanId] = useState(initial?.vlan_id?.toString() ?? '') const [vlanId, setVlanId] = useState(initial?.vlan_id?.toString() ?? '')
const [customColor, setCustomColor] = useState<string | undefined>(initial?.custom_color) const [customColor, setCustomColor] = useState<string | undefined>(initial?.custom_color)
const [pathStyle, setPathStyle] = useState<EdgePathStyle>(initial?.path_style ?? 'bezier') const [pathStyle, setPathStyle] = useState<EdgePathStyle>(initial?.path_style ?? 'bezier')
const [animation, setAnimation] = useState<AnimMode>(() => toAnimMode(initial?.animated)) const [animated, setAnimated] = useState(initial?.animated ?? false)
const effectiveColor = customColor ?? EDGE_DEFAULT_COLORS[type] const effectiveColor = customColor ?? EDGE_DEFAULT_COLORS[type]
@@ -45,7 +37,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined, vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
custom_color: customColor, custom_color: customColor,
path_style: pathStyle, path_style: pathStyle,
animated: animation !== 'none' ? animation : undefined, animated: animated || undefined,
}) })
onClose() onClose()
} }
@@ -123,25 +115,20 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
</div> </div>
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Animation</Label> <Label className="text-xs text-muted-foreground">Flow Animation</Label>
<div className="flex rounded-md overflow-hidden border border-[#30363d]"> <button
{(['none', 'snake', 'flow'] as AnimMode[]).map((mode, i) => ( type="button"
<button onClick={() => setAnimated((a) => !a)}
key={mode} className="relative w-9 h-5 rounded-full transition-colors focus:outline-none shrink-0"
type="button" style={{ background: animated ? '#00d4ff' : '#30363d' }}
onClick={() => setAnimation(mode)} aria-pressed={animated}
className="flex-1 py-1 text-xs capitalize transition-colors" >
style={{ <span
background: animation === mode ? '#00d4ff22' : '#21262d', className="absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform"
color: animation === mode ? '#00d4ff' : '#8b949e', style={{ transform: animated ? 'translateX(16px)' : 'translateX(0)' }}
borderRight: i < 2 ? '1px solid #30363d' : undefined, />
}} </button>
>
{mode === 'none' ? 'None' : mode === 'snake' ? 'Snake' : 'Flow'}
</button>
))}
</div>
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
@@ -8,18 +8,13 @@ import type { TextPosition } from '@/types'
export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none' export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
export type LabelPosition = 'inside' | 'outside'
export interface GroupRectFormData { export interface GroupRectFormData {
label: string label: string
font: string font: string
text_color: string text_color: string
text_position: TextPosition text_position: TextPosition
text_size: number
label_position: LabelPosition
border_color: string border_color: string
border_style: BorderStyle border_style: BorderStyle
border_width: number
background_color: string background_color: string
z_order: number z_order: number
} }
@@ -32,38 +27,13 @@ const BORDER_STYLES: { value: BorderStyle; label: string; preview: string }[] =
{ value: 'none', label: 'None', preview: ' ' }, { value: 'none', label: 'None', preview: ' ' },
] ]
const TEXT_SIZES: { value: number; label: string }[] = [
{ value: 10, label: '10' },
{ value: 12, label: '12' },
{ value: 14, label: '14' },
{ value: 16, label: '16' },
{ value: 18, label: '18' },
{ value: 20, label: '20' },
]
const LABEL_POSITIONS: { value: LabelPosition; label: string }[] = [
{ value: 'inside', label: 'Inside' },
{ value: 'outside', label: 'Outside' },
]
const BORDER_WIDTHS: { value: number; label: string }[] = [
{ value: 1, label: '1px' },
{ value: 2, label: '2px' },
{ value: 3, label: '3px' },
{ value: 4, label: '4px' },
{ value: 5, label: '5px' },
]
const DEFAULT_FORM: GroupRectFormData = { const DEFAULT_FORM: GroupRectFormData = {
label: '', label: '',
font: 'inter', font: 'inter',
text_color: '#e6edf3', text_color: '#e6edf3',
text_position: 'top-left', text_position: 'top-left',
text_size: 12,
label_position: 'inside',
border_color: '#00d4ff', border_color: '#00d4ff',
border_style: 'solid', border_style: 'solid',
border_width: 2,
background_color: '#00d4ff0d', background_color: '#00d4ff0d',
z_order: 1, z_order: 1,
} }
@@ -95,7 +65,7 @@ interface GroupRectModalProps {
title?: string title?: string
} }
export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, title = 'Add Zone' }: GroupRectModalProps) { export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, title = 'Add Rectangle' }: GroupRectModalProps) {
const [form, setForm] = useState<GroupRectFormData>({ ...DEFAULT_FORM, ...initial }) const [form, setForm] = useState<GroupRectFormData>({ ...DEFAULT_FORM, ...initial })
const set = <K extends keyof GroupRectFormData>(key: K, value: GroupRectFormData[K]) => const set = <K extends keyof GroupRectFormData>(key: K, value: GroupRectFormData[K]) =>
@@ -175,31 +145,6 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
</div> </div>
</div> </div>
{/* Label position */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Label Position</Label>
<div className="grid grid-cols-2 gap-1">
{LABEL_POSITIONS.map(({ value, label }) => {
const isSelected = form.label_position === value
return (
<button
key={value}
type="button"
onClick={() => set('label_position', value)}
className="flex items-center justify-center h-8 rounded text-xs transition-colors"
style={{
background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
color: isSelected ? '#00d4ff' : '#8b949e',
}}
>
{label}
</button>
)
})}
</div>
</div>
{/* Colors */} {/* Colors */}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Colors</Label> <Label className="text-xs text-muted-foreground">Colors</Label>
@@ -224,32 +169,6 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
</div> </div>
</div> </div>
{/* Text size */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Text Size</Label>
<div className="grid grid-cols-6 gap-1">
{TEXT_SIZES.map(({ value, label }) => {
const isSelected = form.text_size === value
return (
<button
key={value}
type="button"
onClick={() => set('text_size', value)}
className="flex items-center justify-center h-8 rounded transition-colors"
style={{
background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
color: isSelected ? '#00d4ff' : '#8b949e',
fontSize: value,
}}
>
{label}
</button>
)
})}
</div>
</div>
{/* Border style */} {/* Border style */}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Border Style</Label> <Label className="text-xs text-muted-foreground">Border Style</Label>
@@ -277,31 +196,6 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
</div> </div>
</div> </div>
{/* Border width */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Border Width</Label>
<div className="grid grid-cols-5 gap-1">
{BORDER_WIDTHS.map(({ value, label }) => {
const isSelected = form.border_width === value
return (
<button
key={value}
type="button"
onClick={() => set('border_width', value)}
className="flex items-center justify-center h-8 rounded text-xs transition-colors"
style={{
background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
color: isSelected ? '#00d4ff' : '#8b949e',
}}
>
{label}
</button>
)
})}
</div>
</div>
{/* Z-order */} {/* Z-order */}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Z-Order (1 = furthest back)</Label> <Label className="text-xs text-muted-foreground">Z-Order (1 = furthest back)</Label>
@@ -336,7 +230,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
Cancel Cancel
</Button> </Button>
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"> <Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90">
{title === 'Add Zone' ? 'Add' : 'Save'} {title === 'Add Rectangle' ? 'Add' : 'Save'}
</Button> </Button>
</div> </div>
</div> </div>
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Plus, Trash2, Settings } from 'lucide-react' import { Plus, Trash2 } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -15,12 +15,16 @@ interface ScanConfigModalProps {
export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalProps) { export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalProps) {
const [ranges, setRanges] = useState<string[]>(['']) const [ranges, setRanges] = useState<string[]>([''])
const [interval, setInterval] = useState(60)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
scanApi.getConfig() scanApi.getConfig()
.then((res) => setRanges(res.data.ranges.length > 0 ? res.data.ranges : [''])) .then((res) => {
setRanges(res.data.ranges.length > 0 ? res.data.ranges : [''])
setInterval(res.data.interval_seconds)
})
.catch(() => {/* use defaults */}) .catch(() => {/* use defaults */})
}, [open]) }, [open])
@@ -29,7 +33,7 @@ export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalPro
if (cleaned.length === 0) { toast.error('Add at least one IP range'); return } if (cleaned.length === 0) { toast.error('Add at least one IP range'); return }
setSaving(true) setSaving(true)
try { try {
await scanApi.saveConfig({ ranges: cleaned }) await scanApi.saveConfig({ ranges: cleaned, interval_seconds: interval })
toast.success('Scan config saved') toast.success('Scan config saved')
onClose() onClose()
} catch { } catch {
@@ -91,10 +95,18 @@ export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalPro
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground flex items-center gap-1.5"> {/* Status check interval */}
<Settings size={11} /> <div className="space-y-1.5">
Status check interval can be configured in the sidebar Settings. <Label className="text-sm text-muted-foreground">Status check interval (seconds)</Label>
</p> <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>
</div> </div>
<DialogFooter className="gap-2"> <DialogFooter className="gap-2">
@@ -97,52 +97,26 @@ describe('EdgeModal', () => {
expect(onSubmit.mock.calls[0][0].path_style).toBe('smooth') expect(onSubmit.mock.calls[0][0].path_style).toBe('smooth')
}) })
// ── Animation select ────────────────────────────────────────────────────── // ── Animated toggle ───────────────────────────────────────────────────────
it('animation defaults to None — animated omitted from payload', () => { it('flow animation defaults to off', () => {
const onSubmit = vi.fn() const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />) render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' })) fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].animated).toBeUndefined() // animated: false → omitted (falsy || undefined)
expect(onSubmit.mock.calls[0][0].animated).toBeFalsy()
}) })
it('selecting Snake sends animated: "snake"', () => { it('toggling animation sends animated: true', () => {
const onSubmit = vi.fn() const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />) render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Snake')) // Find the toggle: it's the only button with aria-pressed attribute
const allButtons = screen.getAllByRole('button')
const toggle = allButtons.find((b) => b.hasAttribute('aria-pressed'))!
expect(toggle).toBeDefined()
fireEvent.click(toggle)
fireEvent.click(screen.getByRole('button', { name: 'Connect' })) fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].animated).toBe('snake') expect(onSubmit.mock.calls[0][0].animated).toBe(true)
})
it('selecting Flow sends animated: "flow"', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Flow'))
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].animated).toBe('flow')
})
it('selecting None after Snake omits animated from payload', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Snake'))
fireEvent.click(screen.getByText('None'))
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].animated).toBeUndefined()
})
it('pre-fills animation from initial "snake" string', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ animated: 'snake' }} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].animated).toBe('snake')
})
it('pre-fills animation from legacy initial true (backward compat)', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ animated: true }} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].animated).toBe('snake')
}) })
// ── Pre-fill ────────────────────────────────────────────────────────────── // ── Pre-fill ──────────────────────────────────────────────────────────────
@@ -13,15 +13,14 @@ describe('GroupRectModal', () => {
it('renders form fields when open', () => { it('renders form fields when open', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />) render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByPlaceholderText('Zone name…')).toBeDefined() expect(screen.getByPlaceholderText('Zone name…')).toBeDefined()
expect(screen.getByText('Add Zone')).toBeDefined() expect(screen.getByText('Add Rectangle')).toBeDefined()
expect(screen.getByText('Text Position')).toBeDefined() expect(screen.getByText('Text Position')).toBeDefined()
expect(screen.getByText('Border Width')).toBeDefined()
expect(screen.getByText('Z-Order (1 = furthest back)')).toBeDefined() expect(screen.getByText('Z-Order (1 = furthest back)')).toBeDefined()
}) })
it('renders Edit Zone title when provided', () => { it('renders Edit Rectangle title when provided', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Zone" />) render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Rectangle" />)
expect(screen.getByText('Edit Zone')).toBeDefined() expect(screen.getByText('Edit Rectangle')).toBeDefined()
}) })
it('calls onSubmit with form data on submit', () => { it('calls onSubmit with form data on submit', () => {
@@ -124,124 +123,6 @@ describe('GroupRectModal', () => {
expect(submitted.border_style).toBe('dotted') expect(submitted.border_style).toBe('dotted')
}) })
it('renders Label Position section with inside/outside options', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByText('Label Position')).toBeDefined()
expect(screen.getByText('Inside')).toBeDefined()
expect(screen.getByText('Outside')).toBeDefined()
})
it('defaults label_position to inside', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.label_position).toBe('inside')
})
it('selects outside label position on click', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Outside'))
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.label_position).toBe('outside')
})
it('pre-fills label_position from initial prop', () => {
const onSubmit = vi.fn()
render(
<GroupRectModal
open
onClose={vi.fn()}
onSubmit={onSubmit}
initial={{ label_position: 'outside' }}
/>
)
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.label_position).toBe('outside')
})
it('renders Text Size section with 6 options', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByText('Text Size')).toBeDefined()
expect(screen.getByText('10')).toBeDefined()
expect(screen.getByText('20')).toBeDefined()
})
it('defaults text_size to 12', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.text_size).toBe(12)
})
it('selects text size on click', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('18'))
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.text_size).toBe(18)
})
it('pre-fills text_size from initial prop', () => {
const onSubmit = vi.fn()
render(
<GroupRectModal
open
onClose={vi.fn()}
onSubmit={onSubmit}
initial={{ text_size: 16 }}
/>
)
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.text_size).toBe(16)
})
it('renders Border Width section with 5 options', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByText('Border Width')).toBeDefined()
expect(screen.getByText('1px')).toBeDefined()
expect(screen.getByText('3px')).toBeDefined()
expect(screen.getByText('5px')).toBeDefined()
})
it('defaults border_width to 2', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.border_width).toBe(2)
})
it('selects border width on click', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('4px'))
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.border_width).toBe(4)
})
it('pre-fills border_width from initial prop', () => {
const onSubmit = vi.fn()
render(
<GroupRectModal
open
onClose={vi.fn()}
onSubmit={onSubmit}
initial={{ border_width: 5 }}
/>
)
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.border_width).toBe(5)
})
it('toggles border style — clicking selected style deselects back to solid', () => { it('toggles border style — clicking selected style deselects back to solid', () => {
const onSubmit = vi.fn() const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />) render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
@@ -13,12 +13,11 @@ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.f
import { scanApi } from '@/api/client' import { scanApi } from '@/api/client'
import { toast } from 'sonner' import { toast } from 'sonner'
const defaultConfig = { data: { ranges: ['192.168.1.0/24'] } } const defaultConfig = { data: { ranges: ['192.168.1.0/24'], interval_seconds: 60 } }
describe('ScanConfigModal', () => { describe('ScanConfigModal', () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(scanApi.getConfig).mockResolvedValue(defaultConfig as never) vi.mocked(scanApi.getConfig).mockResolvedValue(defaultConfig as never)
vi.mocked(scanApi.saveConfig).mockReset()
vi.mocked(scanApi.saveConfig).mockResolvedValue({} as never) vi.mocked(scanApi.saveConfig).mockResolvedValue({} as never)
vi.mocked(toast.success).mockReset() vi.mocked(toast.success).mockReset()
vi.mocked(toast.error).mockReset() vi.mocked(toast.error).mockReset()
@@ -38,14 +37,11 @@ describe('ScanConfigModal', () => {
expect(input).toBeDefined() expect(input).toBeDefined()
}) })
it('saves only ranges (interval managed by settings endpoint)', async () => { it('loads interval from API on open', async () => {
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['10.0.0.0/8'] } } as never) vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['10.0.0.0/8'], interval_seconds: 120 } } as never)
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />) render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('10.0.0.0/8') const input = await screen.findByDisplayValue('120')
fireEvent.click(screen.getByRole('button', { name: 'Save' })) expect(input).toBeDefined()
await waitFor(() => {
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['10.0.0.0/8'] })
})
}) })
it('adds a new empty range on "Add range" click', async () => { it('adds a new empty range on "Add range" click', async () => {
@@ -65,7 +61,7 @@ describe('ScanConfigModal', () => {
}) })
it('can remove a range when more than one exist', async () => { it('can remove a range when more than one exist', async () => {
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['192.168.1.0/24', '10.0.0.0/8'], } } as never) vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['192.168.1.0/24', '10.0.0.0/8'], interval_seconds: 60 } } as never)
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />) render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24') await screen.findByDisplayValue('192.168.1.0/24')
// Both trash buttons should be enabled // Both trash buttons should be enabled
@@ -74,7 +70,7 @@ describe('ScanConfigModal', () => {
}) })
it('shows error toast and does not save when all ranges are empty', async () => { it('shows error toast and does not save when all ranges are empty', async () => {
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: [''], } } as never) vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: [''], interval_seconds: 60 } } as never)
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />) render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await waitFor(() => expect(scanApi.getConfig).toHaveBeenCalled()) await waitFor(() => expect(scanApi.getConfig).toHaveBeenCalled())
fireEvent.click(screen.getByRole('button', { name: 'Save' })) fireEvent.click(screen.getByRole('button', { name: 'Save' }))
@@ -90,7 +86,7 @@ describe('ScanConfigModal', () => {
await screen.findByDisplayValue('192.168.1.0/24') await screen.findByDisplayValue('192.168.1.0/24')
fireEvent.click(screen.getByRole('button', { name: 'Save' })) fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => { await waitFor(() => {
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'] }) expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'], interval_seconds: 60 })
expect(toast.success).toHaveBeenCalledWith('Scan config saved') expect(toast.success).toHaveBeenCalledWith('Scan config saved')
expect(onClose).toHaveBeenCalledOnce() expect(onClose).toHaveBeenCalledOnce()
}) })
+150 -297
View File
@@ -1,83 +1,34 @@
import { useState } from 'react' import { useState } from 'react'
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react' import { X, Edit, Trash2, ExternalLink, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData } from '@/types' import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo } from '@/types'
import { getServiceUrl } from '@/utils/serviceUrl' import { getServiceUrl } from '@/utils/serviceUrl'
import type { Node } from '@xyflow/react'
interface DetailPanelProps { interface DetailPanelProps {
onEdit: (id: string) => void onEdit: (id: string) => void
} }
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string }
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '' }
export function DetailPanel({ onEdit }: DetailPanelProps) { export function DetailPanel({ onEdit }: DetailPanelProps) {
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup } = useCanvasStore() const { nodes, selectedNodeId, setSelectedNode, deleteNode, updateNode } = 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) const node = nodes.find((n) => n.id === selectedNodeId)
const [addingService, setAddingService] = useState(false)
const [newSvc, setNewSvc] = useState<{ port: string; protocol: 'tcp' | 'udp'; service_name: string }>({
port: '',
protocol: 'tcp',
service_name: '',
})
if (!node || node.data.type === 'groupRect') return null if (!node || node.data.type === 'groupRect') return null
// Group detail panel
if (node.data.type === 'group') {
return (
<GroupDetailPanel
node={node}
nodes={nodes}
onUngroup={() => { ungroup(node.id) }}
onToggleBorder={() => {
snapshotHistory()
updateNode(node.id, {
custom_colors: {
...node.data.custom_colors,
show_border: !(node.data.custom_colors?.show_border !== false),
},
})
}}
onClose={() => setSelectedNode(null)}
onSelectChild={(id) => setSelectedNode(id)}
/>
)
}
// Normal single-node panel
const addingService = addingForNode === node.id
const editingIndex = editingFor?.nodeId === node.id ? editingFor.index : null
const { data } = node const { data } = node
const services = data.services ?? []
const statusColor = STATUS_COLORS[data.status] const statusColor = STATUS_COLORS[data.status]
const host = data.ip ?? data.hostname const host = data.ip ?? data.hostname
const handleDelete = () => { const handleDelete = () => {
if (confirm(`Delete "${data.label}"?`)) { if (confirm(`Delete "${data.label}"?`)) {
snapshotHistory()
deleteNode(node.id) deleteNode(node.id)
} }
} }
@@ -85,46 +36,35 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
const handleAddService = () => { const handleAddService = () => {
const port = parseInt(newSvc.port, 10) const port = parseInt(newSvc.port, 10)
if (!newSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return if (!newSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
const svc: ServiceInfo = { port, protocol: newSvc.protocol, service_name: newSvc.service_name.trim() } const svc: ServiceInfo = {
updateNode(node.id, { services: [...services, svc] }) port,
setNewSvc(EMPTY_FORM) protocol: newSvc.protocol,
setAddingForNode(null) service_name: newSvc.service_name.trim(),
}
updateNode(node.id, { services: [...(data.services ?? []), svc] })
setNewSvc({ port: '', protocol: 'tcp', service_name: '' })
setAddingService(false)
} }
const handleRemoveService = (index: number) => { const handleRemoveService = (index: number) => {
const updated = services.filter((_, i) => i !== index) const updated = data.services.filter((_, i) => i !== index)
updateNode(node.id, { services: updated }) updateNode(node.id, { services: updated })
if (editingIndex === index) setEditingFor(null)
}
const handleStartEdit = (index: number) => {
const svc = services[index]
if (!svc) return
setEditSvc({ port: String(svc.port), protocol: svc.protocol, service_name: svc.service_name })
setEditingFor({ nodeId: node.id, index })
setAddingForNode(null)
}
const handleSaveEdit = () => {
if (editingIndex === null) return
const port = parseInt(editSvc.port, 10)
if (!editSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
const updated = services.map((svc, i) =>
i === editingIndex ? { ...svc, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() } : svc
)
updateNode(node.id, { services: updated })
setEditingFor(null)
} }
return ( return (
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto"> <aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border"> <div className="flex items-center justify-between px-4 py-3 border-b border-border">
<span className="font-semibold text-sm text-foreground truncate">{data.label}</span> <span className="font-semibold text-sm text-foreground truncate">{data.label}</span>
<button aria-label="Close panel" onClick={() => setSelectedNode(null)} className="text-muted-foreground hover:text-foreground transition-colors"> <button
onClick={() => setSelectedNode(null)}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X size={16} /> <X size={16} />
</button> </button>
</div> </div>
{/* Status */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border"> <div className="flex items-center gap-2 px-4 py-3 border-b border-border">
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: statusColor }} /> <div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: statusColor }} />
<span className="text-sm capitalize" style={{ color: statusColor }}>{data.status}</span> <span className="text-sm capitalize" style={{ color: statusColor }}>{data.status}</span>
@@ -133,13 +73,21 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
)} )}
</div> </div>
{/* Details */}
<div className="flex flex-col gap-3 px-4 py-3 text-sm"> <div className="flex flex-col gap-3 px-4 py-3 text-sm">
<DetailRow label="Type" value={NODE_TYPE_LABELS[data.type]} /> <DetailRow label="Type" value={NODE_TYPE_LABELS[data.type]} />
{data.hostname && ( {data.hostname && (
<div className="flex justify-between gap-2 items-baseline"> <div className="flex justify-between gap-2 items-baseline">
<span className="text-muted-foreground text-xs shrink-0">Hostname</span> <span className="text-muted-foreground text-xs shrink-0">Hostname</span>
<a 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}> <a
{data.hostname}<ExternalLink size={10} className="shrink-0" /> 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> </a>
</div> </div>
)} )}
@@ -147,9 +95,12 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
{data.mac && <DetailRow label="MAC" value={data.mac} mono />} {data.mac && <DetailRow label="MAC" value={data.mac} mono />}
{data.os && <DetailRow label="OS" value={data.os} />} {data.os && <DetailRow label="OS" value={data.os} />}
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />} {data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
{data.last_seen && <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> </div>
{/* Hardware */}
{(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null) && ( {(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null) && (
<div className="flex flex-col gap-3 px-4 py-3 text-sm border-t border-border"> <div className="flex flex-col gap-3 px-4 py-3 text-sm border-t border-border">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Hardware</span> <span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Hardware</span>
@@ -160,28 +111,88 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
</div> </div>
)} )}
{/* Services */}
<div className="px-4 py-3 border-t border-border"> <div className="px-4 py-3 border-t border-border">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground">Services{services.length > 0 ? ` (${services.length})` : ''}</span> <span className="text-xs text-muted-foreground">
<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"> Services{data.services.length > 0 ? ` (${data.services.length})` : ''}
</span>
<button
onClick={() => setAddingService((v) => !v)}
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors"
>
<Plus size={10} /> Add <Plus size={10} /> Add
</button> </button>
</div> </div>
{addingService && <ServiceForm form={newSvc} onChange={setNewSvc} onConfirm={handleAddService} onCancel={() => setAddingForNode(null)} confirmLabel="Add" autoFocus />}
{services.length > 0 && ( {/* Add service form */}
<div className="flex flex-col gap-1.5"> {addingService && (
{services.map((svc, i) => <div className="flex flex-col gap-1.5 mb-2 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
editingIndex === i ? ( <Input
<ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus /> value={newSvc.service_name}
) : ( onChange={(e) => setNewSvc((s) => ({ ...s, service_name: e.target.value }))}
<ServiceBadge key={`${svc.port}-${svc.protocol}-${i}`} svc={svc} host={host} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} /> placeholder="Service name"
) className="bg-[#21262d] border-[#30363d] text-xs h-7"
)} autoFocus
/>
<div className="flex gap-1.5">
<Input
type="number"
value={newSvc.port}
onChange={(e) => setNewSvc((s) => ({ ...s, 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"
/>
<select
value={newSvc.protocol}
onChange={(e) => setNewSvc((s) => ({ ...s, 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={handleAddService}
>
Add
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 text-[10px]"
onClick={() => setAddingService(false)}
>
Cancel
</Button>
</div>
</div> </div>
)} )}
{services.length === 0 && !addingService && <p className="text-[10px] text-muted-foreground/50">No services click Add to register one.</p>}
{data.services.length > 0 && (
<div className="flex flex-col gap-1.5">
{data.services.map((svc, i) => (
<ServiceBadge
key={`${svc.port}-${svc.protocol}-${i}`}
svc={svc}
host={host}
onRemove={() => handleRemoveService(i)}
/>
))}
</div>
)}
{data.services.length === 0 && !addingService && (
<p className="text-[10px] text-muted-foreground/50">No services click Add to register one.</p>
)}
</div> </div>
{/* Notes */}
{data.notes && ( {data.notes && (
<div className="px-4 py-3 border-t border-border"> <div className="px-4 py-3 border-t border-border">
<div className="text-xs text-muted-foreground mb-1">Notes</div> <div className="text-xs text-muted-foreground mb-1">Notes</div>
@@ -189,11 +200,12 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
</div> </div>
)} )}
{/* Actions */}
<div className="mt-auto flex gap-2 px-4 py-3 border-t border-border"> <div className="mt-auto flex gap-2 px-4 py-3 border-t border-border">
<Button size="sm" variant="secondary" className="flex-1 gap-1.5" onClick={() => onEdit(node.id)}> <Button size="sm" variant="secondary" className="flex-1 gap-1.5" onClick={() => onEdit(node.id)}>
<Edit size={14} /> Edit <Edit size={14} /> Edit
</Button> </Button>
<Button size="sm" variant="destructive" className="gap-1.5" aria-label="Delete node" onClick={handleDelete}> <Button size="sm" variant="destructive" className="gap-1.5" onClick={handleDelete}>
<Trash2 size={14} /> <Trash2 size={14} />
</Button> </Button>
</div> </div>
@@ -201,167 +213,6 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
) )
} }
// --- Multi-select panel ---
interface MultiSelectPanelProps {
nodeIds: string[]
nodes: Node<NodeData>[]
groupName: string
setGroupName: (v: string) => void
creatingGroup: boolean
setCreatingGroup: (v: boolean) => void
onCreateGroup: (name: string) => void
onClose: () => void
}
function MultiSelectPanel({ nodeIds, nodes, groupName, setGroupName, creatingGroup, setCreatingGroup, onCreateGroup, onClose }: MultiSelectPanelProps) {
const selectedNodes = nodeIds.map((id) => nodes.find((n) => n.id === id)).filter(Boolean) as Node<NodeData>[]
const handleCreate = () => {
const name = groupName.trim() || 'Group'
onCreateGroup(name)
}
return (
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<div className="flex items-center gap-2">
<Layers size={14} className="text-[#00d4ff]" />
<span className="font-semibold text-sm text-foreground">{nodeIds.length} nodes selected</span>
</div>
<button aria-label="Close panel" onClick={onClose} className="text-muted-foreground hover:text-foreground transition-colors">
<X size={16} />
</button>
</div>
<div className="flex-1 px-4 py-3 space-y-1.5 overflow-y-auto">
{selectedNodes.map((n) => (
<div key={n.id} className="flex items-center gap-2 px-2 py-1.5 rounded-md bg-[#21262d] text-xs">
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: STATUS_COLORS[n.data.status] }} />
<span className="truncate text-foreground font-medium">{n.data.label}</span>
<span className="ml-auto text-muted-foreground shrink-0">{NODE_TYPE_LABELS[n.data.type] ?? n.data.type}</span>
</div>
))}
</div>
<div className="px-4 py-3 border-t border-border space-y-2">
{creatingGroup ? (
<>
<Input
autoFocus
placeholder="Group name…"
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); if (e.key === 'Escape') setCreatingGroup(false) }}
className="bg-[#21262d] border-[#30363d] text-xs h-7"
/>
<div className="flex gap-2">
<Button size="sm" className="flex-1 h-7 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={handleCreate}>
Create Group
</Button>
<Button size="sm" variant="ghost" className="h-7 text-[10px]" onClick={() => setCreatingGroup(false)}>
Cancel
</Button>
</div>
</>
) : (
<Button
size="sm"
className="w-full gap-2 bg-[#00d4ff]/10 text-[#00d4ff] border border-[#00d4ff]/30 hover:bg-[#00d4ff]/20"
variant="ghost"
onClick={() => setCreatingGroup(true)}
>
<Layers size={13} /> Create Group
</Button>
)}
</div>
</aside>
)
}
// --- Group detail panel ---
interface GroupDetailPanelProps {
node: Node<NodeData>
nodes: Node<NodeData>[]
onUngroup: () => void
onToggleBorder: () => void
onClose: () => void
onSelectChild: (id: string) => void
}
function GroupDetailPanel({ node, nodes, onUngroup, onToggleBorder, onClose, onSelectChild }: GroupDetailPanelProps) {
const children = nodes.filter((n) => n.parentId === node.id)
const onlineCount = children.filter((n) => n.data.status === 'online').length
const offlineCount = children.filter((n) => n.data.status === 'offline').length
const showBorder = node.data.custom_colors?.show_border !== false
const handleUngroup = () => {
if (confirm(`Ungroup "${node.data.label}"? Nodes will be released to the canvas.`)) {
onUngroup()
}
}
return (
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<div className="flex items-center gap-2 min-w-0">
<Layers size={14} className="text-[#00d4ff] shrink-0" />
<span className="font-semibold text-sm text-foreground truncate">{node.data.label}</span>
</div>
<button aria-label="Close panel" onClick={onClose} className="text-muted-foreground hover:text-foreground transition-colors shrink-0">
<X size={16} />
</button>
</div>
{/* Status summary */}
<div className="flex items-center gap-4 px-4 py-3 border-b border-border text-xs">
<span className="text-muted-foreground">{children.length} node{children.length !== 1 ? 's' : ''}</span>
{onlineCount > 0 && <span style={{ color: STATUS_COLORS.online }}> {onlineCount} online</span>}
{offlineCount > 0 && <span style={{ color: STATUS_COLORS.offline }}> {offlineCount} offline</span>}
</div>
{/* Children list */}
<div className="flex-1 px-4 py-3 space-y-1.5 overflow-y-auto">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Members</span>
{children.length === 0 && <p className="text-xs text-muted-foreground/50">No nodes in this group.</p>}
{children.map((child) => (
<button
key={child.id}
onClick={() => onSelectChild(child.id)}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md bg-[#21262d] text-xs hover:bg-[#30363d] transition-colors text-left"
>
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: STATUS_COLORS[child.data.status] }} />
<span className="truncate text-foreground font-medium">{child.data.label}</span>
<span className="ml-auto text-muted-foreground shrink-0">{NODE_TYPE_LABELS[child.data.type] ?? child.data.type}</span>
</button>
))}
</div>
{/* Actions */}
<div className="px-4 py-3 border-t border-border space-y-2">
<button
onClick={onToggleBorder}
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-xs text-muted-foreground hover:text-foreground hover:bg-[#21262d] transition-colors"
>
{showBorder ? <Eye size={13} /> : <EyeOff size={13} />}
{showBorder ? 'Hide border & title' : 'Show border & title'}
</button>
<Button
size="sm"
variant="destructive"
className="w-full gap-2"
onClick={handleUngroup}
>
<Ungroup size={13} /> Ungroup
</Button>
</div>
</aside>
)
}
// --- Helpers ---
function formatStorage(gb: number): string { function formatStorage(gb: number): string {
if (gb >= 1024) return `${(gb / 1024).toFixed(1).replace(/\.0$/, '')} TB` if (gb >= 1024) return `${(gb / 1024).toFixed(1).replace(/\.0$/, '')} TB`
return `${gb} GB` return `${gb} GB`
@@ -371,48 +222,38 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono?
return ( return (
<div className="flex justify-between gap-2 items-baseline"> <div className="flex justify-between gap-2 items-baseline">
<span className="text-muted-foreground text-xs shrink-0">{label}</span> <span className="text-muted-foreground text-xs shrink-0">{label}</span>
<span 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} {value}
</span> </span>
</div> </div>
) )
} }
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
onCancel: () => void
confirmLabel: string
autoFocus?: boolean
}) {
return (
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
<Input value={form.service_name} onChange={(e) => onChange({ ...form, service_name: e.target.value })} placeholder="Service name" className="bg-[#21262d] border-[#30363d] text-xs h-7" autoFocus={autoFocus} onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
<div className="flex gap-1.5">
<Input type="number" value={form.port} onChange={(e) => onChange({ ...form, port: e.target.value })} placeholder="Port" min={1} max={65535} className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-20 shrink-0" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
<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>
</div>
</div>
)
}
const CATEGORY_COLORS: Record<string, string> = { 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, onRemove }: { svc: ServiceInfo; host?: string; onRemove: () => void }) {
const url = getServiceUrl(svc, host) const url = getServiceUrl(svc, host)
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e' const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
const inner = ( const inner = (
<div 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"> <div className="flex items-center gap-1.5 min-w-0">
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} /> <span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
<span className="font-medium truncate" style={{ color }}>{svc.service_name}</span> <span className="font-medium truncate" style={{ color }}>{svc.service_name}</span>
@@ -420,11 +261,23 @@ function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?
<div className="flex items-center gap-1.5 shrink-0"> <div className="flex items-center gap-1.5 shrink-0">
<span className="font-mono text-[#8b949e]">{svc.port}/{svc.protocol}</span> <span className="font-mono text-[#8b949e]">{svc.port}/{svc.protocol}</span>
{url && <ExternalLink size={10} className="text-muted-foreground" />} {url && <ExternalLink size={10} className="text-muted-foreground" />}
<button 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
<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> onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }}
className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5"
title="Remove service"
>
<X size={10} />
</button>
</div> </div>
</div> </div>
) )
if (url) return <a href={url} target="_blank" rel="noopener noreferrer" className="block hover:opacity-80 transition-opacity">{inner}</a>
if (url) {
return (
<a href={url} target="_blank" rel="noopener noreferrer" className="block hover:opacity-80 transition-opacity">
{inner}
</a>
)
}
return inner return inner
} }
+6 -107
View File
@@ -1,15 +1,15 @@
import { useState, useCallback, useEffect, useRef } from 'react' import { useState, useCallback, useEffect, useRef } from 'react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle } from 'lucide-react' import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye } from 'lucide-react'
import { Logo } from '@/components/ui/Logo' import { Logo } from '@/components/ui/Logo'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { scanApi, settingsApi } from '@/api/client' import { scanApi } from '@/api/client'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal' import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history' | 'settings' type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history'
const ALL_VIEWS = [ const ALL_VIEWS = [
{ id: 'canvas' as SidebarView, icon: LayoutDashboard, label: 'Canvas' }, { id: 'canvas' as SidebarView, icon: LayoutDashboard, label: 'Canvas' },
@@ -95,7 +95,6 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
{activeView === 'pending' && <PendingDevicesPanel onNodeApproved={onNodeApproved} />} {activeView === 'pending' && <PendingDevicesPanel onNodeApproved={onNodeApproved} />}
{activeView === 'hidden' && <HiddenDevicesPanel />} {activeView === 'hidden' && <HiddenDevicesPanel />}
{activeView === 'history' && <ScanHistoryPanel />} {activeView === 'history' && <ScanHistoryPanel />}
{activeView === 'settings' && <SettingsPanel />}
</div> </div>
)} )}
@@ -125,7 +124,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
{/* Actions */} {/* Actions */}
<div className="flex flex-col gap-0.5 p-2 border-t border-border"> <div className="flex flex-col gap-0.5 p-2 border-t border-border">
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} /> <SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} /> <SidebarItem icon={Square} label="Add Rectangle" collapsed={collapsed} onClick={onAddGroupRect} />
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />} {!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
<SidebarItem <SidebarItem
icon={hideIp ? EyeOff : Eye} icon={hideIp ? EyeOff : Eye}
@@ -142,15 +141,6 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
badge={hasUnsavedChanges} badge={hasUnsavedChanges}
accent accent
/> />
{!STANDALONE && (
<SidebarItem
icon={Settings}
label="Settings"
collapsed={collapsed}
active={activeView === 'settings'}
onClick={() => setActiveView((v) => v === 'settings' ? 'canvas' : 'settings')}
/>
)}
</div> </div>
</aside> </aside>
) )
@@ -313,7 +303,7 @@ function HiddenDevicesPanel() {
} }
}, []) }, [])
useEffect(() => { load() }, [load]) useState(() => { load() })
const handleIgnore = async (id: string) => { const handleIgnore = async (id: string) => {
try { try {
@@ -387,26 +377,8 @@ function ScanHistoryPanel() {
return () => clearInterval(id) return () => clearInterval(id)
}, [runs, load]) }, [runs, load])
const [stopping, setStopping] = useState<string | null>(null)
const handleStop = async (runId: string) => {
setStopping(runId)
try {
await scanApi.stop(runId)
toast.success('Scan stop requested')
} catch {
toast.error('Failed to stop scan')
} finally {
setStopping(null)
}
}
const statusColor = (s: string) => const statusColor = (s: string) =>
s === 'done' ? '#39d353' s === 'done' ? '#39d353' : s === 'running' ? '#e3b341' : s === 'error' ? '#f85149' : '#8b949e'
: s === 'running' ? '#e3b341'
: s === 'error' ? '#f85149'
: s === 'cancelled' ? '#8b949e'
: '#8b949e'
return ( return (
<div className="p-2"> <div className="p-2">
@@ -427,24 +399,6 @@ function ScanHistoryPanel() {
<span className="font-mono text-foreground capitalize">{r.status}</span> <span className="font-mono text-foreground capitalize">{r.status}</span>
{r.status === 'running' && <Loader2 size={10} className="animate-spin text-[#e3b341]" />} {r.status === 'running' && <Loader2 size={10} className="animate-spin text-[#e3b341]" />}
<span className="ml-auto text-muted-foreground font-mono">{r.devices_found} found</span> <span className="ml-auto text-muted-foreground font-mono">{r.devices_found} found</span>
{r.status === 'running' && (
<Tooltip>
<TooltipTrigger>
<button
aria-label="Stop scan"
onClick={() => handleStop(r.id)}
disabled={stopping === r.id}
className="p-0.5 text-[#f85149] hover:bg-[#f85149]/10 rounded transition-colors disabled:opacity-50"
>
{stopping === r.id
? <Loader2 size={11} className="animate-spin" />
: <StopCircle size={11} />
}
</button>
</TooltipTrigger>
<TooltipContent side="left">Stop scan</TooltipContent>
</Tooltip>
)}
</div> </div>
<div className="text-muted-foreground text-[10px] mt-0.5"> <div className="text-muted-foreground text-[10px] mt-0.5">
{new Date(r.started_at).toLocaleString()} {new Date(r.started_at).toLocaleString()}
@@ -463,61 +417,6 @@ 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 }> = { const MAC_OUI: Record<string, { label: string; title: string }> = {
'52:54:00': { label: 'QEMU', title: 'QEMU/KVM Virtual Machine' }, '52:54:00': { label: 'QEMU', title: 'QEMU/KVM Virtual Machine' },
'bc:24:11': { label: 'PVE', title: 'Proxmox Virtual Machine or LXC' }, 'bc:24:11': { label: 'PVE', title: 'Proxmox Virtual Machine or LXC' },
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { DetailPanel } from '../DetailPanel' import { DetailPanel } from '../DetailPanel'
import * as canvasStore from '@/stores/canvasStore' import * as canvasStore from '@/stores/canvasStore'
import type { NodeData } from '@/types' import type { NodeData } from '@/types'
@@ -26,13 +26,9 @@ function setupStore(nodeData: Partial<NodeData> = {}) {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode(nodeData)], nodes: [makeNode(nodeData)],
selectedNodeId: 'n1', selectedNodeId: 'n1',
selectedNodeIds: [],
setSelectedNode: vi.fn(), setSelectedNode: vi.fn(),
deleteNode: vi.fn(), deleteNode: vi.fn(),
updateNode: vi.fn(), updateNode: vi.fn(),
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>) } as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
} }
@@ -41,13 +37,9 @@ describe('DetailPanel', () => {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [], nodes: [],
selectedNodeId: null, selectedNodeId: null,
selectedNodeIds: [],
setSelectedNode: vi.fn(), setSelectedNode: vi.fn(),
deleteNode: vi.fn(), deleteNode: vi.fn(),
updateNode: vi.fn(), updateNode: vi.fn(),
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>) } as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
}) })
@@ -123,182 +115,4 @@ describe('DetailPanel', () => {
expect(screen.getByText('4 TB')).toBeDefined() expect(screen.getByText('4 TB')).toBeDefined()
}) })
}) })
describe('Panel actions', () => {
it('calls setSelectedNode(null) when close button is clicked', () => {
const setSelectedNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({})],
selectedNodeId: 'n1',
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'))
expect(setSelectedNode).toHaveBeenCalledWith(null)
})
it('calls onEdit with node id when Edit button is clicked', () => {
setupStore({})
const onEdit = vi.fn()
render(<DetailPanel onEdit={onEdit} />)
fireEvent.click(screen.getByRole('button', { name: /edit/i }))
expect(onEdit).toHaveBeenCalledWith('n1')
})
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 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()
})
})
describe('Services — add/remove', () => {
it('shows add form when Add is clicked', () => {
setupStore({})
render(<DetailPanel onEdit={vi.fn()} />)
fireEvent.click(screen.getByText('Add'))
expect(screen.getByPlaceholderText('Service name')).toBeDefined()
})
it('calls updateNode with new service on Add confirm', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({})],
selectedNodeId: 'n1',
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'))
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } })
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } })
// Two "Add" buttons exist: the header toggle and the form confirm — pick the form's
const addButtons = screen.getAllByRole('button', { name: 'Add' })
fireEvent.click(addButtons[addButtons.length - 1])
expect(updateNode).toHaveBeenCalledOnce()
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp' })
})
it('calls updateNode without the removed service when X is clicked', () => {
const updateNode = vi.fn()
const svc = { port: 80, protocol: 'tcp' as const, service_name: 'nginx' }
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ services: [svc] })],
selectedNodeId: 'n1',
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'))
expect(updateNode).toHaveBeenCalledOnce()
expect(updateNode.mock.calls[0][1].services).toHaveLength(0)
})
it('does not crash when data.services is undefined', () => {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ services: undefined as unknown as [] })],
selectedNodeId: 'n1',
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()
})
})
describe('Services — edit', () => {
const svc = { port: 80, protocol: 'tcp' as const, service_name: 'nginx' }
it('shows edit form pre-filled when pencil is clicked', () => {
setupStore({ services: [svc] })
render(<DetailPanel onEdit={vi.fn()} />)
// Hover to reveal edit button (fireEvent.mouseOver isn't needed — opacity is CSS only)
const editBtn = screen.getByTitle('Edit service')
fireEvent.click(editBtn)
const nameInput = screen.getByPlaceholderText('Service name') as HTMLInputElement
expect(nameInput.value).toBe('nginx')
const portInput = screen.getByPlaceholderText('Port') as HTMLInputElement
expect(portInput.value).toBe('80')
})
it('calls updateNode with updated values on Save', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ services: [svc] })],
selectedNodeId: 'n1',
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('Edit service'))
const nameInput = screen.getByPlaceholderText('Service name')
fireEvent.change(nameInput, { target: { value: 'apache' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(updateNode).toHaveBeenCalledOnce()
expect(updateNode.mock.calls[0][1].services[0].service_name).toBe('apache')
expect(updateNode.mock.calls[0][1].services[0].port).toBe(80)
})
it('cancels edit without updating', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ services: [svc] })],
selectedNodeId: 'n1',
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('Edit service'))
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(updateNode).not.toHaveBeenCalled()
expect(screen.getByText('nginx')).toBeDefined()
})
})
}) })
@@ -1,233 +0,0 @@
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')
})
})
@@ -1,155 +0,0 @@
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()
})
})
@@ -1,104 +0,0 @@
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,15 +111,6 @@
background-color: var(--surface-card) !important; background-color: var(--surface-card) !important;
} }
/* Transparent wrapper for container node types */
.react-flow__node-proxmox,
.react-flow__node-group {
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
}
/* Mono font utility */ /* Mono font utility */
.font-mono { .font-mono {
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
+1 -4
View File
@@ -2,12 +2,9 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
import LiveView from './components/LiveView.tsx'
const isLiveView = window.location.pathname === '/view'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
{isLiveView ? <LiveView /> : <App />} <App />
</StrictMode>, </StrictMode>,
) )
@@ -25,7 +25,6 @@ describe('canvasStore', () => {
edges: [], edges: [],
hasUnsavedChanges: false, hasUnsavedChanges: false,
selectedNodeId: null, selectedNodeId: null,
selectedNodeIds: [],
editingGroupRectId: null, editingGroupRectId: null,
past: [], past: [],
future: [], future: [],
@@ -99,32 +98,18 @@ describe('canvasStore', () => {
expect(useCanvasStore.getState().selectedNodeId).toBeNull() expect(useCanvasStore.getState().selectedNodeId).toBeNull()
}) })
it('onNodesChange marks unsaved for position changes', () => { it('onNodesChange marks unsaved', () => {
useCanvasStore.getState().addNode(makeNode('n1'))
useCanvasStore.getState().markSaved()
useCanvasStore.getState().onNodesChange([{ type: 'position', id: 'n1', dragging: false }])
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
it('onNodesChange does not mark unsaved for select-only changes', () => {
useCanvasStore.getState().addNode(makeNode('n1')) useCanvasStore.getState().addNode(makeNode('n1'))
useCanvasStore.getState().markSaved() useCanvasStore.getState().markSaved()
useCanvasStore.getState().onNodesChange([{ type: 'select', id: 'n1', selected: true }]) useCanvasStore.getState().onNodesChange([{ type: 'select', id: 'n1', selected: true }])
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
})
it('onEdgesChange marks unsaved for remove changes', () => {
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] }))
useCanvasStore.getState().markSaved()
useCanvasStore.getState().onEdgesChange([{ type: 'remove', id: 'e1' }])
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
}) })
it('onEdgesChange does not mark unsaved for select-only changes', () => { it('onEdgesChange marks unsaved', () => {
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] })) useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] }))
useCanvasStore.getState().markSaved() useCanvasStore.getState().markSaved()
useCanvasStore.getState().onEdgesChange([{ type: 'select', id: 'e1', selected: true }]) useCanvasStore.getState().onEdgesChange([{ type: 'select', id: 'e1', selected: true }])
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false) expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
}) })
it('onConnect adds an edge between two nodes', () => { it('onConnect adds an edge between two nodes', () => {
@@ -145,13 +130,6 @@ describe('canvasStore', () => {
expect(edges[0].data?.label).toBe('uplink') expect(edges[0].data?.label).toBe('uplink')
}) })
it('onConnect preserves animated from edge data', () => {
const conn = Object.assign({ source: 'n1', target: 'n2', sourceHandle: null, targetHandle: null }, { type: 'ethernet', animated: 'snake' })
useCanvasStore.getState().onConnect(conn)
const { edges } = useCanvasStore.getState()
expect(edges[0].data?.animated).toBe('snake')
})
it('onConnect preserves sourceHandle and targetHandle for cluster edges', () => { it('onConnect preserves sourceHandle and targetHandle for cluster edges', () => {
const conn = Object.assign({ source: 'n1', target: 'n2', sourceHandle: 'cluster-right', targetHandle: 'cluster-left' }, { type: 'cluster' }) const conn = Object.assign({ source: 'n1', target: 'n2', sourceHandle: 'cluster-right', targetHandle: 'cluster-left' }, { type: 'cluster' })
useCanvasStore.getState().onConnect(conn) useCanvasStore.getState().onConnect(conn)
@@ -162,15 +140,6 @@ describe('canvasStore', () => {
expect(edges[0].type).toBe('cluster') expect(edges[0].type).toBe('cluster')
}) })
it('deleteNode also removes children with matching parentId', () => {
useCanvasStore.getState().addNode(makeNode('parent'))
useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' }))
useCanvasStore.getState().deleteNode('parent')
const { nodes } = useCanvasStore.getState()
expect(nodes.find((n) => n.id === 'parent')).toBeUndefined()
expect(nodes.find((n) => n.id === 'child')).toBeUndefined()
})
it('addNode with parent_id sets parentId and extent', () => { it('addNode with parent_id sets parentId and extent', () => {
useCanvasStore.getState().addNode(makeNode('parent')) useCanvasStore.getState().addNode(makeNode('parent'))
useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' })) useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' }))
@@ -179,178 +148,6 @@ describe('canvasStore', () => {
expect(child?.extent).toBe('parent') expect(child?.extent).toBe('parent')
}) })
// ── selectedNodeIds ───────────────────────────────────────────────────────
it('selectedNodeIds starts empty', () => {
expect(useCanvasStore.getState().selectedNodeIds).toEqual([])
})
it('onNodesChange syncs selectedNodeIds from select changes', () => {
useCanvasStore.getState().addNode(makeNode('n1'))
useCanvasStore.getState().addNode(makeNode('n2'))
useCanvasStore.getState().onNodesChange([
{ type: 'select', id: 'n1', selected: true },
{ type: 'select', id: 'n2', selected: true },
])
expect(useCanvasStore.getState().selectedNodeIds).toEqual(expect.arrayContaining(['n1', 'n2']))
expect(useCanvasStore.getState().selectedNodeIds).toHaveLength(2)
})
it('setSelectedNode(null) resets selectedNodeIds to empty', () => {
useCanvasStore.setState({ selectedNodeIds: ['n1', 'n2'] })
useCanvasStore.getState().setSelectedNode(null)
expect(useCanvasStore.getState().selectedNodeIds).toEqual([])
})
it('setSelectedNode(id) preserves existing selectedNodeIds', () => {
useCanvasStore.setState({ selectedNodeIds: ['n1', 'n2'] })
useCanvasStore.getState().setSelectedNode('n1')
// does NOT wipe selectedNodeIds when setting a specific id
expect(useCanvasStore.getState().selectedNodeIds).toEqual(['n1', 'n2'])
})
// ── createGroup ───────────────────────────────────────────────────────────
it('createGroup creates a group node at the bounding box of selected nodes', () => {
// n1 at (100,100), n2 at (300,200); both default to 200x80
const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 }, width: 200, height: 80 }
const n2 = { ...makeNode('n2'), position: { x: 300, y: 200 }, width: 200, height: 80 }
useCanvasStore.setState({ nodes: [n1, n2] })
useCanvasStore.getState().createGroup(['n1', 'n2'], 'My Group')
const { nodes } = useCanvasStore.getState()
const group = nodes.find((n) => n.data.type === 'group')
expect(group).toBeDefined()
expect(group?.data.label).toBe('My Group')
// groupX = 100-24=76, groupY = 100-48=52
expect(group?.position.x).toBe(76)
expect(group?.position.y).toBe(52)
// groupW = (500-100)+48=448, groupH = (280-100)+48+24=252
expect(group?.width).toBe(448)
expect(group?.height).toBe(252)
})
it('createGroup converts children to relative positions', () => {
const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 }, width: 200, height: 80 }
const n2 = { ...makeNode('n2'), position: { x: 300, y: 200 }, width: 200, height: 80 }
useCanvasStore.setState({ nodes: [n1, n2] })
useCanvasStore.getState().createGroup(['n1', 'n2'], 'G')
const { nodes } = useCanvasStore.getState()
const c1 = nodes.find((n) => n.id === 'n1')
const c2 = nodes.find((n) => n.id === 'n2')
// groupX=76, groupY=52 → relative: n1=(24,48), n2=(224,148)
expect(c1?.position).toEqual({ x: 24, y: 48 })
expect(c2?.position).toEqual({ x: 224, y: 148 })
})
it('createGroup sets parentId and extent on children', () => {
const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 } }
const n2 = { ...makeNode('n2'), position: { x: 200, y: 100 } }
useCanvasStore.setState({ nodes: [n1, n2] })
useCanvasStore.getState().createGroup(['n1', 'n2'], 'G')
const { nodes } = useCanvasStore.getState()
const group = nodes.find((n) => n.data.type === 'group')!
const c1 = nodes.find((n) => n.id === 'n1')
const c2 = nodes.find((n) => n.id === 'n2')
expect(c1?.parentId).toBe(group.id)
expect(c1?.extent).toBe('parent')
expect(c2?.parentId).toBe(group.id)
})
it('createGroup places the group node before its children in the array', () => {
const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 } }
const n2 = { ...makeNode('n2'), position: { x: 200, y: 100 } }
useCanvasStore.setState({ nodes: [n1, n2] })
useCanvasStore.getState().createGroup(['n1', 'n2'], 'G')
const { nodes } = useCanvasStore.getState()
const groupIdx = nodes.findIndex((n) => n.data.type === 'group')
const c1Idx = nodes.findIndex((n) => n.id === 'n1')
const c2Idx = nodes.findIndex((n) => n.id === 'n2')
expect(groupIdx).toBeLessThan(c1Idx)
expect(groupIdx).toBeLessThan(c2Idx)
})
it('createGroup snapshots history and marks unsaved', () => {
const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 } }
useCanvasStore.setState({ nodes: [n1] })
useCanvasStore.getState().markSaved()
useCanvasStore.getState().createGroup(['n1'], 'G')
expect(useCanvasStore.getState().past).toHaveLength(1)
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
it('createGroup clears selection', () => {
const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 } }
useCanvasStore.setState({ nodes: [n1], selectedNodeId: 'n1', selectedNodeIds: ['n1'] })
useCanvasStore.getState().createGroup(['n1'], 'G')
expect(useCanvasStore.getState().selectedNodeId).toBeNull()
expect(useCanvasStore.getState().selectedNodeIds).toEqual([])
})
// ── ungroup ───────────────────────────────────────────────────────────────
it('ungroup restores children to absolute positions', () => {
const group = {
...makeNode('g1', { type: 'group', label: 'G' }),
position: { x: 76, y: 52 },
}
const c1 = { ...makeNode('n1'), position: { x: 24, y: 48 }, parentId: 'g1', extent: 'parent' as const }
const c2 = { ...makeNode('n2'), position: { x: 224, y: 148 }, parentId: 'g1', extent: 'parent' as const }
useCanvasStore.setState({ nodes: [group, c1, c2] })
useCanvasStore.getState().ungroup('g1')
const { nodes } = useCanvasStore.getState()
const r1 = nodes.find((n) => n.id === 'n1')
const r2 = nodes.find((n) => n.id === 'n2')
expect(r1?.position).toEqual({ x: 100, y: 100 })
expect(r2?.position).toEqual({ x: 300, y: 200 })
})
it('ungroup removes parentId and extent from children', () => {
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
const child = { ...makeNode('n1'), position: { x: 50, y: 50 }, parentId: 'g1', extent: 'parent' as const }
useCanvasStore.setState({ nodes: [group, child] })
useCanvasStore.getState().ungroup('g1')
const { nodes } = useCanvasStore.getState()
const released = nodes.find((n) => n.id === 'n1')
expect(released?.parentId).toBeUndefined()
expect(released?.extent).toBeUndefined()
})
it('ungroup deletes the group node', () => {
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
useCanvasStore.setState({ nodes: [group] })
useCanvasStore.getState().ungroup('g1')
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'g1')).toBeUndefined()
})
it('ungroup snapshots history and marks unsaved', () => {
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
useCanvasStore.setState({ nodes: [group] })
useCanvasStore.getState().markSaved()
useCanvasStore.getState().ungroup('g1')
expect(useCanvasStore.getState().past).toHaveLength(1)
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
it('updateEdge updates edge data and marks unsaved', () => { it('updateEdge updates edge data and marks unsaved', () => {
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] })) useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] }))
useCanvasStore.getState().markSaved() useCanvasStore.getState().markSaved()
+16 -145
View File
@@ -19,7 +19,6 @@ interface CanvasState {
edges: Edge<EdgeData>[] edges: Edge<EdgeData>[]
hasUnsavedChanges: boolean hasUnsavedChanges: boolean
selectedNodeId: string | null selectedNodeId: string | null
selectedNodeIds: string[]
scanEventTs: number scanEventTs: number
// History // History
@@ -47,8 +46,6 @@ interface CanvasState {
setNodeZIndex: (id: string, zIndex: number) => void setNodeZIndex: (id: string, zIndex: number) => void
editingGroupRectId: string | null editingGroupRectId: string | null
setEditingGroupRectId: (id: string | null) => void setEditingGroupRectId: (id: string | null) => void
createGroup: (nodeIds: string[], name: string) => void
ungroup: (groupId: string) => void
markSaved: () => void markSaved: () => void
markUnsaved: () => void markUnsaved: () => void
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
@@ -62,7 +59,6 @@ export const useCanvasStore = create<CanvasState>((set) => ({
edges: [], edges: [],
hasUnsavedChanges: false, hasUnsavedChanges: false,
selectedNodeId: null, selectedNodeId: null,
selectedNodeIds: [],
editingGroupRectId: null, editingGroupRectId: null,
hideIp: false, hideIp: false,
scanEventTs: 0, scanEventTs: 0,
@@ -129,20 +125,15 @@ export const useCanvasStore = create<CanvasState>((set) => ({
}), }),
onNodesChange: (changes) => onNodesChange: (changes) =>
set((state) => { set((state) => ({
const nodes = applyNodeChanges(changes, state.nodes) nodes: applyNodeChanges(changes, state.nodes),
const selectedNodeIds = nodes.filter((n) => n.selected).map((n) => n.id) hasUnsavedChanges: true,
return { })),
nodes,
selectedNodeIds,
hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'),
}
}),
onEdgesChange: (changes) => onEdgesChange: (changes) =>
set((state) => ({ set((state) => ({
edges: applyEdgeChanges(changes, state.edges), edges: applyEdgeChanges(changes, state.edges),
hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'), hasUnsavedChanges: true,
})), })),
onConnect: (connection) => onConnect: (connection) =>
@@ -159,29 +150,23 @@ export const useCanvasStore = create<CanvasState>((set) => ({
sourceHandle: normalizeHandle(extra.sourceHandle), sourceHandle: normalizeHandle(extra.sourceHandle),
targetHandle: normalizeHandle(extra.targetHandle), targetHandle: normalizeHandle(extra.targetHandle),
type: edgeType, type: edgeType,
data: { type: edgeType, label: extra.label, vlan_id: extra.vlan_id, custom_color: extra.custom_color, path_style: extra.path_style, animated: extra.animated }, data: { type: edgeType, label: extra.label, vlan_id: extra.vlan_id, custom_color: extra.custom_color, path_style: extra.path_style },
}, state.edges), }, state.edges),
hasUnsavedChanges: true, hasUnsavedChanges: true,
} }
}), }),
setSelectedNode: (id) => set((state) => ({ setSelectedNode: (id) => set({ selectedNodeId: id }),
selectedNodeId: id,
selectedNodeIds: id ? state.selectedNodeIds : [],
})),
addNode: (node) => addNode: (node) =>
set((state) => { set((state) => {
const enriched = node.data.parent_id const enriched = node.data.parent_id
? { ...node, parentId: node.data.parent_id, extent: 'parent' as const } ? { ...node, parentId: node.data.parent_id, extent: 'parent' as const }
: node : node
// Parents must come before children in the array (React Flow requirement) // Parents must come before children in the array
const withoutNew = state.nodes.filter((n) => n.id !== node.id) const withoutNew = state.nodes.filter((n) => n.id !== node.id)
if (enriched.parentId) { if (enriched.parentId) {
const parentIdx = withoutNew.findIndex((n) => n.id === enriched.parentId) return { nodes: [...withoutNew, enriched], hasUnsavedChanges: true }
const insertAt = parentIdx >= 0 ? parentIdx + 1 : withoutNew.length
const nodes = [...withoutNew.slice(0, insertAt), enriched, ...withoutNew.slice(insertAt)]
return { nodes, hasUnsavedChanges: true }
} }
return { nodes: [...withoutNew, enriched], hasUnsavedChanges: true } return { nodes: [...withoutNew, enriched], hasUnsavedChanges: true }
}), }),
@@ -195,20 +180,12 @@ export const useCanvasStore = create<CanvasState>((set) => ({
})), })),
deleteNode: (id) => deleteNode: (id) =>
set((state) => { set((state) => ({
const idsToRemove = new Set<string>() nodes: state.nodes.filter((n) => n.id !== id),
const collect = (nodeId: string) => { edges: state.edges.filter((e) => e.source !== id && e.target !== id),
idsToRemove.add(nodeId) selectedNodeId: state.selectedNodeId === id ? null : state.selectedNodeId,
state.nodes.filter((n) => n.parentId === nodeId).forEach((n) => collect(n.id)) hasUnsavedChanges: true,
} })),
collect(id)
return {
nodes: state.nodes.filter((n) => !idsToRemove.has(n.id)),
edges: state.edges.filter((e) => !idsToRemove.has(e.source) && !idsToRemove.has(e.target)),
selectedNodeId: idsToRemove.has(state.selectedNodeId ?? '') ? null : state.selectedNodeId,
hasUnsavedChanges: true,
}
}),
updateEdge: (id, data) => updateEdge: (id, data) =>
set((state) => ({ set((state) => ({
@@ -256,112 +233,6 @@ export const useCanvasStore = create<CanvasState>((set) => ({
setEditingGroupRectId: (id) => set({ editingGroupRectId: id }), setEditingGroupRectId: (id) => set({ editingGroupRectId: id }),
createGroup: (nodeIds, name) =>
set((state) => {
const PADDING_H = 24
const PADDING_TOP = 48
const PADDING_BOTTOM = 24
const targets = state.nodes.filter((n) => nodeIds.includes(n.id))
if (targets.length === 0) return state
// Bounding box in absolute coordinates
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
for (const n of targets) {
const w = n.width ?? 200
const h = n.height ?? 80
minX = Math.min(minX, n.position.x)
minY = Math.min(minY, n.position.y)
maxX = Math.max(maxX, n.position.x + w)
maxY = Math.max(maxY, n.position.y + h)
}
const groupX = minX - PADDING_H
const groupY = minY - PADDING_TOP
const groupW = maxX - minX + PADDING_H * 2
const groupH = maxY - minY + PADDING_TOP + PADDING_BOTTOM
const groupId = generateUUID()
const groupNode: Node<NodeData> = {
id: groupId,
type: 'group',
position: { x: groupX, y: groupY },
width: groupW,
height: groupH,
data: {
label: name,
type: 'group',
status: 'unknown',
services: [],
custom_colors: { show_border: true },
},
selected: false,
}
// Convert children to relative positions and assign parentId
const updatedNodes = state.nodes.map((n) => {
if (!nodeIds.includes(n.id)) return n
return {
...n,
parentId: groupId,
extent: 'parent' as const,
position: {
x: n.position.x - groupX,
y: n.position.y - groupY,
},
selected: false,
data: { ...n.data, parent_id: groupId },
}
})
// Group node must come before its children
const withoutTargets = updatedNodes.filter((n) => !nodeIds.includes(n.id))
const children = updatedNodes.filter((n) => nodeIds.includes(n.id))
const nodes = [...withoutTargets, groupNode, ...children]
return {
nodes,
selectedNodeIds: [],
selectedNodeId: null,
hasUnsavedChanges: true,
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
future: [],
}
}),
ungroup: (groupId) =>
set((state) => {
const group = state.nodes.find((n) => n.id === groupId)
if (!group) return state
const groupAbsX = group.position.x
const groupAbsY = group.position.y
const nodes = state.nodes
.filter((n) => n.id !== groupId)
.map((n) => {
if (n.parentId !== groupId) return n
return {
...n,
parentId: undefined,
extent: undefined,
position: {
x: n.position.x + groupAbsX,
y: n.position.y + groupAbsY,
},
data: { ...n.data, parent_id: undefined },
}
})
return {
nodes,
selectedNodeId: null,
selectedNodeIds: [],
hasUnsavedChanges: true,
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
future: [],
}
}),
markSaved: () => set({ hasUnsavedChanges: false }), markSaved: () => set({ hasUnsavedChanges: false }),
markUnsaved: () => set({ hasUnsavedChanges: true }), markUnsaved: () => set({ hasUnsavedChanges: true }),
@@ -374,6 +245,6 @@ export const useCanvasStore = create<CanvasState>((set) => ({
// React Flow requires parents before children in the array // React Flow requires parents before children in the array
const parents = nodes.filter((n) => !n.parentId) const parents = nodes.filter((n) => !n.parentId)
const children = nodes.filter((n) => !!n.parentId) const children = nodes.filter((n) => !!n.parentId)
set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], clipboard: [] }) set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null })
}, },
})) }))
+1 -7
View File
@@ -16,7 +16,6 @@ export type NodeType =
| 'docker' | 'docker'
| 'generic' | 'generic'
| 'groupRect' | 'groupRect'
| 'group'
export type TextPosition = export type TextPosition =
| 'top-left' | 'top-left'
@@ -73,11 +72,7 @@ export interface NodeData extends Record<string, unknown> {
text_position?: TextPosition text_position?: TextPosition
font?: string font?: string
border_style?: 'solid' | 'dashed' | 'dotted' | 'double' | 'none' border_style?: 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
border_width?: number
label_position?: 'inside' | 'outside'
text_size?: number
z_order?: number z_order?: number
show_border?: boolean
width?: number width?: number
height?: number height?: number
} }
@@ -93,7 +88,7 @@ export interface EdgeData extends Record<string, unknown> {
speed?: string speed?: string
custom_color?: string custom_color?: string
path_style?: EdgePathStyle path_style?: EdgePathStyle
animated?: boolean | 'snake' | 'flow' | 'none' animated?: boolean
} }
export const NODE_TYPE_LABELS: Record<NodeType, string> = { export const NODE_TYPE_LABELS: Record<NodeType, string> = {
@@ -114,7 +109,6 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
docker: 'Docker Host', docker: 'Docker Host',
generic: 'Generic Device', generic: 'Generic Device',
groupRect: 'Group Rectangle', groupRect: 'Group Rectangle',
group: 'Node Group',
} }
export const STATUS_COLORS: Record<NodeStatus, string> = { export const STATUS_COLORS: Record<NodeStatus, string> = {
+2 -3
View File
@@ -41,7 +41,7 @@ export interface ApiEdge {
speed?: string | null speed?: string | null
custom_color?: string | null custom_color?: string | null
path_style?: string | null path_style?: string | null
animated?: boolean | 'snake' | 'flow' | 'none' animated?: boolean
source_handle?: string | null source_handle?: string | null
target_handle?: string | null target_handle?: string | null
} }
@@ -63,7 +63,7 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
check_target: null, check_target: null,
services: [], services: [],
notes: null, notes: null,
parent_id: n.data.parent_id ?? null, parent_id: null,
container_mode: false, container_mode: false,
custom_icon: null, custom_icon: null,
pos_x: n.position.x, pos_x: n.position.x,
@@ -142,7 +142,6 @@ export function deserializeApiNode(
width: w, width: w,
height: h, height: h,
zIndex: z - 10, zIndex: z - 10,
...(n.parent_id ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
} }
} }
const parentIsContainer = n.parent_id ? (proxmoxContainerMap.get(n.parent_id) ?? false) : false const parentIsContainer = n.parent_id ? (proxmoxContainerMap.get(n.parent_id) ?? false) : false
-5
View File
@@ -59,7 +59,6 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
docker: { border: '#2496ED', icon: '#2496ED' }, docker: { border: '#2496ED', icon: '#2496ED' },
generic: { border: '#8b949e', icon: '#8b949e' }, generic: { border: '#8b949e', icon: '#8b949e' },
groupRect:{ border: '#00d4ff', icon: '#00d4ff' }, groupRect:{ border: '#00d4ff', icon: '#00d4ff' },
group: { border: '#00d4ff', icon: '#00d4ff' },
}, },
nodeCardBackground: '#21262d', nodeCardBackground: '#21262d',
nodeIconBackground: '#161b22', nodeIconBackground: '#161b22',
@@ -114,7 +113,6 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
docker: { border: '#2496ED', icon: '#2496ED' }, docker: { border: '#2496ED', icon: '#2496ED' },
generic: { border: '#94a3b8', icon: '#94a3b8' }, generic: { border: '#94a3b8', icon: '#94a3b8' },
groupRect:{ border: '#22d3ee', icon: '#22d3ee' }, groupRect:{ border: '#22d3ee', icon: '#22d3ee' },
group: { border: '#22d3ee', icon: '#22d3ee' },
}, },
nodeCardBackground: '#0a0a0a', nodeCardBackground: '#0a0a0a',
nodeIconBackground: '#111111', nodeIconBackground: '#111111',
@@ -169,7 +167,6 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
docker: { border: '#2496ED', icon: '#2496ED' }, docker: { border: '#2496ED', icon: '#2496ED' },
generic: { border: '#6b7280', icon: '#6b7280' }, generic: { border: '#6b7280', icon: '#6b7280' },
groupRect:{ border: '#0284c7', icon: '#0284c7' }, groupRect:{ border: '#0284c7', icon: '#0284c7' },
group: { border: '#0284c7', icon: '#0284c7' },
}, },
nodeCardBackground: '#ffffff', nodeCardBackground: '#ffffff',
nodeIconBackground: '#f0f6ff', nodeIconBackground: '#f0f6ff',
@@ -224,7 +221,6 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
docker: { border: '#00aaff', icon: '#00aaff' }, docker: { border: '#00aaff', icon: '#00aaff' },
generic: { border: '#8888ff', icon: '#8888ff' }, generic: { border: '#8888ff', icon: '#8888ff' },
groupRect:{ border: '#00ffff', icon: '#00ffff' }, groupRect:{ border: '#00ffff', icon: '#00ffff' },
group: { border: '#00ffff', icon: '#00ffff' },
}, },
nodeCardBackground: '#0f0f2a', nodeCardBackground: '#0f0f2a',
nodeIconBackground: '#0a0a1a', nodeIconBackground: '#0a0a1a',
@@ -279,7 +275,6 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
docker: { border: '#00cc88', icon: '#00cc88' }, docker: { border: '#00cc88', icon: '#00cc88' },
generic: { border: '#006600', icon: '#006600' }, generic: { border: '#006600', icon: '#006600' },
groupRect:{ border: '#00ff41', icon: '#00ff41' }, groupRect:{ border: '#00ff41', icon: '#00ff41' },
group: { border: '#00ff41', icon: '#00ff41' },
}, },
nodeCardBackground: '#001100', nodeCardBackground: '#001100',
nodeIconBackground: '#002200', nodeIconBackground: '#002200',
+1 -1
View File
@@ -85,7 +85,7 @@ pct create "$CTID" "$TEMPLATE" \
--rootfs "${STORAGE}:${DISK_SIZE}" \ --rootfs "${STORAGE}:${DISK_SIZE}" \
--memory "$RAM" \ --memory "$RAM" \
--cores "$CORES" \ --cores "$CORES" \
--net0 "name=eth0,bridge=${BRIDGE},ip=dhcp${VLAN_TAG:+,tag=${VLAN_TAG}}" \ --net0 "name=eth0,bridge=${BRIDGE},ip=dhcp" \
--ostype debian \ --ostype debian \
--unprivileged 1 \ --unprivileged 1 \
--features "nesting=1" \ --features "nesting=1" \
+2 -2
View File
@@ -30,8 +30,8 @@ info "Detected: $PRETTY_NAME"
# ── System deps ─────────────────────────────────────────────────────────────── # ── System deps ───────────────────────────────────────────────────────────────
info "Installing system dependencies..." info "Installing system dependencies..."
apt-get update apt-get update -qq
apt-get install -y --fix-missing python3 python3-pip python3-venv nmap curl git nginx apt-get install -y -qq python3 python3-pip python3-venv nmap curl git nginx
# ── Node.js 20 ──────────────────────────────────────────────────────────────── # ── Node.js 20 ────────────────────────────────────────────────────────────────
if ! command -v node &>/dev/null; then if ! command -v node &>/dev/null; then