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
50 changed files with 387 additions and 1935 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=
+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,
)
+6 -1
View File
@@ -17,6 +17,7 @@ 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__)
@@ -102,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'
+3 -39
View File
@@ -7,7 +7,7 @@ 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__)
@@ -107,54 +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:
# 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:
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",
) )
) )
@@ -167,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"),
+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"
+1 -114
View File
@@ -7,7 +7,7 @@ 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 run_scan from app.services.scanner import run_scan
@@ -199,119 +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
@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.6.0", "version": "1.3.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+3 -13
View File
@@ -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
@@ -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 -14
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,11 +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`),
getConfig: () => api.get<{ ranges: string[] }>('/scan/config'), getConfig: () => api.get<{ ranges: string[]; interval_seconds: number }>('/scan/config'),
saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data), saveConfig: (data: { ranges: string[]; interval_seconds: number }) => 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')
.map((n: ApiNode) => [n.id, 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' } })
@@ -27,7 +27,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
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)
@@ -59,8 +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 }}
snapToGrid snapToGrid
snapGrid={[16, 16]} snapGrid={[16, 16]}
fitView fitView
@@ -143,22 +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'])
})
// ── 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)
})
}) })
+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
@@ -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>
)} )}
+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()
}) })
+74 -158
View File
@@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { X, Edit, Trash2, ExternalLink, Plus, Pencil } 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'
@@ -10,32 +10,25 @@ 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, setSelectedNode, deleteNode, updateNode, snapshotHistory } = useCanvasStore() const { nodes, selectedNodeId, setSelectedNode, deleteNode, updateNode } = useCanvasStore()
const node = nodes.find((n) => n.id === selectedNodeId) const node = nodes.find((n) => n.id === selectedNodeId)
const [addingForNode, setAddingForNode] = useState<string | null>(null) const [addingService, setAddingService] = useState(false)
const [newSvc, setNewSvc] = useState<SvcForm>(EMPTY_FORM) const [newSvc, setNewSvc] = useState<{ port: string; protocol: 'tcp' | 'udp'; service_name: string }>({
const [editingFor, setEditingFor] = useState<{ nodeId: string; index: number } | null>(null) port: '',
const [editSvc, setEditSvc] = useState<SvcForm>(EMPTY_FORM) protocol: 'tcp',
service_name: '',
})
if (!node || node.data.type === 'groupRect') return null if (!node || node.data.type === 'groupRect') return null
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)
} }
} }
@@ -48,36 +41,14 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
protocol: newSvc.protocol, protocol: newSvc.protocol,
service_name: newSvc.service_name.trim(), service_name: newSvc.service_name.trim(),
} }
updateNode(node.id, { services: [...services, svc] }) updateNode(node.id, { services: [...(data.services ?? []), svc] })
setNewSvc(EMPTY_FORM) setNewSvc({ port: '', protocol: 'tcp', service_name: '' })
setAddingForNode(null) 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 (
@@ -86,7 +57,6 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
<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 <button
aria-label="Close panel"
onClick={() => setSelectedNode(null)} onClick={() => setSelectedNode(null)}
className="text-muted-foreground hover:text-foreground transition-colors" className="text-muted-foreground hover:text-foreground transition-colors"
> >
@@ -145,10 +115,10 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
<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"> <span className="text-xs text-muted-foreground">
Services{services.length > 0 ? ` (${services.length})` : ''} Services{data.services.length > 0 ? ` (${data.services.length})` : ''}
</span> </span>
<button <button
onClick={() => { setAddingForNode((v) => v === node.id ? null : node.id); setEditingFor(null) }} onClick={() => setAddingService((v) => !v)}
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors" className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors"
> >
<Plus size={10} /> Add <Plus size={10} /> Add
@@ -157,43 +127,67 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
{/* Add service form */} {/* Add service form */}
{addingService && ( {addingService && (
<ServiceForm <div className="flex flex-col gap-1.5 mb-2 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
form={newSvc} <Input
onChange={setNewSvc} value={newSvc.service_name}
onConfirm={handleAddService} onChange={(e) => setNewSvc((s) => ({ ...s, service_name: e.target.value }))}
onCancel={() => setAddingForNode(null)} placeholder="Service name"
confirmLabel="Add" className="bg-[#21262d] border-[#30363d] text-xs h-7"
autoFocus autoFocus
/> />
)} <div className="flex gap-1.5">
<Input
{services.length > 0 && ( type="number"
<div className="flex flex-col gap-1.5"> value={newSvc.port}
{services.map((svc, i) => onChange={(e) => setNewSvc((s) => ({ ...s, port: e.target.value }))}
editingIndex === i ? ( placeholder="Port"
<ServiceForm min={1}
key={`edit-${i}`} max={65535}
form={editSvc} className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-20 shrink-0"
onChange={setEditSvc} />
onConfirm={handleSaveEdit} <select
onCancel={() => setEditingFor(null)} value={newSvc.protocol}
confirmLabel="Save" onChange={(e) => setNewSvc((s) => ({ ...s, protocol: e.target.value as 'tcp' | 'udp' }))}
autoFocus className="flex-1 bg-[#21262d] border border-[#30363d] rounded-md text-xs h-7 px-1.5 text-foreground"
/> >
) : ( <option value="tcp">tcp</option>
<ServiceBadge <option value="udp">udp</option>
key={`${svc.port}-${svc.protocol}-${i}`} </select>
svc={svc} </div>
host={host} <div className="flex gap-1.5">
onEdit={() => handleStartEdit(i)} <Button
onRemove={() => handleRemoveService(i)} 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 && ( {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> <p className="text-[10px] text-muted-foreground/50">No services click Add to register one.</p>
)} )}
</div> </div>
@@ -211,7 +205,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
<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>
@@ -238,67 +232,6 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono?
) )
} }
function ServiceForm({
form,
onChange,
onConfirm,
onCancel,
confirmLabel,
autoFocus,
}: {
form: { port: string; protocol: 'tcp' | 'udp'; service_name: string }
onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string }) => void
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', web: '#00d4ff',
database: '#a855f7', database: '#a855f7',
@@ -308,17 +241,7 @@ const CATEGORY_COLORS: Record<string, string> = {
remote: '#8b949e', remote: '#8b949e',
} }
function ServiceBadge({ function ServiceBadge({ svc, host, onRemove }: { svc: ServiceInfo; host?: string; onRemove: () => void }) {
svc,
host,
onEdit,
onRemove,
}: {
svc: ServiceInfo
host?: string
onEdit: () => void
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'
@@ -338,13 +261,6 @@ function ServiceBadge({
<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() }} onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }}
className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5" className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5"
+5 -70
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 } 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 {
@@ -427,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'
@@ -29,7 +29,6 @@ function setupStore(nodeData: Partial<NodeData> = {}) {
setSelectedNode: vi.fn(), setSelectedNode: vi.fn(),
deleteNode: vi.fn(), deleteNode: vi.fn(),
updateNode: vi.fn(), updateNode: vi.fn(),
snapshotHistory: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>) } as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
} }
@@ -41,7 +40,6 @@ describe('DetailPanel', () => {
setSelectedNode: vi.fn(), setSelectedNode: vi.fn(),
deleteNode: vi.fn(), deleteNode: vi.fn(),
updateNode: vi.fn(), updateNode: vi.fn(),
snapshotHistory: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>) } as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
}) })
@@ -117,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,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()
})
})
+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>,
) )
@@ -98,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', () => {
@@ -144,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)
@@ -161,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' }))
+12 -23
View File
@@ -127,13 +127,13 @@ export const useCanvasStore = create<CanvasState>((set) => ({
onNodesChange: (changes) => onNodesChange: (changes) =>
set((state) => ({ set((state) => ({
nodes: applyNodeChanges(changes, state.nodes), nodes: applyNodeChanges(changes, state.nodes),
hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'), hasUnsavedChanges: true,
})), })),
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) =>
@@ -150,7 +150,7 @@ 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,
} }
@@ -163,13 +163,10 @@ export const useCanvasStore = create<CanvasState>((set) => ({
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 }
}), }),
@@ -183,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,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 -4
View File
@@ -72,9 +72,6 @@ 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
width?: number width?: number
height?: number height?: number
@@ -91,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> = {
+1 -1
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
} }
+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" \