Compare commits

...

32 Commits

Author SHA1 Message Date
Pouzor 985ced6bf5 chore: bump version to 1.6.0 2026-03-31 00:33:28 +02:00
Remy 05a647aac7 Merge pull request #30 from Pouzor/fix/delete-key-and-undo
fix: DEL key deletes nodes and deletion is undoable
2026-03-31 00:29:16 +02:00
Pouzor 1444a81150 fix: DEL key deletes nodes and deletion is now undoable
- Add 'Delete' to deleteKeyCode so both Backspace and Delete remove nodes
- Call snapshotHistory() in onBeforeDelete (keyboard) and in DetailPanel
  handleDelete (button) so deletions can be undone with Ctrl+Z
2026-03-31 00:19:27 +02:00
Pouzor 1f884fd1db fix: guard scheduler against double-start and unguarded reschedule 2026-03-31 00:03:01 +02:00
Pouzor e9152df17a fix: remove stale reschedule call from scan config after settings endpoint split 2026-03-31 00:01:31 +02:00
Pouzor e7fc091701 fix: reschedule APScheduler job immediately when status check interval is updated
Interval was read once at startup — changing it via API had no effect
until server restart. Now calls reschedule_status_checks() after saving.
2026-03-30 23:59:47 +02:00
Remy 7071f8ef5a Merge pull request #29 from Pouzor/feat/scan-dedup-skip-canvas
feat: scan dedup, skip canvas/hidden nodes, settings endpoint
2026-03-30 23:37:18 +02:00
Pouzor 350dc14a16 test: add SettingsPanel tests covering settingsApi integration
- Opens panel and calls settingsApi.get
- Displays interval loaded from API
- Saves updated interval via settingsApi.save
- Shows error toast on save failure
- Toggles panel closed on second Settings click
2026-03-30 23:14:42 +02:00
Pouzor 68c7672cea feat: split scan config and app settings into separate endpoints
- New GET/POST /api/v1/settings for status check interval
- Scan /api/v1/scan/config now handles ranges only
- Frontend: settingsApi client, SettingsPanel uses settingsApi
- ScanConfigModal no longer reads/writes interval
- 4 new backend tests for settings endpoint
2026-03-30 23:06:51 +02:00
Pouzor 381f870bb5 fix: update ScanConfigModal tests after removing interval field
- Replace interval display test with interval-preservation test
- Reset saveConfig mock call history in beforeEach to prevent test bleed
2026-03-30 22:24:30 +02:00
Pouzor 58381b97d2 feat: move status check interval to sidebar Settings panel
- Add Settings item in sidebar actions section (below Save Canvas)
- Settings panel shows status check interval with save button
- Remove interval field from ScanConfigModal (now belongs in Settings)
- Scan modal shows a hint pointing to sidebar Settings
2026-03-30 22:21:36 +02:00
Pouzor ce4af14ee6 feat: deduplicate pending devices and skip canvas/hidden nodes on scan
- At scan start, purge any pending entries whose IPs already exist in canvas
- Skip canvas nodes (approved) during scan — don't re-add to pending
- Skip hidden devices during scan — respect user's hide decision
- Add 4 tests covering all new behaviors
2026-03-30 22:01:47 +02:00
Pouzor 49963c79f7 chore: bump version to 1.5.0 2026-03-29 19:35:08 +02:00
Pouzor ea539d6e31 fix: null guards, aria-labels, and missing tests for DetailPanel
- Extract const services = data.services ?? [] for consistent null safety
- Add aria-label to close and delete buttons
- Add tests: close, edit callback, delete (confirm/cancel), add service, remove service, undefined services
2026-03-29 19:28:52 +02:00
Pouzor f657e45995 fix: null guard data.services and aria-label on delete button
Fixes crash when services is undefined on legacy nodes.
Adds aria-label="Delete node" for accessibility.
2026-03-29 19:25:55 +02:00
Pouzor 3fe9fa7ca8 feat: add inline edit for services in detail panel
Replaces service badge with in-place form when pencil icon is clicked.
State is scoped to nodeId so switching nodes auto-resets edit/add forms.
2026-03-29 19:24:22 +02:00
Pouzor f0222247bb feat: add inline edit for services in detail panel 2026-03-29 19:19:29 +02:00
Pouzor 9c92d39629 fix: return 401 (not 500) when bcrypt hash is malformed (#21)
- verify_password catches ValueError from passlib so a mangled hash
  ($ signs stripped by shell/Docker) returns False instead of crashing
- Settings.check_password_hash logs a clear startup error with fix
  instructions when AUTH_PASSWORD_HASH doesn't start with '$2'
2026-03-29 16:03:40 +02:00
Pouzor e4c0d820f4 fix: render snake vs flow edge animations correctly
edges/index.tsx was never committed — both animation modes were
rendering as snake (truthy string check). Now uses animMode to
distinguish 'snake' (moving blob) from 'flow' (continuous flowing dashes).
2026-03-29 15:18:19 +02:00
Remy f9c8e37de3 Merge pull request #22 from Pouzor/feat/front-improvement
feat: Zone improvements + edge animation modes
2026-03-29 15:07:53 +02:00
Pouzor 7ed6b77165 fix: update EdgeModal tests for None/Snake/Flow animation selector 2026-03-29 15:01:20 +02:00
Pouzor 95a3db34f1 fix: move AnimMode type to module scope, fix tsc -b build error 2026-03-29 14:57:25 +02:00
Pouzor 37cb97dca1 fix: add border_width, text_size, label_position to custom_colors type 2026-03-29 14:46:23 +02:00
Pouzor 32b60a201b fix: persist edge animation mode (None/Snake/Flow) end-to-end
- canvasStore.onConnect: include animated in edge data object (was silently dropped)
- Backend schemas: normalize animated bool/int to string ('none'/'snake'/'flow') via field_validator
- ORM model: change animated column from Boolean to String
- DB migration: convert existing 0/1 boolean rows to 'none'/'snake' strings
2026-03-29 14:41:18 +02:00
Pouzor 4ccdbed711 feat: add label position (inside/outside) and text size to Zone modal
- Label position toggle: inside (default) or outside the border
- Outside mode renders the label above/below the zone based on text_position
- Text size selector: 10/12/14/16/18/20px (default 12)
- Both fields persisted in custom_colors (no backend schema change needed)
- 8 new frontend tests, 1 new backend test
2026-03-29 03:04:29 +02:00
Pouzor 38a06682e5 feat: rename Rectangle to Zone, add border width selector
- Rename "Rectangle" → "Zone" in sidebar, add modal and edit modal
- Add border width selector (1–5px, default 2px) to the Zone modal
- Border width persisted in custom_colors.border_width and applied in GroupRectNode
2026-03-29 01:47:04 +01:00
Remy 900cc62b27 Update README.md 2026-03-28 18:37:57 +01:00
Pouzor 343249fbcd fix: update login test to use http error object after network/auth error distinction 2026-03-28 18:30:50 +01:00
Pouzor 4aca82fb1a fix: remove hardcoded CORS_ORIGINS from docker-compose, improve login errors
CORS_ORIGINS was hardcoded in docker-compose.yml, silently overriding .env
and breaking login for users who change the frontend port. It now comes
from .env exclusively, with a clear comment in .env.example.

Login page now distinguishes network errors (CORS/offline) from wrong
credentials, and footer correctly references .env instead of config.yml.
2026-03-28 18:27:54 +01:00
Pouzor bd047e594e fix: restore package-lock.json — revert parseurl/tiny-invariant version corruption from sed bump 2026-03-28 18:08:04 +01:00
Pouzor 61b30a95fe chore: bump version to 1.4.0 2026-03-28 17:59:25 +01:00
Remy 0b97b7127a Merge pull request #14 from Pouzor/feat/liveview
feat: read-only live view at /view?key=<LIVEVIEW_KEY>
2026-03-28 17:53:44 +01:00
43 changed files with 1377 additions and 236 deletions
+1
View File
@@ -1,6 +1,7 @@
# 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
+2 -2
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="40%" /> <img src="docs/homelable3.png" alt="Homelable sidebar and scan" width="48%" />
<img src="docs/homelable4.png" alt="Homelable edit pannel" width="40%" /> <img src="docs/homelable4.png" alt="Homelable edit pannel" width="48%" />
</p> </p>
--- ---
+1 -6
View File
@@ -17,7 +17,6 @@ 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__)
@@ -103,17 +102,13 @@ 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( return ScanConfig(ranges=settings.scanner_ranges)
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
@@ -0,0 +1,29 @@
"""App-level settings (status checker interval, etc.)."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from app.api.deps import get_current_user
from app.core.config import settings
router = APIRouter()
class AppSettings(BaseModel):
interval_seconds: int
@router.get("", response_model=AppSettings)
async def get_settings(_: str = Depends(get_current_user)) -> AppSettings:
return AppSettings(interval_seconds=settings.status_checker_interval)
@router.post("", response_model=AppSettings)
async def update_settings(
payload: AppSettings, _: str = Depends(get_current_user)
) -> AppSettings:
try:
settings.status_checker_interval = payload.interval_seconds
settings.save_overrides()
return payload
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
+15
View File
@@ -1,8 +1,12 @@
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")
@@ -19,6 +23,17 @@ 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"]
+13 -1
View File
@@ -47,11 +47,23 @@ 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:
scheduler.shutdown(wait=False) if scheduler.running:
scheduler.shutdown(wait=False)
+4 -1
View File
@@ -9,7 +9,10 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain: str, hashed: str) -> bool: def verify_password(plain: str, hashed: str) -> bool:
return bool(pwd_context.verify(plain, hashed)) try:
return bool(pwd_context.verify(plain, hashed))
except ValueError:
return False
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
+22 -15
View File
@@ -2,6 +2,7 @@ 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
@@ -26,36 +27,42 @@ 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(Exception): with suppress(OperationalError):
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(Exception): with suppress(OperationalError):
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(Exception): with suppress(OperationalError):
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(Exception): with suppress(OperationalError):
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(Exception): with suppress(OperationalError):
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(Exception): with suppress(OperationalError):
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(Exception): with suppress(OperationalError):
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(Exception): with suppress(OperationalError):
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(Exception): with suppress(OperationalError):
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(Exception): with suppress(OperationalError):
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(Exception): with suppress(OperationalError):
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(Exception): with suppress(OperationalError):
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(Exception): with suppress(OperationalError):
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(Exception): with suppress(OperationalError):
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(Exception): with suppress(OperationalError):
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")) parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
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[bool] = mapped_column(Boolean, default=False) animated: Mapped[str] = mapped_column(String, nullable=False, default='none')
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)
+3 -1
View File
@@ -6,6 +6,7 @@ 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, liveview, 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
@@ -22,7 +23,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
app = FastAPI( app = FastAPI(
title="Homelable API", title="Homelable API",
version="1.3.3", version="1.4.0",
lifespan=lifespan, lifespan=lifespan,
) )
@@ -40,6 +41,7 @@ app.include_router(edges.router, prefix="/api/v1/edges", tags=["edges"])
app.include_router(canvas.router, prefix="/api/v1/canvas", tags=["canvas"]) app.include_router(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.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"])
+8 -2
View File
@@ -1,9 +1,10 @@
from typing import Any from typing import Any
from pydantic import BaseModel from pydantic import BaseModel, field_validator
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):
@@ -44,10 +45,15 @@ 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: bool = False animated: str = '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:
return normalize_animated(v)
class CanvasSaveRequest(BaseModel): class CanvasSaveRequest(BaseModel):
nodes: list[NodeSave] = [] nodes: list[NodeSave] = []
+17 -3
View File
@@ -1,6 +1,8 @@
from datetime import datetime from datetime import datetime
from pydantic import BaseModel from pydantic import BaseModel, field_validator
from app.schemas.utils import normalize_animated
class EdgeBase(BaseModel): class EdgeBase(BaseModel):
@@ -12,10 +14,15 @@ 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: bool = False animated: str = '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:
return normalize_animated(v)
class EdgeCreate(EdgeBase): class EdgeCreate(EdgeBase):
pass pass
@@ -28,10 +35,17 @@ 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: bool | None = None animated: str | 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
@@ -0,0 +1,9 @@
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'
+39 -3
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 PendingDevice, ScanRun from app.db.models import Node, 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,18 +107,54 @@ 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 == host["ip"], PendingDevice.ip == ip,
PendingDevice.status == "pending", PendingDevice.status == "pending",
) )
) )
@@ -131,7 +167,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=host["ip"], ip=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"),
+12
View File
@@ -56,3 +56,15 @@ 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,6 +104,25 @@ 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()
+114 -1
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 PendingDevice, ScanRun from app.db.models import Node, PendingDevice, ScanRun
from app.services.scanner import run_scan from app.services.scanner import run_scan
@@ -199,6 +199,119 @@ 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
@@ -0,0 +1,47 @@
"""Tests for GET/POST /api/v1/settings."""
from unittest.mock import patch
import pytest
from httpx import AsyncClient
@pytest.fixture
async def headers(client: AsyncClient):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
token = res.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
@pytest.mark.asyncio
async def test_get_settings_requires_auth(client: AsyncClient):
res = await client.get("/api/v1/settings")
assert res.status_code == 401
@pytest.mark.asyncio
async def test_get_settings_returns_interval(client: AsyncClient, headers):
res = await client.get("/api/v1/settings", headers=headers)
assert res.status_code == 200
data = res.json()
assert "interval_seconds" in data
assert isinstance(data["interval_seconds"], int)
@pytest.mark.asyncio
async def test_update_settings_saves_interval(client: AsyncClient, headers):
with patch("app.api.routes.settings.settings") as mock_settings:
mock_settings.status_checker_interval = 60
mock_settings.save_overrides = lambda: None
res = await client.post(
"/api/v1/settings",
json={"interval_seconds": 120},
headers=headers,
)
assert res.status_code == 200
assert res.json()["interval_seconds"] == 120
@pytest.mark.asyncio
async def test_update_settings_requires_auth(client: AsyncClient):
res = await client.post("/api/v1/settings", json={"interval_seconds": 30})
assert res.status_code == 401
+1 -2
View File
@@ -7,9 +7,8 @@ services:
env_file: env_file:
- .env - .env
environment: environment:
# Override env_file values that differ in Docker # Override env_file: SQLite path must point inside the container volume
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.3.3", "version": "1.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "frontend", "name": "frontend",
"version": "1.3.3", "version": "1.4.0",
"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.3.3", "version": "1.6.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+13 -3
View File
@@ -182,9 +182,12 @@ 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,
}, },
@@ -198,6 +201,7 @@ 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,
@@ -205,16 +209,19 @@ 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]) }, [editingGroupRectId, nodes, updateNode, setNodeZIndex, setEditingGroupRectId, snapshotHistory])
const handleDeleteGroupRect = useCallback(() => { const handleDeleteGroupRect = useCallback(() => {
if (!editingGroupRectId) return if (!editingGroupRectId) return
@@ -436,7 +443,7 @@ export default function App() {
open={addGroupRectOpen} open={addGroupRectOpen}
onClose={() => setAddGroupRectOpen(false)} onClose={() => setAddGroupRectOpen(false)}
onSubmit={handleAddGroupRect} onSubmit={handleAddGroupRect}
title="Add Rectangle" title="Add Zone"
/> />
{/* key forces re-mount when editing a different rect */} {/* key forces re-mount when editing a different rect */}
@@ -457,11 +464,14 @@ 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 Rectangle" title="Edit Zone"
/> />
{/* 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 */}
+7 -2
View File
@@ -59,6 +59,11 @@ 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[]; interval_seconds: number }>('/scan/config'), getConfig: () => api.get<{ ranges: string[] }>('/scan/config'),
saveConfig: (data: { ranges: string[]; interval_seconds: number }) => api.post('/scan/config', data), saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
}
export const settingsApi = {
get: () => api.get<{ interval_seconds: number }>('/settings'),
save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data),
} }
+4 -3
View File
@@ -20,8 +20,9 @@ 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 { } catch (err: unknown) {
setError('Invalid username or password') const hasResponse = err && typeof err === 'object' && 'response' in err
setError(hasResponse ? 'Invalid username or password' : 'Could not reach the server — check your CORS_ORIGINS setting')
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -95,7 +96,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">config.yml</span> Credentials configured in <span className="font-mono">.env</span>
</p> </p>
</div> </div>
</div> </div>
@@ -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(new Error('401')) vi.mocked(authApi.login).mockRejectedValue({ response: { status: 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,10 +65,21 @@ 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(new Error('401')) .mockRejectedValueOnce({ response: { status: 401 } })
.mockRejectedValueOnce(new Error('401')) .mockRejectedValueOnce({ response: { status: 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, setSelectedNode, snapshotHistory,
} = useCanvasStore() } = useCanvasStore()
const activeTheme = useThemeStore((s) => s.activeTheme) const activeTheme = useThemeStore((s) => s.activeTheme)
@@ -59,6 +59,8 @@ 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,4 +143,22 @@ 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)
})
}) })
+26 -20
View File
@@ -50,42 +50,48 @@ 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)` } : {}),
} }
// Animated dot: slightly brighter + thicker than the base edge, travels source→target // Normalize animated value — supports legacy boolean (true → 'snake')
const dotColor = customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : edgeColors[edgeType as keyof typeof edgeColors] as string) const animMode: 'none' | 'snake' | 'flow' =
const dotWidth = ((style.strokeWidth as number ?? 2) + 1.5) * 2 data?.animated === true || data?.animated === 'snake' ? 'snake' :
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} />
{data?.animated && ( {animMode === 'snake' && (
<path <path
d={edgePath} d={edgePath}
fill="none" fill="none"
stroke={dotColor} stroke={animColor}
strokeWidth={dotWidth} strokeWidth={((style.strokeWidth as number ?? 2) + 1.5) * 2}
strokeDasharray="20 10000" strokeDasharray="20 10000"
strokeLinecap="round" strokeLinecap="round"
style={{ pointerEvents: 'none' }} style={{ pointerEvents: 'none' }}
> >
{isBidirectional ? ( {isBidirectional ? (
<animate <animate attributeName="stroke-dashoffset" values="-10000;0;-10000" keyTimes="0;0.5;1" dur="20s" repeatCount="indefinite" />
attributeName="stroke-dashoffset"
values="-10000;0;-10000"
keyTimes="0;0.5;1"
dur="20s"
repeatCount="indefinite"
/>
) : ( ) : (
<animate <animate attributeName="stroke-dashoffset" from="-10000" to="0" dur="10s" repeatCount="indefinite" />
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,12 +32,34 @@ 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
@@ -55,6 +77,8 @@ 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',
@@ -62,12 +86,8 @@ 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 ? 2 : 1}px ${selected ? 'solid' : borderStyle} ${selected ? '#00d4ff' : borderColor}`, border: `${selected ? borderWidth + 1 : borderWidth}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',
}} }}
@@ -76,8 +96,24 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
setEditingGroupRectId(id) setEditingGroupRectId(id)
}} }}
> >
{data.label && ( {labelPosition === 'outside' && data.label && (
<span style={{ textAlign: posStyle.textAlign, userSelect: 'none', whiteSpace: 'pre-wrap' }}> <span
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>
)} )}
+29 -16
View File
@@ -10,6 +10,14 @@ 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
@@ -25,7 +33,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 [animated, setAnimated] = useState(initial?.animated ?? false) const [animation, setAnimation] = useState<AnimMode>(() => toAnimMode(initial?.animated))
const effectiveColor = customColor ?? EDGE_DEFAULT_COLORS[type] const effectiveColor = customColor ?? EDGE_DEFAULT_COLORS[type]
@@ -37,7 +45,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: animated || undefined, animated: animation !== 'none' ? animation : undefined,
}) })
onClose() onClose()
} }
@@ -115,20 +123,25 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
</div> </div>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Flow Animation</Label> <Label className="text-xs text-muted-foreground">Animation</Label>
<button <div className="flex rounded-md overflow-hidden border border-[#30363d]">
type="button" {(['none', 'snake', 'flow'] as AnimMode[]).map((mode, i) => (
onClick={() => setAnimated((a) => !a)} <button
className="relative w-9 h-5 rounded-full transition-colors focus:outline-none shrink-0" key={mode}
style={{ background: animated ? '#00d4ff' : '#30363d' }} type="button"
aria-pressed={animated} onClick={() => setAnimation(mode)}
> className="flex-1 py-1 text-xs capitalize transition-colors"
<span style={{
className="absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform" background: animation === mode ? '#00d4ff22' : '#21262d',
style={{ transform: animated ? 'translateX(16px)' : 'translateX(0)' }} color: animation === mode ? '#00d4ff' : '#8b949e',
/> 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,13 +8,18 @@ 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
} }
@@ -27,13 +32,38 @@ 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,
} }
@@ -65,7 +95,7 @@ interface GroupRectModalProps {
title?: string title?: string
} }
export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, title = 'Add Rectangle' }: GroupRectModalProps) { export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, title = 'Add Zone' }: 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]) =>
@@ -145,6 +175,31 @@ 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>
@@ -169,6 +224,32 @@ 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>
@@ -196,6 +277,31 @@ 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>
@@ -230,7 +336,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 Rectangle' ? 'Add' : 'Save'} {title === 'Add Zone' ? '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 } from 'lucide-react' import { Plus, Trash2, Settings } 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,16 +15,12 @@ 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) => { .then((res) => setRanges(res.data.ranges.length > 0 ? res.data.ranges : ['']))
setRanges(res.data.ranges.length > 0 ? res.data.ranges : [''])
setInterval(res.data.interval_seconds)
})
.catch(() => {/* use defaults */}) .catch(() => {/* use defaults */})
}, [open]) }, [open])
@@ -33,7 +29,7 @@ export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalPro
if (cleaned.length === 0) { toast.error('Add at least one IP range'); return } if (cleaned.length === 0) { toast.error('Add at least one IP range'); return }
setSaving(true) setSaving(true)
try { try {
await scanApi.saveConfig({ ranges: cleaned, interval_seconds: interval }) await scanApi.saveConfig({ ranges: cleaned })
toast.success('Scan config saved') toast.success('Scan config saved')
onClose() onClose()
} catch { } catch {
@@ -95,18 +91,10 @@ export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalPro
</Button> </Button>
</div> </div>
{/* Status check interval */} <p className="text-xs text-muted-foreground flex items-center gap-1.5">
<div className="space-y-1.5"> <Settings size={11} />
<Label className="text-sm text-muted-foreground">Status check interval (seconds)</Label> Status check interval can be configured in the sidebar Settings.
<Input </p>
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,26 +97,52 @@ describe('EdgeModal', () => {
expect(onSubmit.mock.calls[0][0].path_style).toBe('smooth') expect(onSubmit.mock.calls[0][0].path_style).toBe('smooth')
}) })
// ── Animated toggle ─────────────────────────────────────────────────────── // ── Animation select ──────────────────────────────────────────────────────
it('flow animation defaults to off', () => { it('animation defaults to None — animated omitted from payload', () => {
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' }))
// animated: false → omitted (falsy || undefined) expect(onSubmit.mock.calls[0][0].animated).toBeUndefined()
expect(onSubmit.mock.calls[0][0].animated).toBeFalsy()
}) })
it('toggling animation sends animated: true', () => { it('selecting Snake sends animated: "snake"', () => {
const onSubmit = vi.fn() const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />) render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
// Find the toggle: it's the only button with aria-pressed attribute fireEvent.click(screen.getByText('Snake'))
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(true) expect(onSubmit.mock.calls[0][0].animated).toBe('snake')
})
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,14 +13,15 @@ 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 Rectangle')).toBeDefined() expect(screen.getByText('Add Zone')).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 Rectangle title when provided', () => { it('renders Edit Zone title when provided', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Rectangle" />) render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Zone" />)
expect(screen.getByText('Edit Rectangle')).toBeDefined() expect(screen.getByText('Edit Zone')).toBeDefined()
}) })
it('calls onSubmit with form data on submit', () => { it('calls onSubmit with form data on submit', () => {
@@ -123,6 +124,124 @@ 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,11 +13,12 @@ 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'], interval_seconds: 60 } } const defaultConfig = { data: { ranges: ['192.168.1.0/24'] } }
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()
@@ -37,11 +38,14 @@ describe('ScanConfigModal', () => {
expect(input).toBeDefined() expect(input).toBeDefined()
}) })
it('loads interval from API on open', async () => { it('saves only ranges (interval managed by settings endpoint)', async () => {
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['10.0.0.0/8'], interval_seconds: 120 } } as never) vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['10.0.0.0/8'] } } as never)
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />) render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
const input = await screen.findByDisplayValue('120') await screen.findByDisplayValue('10.0.0.0/8')
expect(input).toBeDefined() fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['10.0.0.0/8'] })
})
}) })
it('adds a new empty range on "Add range" click', async () => { it('adds a new empty range on "Add range" click', async () => {
@@ -61,7 +65,7 @@ describe('ScanConfigModal', () => {
}) })
it('can remove a range when more than one exist', async () => { it('can remove a range when more than one exist', async () => {
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['192.168.1.0/24', '10.0.0.0/8'], interval_seconds: 60 } } as never) vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['192.168.1.0/24', '10.0.0.0/8'], } } as never)
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />) 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
@@ -70,7 +74,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: [''], interval_seconds: 60 } } as never) vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: [''], } } 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' }))
@@ -86,7 +90,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'], interval_seconds: 60 }) expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'] })
expect(toast.success).toHaveBeenCalledWith('Scan config saved') expect(toast.success).toHaveBeenCalledWith('Scan config saved')
expect(onClose).toHaveBeenCalledOnce() expect(onClose).toHaveBeenCalledOnce()
}) })
+155 -71
View File
@@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { X, Edit, Trash2, ExternalLink, Plus } from 'lucide-react' import { X, Edit, Trash2, ExternalLink, Plus, Pencil } 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,25 +10,32 @@ 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 } = useCanvasStore() const { nodes, selectedNodeId, setSelectedNode, deleteNode, updateNode, snapshotHistory } = useCanvasStore()
const node = nodes.find((n) => n.id === selectedNodeId) const node = nodes.find((n) => n.id === selectedNodeId)
const [addingService, setAddingService] = useState(false) const [addingForNode, setAddingForNode] = useState<string | null>(null)
const [newSvc, setNewSvc] = useState<{ port: string; protocol: 'tcp' | 'udp'; service_name: string }>({ const [newSvc, setNewSvc] = useState<SvcForm>(EMPTY_FORM)
port: '', const [editingFor, setEditingFor] = useState<{ nodeId: string; index: number } | null>(null)
protocol: 'tcp', const [editSvc, setEditSvc] = useState<SvcForm>(EMPTY_FORM)
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)
} }
} }
@@ -41,14 +48,36 @@ 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: [...(data.services ?? []), svc] }) updateNode(node.id, { services: [...services, svc] })
setNewSvc({ port: '', protocol: 'tcp', service_name: '' }) setNewSvc(EMPTY_FORM)
setAddingService(false) setAddingForNode(null)
} }
const handleRemoveService = (index: number) => { const handleRemoveService = (index: number) => {
const updated = data.services.filter((_, i) => i !== index) const updated = 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 (
@@ -57,6 +86,7 @@ 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"
> >
@@ -115,10 +145,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{data.services.length > 0 ? ` (${data.services.length})` : ''} Services{services.length > 0 ? ` (${services.length})` : ''}
</span> </span>
<button <button
onClick={() => setAddingService((v) => !v)} onClick={() => { setAddingForNode((v) => v === node.id ? null : node.id); setEditingFor(null) }}
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors" className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors"
> >
<Plus size={10} /> Add <Plus size={10} /> Add
@@ -127,67 +157,43 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
{/* Add service form */} {/* Add service form */}
{addingService && ( {addingService && (
<div className="flex flex-col gap-1.5 mb-2 p-2 rounded-md bg-[#0d1117] border border-[#30363d]"> <ServiceForm
<Input form={newSvc}
value={newSvc.service_name} onChange={setNewSvc}
onChange={(e) => setNewSvc((s) => ({ ...s, service_name: e.target.value }))} onConfirm={handleAddService}
placeholder="Service name" onCancel={() => setAddingForNode(null)}
className="bg-[#21262d] border-[#30363d] text-xs h-7" confirmLabel="Add"
autoFocus autoFocus
/> />
<div className="flex gap-1.5">
<Input
type="number"
value={newSvc.port}
onChange={(e) => setNewSvc((s) => ({ ...s, port: e.target.value }))}
placeholder="Port"
min={1}
max={65535}
className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-20 shrink-0"
/>
<select
value={newSvc.protocol}
onChange={(e) => setNewSvc((s) => ({ ...s, protocol: e.target.value as 'tcp' | 'udp' }))}
className="flex-1 bg-[#21262d] border border-[#30363d] rounded-md text-xs h-7 px-1.5 text-foreground"
>
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
</div>
<div className="flex gap-1.5">
<Button
size="sm"
className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
onClick={handleAddService}
>
Add
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 text-[10px]"
onClick={() => setAddingService(false)}
>
Cancel
</Button>
</div>
</div>
)} )}
{data.services.length > 0 && ( {services.length > 0 && (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
{data.services.map((svc, i) => ( {services.map((svc, i) =>
<ServiceBadge editingIndex === i ? (
key={`${svc.port}-${svc.protocol}-${i}`} <ServiceForm
svc={svc} key={`edit-${i}`}
host={host} form={editSvc}
onRemove={() => handleRemoveService(i)} onChange={setEditSvc}
/> onConfirm={handleSaveEdit}
))} onCancel={() => setEditingFor(null)}
confirmLabel="Save"
autoFocus
/>
) : (
<ServiceBadge
key={`${svc.port}-${svc.protocol}-${i}`}
svc={svc}
host={host}
onEdit={() => handleStartEdit(i)}
onRemove={() => handleRemoveService(i)}
/>
)
)}
</div> </div>
)} )}
{data.services.length === 0 && !addingService && ( {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>
@@ -205,7 +211,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" onClick={handleDelete}> <Button size="sm" variant="destructive" className="gap-1.5" aria-label="Delete node" onClick={handleDelete}>
<Trash2 size={14} /> <Trash2 size={14} />
</Button> </Button>
</div> </div>
@@ -232,6 +238,67 @@ 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',
@@ -241,7 +308,17 @@ const CATEGORY_COLORS: Record<string, string> = {
remote: '#8b949e', remote: '#8b949e',
} }
function ServiceBadge({ svc, host, onRemove }: { svc: ServiceInfo; host?: string; onRemove: () => void }) { function ServiceBadge({
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'
@@ -261,6 +338,13 @@ function ServiceBadge({ svc, host, onRemove }: { svc: ServiceInfo; host?: string
<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"
+70 -5
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 } from 'lucide-react' import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings } 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 } from '@/api/client' import { scanApi, settingsApi } 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' type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history' | 'settings'
const ALL_VIEWS = [ const ALL_VIEWS = [
{ id: 'canvas' as SidebarView, icon: LayoutDashboard, label: 'Canvas' }, { id: 'canvas' as SidebarView, icon: LayoutDashboard, label: 'Canvas' },
@@ -95,6 +95,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
{activeView === 'pending' && <PendingDevicesPanel onNodeApproved={onNodeApproved} />} {activeView === 'pending' && <PendingDevicesPanel onNodeApproved={onNodeApproved} />}
{activeView === 'hidden' && <HiddenDevicesPanel />} {activeView === 'hidden' && <HiddenDevicesPanel />}
{activeView === 'history' && <ScanHistoryPanel />} {activeView === 'history' && <ScanHistoryPanel />}
{activeView === 'settings' && <SettingsPanel />}
</div> </div>
)} )}
@@ -124,7 +125,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 Rectangle" collapsed={collapsed} onClick={onAddGroupRect} /> <SidebarItem icon={Square} label="Add Zone" 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}
@@ -141,6 +142,15 @@ 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>
) )
@@ -303,7 +313,7 @@ function HiddenDevicesPanel() {
} }
}, []) }, [])
useState(() => { load() }) useEffect(() => { load() }, [load])
const handleIgnore = async (id: string) => { const handleIgnore = async (id: string) => {
try { try {
@@ -417,6 +427,61 @@ function ScanHistoryPanel() {
) )
} }
function SettingsPanel() {
const [interval, setIntervalValue] = useState(60)
const [saving, setSaving] = useState(false)
useEffect(() => {
settingsApi.get()
.then((res) => setIntervalValue(res.data.interval_seconds))
.catch(() => {/* use default */})
}, [])
const handleSave = async () => {
setSaving(true)
try {
await settingsApi.save({ interval_seconds: interval })
toast.success('Settings saved')
} catch {
toast.error('Failed to save settings')
} finally {
setSaving(false)
}
}
return (
<div className="p-3 space-y-4">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Settings</span>
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">Status check interval (s)</label>
<div className="flex items-center gap-2">
<input
type="number"
min={10}
max={3600}
value={interval}
onChange={(e) => setIntervalValue(Number(e.target.value))}
className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]"
/>
<span className="text-xs text-muted-foreground">seconds</span>
</div>
<p className="text-[10px] text-muted-foreground leading-tight">
How often node health is polled (ping, HTTP, SSH)
</p>
</div>
<button
onClick={handleSave}
disabled={saving}
className="w-full py-1.5 rounded-md text-xs font-medium bg-[#00d4ff]/10 text-[#00d4ff] border border-[#00d4ff]/30 hover:bg-[#00d4ff]/20 transition-colors disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
)
}
const MAC_OUI: Record<string, { label: string; title: string }> = { 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 } from '@testing-library/react' import { render, screen, fireEvent } 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,6 +29,7 @@ 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>)
} }
@@ -40,6 +41,7 @@ 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>)
}) })
@@ -115,4 +117,182 @@ 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()
})
})
}) })
@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { Sidebar } from '../Sidebar'
import * as canvasStore from '@/stores/canvasStore'
import { TooltipProvider } from '@/components/ui/tooltip'
vi.mock('@/stores/canvasStore')
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('@/api/client', () => ({
scanApi: {
trigger: vi.fn(),
pending: vi.fn().mockResolvedValue({ data: [] }),
hidden: vi.fn().mockResolvedValue({ data: [] }),
runs: vi.fn().mockResolvedValue({ data: [] }),
getConfig: vi.fn().mockResolvedValue({ data: { ranges: [] } }),
},
settingsApi: {
get: vi.fn(),
save: vi.fn(),
},
}))
import { settingsApi } from '@/api/client'
import { toast } from 'sonner'
function renderSidebar() {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [],
hasUnsavedChanges: false,
hideIp: false,
toggleHideIp: vi.fn(),
addNode: vi.fn(),
scanEventTs: 0,
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
return render(
<TooltipProvider>
<Sidebar
onAddNode={vi.fn()}
onAddGroupRect={vi.fn()}
onScan={vi.fn()}
onSave={vi.fn()}
onNodeApproved={vi.fn()}
/>
</TooltipProvider>
)
}
describe('SettingsPanel', () => {
beforeEach(() => {
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60 } } as never)
vi.mocked(settingsApi.save).mockResolvedValue({ data: { interval_seconds: 60 } } as never)
vi.mocked(toast.success).mockReset()
vi.mocked(toast.error).mockReset()
})
it('opens when Settings item is clicked', async () => {
renderSidebar()
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
await waitFor(() => {
expect(settingsApi.get).toHaveBeenCalledOnce()
})
expect(screen.getByText('Status check interval (s)')).toBeDefined()
})
it('displays interval loaded from API', async () => {
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 120 } } as never)
renderSidebar()
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
const input = await screen.findByDisplayValue('120')
expect(input).toBeDefined()
})
it('saves interval via settingsApi on Save click', async () => {
renderSidebar()
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
const input = await screen.findByDisplayValue('60')
fireEvent.change(input, { target: { value: '180' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(settingsApi.save).toHaveBeenCalledWith({ interval_seconds: 180 })
expect(toast.success).toHaveBeenCalledWith('Settings saved')
})
})
it('shows error toast when save fails', async () => {
vi.mocked(settingsApi.save).mockRejectedValue(new Error('network'))
renderSidebar()
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
await screen.findByDisplayValue('60')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Failed to save settings')
})
})
it('closes panel when Settings is clicked again', async () => {
renderSidebar()
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
await screen.findByText('Status check interval (s)')
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
expect(screen.queryByText('Status check interval (s)')).toBeNull()
})
})
@@ -98,18 +98,32 @@ describe('canvasStore', () => {
expect(useCanvasStore.getState().selectedNodeId).toBeNull() expect(useCanvasStore.getState().selectedNodeId).toBeNull()
}) })
it('onNodesChange marks unsaved', () => { it('onNodesChange marks unsaved for position 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: 'position', id: 'n1', dragging: false }])
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
}) })
it('onEdgesChange marks unsaved', () => { it('onNodesChange does not mark unsaved for select-only changes', () => {
useCanvasStore.getState().addNode(makeNode('n1'))
useCanvasStore.getState().markSaved()
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)
})
it('onEdgesChange does not mark unsaved for select-only changes', () => {
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(true) expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
}) })
it('onConnect adds an edge between two nodes', () => { it('onConnect adds an edge between two nodes', () => {
@@ -130,6 +144,13 @@ 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)
@@ -140,6 +161,15 @@ 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' }))
+23 -12
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: true, hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'),
})), })),
onEdgesChange: (changes) => onEdgesChange: (changes) =>
set((state) => ({ set((state) => ({
edges: applyEdgeChanges(changes, state.edges), edges: applyEdgeChanges(changes, state.edges),
hasUnsavedChanges: true, hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'),
})), })),
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 }, data: { type: edgeType, label: extra.label, vlan_id: extra.vlan_id, custom_color: extra.custom_color, path_style: extra.path_style, animated: extra.animated },
}, state.edges), }, state.edges),
hasUnsavedChanges: true, hasUnsavedChanges: true,
} }
@@ -163,10 +163,13 @@ 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 // Parents must come before children in the array (React Flow requirement)
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) {
return { nodes: [...withoutNew, enriched], hasUnsavedChanges: true } const parentIdx = withoutNew.findIndex((n) => n.id === enriched.parentId)
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 }
}), }),
@@ -180,12 +183,20 @@ export const useCanvasStore = create<CanvasState>((set) => ({
})), })),
deleteNode: (id) => deleteNode: (id) =>
set((state) => ({ set((state) => {
nodes: state.nodes.filter((n) => n.id !== id), const idsToRemove = new Set<string>()
edges: state.edges.filter((e) => e.source !== id && e.target !== id), const collect = (nodeId: string) => {
selectedNodeId: state.selectedNodeId === id ? null : state.selectedNodeId, idsToRemove.add(nodeId)
hasUnsavedChanges: true, state.nodes.filter((n) => n.parentId === nodeId).forEach((n) => collect(n.id))
})), }
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) => ({
@@ -245,6 +256,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 }) set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], clipboard: [] })
}, },
})) }))
+4 -1
View File
@@ -72,6 +72,9 @@ 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
@@ -88,7 +91,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 animated?: boolean | 'snake' | 'flow' | 'none'
} }
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 animated?: boolean | 'snake' | 'flow' | 'none'
source_handle?: string | null source_handle?: string | null
target_handle?: string | null target_handle?: string | null
} }