Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 985ced6bf5 | |||
| 05a647aac7 | |||
| 1444a81150 | |||
| 1f884fd1db | |||
| e9152df17a | |||
| e7fc091701 | |||
| 7071f8ef5a | |||
| 350dc14a16 | |||
| 68c7672cea | |||
| 381f870bb5 | |||
| 58381b97d2 | |||
| ce4af14ee6 | |||
| 49963c79f7 | |||
| ea539d6e31 | |||
| f657e45995 | |||
| 3fe9fa7ca8 | |||
| f0222247bb | |||
| 9c92d39629 | |||
| e4c0d820f4 | |||
| f9c8e37de3 | |||
| 7ed6b77165 | |||
| 95a3db34f1 | |||
| 37cb97dca1 | |||
| 32b60a201b | |||
| 4ccdbed711 | |||
| 38a06682e5 | |||
| 900cc62b27 | |||
| 343249fbcd | |||
| 4aca82fb1a | |||
| bd047e594e | |||
| 61b30a95fe | |||
| 0b97b7127a | |||
| 2ce942ae61 | |||
| 5897be70c2 | |||
| 210304394e | |||
| b35f34ae73 | |||
| d84692fe4f | |||
| 52cc5cf666 | |||
| 4643aabe28 |
@@ -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
|
||||||
@@ -22,3 +23,8 @@ STATUS_CHECKER_INTERVAL=60
|
|||||||
# Generate keys: python3 -c "import secrets; print(secrets.token_hex(32))"
|
# Generate keys: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
MCP_API_KEY=mcp_sk_changeme
|
MCP_API_KEY=mcp_sk_changeme
|
||||||
MCP_SERVICE_KEY=svc_changeme
|
MCP_SERVICE_KEY=svc_changeme
|
||||||
|
|
||||||
|
# Live view — read-only public canvas at /view?key=<value>
|
||||||
|
# Off by default. Set to a random secret to enable.
|
||||||
|
# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
|
# LIVEVIEW_KEY=
|
||||||
|
|||||||
+3
-1
@@ -1,5 +1,7 @@
|
|||||||
# Stage 1: build
|
# Stage 1: build
|
||||||
FROM node:20-alpine AS builder
|
# Use the native build platform so npm ci never runs under QEMU emulation.
|
||||||
|
# The build output (static HTML/JS/CSS) is platform-independent.
|
||||||
|
FROM --platform=$BUILDPLATFORM node:20-alpine AS builder
|
||||||
|
|
||||||
ARG VITE_STANDALONE=false
|
ARG VITE_STANDALONE=false
|
||||||
ENV VITE_STANDALONE=$VITE_STANDALONE
|
ENV VITE_STANDALONE=$VITE_STANDALONE
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -74,6 +74,31 @@ Homelable continuously monitors your nodes and displays their live status (onlin
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Live View (read-only public canvas)
|
||||||
|
|
||||||
|
Live View lets you share a read-only snapshot of your canvas with anyone on your network — no login required. It is disabled by default.
|
||||||
|
|
||||||
|
### Activation
|
||||||
|
|
||||||
|
Add LIVEVIEW_KEY to your .env:
|
||||||
|
|
||||||
|
`LIVEVIEW_KEY=your-secret-key`
|
||||||
|
|
||||||
|
|
||||||
|
Then restart the backend:
|
||||||
|
|
||||||
|
`docker compose restart backend`
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
Use this URL to view your canvas:
|
||||||
|
|
||||||
|
http://<your-homelab-ip>/view?key=your-secret-key
|
||||||
|
|
||||||
|
The page shows your canvas in pan/zoom-only mode — no editing, no credentials needed. Clicking a node that has an IP opens it in a new tab.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## MCP Server (AI Integration) (optionnal)
|
## MCP Server (AI Integration) (optionnal)
|
||||||
|
|
||||||
Homelable can exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so any MCP-compatible AI client (Claude Code, Claude Desktop, Open WebUI…) can read your homelab topology and act on it.
|
Homelable can exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so any MCP-compatible AI client (Claude Code, Claude Desktop, Open WebUI…) can read your homelab topology and act on it.
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import hmac
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.db.database import get_db
|
||||||
|
from app.db.models import CanvasState, Edge, Node
|
||||||
|
from app.schemas.canvas import CanvasStateResponse
|
||||||
|
from app.schemas.edges import EdgeResponse
|
||||||
|
from app.schemas.nodes import NodeResponse
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=CanvasStateResponse)
|
||||||
|
async def liveview_canvas(
|
||||||
|
key: str | None = Query(default=None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> CanvasStateResponse:
|
||||||
|
"""Read-only public canvas endpoint.
|
||||||
|
|
||||||
|
Disabled by default — requires LIVEVIEW_KEY to be set in .env.
|
||||||
|
Always returns 403 when disabled, regardless of the key provided.
|
||||||
|
"""
|
||||||
|
if not settings.liveview_key:
|
||||||
|
raise HTTPException(status_code=403, detail="Live view is disabled")
|
||||||
|
if not key or not hmac.compare_digest(key, settings.liveview_key):
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid live view key")
|
||||||
|
|
||||||
|
nodes = (await db.execute(select(Node))).scalars().all()
|
||||||
|
edges = (await db.execute(select(Edge))).scalars().all()
|
||||||
|
state = await db.get(CanvasState, 1)
|
||||||
|
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
|
||||||
|
return CanvasStateResponse(
|
||||||
|
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
||||||
|
edges=[EdgeResponse.model_validate(e) for e in edges],
|
||||||
|
viewport=viewport,
|
||||||
|
)
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
|
|
||||||
@@ -30,6 +45,11 @@ class Settings(BaseSettings):
|
|||||||
# Leave empty to disable MCP service key auth.
|
# Leave empty to disable MCP service key auth.
|
||||||
mcp_service_key: str = ""
|
mcp_service_key: str = ""
|
||||||
|
|
||||||
|
# Live view — optional read-only public canvas endpoint.
|
||||||
|
# Set to a random secret string to enable /api/v1/liveview?key=<value>.
|
||||||
|
# Leave unset (or empty) to keep the feature disabled (default).
|
||||||
|
liveview_key: str | None = None
|
||||||
|
|
||||||
def _override_path(self) -> Path:
|
def _override_path(self) -> Path:
|
||||||
return Path(self.sqlite_path).parent / "scan_config.json"
|
return Path(self.sqlite_path).parent / "scan_config.json"
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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]:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+5
-2
@@ -5,7 +5,8 @@ from typing import Any
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.api.routes import auth, canvas, edges, 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,8 @@ app.include_router(edges.router, prefix="/api/v1/edges", tags=["edges"])
|
|||||||
app.include_router(canvas.router, prefix="/api/v1/canvas", tags=["canvas"])
|
app.include_router(canvas.router, prefix="/api/v1/canvas", tags=["canvas"])
|
||||||
app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"])
|
app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"])
|
||||||
app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
|
app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
|
||||||
|
app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
|
||||||
|
app.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/health")
|
@app.get("/api/v1/health")
|
||||||
|
|||||||
@@ -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] = []
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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"),
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
[
|
|
||||||
{"port": 8006, "protocol": "tcp", "banner_regex": null, "service_name": "Proxmox VE", "icon": "layers", "category": "hypervisor", "suggested_node_type": "proxmox"},
|
|
||||||
|
|
||||||
{"port": 5000, "protocol": "tcp", "banner_regex": "synology|DSM", "service_name": "Synology DSM", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
|
|
||||||
{"port": 5001, "protocol": "tcp", "banner_regex": null, "service_name": "Synology DSM HTTPS", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
|
|
||||||
{"port": 5006, "protocol": "tcp", "banner_regex": null, "service_name": "Synology DSM Mobile", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
|
|
||||||
{"port": 8080, "protocol": "tcp", "banner_regex": "QNAP|qnap|QTS", "service_name": "QNAP NAS", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
|
|
||||||
{"port": 5005, "protocol": "tcp", "banner_regex": null, "service_name": "TrueNAS", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
|
|
||||||
{"port": 445, "protocol": "tcp", "banner_regex": null, "service_name": "SMB / CIFS", "icon": "share-2", "category": "storage", "suggested_node_type": "nas"},
|
|
||||||
{"port": 2049, "protocol": "tcp", "banner_regex": null, "service_name": "NFS", "icon": "share-2", "category": "storage", "suggested_node_type": "nas"},
|
|
||||||
{"port": 548, "protocol": "tcp", "banner_regex": null, "service_name": "AFP (Apple Filing)", "icon": "share-2", "category": "storage", "suggested_node_type": "nas"},
|
|
||||||
{"port": 873, "protocol": "tcp", "banner_regex": null, "service_name": "rsync", "icon": "refresh-cw", "category": "storage", "suggested_node_type": "nas"},
|
|
||||||
|
|
||||||
{"port": 32400, "protocol": "tcp", "banner_regex": null, "service_name": "Plex Media Server", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
|
|
||||||
{"port": 32469, "protocol": "tcp", "banner_regex": null, "service_name": "Plex DLNA", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
|
|
||||||
{"port": 8096, "protocol": "tcp", "banner_regex": "Jellyfin", "service_name": "Jellyfin", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
|
|
||||||
{"port": 8096, "protocol": "tcp", "banner_regex": "Emby", "service_name": "Emby", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
|
|
||||||
{"port": 8096, "protocol": "tcp", "banner_regex": null, "service_name": "Jellyfin / Emby", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
|
|
||||||
{"port": 8920, "protocol": "tcp", "banner_regex": null, "service_name": "Jellyfin HTTPS", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
|
|
||||||
{"port": 8181, "protocol": "tcp", "banner_regex": null, "service_name": "Tautulli", "icon": "bar-chart", "category": "media", "suggested_node_type": "server"},
|
|
||||||
{"port": 8013, "protocol": "tcp", "banner_regex": null, "service_name": "Komga", "icon": "book-open", "category": "media", "suggested_node_type": "server"},
|
|
||||||
{"port": 1935, "protocol": "tcp", "banner_regex": null, "service_name": "RTMP (Stream)", "icon": "video", "category": "media", "suggested_node_type": "server"},
|
|
||||||
|
|
||||||
{"port": 8989, "protocol": "tcp", "banner_regex": null, "service_name": "Sonarr", "icon": "tv", "category": "media", "suggested_node_type": "server"},
|
|
||||||
{"port": 7878, "protocol": "tcp", "banner_regex": null, "service_name": "Radarr", "icon": "film", "category": "media", "suggested_node_type": "server"},
|
|
||||||
{"port": 8686, "protocol": "tcp", "banner_regex": null, "service_name": "Lidarr", "icon": "music", "category": "media", "suggested_node_type": "server"},
|
|
||||||
{"port": 9696, "protocol": "tcp", "banner_regex": null, "service_name": "Prowlarr", "icon": "search", "category": "media", "suggested_node_type": "server"},
|
|
||||||
{"port": 8787, "protocol": "tcp", "banner_regex": null, "service_name": "Readarr", "icon": "book", "category": "media", "suggested_node_type": "server"},
|
|
||||||
{"port": 6767, "protocol": "tcp", "banner_regex": null, "service_name": "Bazarr", "icon": "subtitles", "category": "media", "suggested_node_type": "server"},
|
|
||||||
{"port": 5055, "protocol": "tcp", "banner_regex": null, "service_name": "Overseerr / Jellyseerr", "icon": "search", "category": "media", "suggested_node_type": "server"},
|
|
||||||
{"port": 9117, "protocol": "tcp", "banner_regex": null, "service_name": "Jackett", "icon": "search", "category": "media", "suggested_node_type": "server"},
|
|
||||||
{"port": 6969, "protocol": "tcp", "banner_regex": null, "service_name": "Whisparr", "icon": "film", "category": "media", "suggested_node_type": "server"},
|
|
||||||
{"port": 5454, "protocol": "tcp", "banner_regex": null, "service_name": "Notifiarr", "icon": "bell", "category": "media", "suggested_node_type": "server"},
|
|
||||||
{"port": 8191, "protocol": "tcp", "banner_regex": null, "service_name": "FlareSolverr", "icon": "shield", "category": "network", "suggested_node_type": "server"},
|
|
||||||
|
|
||||||
{"port": 9091, "protocol": "tcp", "banner_regex": "Transmission", "service_name": "Transmission", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
|
||||||
{"port": 8112, "protocol": "tcp", "banner_regex": null, "service_name": "Deluge", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
|
||||||
{"port": 6789, "protocol": "tcp", "banner_regex": null, "service_name": "NZBGet", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
|
||||||
{"port": 6800, "protocol": "tcp", "banner_regex": null, "service_name": "Aria2 RPC", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
|
||||||
{"port": 51413, "protocol": "tcp", "banner_regex": null, "service_name": "Transmission BitTorrent", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
|
||||||
{"port": 6881, "protocol": "tcp", "banner_regex": null, "service_name": "BitTorrent Peer", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
|
||||||
|
|
||||||
{"port": 8123, "protocol": "tcp", "banner_regex": null, "service_name": "Home Assistant", "icon": "home", "category": "automation", "suggested_node_type": "iot"},
|
|
||||||
{"port": 1883, "protocol": "tcp", "banner_regex": null, "service_name": "MQTT Broker", "icon": "radio", "category": "iot", "suggested_node_type": "iot"},
|
|
||||||
{"port": 8883, "protocol": "tcp", "banner_regex": null, "service_name": "MQTT Broker TLS", "icon": "radio", "category": "iot", "suggested_node_type": "iot"},
|
|
||||||
{"port": 6052, "protocol": "tcp", "banner_regex": null, "service_name": "ESPHome", "icon": "cpu", "category": "iot", "suggested_node_type": "iot"},
|
|
||||||
{"port": 1880, "protocol": "tcp", "banner_regex": null, "service_name": "Node-RED", "icon": "git-branch", "category": "automation", "suggested_node_type": "iot"},
|
|
||||||
{"port": 8971, "protocol": "tcp", "banner_regex": null, "service_name": "Frigate NVR", "icon": "camera", "category": "nvr", "suggested_node_type": "camera"},
|
|
||||||
{"port": 10443, "protocol": "tcp", "banner_regex": null, "service_name": "Scrypted", "icon": "camera", "category": "nvr", "suggested_node_type": "camera"},
|
|
||||||
{"port": 5000, "protocol": "tcp", "banner_regex": "frigate", "service_name": "Frigate NVR", "icon": "camera", "category": "nvr", "suggested_node_type": "camera"},
|
|
||||||
{"port": 8081, "protocol": "tcp", "banner_regex": "iobroker|ioBroker", "service_name": "ioBroker", "icon": "cpu", "category": "automation", "suggested_node_type": "iot"},
|
|
||||||
{"port": 8080, "protocol": "tcp", "banner_regex": "Domoticz|domoticz", "service_name": "Domoticz", "icon": "home", "category": "automation", "suggested_node_type": "iot"},
|
|
||||||
{"port": 5683, "protocol": "udp", "banner_regex": null, "service_name": "CoAP (IoT)", "icon": "radio", "category": "iot", "suggested_node_type": "iot"},
|
|
||||||
|
|
||||||
{"port": 554, "protocol": "tcp", "banner_regex": null, "service_name": "RTSP (Camera)", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
|
|
||||||
{"port": 8554, "protocol": "tcp", "banner_regex": null, "service_name": "RTSP Alt (Camera)", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
|
|
||||||
{"port": 37777, "protocol": "tcp", "banner_regex": null, "service_name": "Dahua Camera SDK", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
|
|
||||||
{"port": 34567, "protocol": "tcp", "banner_regex": null, "service_name": "Amcrest / Dahua Camera", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
|
|
||||||
{"port": 8000, "protocol": "tcp", "banner_regex": "[Hh]ikvision|[Dd]ahua", "service_name": "IP Camera SDK", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
|
|
||||||
{"port": 2020, "protocol": "tcp", "banner_regex": null, "service_name": "TP-Link Tapo Camera", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
|
|
||||||
{"port": 9000, "protocol": "tcp", "banner_regex": "[Rr]eolink", "service_name": "Reolink Camera", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
|
|
||||||
|
|
||||||
{"port": 8291, "protocol": "tcp", "banner_regex": null, "service_name": "MikroTik Winbox", "icon": "router", "category": "network", "suggested_node_type": "router"},
|
|
||||||
{"port": 8880, "protocol": "tcp", "banner_regex": null, "service_name": "UniFi HTTP Portal", "icon": "wifi", "category": "network", "suggested_node_type": "ap"},
|
|
||||||
{"port": 8443, "protocol": "tcp", "banner_regex": "[Uu]ni[Ff]i", "service_name": "UniFi Controller", "icon": "wifi", "category": "network", "suggested_node_type": "ap"},
|
|
||||||
{"port": 4711, "protocol": "tcp", "banner_regex": null, "service_name": "Pi-hole API", "icon": "shield", "category": "network", "suggested_node_type": "router"},
|
|
||||||
{"port": 3000, "protocol": "tcp", "banner_regex": "[Aa]d[Gg]uard", "service_name": "AdGuard Home", "icon": "shield", "category": "network", "suggested_node_type": "router"},
|
|
||||||
{"port": 81, "protocol": "tcp", "banner_regex": null, "service_name": "Nginx Proxy Manager", "icon": "arrow-right", "category": "network", "suggested_node_type": "router"},
|
|
||||||
{"port": 23, "protocol": "tcp", "banner_regex": null, "service_name": "Telnet", "icon": "terminal", "category": "network", "suggested_node_type": "switch"},
|
|
||||||
{"port": 161, "protocol": "udp", "banner_regex": null, "service_name": "SNMP", "icon": "activity", "category": "network", "suggested_node_type": "switch"},
|
|
||||||
|
|
||||||
{"port": 8200, "protocol": "tcp", "banner_regex": null, "service_name": "HashiCorp Vault", "icon": "lock", "category": "security", "suggested_node_type": "server"},
|
|
||||||
{"port": 389, "protocol": "tcp", "banner_regex": null, "service_name": "LDAP", "icon": "users", "category": "auth", "suggested_node_type": "server"},
|
|
||||||
{"port": 636, "protocol": "tcp", "banner_regex": null, "service_name": "LDAPS", "icon": "users", "category": "auth", "suggested_node_type": "server"},
|
|
||||||
{"port": 9091, "protocol": "tcp", "banner_regex": "[Aa]uthelia", "service_name": "Authelia", "icon": "shield", "category": "security", "suggested_node_type": "server"},
|
|
||||||
{"port": 9000, "protocol": "tcp", "banner_regex": "[Aa]uthentik", "service_name": "Authentik", "icon": "shield", "category": "security", "suggested_node_type": "server"},
|
|
||||||
{"port": 8080, "protocol": "tcp", "banner_regex": "[Kk]eycloak", "service_name": "Keycloak", "icon": "shield", "category": "auth", "suggested_node_type": "server"},
|
|
||||||
|
|
||||||
{"port": 3000, "protocol": "tcp", "banner_regex": "[Gg]rafana", "service_name": "Grafana", "icon": "bar-chart-2", "category": "monitoring", "suggested_node_type": "server"},
|
|
||||||
{"port": 9090, "protocol": "tcp", "banner_regex": null, "service_name": "Prometheus", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
|
||||||
{"port": 9093, "protocol": "tcp", "banner_regex": null, "service_name": "Alertmanager", "icon": "bell", "category": "monitoring", "suggested_node_type": "server"},
|
|
||||||
{"port": 9100, "protocol": "tcp", "banner_regex": null, "service_name": "Node Exporter", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
|
||||||
{"port": 8086, "protocol": "tcp", "banner_regex": null, "service_name": "InfluxDB", "icon": "database", "category": "monitoring", "suggested_node_type": "server"},
|
|
||||||
{"port": 3100, "protocol": "tcp", "banner_regex": null, "service_name": "Grafana Loki", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
|
||||||
{"port": 8428, "protocol": "tcp", "banner_regex": null, "service_name": "VictoriaMetrics", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
|
||||||
{"port": 19999, "protocol": "tcp", "banner_regex": null, "service_name": "Netdata", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
|
||||||
{"port": 3001, "protocol": "tcp", "banner_regex": null, "service_name": "Uptime Kuma", "icon": "heart", "category": "monitoring", "suggested_node_type": "server"},
|
|
||||||
{"port": 8581, "protocol": "tcp", "banner_regex": null, "service_name": "Uptime Kuma", "icon": "heart", "category": "monitoring", "suggested_node_type": "server"},
|
|
||||||
{"port": 10051, "protocol": "tcp", "banner_regex": null, "service_name": "Zabbix Server", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
|
||||||
{"port": 9411, "protocol": "tcp", "banner_regex": null, "service_name": "Zipkin", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
|
||||||
{"port": 16686, "protocol": "tcp", "banner_regex": null, "service_name": "Jaeger UI", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
|
||||||
{"port": 5601, "protocol": "tcp", "banner_regex": null, "service_name": "Kibana", "icon": "bar-chart-2", "category": "monitoring", "suggested_node_type": "server"},
|
|
||||||
|
|
||||||
{"port": 9443, "protocol": "tcp", "banner_regex": "[Pp]ortainer", "service_name": "Portainer HTTPS", "icon": "box", "category": "containers", "suggested_node_type": "lxc"},
|
|
||||||
{"port": 9000, "protocol": "tcp", "banner_regex": "[Pp]ortainer", "service_name": "Portainer", "icon": "box", "category": "containers", "suggested_node_type": "lxc"},
|
|
||||||
{"port": 2375, "protocol": "tcp", "banner_regex": null, "service_name": "Docker API", "icon": "box", "category": "containers", "suggested_node_type": "server"},
|
|
||||||
{"port": 2376, "protocol": "tcp", "banner_regex": null, "service_name": "Docker API TLS", "icon": "box", "category": "containers", "suggested_node_type": "server"},
|
|
||||||
{"port": 6443, "protocol": "tcp", "banner_regex": null, "service_name": "Kubernetes API", "icon": "layers", "category": "containers", "suggested_node_type": "server"},
|
|
||||||
|
|
||||||
{"port": 3306, "protocol": "tcp", "banner_regex": null, "service_name": "MySQL / MariaDB", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
|
||||||
{"port": 5432, "protocol": "tcp", "banner_regex": null, "service_name": "PostgreSQL", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
|
||||||
{"port": 6379, "protocol": "tcp", "banner_regex": null, "service_name": "Redis", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
|
||||||
{"port": 27017, "protocol": "tcp", "banner_regex": null, "service_name": "MongoDB", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
|
||||||
{"port": 9200, "protocol": "tcp", "banner_regex": null, "service_name": "Elasticsearch", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
|
||||||
{"port": 9300, "protocol": "tcp", "banner_regex": null, "service_name": "Elasticsearch Transport", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
|
||||||
{"port": 5984, "protocol": "tcp", "banner_regex": null, "service_name": "CouchDB", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
|
||||||
{"port": 1521, "protocol": "tcp", "banner_regex": null, "service_name": "Oracle DB", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
|
||||||
{"port": 6432, "protocol": "tcp", "banner_regex": null, "service_name": "PgBouncer", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
|
||||||
|
|
||||||
{"port": 22, "protocol": "tcp", "banner_regex": null, "service_name": "SSH", "icon": "terminal", "category": "remote", "suggested_node_type": "server"},
|
|
||||||
{"port": 21, "protocol": "tcp", "banner_regex": null, "service_name": "FTP", "icon": "upload", "category": "storage", "suggested_node_type": "server"},
|
|
||||||
{"port": 25, "protocol": "tcp", "banner_regex": null, "service_name": "SMTP", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
|
|
||||||
{"port": 110, "protocol": "tcp", "banner_regex": null, "service_name": "POP3", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
|
|
||||||
{"port": 143, "protocol": "tcp", "banner_regex": null, "service_name": "IMAP", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
|
|
||||||
{"port": 465, "protocol": "tcp", "banner_regex": null, "service_name": "SMTPS", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
|
|
||||||
{"port": 587, "protocol": "tcp", "banner_regex": null, "service_name": "SMTP Submission", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
|
|
||||||
{"port": 993, "protocol": "tcp", "banner_regex": null, "service_name": "IMAPS", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
|
|
||||||
{"port": 995, "protocol": "tcp", "banner_regex": null, "service_name": "POP3S", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
|
|
||||||
{"port": 3389, "protocol": "tcp", "banner_regex": null, "service_name": "RDP", "icon": "monitor", "category": "remote", "suggested_node_type": "server"},
|
|
||||||
{"port": 5900, "protocol": "tcp", "banner_regex": null, "service_name": "VNC", "icon": "monitor", "category": "remote", "suggested_node_type": "server"},
|
|
||||||
{"port": 5800, "protocol": "tcp", "banner_regex": null, "service_name": "VNC (HTTP)", "icon": "monitor", "category": "remote", "suggested_node_type": "server"},
|
|
||||||
|
|
||||||
{"port": 8888, "protocol": "tcp", "banner_regex": null, "service_name": "Jupyter Notebook", "icon": "code", "category": "dev", "suggested_node_type": "server"},
|
|
||||||
{"port": 3000, "protocol": "tcp", "banner_regex": "[Gg]itea", "service_name": "Gitea", "icon": "git-branch", "category": "dev", "suggested_node_type": "server"},
|
|
||||||
|
|
||||||
{"port": 80, "protocol": "tcp", "banner_regex": null, "service_name": "HTTP", "icon": "globe", "category": "web", "suggested_node_type": "server"},
|
|
||||||
{"port": 443, "protocol": "tcp", "banner_regex": null, "service_name": "HTTPS", "icon": "lock", "category": "web", "suggested_node_type": "server"},
|
|
||||||
{"port": 8080, "protocol": "tcp", "banner_regex": null, "service_name": "HTTP Alt", "icon": "globe", "category": "web", "suggested_node_type": "server"},
|
|
||||||
{"port": 8443, "protocol": "tcp", "banner_regex": null, "service_name": "HTTPS Alt", "icon": "lock", "category": "web", "suggested_node_type": "server"},
|
|
||||||
{"port": 8008, "protocol": "tcp", "banner_regex": null, "service_name": "HTTP Alt", "icon": "globe", "category": "web", "suggested_node_type": "server"},
|
|
||||||
{"port": 3000, "protocol": "tcp", "banner_regex": null, "service_name": "Web service", "icon": "globe", "category": "web", "suggested_node_type": "server"},
|
|
||||||
{"port": 9091, "protocol": "tcp", "banner_regex": null, "service_name": "Transmission", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
|
||||||
{"port": 9000, "protocol": "tcp", "banner_regex": null, "service_name": "Web service", "icon": "globe", "category": "web", "suggested_node_type": "server"},
|
|
||||||
{"port": 9443, "protocol": "tcp", "banner_regex": null, "service_name": "HTTPS Alt", "icon": "lock", "category": "web", "suggested_node_type": "server"},
|
|
||||||
{"port": 5000, "protocol": "tcp", "banner_regex": null, "service_name": "Web service", "icon": "globe", "category": "web", "suggested_node_type": "server"},
|
|
||||||
|
|
||||||
{"port": 8448, "protocol": "tcp", "banner_regex": null, "service_name": "Matrix (Synapse)", "icon": "message-square", "category": "communication", "suggested_node_type": "server"},
|
|
||||||
{"port": 64738, "protocol": "tcp", "banner_regex": null, "service_name": "Mumble", "icon": "mic", "category": "communication", "suggested_node_type": "server"},
|
|
||||||
{"port": 25565, "protocol": "tcp", "banner_regex": null, "service_name": "Minecraft Server", "icon": "cpu", "category": "gaming", "suggested_node_type": "server"},
|
|
||||||
|
|
||||||
{"port": 51820, "protocol": "udp", "banner_regex": null, "service_name": "WireGuard", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
|
|
||||||
{"port": 1194, "protocol": "udp", "banner_regex": null, "service_name": "OpenVPN", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
|
|
||||||
{"port": 500, "protocol": "udp", "banner_regex": null, "service_name": "IPsec IKE", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
|
|
||||||
{"port": 53, "protocol": "udp", "banner_regex": null, "service_name": "DNS", "icon": "search", "category": "network", "suggested_node_type": "router"},
|
|
||||||
{"port": 67, "protocol": "udp", "banner_regex": null, "service_name": "DHCP", "icon": "wifi", "category": "network", "suggested_node_type": "router"}
|
|
||||||
]
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""
|
||||||
|
Tests for the /api/v1/liveview read-only canvas endpoint.
|
||||||
|
|
||||||
|
The endpoint is:
|
||||||
|
- Disabled by default (LIVEVIEW_KEY not set) → 403
|
||||||
|
- Returns 403 for missing or wrong key even when enabled
|
||||||
|
- Returns canvas data for a valid key (no JWT required)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_liveview_key():
|
||||||
|
"""Restore liveview_key after each test so tests are isolated."""
|
||||||
|
original = settings.liveview_key
|
||||||
|
yield
|
||||||
|
settings.liveview_key = original
|
||||||
|
|
||||||
|
|
||||||
|
# ── Disabled (no key configured) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_liveview_disabled_by_default(client: AsyncClient):
|
||||||
|
settings.liveview_key = None
|
||||||
|
res = await client.get("/api/v1/liveview?key=anything")
|
||||||
|
assert res.status_code == 403
|
||||||
|
assert res.json()["detail"] == "Live view is disabled"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_liveview_disabled_when_key_empty(client: AsyncClient):
|
||||||
|
settings.liveview_key = ""
|
||||||
|
res = await client.get("/api/v1/liveview?key=anything")
|
||||||
|
assert res.status_code == 403
|
||||||
|
assert res.json()["detail"] == "Live view is disabled"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Enabled but wrong / missing key ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_liveview_wrong_key(client: AsyncClient):
|
||||||
|
settings.liveview_key = "correct-secret"
|
||||||
|
res = await client.get("/api/v1/liveview?key=wrong-key")
|
||||||
|
assert res.status_code == 403
|
||||||
|
assert res.json()["detail"] == "Invalid live view key"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_liveview_missing_key_param(client: AsyncClient):
|
||||||
|
settings.liveview_key = "correct-secret"
|
||||||
|
res = await client.get("/api/v1/liveview")
|
||||||
|
assert res.status_code == 403
|
||||||
|
assert res.json()["detail"] == "Invalid live view key"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Valid key — no JWT needed ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_liveview_valid_key_returns_canvas(client: AsyncClient):
|
||||||
|
settings.liveview_key = "my-secret-key"
|
||||||
|
res = await client.get("/api/v1/liveview?key=my-secret-key")
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert "nodes" in data
|
||||||
|
assert "edges" in data
|
||||||
|
assert "viewport" in data
|
||||||
|
assert isinstance(data["nodes"], list)
|
||||||
|
assert isinstance(data["edges"], list)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_liveview_does_not_require_jwt(client: AsyncClient):
|
||||||
|
"""Accessing without Authorization header must work when key is correct."""
|
||||||
|
settings.liveview_key = "open-sesame"
|
||||||
|
# client has no auth headers set here
|
||||||
|
res = await client.get("/api/v1/liveview?key=open-sesame")
|
||||||
|
assert res.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_liveview_returns_saved_canvas(client: AsyncClient, auth_headers):
|
||||||
|
"""Canvas saved via POST /canvas/save appears in liveview response."""
|
||||||
|
settings.liveview_key = "test-key"
|
||||||
|
headers = await auth_headers()
|
||||||
|
|
||||||
|
# Save a canvas with one node
|
||||||
|
payload = {
|
||||||
|
"nodes": [{
|
||||||
|
"id": "lv-node-1",
|
||||||
|
"type": "server",
|
||||||
|
"label": "Live Node",
|
||||||
|
"status": "online",
|
||||||
|
"services": [],
|
||||||
|
"pos_x": 10,
|
||||||
|
"pos_y": 20,
|
||||||
|
}],
|
||||||
|
"edges": [],
|
||||||
|
"viewport": {"x": 0, "y": 0, "zoom": 1},
|
||||||
|
}
|
||||||
|
await client.post("/api/v1/canvas/save", json=payload, headers=headers)
|
||||||
|
|
||||||
|
# Liveview should return the same node
|
||||||
|
res = await client.get("/api/v1/liveview?key=test-key")
|
||||||
|
assert res.status_code == 200
|
||||||
|
nodes = res.json()["nodes"]
|
||||||
|
assert len(nodes) == 1
|
||||||
|
assert nodes[0]["id"] == "lv-node-1"
|
||||||
|
assert nodes[0]["label"] == "Live Node"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Re-disable after enabling ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_liveview_disabled_after_key_cleared(client: AsyncClient):
|
||||||
|
settings.liveview_key = "was-enabled"
|
||||||
|
res = await client.get("/api/v1/liveview?key=was-enabled")
|
||||||
|
assert res.status_code == 200
|
||||||
|
|
||||||
|
settings.liveview_key = None
|
||||||
|
res = await client.get("/api/v1/liveview?key=was-enabled")
|
||||||
|
assert res.status_code == 403
|
||||||
|
assert res.json()["detail"] == "Live view is disabled"
|
||||||
+114
-1
@@ -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."""
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
Generated
+2
-2
@@ -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,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
@@ -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 */}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ export const api = axios.create({
|
|||||||
baseURL: '/api/v1',
|
baseURL: '/api/v1',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Unauthenticated axios instance — no JWT, no 401 redirect (used for public endpoints)
|
||||||
|
const publicApi = axios.create({ baseURL: '/api/v1' })
|
||||||
|
|
||||||
api.interceptors.request.use((config) => {
|
api.interceptors.request.use((config) => {
|
||||||
const token = useAuthStore.getState().token
|
const token = useAuthStore.getState().token
|
||||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||||
@@ -44,6 +47,10 @@ export const edgesApi = {
|
|||||||
delete: (id: string) => api.delete(`/edges/${id}`),
|
delete: (id: string) => api.delete(`/edges/${id}`),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const liveviewApi = {
|
||||||
|
load: (key: string) => publicApi.get('/liveview', { params: { key } }),
|
||||||
|
}
|
||||||
|
|
||||||
export const scanApi = {
|
export const scanApi = {
|
||||||
trigger: () => api.post('/scan/trigger'),
|
trigger: () => api.post('/scan/trigger'),
|
||||||
pending: () => api.get('/scan/pending'),
|
pending: () => api.get('/scan/pending'),
|
||||||
@@ -52,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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* LiveView — read-only canvas accessible at /view?key=<LIVEVIEW_KEY>.
|
||||||
|
*
|
||||||
|
* - Non-standalone: fetches canvas from /api/v1/liveview?key=... (no JWT needed).
|
||||||
|
* Returns 403 when the feature is disabled or the key is wrong.
|
||||||
|
* - Standalone: loads canvas from localStorage directly (no key required,
|
||||||
|
* since there is no backend to validate against).
|
||||||
|
*
|
||||||
|
* Pan and zoom work. Editing is fully disabled.
|
||||||
|
* Clicking a node with an IP opens http://<ip> in a new tab.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
ReactFlowProvider,
|
||||||
|
ReactFlow,
|
||||||
|
Background,
|
||||||
|
BackgroundVariant,
|
||||||
|
Controls,
|
||||||
|
ConnectionMode,
|
||||||
|
type Node,
|
||||||
|
} from '@xyflow/react'
|
||||||
|
import '@xyflow/react/dist/style.css'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
|
import { THEMES } from '@/utils/themes'
|
||||||
|
import { nodeTypes } from '@/components/canvas/nodes/nodeTypes'
|
||||||
|
import { edgeTypes } from '@/components/canvas/edges/edgeTypes'
|
||||||
|
import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
||||||
|
import { liveviewApi } from '@/api/client'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
|
||||||
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
|
const STORAGE_KEY = 'homelable_canvas'
|
||||||
|
|
||||||
|
type ViewState = 'loading' | 'disabled' | 'invalid-key' | 'no-key' | 'network-error' | 'ready'
|
||||||
|
|
||||||
|
function LiveViewCanvas() {
|
||||||
|
const { nodes, edges, loadCanvas } = useCanvasStore()
|
||||||
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
|
const theme = THEMES[activeTheme]
|
||||||
|
// Derive initial view state synchronously (avoids calling setState inside an effect):
|
||||||
|
// - standalone → always ready (localStorage, no key required)
|
||||||
|
// - non-standalone, no ?key= → no-key error immediately
|
||||||
|
// - non-standalone, key present → loading (API call below)
|
||||||
|
const [viewState, setViewState] = useState<ViewState>(() => {
|
||||||
|
if (STANDALONE) return 'ready'
|
||||||
|
return new URLSearchParams(window.location.search).get('key') ? 'loading' : 'no-key'
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (STANDALONE) {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (saved) {
|
||||||
|
const { nodes: savedNodes, edges: savedEdges } = JSON.parse(saved)
|
||||||
|
loadCanvas(savedNodes, savedEdges)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// empty canvas on parse error — show empty canvas
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already handled synchronously in useState initializer
|
||||||
|
const key = new URLSearchParams(window.location.search).get('key')
|
||||||
|
if (!key) return
|
||||||
|
|
||||||
|
liveviewApi.load(key)
|
||||||
|
.then((res) => {
|
||||||
|
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||||
|
const proxmoxMap = new Map<string, boolean>(
|
||||||
|
(apiNodes as ApiNode[])
|
||||||
|
.filter((n: ApiNode) => n.type === 'proxmox')
|
||||||
|
.map((n: ApiNode) => [n.id, n.container_mode !== false])
|
||||||
|
)
|
||||||
|
loadCanvas(
|
||||||
|
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
|
||||||
|
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
|
||||||
|
)
|
||||||
|
setViewState('ready')
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!err.response) { setViewState('network-error'); return }
|
||||||
|
const detail: string = err.response.data?.detail ?? ''
|
||||||
|
setViewState(detail === 'Live view is disabled' ? 'disabled' : 'invalid-key')
|
||||||
|
})
|
||||||
|
}, [loadCanvas])
|
||||||
|
|
||||||
|
const onNodeClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => {
|
||||||
|
const ip = node.data.ip
|
||||||
|
if (ip) window.open(`http://${ip}`, '_blank', 'noopener,noreferrer')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (viewState === 'loading') {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-screen items-center justify-center bg-[#0d1117] text-[#8b949e]">
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewState !== 'ready') {
|
||||||
|
const messages: Record<Exclude<ViewState, 'loading' | 'ready'>, string> = {
|
||||||
|
disabled: 'Live view is disabled on this instance.',
|
||||||
|
'invalid-key': 'Invalid or expired live view key.',
|
||||||
|
'no-key': 'Missing key — use ?key=your-secret in the URL.',
|
||||||
|
'network-error': 'Could not reach the server. Check your connection.',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-screen items-center justify-center bg-[#0d1117]">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<p className="text-[#f85149] text-lg font-medium">Access Denied</p>
|
||||||
|
<p className="text-[#8b949e] text-sm">{messages[viewState]}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-screen" style={{ background: theme.colors.canvasBackground }}>
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
edgeTypes={edgeTypes}
|
||||||
|
nodesDraggable={false}
|
||||||
|
nodesConnectable={false}
|
||||||
|
elementsSelectable={false}
|
||||||
|
panOnDrag
|
||||||
|
zoomOnScroll
|
||||||
|
fitView
|
||||||
|
colorMode={theme.colors.reactFlowColorMode}
|
||||||
|
connectionMode={ConnectionMode.Loose}
|
||||||
|
onNodeClick={onNodeClick}
|
||||||
|
>
|
||||||
|
<Background
|
||||||
|
variant={BackgroundVariant.Dots}
|
||||||
|
gap={24}
|
||||||
|
size={1}
|
||||||
|
color={theme.colors.canvasDotColor}
|
||||||
|
/>
|
||||||
|
<Controls showInteractive={false} />
|
||||||
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LiveView() {
|
||||||
|
return (
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<LiveViewCanvas />
|
||||||
|
</ReactFlowProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
|
||||||
|
// ── Mock heavy dependencies ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
vi.mock('@xyflow/react', () => ({
|
||||||
|
ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
ReactFlow: () => <div data-testid="react-flow" />,
|
||||||
|
Background: () => null,
|
||||||
|
Controls: () => null,
|
||||||
|
BackgroundVariant: { Dots: 'dots' },
|
||||||
|
ConnectionMode: { Loose: 'loose' },
|
||||||
|
}))
|
||||||
|
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
liveviewApi: { load: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { liveviewApi } from '@/api/client'
|
||||||
|
import LiveView from '../LiveView'
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function setSearch(params: string) {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
writable: true,
|
||||||
|
value: { ...window.location, search: params, pathname: '/view' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvasPayload = {
|
||||||
|
data: {
|
||||||
|
nodes: [{
|
||||||
|
id: 'n1', type: 'server', label: 'CI Node', status: 'online',
|
||||||
|
services: [], pos_x: 0, pos_y: 0,
|
||||||
|
created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
|
||||||
|
}],
|
||||||
|
edges: [],
|
||||||
|
viewport: { x: 0, y: 0, zoom: 1 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('LiveView (non-standalone)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(liveviewApi.load).mockReset()
|
||||||
|
useCanvasStore.setState({ nodes: [], edges: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── No key ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('shows no-key error when ?key= is missing', async () => {
|
||||||
|
setSearch('')
|
||||||
|
render(<LiveView />)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Access Denied')).toBeDefined()
|
||||||
|
expect(screen.getByText(/Missing key/)).toBeDefined()
|
||||||
|
})
|
||||||
|
expect(liveviewApi.load).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Disabled ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('shows disabled error when backend returns "Live view is disabled"', async () => {
|
||||||
|
setSearch('?key=anything')
|
||||||
|
vi.mocked(liveviewApi.load).mockRejectedValue({
|
||||||
|
response: { data: { detail: 'Live view is disabled' } },
|
||||||
|
})
|
||||||
|
render(<LiveView />)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/disabled on this instance/)).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Invalid key ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('shows invalid-key error when backend returns "Invalid live view key"', async () => {
|
||||||
|
setSearch('?key=wrong')
|
||||||
|
vi.mocked(liveviewApi.load).mockRejectedValue({
|
||||||
|
response: { data: { detail: 'Invalid live view key' } },
|
||||||
|
})
|
||||||
|
render(<LiveView />)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Invalid or expired/)).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows network-error for non-response errors (offline, CORS, 500)', async () => {
|
||||||
|
setSearch('?key=anything')
|
||||||
|
vi.mocked(liveviewApi.load).mockRejectedValue(new Error('network'))
|
||||||
|
render(<LiveView />)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Could not reach the server/)).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Valid key → canvas rendered ───────────────────────────────────────────
|
||||||
|
|
||||||
|
it('renders the canvas on valid key', async () => {
|
||||||
|
setSearch('?key=correct-key')
|
||||||
|
vi.mocked(liveviewApi.load).mockResolvedValue(canvasPayload as never)
|
||||||
|
render(<LiveView />)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('react-flow')).toBeDefined()
|
||||||
|
})
|
||||||
|
expect(liveviewApi.load).toHaveBeenCalledWith('correct-key')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads nodes into the canvas store on success', async () => {
|
||||||
|
setSearch('?key=secret')
|
||||||
|
vi.mocked(liveviewApi.load).mockResolvedValue(canvasPayload as never)
|
||||||
|
render(<LiveView />)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('react-flow')).toBeDefined()
|
||||||
|
})
|
||||||
|
const { nodes } = useCanvasStore.getState()
|
||||||
|
expect(nodes.find((n) => n.id === 'n1')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── No editing props passed ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('does not show any Access Denied when key is valid', async () => {
|
||||||
|
setSearch('?key=valid')
|
||||||
|
vi.mocked(liveviewApi.load).mockResolvedValue(canvasPayload as never)
|
||||||
|
render(<LiveView />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
|
||||||
|
expect(screen.queryByText('Access Denied')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Standalone mode ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('LiveView (standalone — localStorage)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
useCanvasStore.setState({ nodes: [], edges: [] })
|
||||||
|
vi.mocked(liveviewApi.load).mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads canvas from localStorage without calling the API', async () => {
|
||||||
|
const stored = {
|
||||||
|
nodes: [{
|
||||||
|
id: 'ls-node', type: 'router',
|
||||||
|
position: { x: 10, y: 20 },
|
||||||
|
data: { label: 'Router', type: 'router', status: 'unknown', services: [] },
|
||||||
|
}],
|
||||||
|
edges: [],
|
||||||
|
}
|
||||||
|
localStorage.setItem('homelable_canvas', JSON.stringify(stored))
|
||||||
|
|
||||||
|
// Stub VITE_STANDALONE before re-importing
|
||||||
|
vi.stubEnv('VITE_STANDALONE', 'true')
|
||||||
|
vi.resetModules()
|
||||||
|
const { default: LiveViewStandalone } = await import('../LiveView')
|
||||||
|
|
||||||
|
setSearch('') // no key needed in standalone
|
||||||
|
render(<LiveViewStandalone />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('react-flow')).toBeDefined()
|
||||||
|
})
|
||||||
|
expect(liveviewApi.load).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
vi.unstubAllEnvs()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows canvas (empty) when localStorage has no saved data', async () => {
|
||||||
|
vi.stubEnv('VITE_STANDALONE', 'true')
|
||||||
|
vi.resetModules()
|
||||||
|
const { default: LiveViewStandalone } = await import('../LiveView')
|
||||||
|
|
||||||
|
setSearch('')
|
||||||
|
render(<LiveViewStandalone />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('react-flow')).toBeDefined()
|
||||||
|
})
|
||||||
|
expect(liveviewApi.load).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
vi.unstubAllEnvs()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -51,7 +51,7 @@ describe('LoginPage', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('shows a generic error message — no credential enumeration', async () => {
|
it('shows a generic error message — no credential enumeration', async () => {
|
||||||
vi.mocked(authApi.login).mockRejectedValue(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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,9 +2,12 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import LiveView from './components/LiveView.tsx'
|
||||||
|
|
||||||
|
const isLiveView = window.location.pathname === '/view'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
{isLiveView ? <LiveView /> : <App />}
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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' }))
|
||||||
|
|||||||
@@ -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: [] })
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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> = {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ pct create "$CTID" "$TEMPLATE" \
|
|||||||
--rootfs "${STORAGE}:${DISK_SIZE}" \
|
--rootfs "${STORAGE}:${DISK_SIZE}" \
|
||||||
--memory "$RAM" \
|
--memory "$RAM" \
|
||||||
--cores "$CORES" \
|
--cores "$CORES" \
|
||||||
--net0 "name=eth0,bridge=${BRIDGE},ip=dhcp" \
|
--net0 "name=eth0,bridge=${BRIDGE},ip=dhcp${VLAN_TAG:+,tag=${VLAN_TAG}}" \
|
||||||
--ostype debian \
|
--ostype debian \
|
||||||
--unprivileged 1 \
|
--unprivileged 1 \
|
||||||
--features "nesting=1" \
|
--features "nesting=1" \
|
||||||
|
|||||||
Reference in New Issue
Block a user