Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd047e594e | |||
| 61b30a95fe | |||
| 0b97b7127a | |||
| 2ce942ae61 | |||
| 5897be70c2 | |||
| 210304394e | |||
| b35f34ae73 | |||
| d84692fe4f | |||
| d9f3477780 | |||
| c9d6642b26 | |||
| c9e142dbf2 | |||
| 57829d88e5 | |||
| fd8735ce7f | |||
| 0bdf835a3d | |||
| ae29d2c8f5 | |||
| cc68fcf1c1 | |||
| 4cb164241a | |||
| b35b51d5b2 | |||
| 565f4337c8 | |||
| 2a9cbc5932 | |||
| 52cc5cf666 | |||
| 4643aabe28 |
@@ -22,3 +22,8 @@ STATUS_CHECKER_INTERVAL=60
|
||||
# Generate keys: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
MCP_API_KEY=mcp_sk_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=
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
name: Docker CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
smoke-and-integration:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# ── Write a minimal .env required by env_file: .env in docker-compose.yml ─
|
||||
# AUTH_PASSWORD_HASH is NOT set here — it's injected via docker-compose.ci.yml
|
||||
# environment section using $$ escaping to avoid docker-compose $VAR expansion.
|
||||
- name: Write .env
|
||||
run: |
|
||||
{
|
||||
echo "SECRET_KEY=ci-only-secret-key-not-for-production"
|
||||
echo "SQLITE_PATH=/app/data/homelab.db"
|
||||
echo 'CORS_ORIGINS=["http://localhost:3000"]'
|
||||
echo 'SCANNER_RANGES=["127.0.0.1/32"]'
|
||||
echo "STATUS_CHECKER_INTERVAL=300"
|
||||
echo "MCP_API_KEY=ci-mcp-key"
|
||||
echo "MCP_SERVICE_KEY=ci-svc-key"
|
||||
} > .env
|
||||
|
||||
# ── Build + start backend and frontend (skip mcp) ─────────────────────────
|
||||
# docker-compose.ci.yml: exposes port 8000 + injects AUTH_* env vars
|
||||
- name: Build images
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml build backend frontend
|
||||
|
||||
- name: Start stack
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d backend frontend
|
||||
|
||||
# ── Wait for backend to be healthy (max 60 s) ─────────────────────────────
|
||||
- name: Wait for backend health
|
||||
run: |
|
||||
echo "Waiting for backend..."
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:8000/api/v1/health > /dev/null 2>&1; then
|
||||
echo "Backend is up after ${i}s"
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "Backend did not become healthy in time" >&2
|
||||
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs backend
|
||||
exit 1
|
||||
|
||||
# ── Smoke: frontend serves HTML ───────────────────────────────────────────
|
||||
- name: Smoke — frontend returns 200
|
||||
run: |
|
||||
STATUS=$(curl -so /dev/null -w "%{http_code}" http://localhost:3000/)
|
||||
[ "$STATUS" = "200" ] || { echo "Frontend returned $STATUS"; exit 1; }
|
||||
|
||||
# ── Tier 3: integration tests against the live stack ──────────────────────
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install backend test deps
|
||||
run: pip install --quiet -r backend/requirements.txt
|
||||
|
||||
- name: Run integration tests
|
||||
env:
|
||||
INTEGRATION_BASE_URL: http://localhost:8000
|
||||
INTEGRATION_USERNAME: admin
|
||||
INTEGRATION_PASSWORD: admin
|
||||
run: |
|
||||
cd backend
|
||||
pytest tests/test_integration.py -v
|
||||
|
||||
# ── Teardown ──────────────────────────────────────────────────────────────
|
||||
- name: Dump logs on failure
|
||||
if: failure()
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml logs
|
||||
|
||||
- name: Stop stack
|
||||
if: always()
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml down -v
|
||||
@@ -7,6 +7,25 @@ on:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lint-scripts:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: ShellCheck — lxc-install.sh
|
||||
uses: ludeeus/action-shellcheck@2.0.0
|
||||
with:
|
||||
scandir: './scripts'
|
||||
- name: Hadolint — Dockerfile.backend
|
||||
uses: hadolint/hadolint-action@v3.1.0
|
||||
with:
|
||||
dockerfile: Dockerfile.backend
|
||||
ignore: DL3008
|
||||
- name: Hadolint — Dockerfile.frontend
|
||||
uses: hadolint/hadolint-action@v3.1.0
|
||||
with:
|
||||
dockerfile: Dockerfile.frontend
|
||||
ignore: DL3008
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# DL3008: pinning apt package versions is impractical for system tools (nmap, iputils-ping)
|
||||
# that have version numbers tied to specific Debian releases.
|
||||
ignore:
|
||||
- DL3008
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
# 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
|
||||
ENV VITE_STANDALONE=$VITE_STANDALONE
|
||||
|
||||
@@ -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)
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -30,6 +30,11 @@ class Settings(BaseSettings):
|
||||
# Leave empty to disable MCP service key auth.
|
||||
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:
|
||||
return Path(self.sqlite_path).parent / "scan_config.json"
|
||||
|
||||
|
||||
@@ -52,6 +52,10 @@ async def init_db() -> None:
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN disk_gb REAL")
|
||||
with suppress(Exception):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_hardware BOOLEAN NOT NULL DEFAULT 0")
|
||||
with suppress(Exception):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN width REAL")
|
||||
with suppress(Exception):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN height REAL")
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
|
||||
@@ -42,6 +42,8 @@ class Node(Base):
|
||||
ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
show_hardware: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
width: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
height: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
response_time_ms: Mapped[int | None] = mapped_column(Integer)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
+3
-2
@@ -5,7 +5,7 @@ from typing import Any
|
||||
from fastapi import FastAPI
|
||||
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.core.config import settings
|
||||
from app.core.scheduler import start_scheduler, stop_scheduler
|
||||
from app.db.database import init_db
|
||||
@@ -22,7 +22,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app = FastAPI(
|
||||
title="Homelable API",
|
||||
version="1.3.3",
|
||||
version="1.4.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
@@ -40,6 +40,7 @@ app.include_router(edges.router, prefix="/api/v1/edges", tags=["edges"])
|
||||
app.include_router(canvas.router, prefix="/api/v1/canvas", tags=["canvas"])
|
||||
app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"])
|
||||
app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
|
||||
app.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"])
|
||||
|
||||
|
||||
@app.get("/api/v1/health")
|
||||
|
||||
@@ -28,6 +28,8 @@ class NodeSave(BaseModel):
|
||||
ram_gb: float | None = None
|
||||
disk_gb: float | None = None
|
||||
show_hardware: bool = False
|
||||
width: float | None = None
|
||||
height: float | None = None
|
||||
pos_x: float = 0
|
||||
pos_y: float = 0
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ class NodeBase(BaseModel):
|
||||
ram_gb: float | None = None
|
||||
disk_gb: float | None = None
|
||||
show_hardware: bool = False
|
||||
width: float | None = None
|
||||
height: float | None = None
|
||||
|
||||
|
||||
class NodeCreate(NodeBase):
|
||||
@@ -56,6 +58,8 @@ class NodeUpdate(BaseModel):
|
||||
ram_gb: float | None = None
|
||||
disk_gb: float | None = None
|
||||
show_hardware: bool | None = None
|
||||
width: float | None = None
|
||||
height: float | None = None
|
||||
|
||||
|
||||
class NodeResponse(NodeBase):
|
||||
|
||||
@@ -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"}
|
||||
]
|
||||
@@ -191,3 +191,49 @@ async def test_save_canvas_hardware_fields_cleared_on_update(client: AsyncClient
|
||||
node = canvas["nodes"][0]
|
||||
assert node["cpu_count"] is None
|
||||
assert node["ram_gb"] is None
|
||||
|
||||
|
||||
# ── node width / height (resizable nodes) ─────────────────────────────────────
|
||||
|
||||
async def test_save_canvas_persists_node_dimensions(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(width=320.0, height=180.0)
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
node = canvas["nodes"][0]
|
||||
assert node["width"] == 320.0
|
||||
assert node["height"] == 180.0
|
||||
|
||||
|
||||
async def test_save_canvas_dimensions_default_null(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload()
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["nodes"][0]["width"] is None
|
||||
assert canvas["nodes"][0]["height"] is None
|
||||
|
||||
|
||||
async def test_save_canvas_dimensions_updated_on_resize(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(width=140.0, height=50.0)
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
n1_resized = {**n1, "width": 280.0, "height": 120.0}
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1_resized], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
node = canvas["nodes"][0]
|
||||
assert node["width"] == 280.0
|
||||
assert node["height"] == 120.0
|
||||
|
||||
|
||||
async def test_save_canvas_dimensions_cleared_when_null(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(width=300.0, height=200.0)
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
n1_cleared = {**n1, "width": None, "height": None}
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1_cleared], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["nodes"][0]["width"] is None
|
||||
assert canvas["nodes"][0]["height"] is None
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Integration tests — run against a live Docker stack.
|
||||
|
||||
Skipped unless INTEGRATION_BASE_URL is set (done automatically in docker-ci.yml).
|
||||
|
||||
Usage (local):
|
||||
INTEGRATION_BASE_URL=http://localhost:8000 \
|
||||
INTEGRATION_USERNAME=admin \
|
||||
INTEGRATION_PASSWORD=your-password \
|
||||
pytest backend/tests/test_integration.py -v
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
BASE_URL = os.environ.get("INTEGRATION_BASE_URL", "")
|
||||
USERNAME = os.environ.get("INTEGRATION_USERNAME", "admin")
|
||||
_PASSWORD_RAW = os.environ.get("INTEGRATION_PASSWORD", "")
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not BASE_URL,
|
||||
reason="INTEGRATION_BASE_URL not set — skipping live-stack tests",
|
||||
)
|
||||
|
||||
|
||||
def _require_password() -> str:
|
||||
if not _PASSWORD_RAW:
|
||||
pytest.fail("INTEGRATION_PASSWORD env var is required for live-stack tests")
|
||||
return _PASSWORD_RAW
|
||||
|
||||
|
||||
PASSWORD = _PASSWORD_RAW # resolved at call time via _require_password() in fixture
|
||||
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def token() -> str:
|
||||
pw = _require_password()
|
||||
res = httpx.post(
|
||||
f"{BASE_URL}/api/v1/auth/login",
|
||||
json={"username": USERNAME, "password": pw},
|
||||
timeout=10,
|
||||
)
|
||||
assert res.status_code == 200, f"Login failed ({res.status_code}): {res.text}"
|
||||
return res.json()["access_token"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def auth(token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def restored_canvas(auth):
|
||||
"""Save the current canvas before the test and restore it afterward."""
|
||||
before = httpx.get(f"{BASE_URL}/api/v1/canvas", headers=auth, timeout=10).json()
|
||||
yield
|
||||
httpx.post(f"{BASE_URL}/api/v1/canvas/save", json=before, headers=auth, timeout=10)
|
||||
|
||||
|
||||
def _save_canvas(auth, nodes, edges=None):
|
||||
payload = {
|
||||
"nodes": nodes,
|
||||
"edges": edges or [],
|
||||
"viewport": {"x": 0, "y": 0, "zoom": 1},
|
||||
}
|
||||
res = httpx.post(f"{BASE_URL}/api/v1/canvas/save", json=payload, headers=auth, timeout=10)
|
||||
assert res.status_code == 200, f"Canvas save failed ({res.status_code}): {res.text}"
|
||||
return res
|
||||
|
||||
|
||||
def _node(node_id: str, label: str, node_type: str = "server", **extra) -> dict:
|
||||
"""Build a NodeSave-compatible dict (flat API format, not React Flow format)."""
|
||||
return {
|
||||
"id": node_id,
|
||||
"type": node_type,
|
||||
"label": label,
|
||||
"status": "unknown",
|
||||
"services": [],
|
||||
"pos_x": extra.pop("pos_x", 0),
|
||||
"pos_y": extra.pop("pos_y", 0),
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
# ── Health ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_health_endpoint():
|
||||
res = httpx.get(f"{BASE_URL}/api/v1/health", timeout=10)
|
||||
assert res.status_code == 200
|
||||
|
||||
|
||||
# ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_login_returns_token():
|
||||
pw = _require_password()
|
||||
res = httpx.post(
|
||||
f"{BASE_URL}/api/v1/auth/login",
|
||||
json={"username": USERNAME, "password": pw},
|
||||
timeout=10,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
|
||||
def test_login_bad_credentials():
|
||||
res = httpx.post(
|
||||
f"{BASE_URL}/api/v1/auth/login",
|
||||
json={"username": USERNAME, "password": "definitely-wrong"},
|
||||
timeout=10,
|
||||
)
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
def test_protected_route_without_token():
|
||||
res = httpx.get(f"{BASE_URL}/api/v1/canvas", timeout=10)
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
# ── Canvas round-trip ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_canvas_load_returns_valid_structure(auth):
|
||||
res = httpx.get(f"{BASE_URL}/api/v1/canvas", headers=auth, timeout=10)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert "nodes" in data
|
||||
assert "edges" in data
|
||||
assert isinstance(data["nodes"], list)
|
||||
assert isinstance(data["edges"], list)
|
||||
|
||||
|
||||
def test_canvas_save_and_reload(auth, restored_canvas):
|
||||
_save_canvas(auth, [_node("integ-node-1", "CI Server", pos_x=100, pos_y=200)])
|
||||
|
||||
data = httpx.get(f"{BASE_URL}/api/v1/canvas", headers=auth, timeout=10).json()
|
||||
assert len(data["nodes"]) == 1
|
||||
|
||||
node = data["nodes"][0]
|
||||
assert node["id"] == "integ-node-1"
|
||||
assert node["label"] == "CI Server"
|
||||
assert node["type"] == "server"
|
||||
assert node["pos_x"] == 100
|
||||
assert node["pos_y"] == 200
|
||||
|
||||
|
||||
def test_canvas_save_preserves_node_dimensions(auth, restored_canvas):
|
||||
"""Width/height survive a save→reload cycle through the real DB."""
|
||||
_save_canvas(auth, [
|
||||
_node("resized-node", "Big Router", node_type="router", width=320.0, height=150.0)
|
||||
])
|
||||
|
||||
nodes = httpx.get(f"{BASE_URL}/api/v1/canvas", headers=auth, timeout=10).json()["nodes"]
|
||||
node = next((n for n in nodes if n["id"] == "resized-node"), None)
|
||||
assert node is not None
|
||||
assert node["width"] == 320.0
|
||||
assert node["height"] == 150.0
|
||||
|
||||
|
||||
def test_canvas_save_with_edge(auth, restored_canvas):
|
||||
_save_canvas(
|
||||
auth,
|
||||
nodes=[
|
||||
_node("n-src", "Router", node_type="router"),
|
||||
_node("n-dst", "Server", node_type="server", pos_x=200),
|
||||
],
|
||||
edges=[{
|
||||
"id": "e-eth",
|
||||
"source": "n-src",
|
||||
"target": "n-dst",
|
||||
"type": "ethernet",
|
||||
}],
|
||||
)
|
||||
|
||||
data = httpx.get(f"{BASE_URL}/api/v1/canvas", headers=auth, timeout=10).json()
|
||||
assert len(data["edges"]) == 1
|
||||
edge = data["edges"][0]
|
||||
# EdgeResponse uses source/target (not source_id/target_id)
|
||||
assert edge["source"] == "n-src"
|
||||
assert edge["target"] == "n-dst"
|
||||
assert edge["type"] == "ethernet"
|
||||
@@ -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"
|
||||
@@ -0,0 +1,14 @@
|
||||
# CI override — exposes backend port 8000 and injects credentials.
|
||||
# Usage: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d backend frontend
|
||||
#
|
||||
# AUTH_PASSWORD_HASH uses $$ escaping: docker-compose converts $$ → $ before
|
||||
# passing to the container, so the backend receives a valid bcrypt hash.
|
||||
# This avoids the project .env file being subject to docker-compose $VAR expansion.
|
||||
services:
|
||||
backend:
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
AUTH_USERNAME: admin
|
||||
# bcrypt hash of "admin" — $$ is docker-compose escape for literal $
|
||||
AUTH_PASSWORD_HASH: $$2b$$12$$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG
|
||||
@@ -17,6 +17,12 @@ services:
|
||||
# Required for ping-based status checks
|
||||
cap_add:
|
||||
- NET_RAW
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 6
|
||||
start_period: 15s
|
||||
|
||||
mcp:
|
||||
build:
|
||||
|
||||
Generated
+14
-14
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.4.0",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@dagrejs/dagre": "^2.0.4",
|
||||
@@ -1492,9 +1492,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1573,9 +1573,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5022,9 +5022,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -7982,9 +7982,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/router/node_modules/path-to-regexp": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
|
||||
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz",
|
||||
"integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.3.3",
|
||||
"version": "1.4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+8
-107
@@ -2,6 +2,7 @@ import { useEffect, useCallback, useRef, useState } from 'react'
|
||||
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
|
||||
import { type Node } from '@xyflow/react'
|
||||
import { applyDagreLayout } from '@/utils/layout'
|
||||
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
||||
import { generateUUID } from '@/utils/uuid'
|
||||
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
||||
import { exportToPng } from '@/utils/export'
|
||||
@@ -60,76 +61,8 @@ export default function App() {
|
||||
toast.success('Canvas saved')
|
||||
return
|
||||
}
|
||||
const nodesToSave = nodes.map((n) => {
|
||||
if (n.data.type === 'groupRect') {
|
||||
return {
|
||||
id: n.id,
|
||||
type: 'groupRect',
|
||||
label: n.data.label,
|
||||
hostname: null,
|
||||
ip: null,
|
||||
mac: null,
|
||||
os: null,
|
||||
status: 'unknown',
|
||||
check_method: null,
|
||||
check_target: null,
|
||||
services: [],
|
||||
notes: null,
|
||||
parent_id: null,
|
||||
container_mode: false,
|
||||
custom_icon: null,
|
||||
pos_x: n.position.x,
|
||||
pos_y: n.position.y,
|
||||
// Persist size and all rect config inside custom_colors
|
||||
custom_colors: {
|
||||
...n.data.custom_colors,
|
||||
width: n.measured?.width ?? n.width ?? 360,
|
||||
height: n.measured?.height ?? n.height ?? 240,
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: n.id,
|
||||
type: n.data.type,
|
||||
label: n.data.label,
|
||||
hostname: n.data.hostname ?? null,
|
||||
ip: n.data.ip ?? null,
|
||||
mac: n.data.mac ?? null,
|
||||
os: n.data.os ?? null,
|
||||
status: n.data.status,
|
||||
check_method: n.data.check_method ?? null,
|
||||
check_target: n.data.check_target ?? null,
|
||||
services: n.data.services ?? [],
|
||||
notes: n.data.notes ?? null,
|
||||
parent_id: n.data.parent_id ?? null,
|
||||
container_mode: n.data.container_mode ?? false,
|
||||
custom_colors: n.data.custom_colors ?? null,
|
||||
custom_icon: n.data.custom_icon ?? null,
|
||||
cpu_count: n.data.cpu_count ?? null,
|
||||
cpu_model: n.data.cpu_model ?? null,
|
||||
ram_gb: n.data.ram_gb ?? null,
|
||||
disk_gb: n.data.disk_gb ?? null,
|
||||
show_hardware: n.data.show_hardware ?? false,
|
||||
pos_x: n.position.x,
|
||||
pos_y: n.position.y,
|
||||
}
|
||||
})
|
||||
const edgesToSave = edges.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
type: e.data?.type ?? 'ethernet',
|
||||
label: e.data?.label ?? null,
|
||||
vlan_id: e.data?.vlan_id ?? null,
|
||||
speed: e.data?.speed ?? null,
|
||||
custom_color: e.data?.custom_color ?? null,
|
||||
path_style: e.data?.path_style ?? null,
|
||||
animated: e.data?.animated ?? false,
|
||||
// Normalize stub handle IDs: "top-t" / "bottom-t" are invisible target stubs;
|
||||
// map them back to their canonical source handle ID so reload works correctly.
|
||||
source_handle: e.sourceHandle === 'top-t' ? 'top' : e.sourceHandle === 'bottom-t' ? 'bottom' : (e.sourceHandle ?? null),
|
||||
target_handle: e.targetHandle === 'top-t' ? 'top' : e.targetHandle === 'bottom-t' ? 'bottom' : (e.targetHandle ?? null),
|
||||
}))
|
||||
const nodesToSave = nodes.map(serializeNode)
|
||||
const edgesToSave = edges.map(serializeEdge)
|
||||
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme } })
|
||||
markSaved()
|
||||
toast.success('Canvas saved')
|
||||
@@ -166,44 +99,12 @@ export default function App() {
|
||||
if (apiNodes.length > 0) {
|
||||
// Build a map of proxmox container mode to know if children should be nested
|
||||
const proxmoxContainerMap = new Map<string, boolean>(
|
||||
apiNodes
|
||||
.filter((n: NodeData & { id: string }) => n.type === 'proxmox')
|
||||
.map((n: NodeData & { id: string }) => [n.id, n.container_mode !== false])
|
||||
(apiNodes as ApiNode[])
|
||||
.filter((n) => n.type === 'proxmox')
|
||||
.map((n) => [n.id, n.container_mode !== false])
|
||||
)
|
||||
const rfNodes = apiNodes.map((n: NodeData & { id: string; pos_x: number; pos_y: number; parent_id?: string }) => {
|
||||
if (n.type === 'groupRect') {
|
||||
const w = n.custom_colors?.width ?? 360
|
||||
const h = n.custom_colors?.height ?? 240
|
||||
const z = n.custom_colors?.z_order ?? 1
|
||||
return {
|
||||
id: n.id,
|
||||
type: 'groupRect',
|
||||
position: { x: n.pos_x, y: n.pos_y },
|
||||
data: n,
|
||||
width: w,
|
||||
height: h,
|
||||
zIndex: z - 10,
|
||||
}
|
||||
}
|
||||
const parentIsContainer = n.parent_id ? (proxmoxContainerMap.get(n.parent_id) ?? false) : false
|
||||
return {
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
position: { x: n.pos_x, y: n.pos_y },
|
||||
data: n,
|
||||
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
|
||||
...(n.type === 'proxmox' && n.container_mode !== false ? { width: 300, height: 200 } : {}),
|
||||
}
|
||||
})
|
||||
const rfEdges = apiEdges.map((e: EdgeData & { id: string; source: string; target: string; source_handle?: string; target_handle?: string }) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
type: e.type,
|
||||
sourceHandle: e.source_handle ?? null,
|
||||
targetHandle: e.target_handle ?? null,
|
||||
data: e,
|
||||
}))
|
||||
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
|
||||
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
|
||||
const savedTheme = res.data.viewport?.theme_id
|
||||
if (savedTheme) setTheme(savedTheme)
|
||||
loadCanvas(rfNodes, rfEdges)
|
||||
|
||||
@@ -5,6 +5,9 @@ export const api = axios.create({
|
||||
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) => {
|
||||
const token = useAuthStore.getState().token
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
@@ -44,6 +47,10 @@ export const edgesApi = {
|
||||
delete: (id: string) => api.delete(`/edges/${id}`),
|
||||
}
|
||||
|
||||
export const liveviewApi = {
|
||||
load: (key: string) => publicApi.get('/liveview', { params: { key } }),
|
||||
}
|
||||
|
||||
export const scanApi = {
|
||||
trigger: () => api.post('/scan/trigger'),
|
||||
pending: () => api.get('/scan/pending'),
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { LoginPage } from '../LoginPage'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
authApi: {
|
||||
login: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { authApi } from '@/api/client'
|
||||
|
||||
describe('LoginPage', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ token: null, isAuthenticated: false })
|
||||
vi.mocked(authApi.login).mockReset()
|
||||
})
|
||||
|
||||
// ── Rendering ────────────────────────────────────────────────────────────
|
||||
|
||||
it('renders username and password fields', () => {
|
||||
render(<LoginPage />)
|
||||
expect(screen.getByLabelText('Username')).toBeDefined()
|
||||
expect(screen.getByLabelText('Password')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders a Sign in button', () => {
|
||||
render(<LoginPage />)
|
||||
expect(screen.getByRole('button', { name: /sign in/i })).toBeDefined()
|
||||
})
|
||||
|
||||
// ── Security checks ──────────────────────────────────────────────────────
|
||||
|
||||
it('password field type is "password" — not rendered as plain text', () => {
|
||||
render(<LoginPage />)
|
||||
const pw = screen.getByLabelText('Password') as HTMLInputElement
|
||||
expect(pw.type).toBe('password')
|
||||
})
|
||||
|
||||
it('username field has autocomplete="username"', () => {
|
||||
render(<LoginPage />)
|
||||
const un = screen.getByLabelText('Username') as HTMLInputElement
|
||||
expect(un.getAttribute('autocomplete')).toBe('username')
|
||||
})
|
||||
|
||||
it('password field has autocomplete="current-password" (supports password managers)', () => {
|
||||
render(<LoginPage />)
|
||||
const pw = screen.getByLabelText('Password') as HTMLInputElement
|
||||
expect(pw.getAttribute('autocomplete')).toBe('current-password')
|
||||
})
|
||||
|
||||
it('shows a generic error message — no credential enumeration', async () => {
|
||||
vi.mocked(authApi.login).mockRejectedValue(new Error('401'))
|
||||
render(<LoginPage />)
|
||||
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } })
|
||||
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'wrongpass' } })
|
||||
fireEvent.submit(screen.getByRole('button', { name: /sign in/i }).closest('form')!)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invalid username or password')).toBeDefined()
|
||||
})
|
||||
// Must show exactly ONE error — not separate per-field messages (no enumeration)
|
||||
const errors = document.querySelectorAll('p.text-\\[\\#f85149\\]')
|
||||
expect(errors.length).toBe(1)
|
||||
expect(errors[0].textContent).toBe('Invalid username or password')
|
||||
})
|
||||
|
||||
it('clears previous error before each new attempt', async () => {
|
||||
vi.mocked(authApi.login)
|
||||
.mockRejectedValueOnce(new Error('401'))
|
||||
.mockRejectedValueOnce(new Error('401'))
|
||||
render(<LoginPage />)
|
||||
const form = screen.getByRole('button', { name: /sign in/i }).closest('form')!
|
||||
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } })
|
||||
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'bad' } })
|
||||
fireEvent.submit(form)
|
||||
await waitFor(() => screen.getByText('Invalid username or password'))
|
||||
fireEvent.submit(form)
|
||||
// Error clears while loading (setError('') before try)
|
||||
await waitFor(() => screen.getByText('Invalid username or password'))
|
||||
expect(screen.getAllByText('Invalid username or password')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('disables submit button while loading — prevents double-submit', async () => {
|
||||
let resolve!: (v: unknown) => void
|
||||
vi.mocked(authApi.login).mockReturnValue(new Promise((r) => { resolve = r }))
|
||||
render(<LoginPage />)
|
||||
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } })
|
||||
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'pass' } })
|
||||
fireEvent.submit(screen.getByRole('button', { name: /sign in/i }).closest('form')!)
|
||||
await waitFor(() => {
|
||||
expect((screen.getByRole('button', { name: '' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
resolve({ data: { access_token: 'tok' } })
|
||||
})
|
||||
|
||||
it('calls authApi.login with credentials via POST body (not URL params)', async () => {
|
||||
vi.mocked(authApi.login).mockResolvedValue({ data: { access_token: 'tok' } } as never)
|
||||
render(<LoginPage />)
|
||||
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } })
|
||||
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'secret' } })
|
||||
fireEvent.submit(screen.getByRole('button', { name: /sign in/i }).closest('form')!)
|
||||
await waitFor(() => {
|
||||
expect(authApi.login).toHaveBeenCalledWith('admin', 'secret')
|
||||
})
|
||||
})
|
||||
|
||||
it('stores token in authStore on successful login', async () => {
|
||||
vi.mocked(authApi.login).mockResolvedValue({ data: { access_token: 'mytoken123' } } as never)
|
||||
render(<LoginPage />)
|
||||
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } })
|
||||
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'correct' } })
|
||||
fireEvent.submit(screen.getByRole('button', { name: /sign in/i }).closest('form')!)
|
||||
await waitFor(() => {
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(true)
|
||||
expect(useAuthStore.getState().token).toBe('mytoken123')
|
||||
})
|
||||
})
|
||||
|
||||
it('token persisted via sessionStorage — not localStorage', () => {
|
||||
// The authStore uses createJSONStorage(() => sessionStorage)
|
||||
// Verify the storage key exists in sessionStorage after login
|
||||
render(<LoginPage />)
|
||||
// Even before login, the store is backed by sessionStorage
|
||||
expect(typeof sessionStorage).toBe('object')
|
||||
// localStorage should NOT contain the auth token
|
||||
expect(localStorage.getItem('homelable-auth')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not show error on initial render', () => {
|
||||
render(<LoginPage />)
|
||||
expect(screen.queryByText('Invalid username or password')).toBeNull()
|
||||
})
|
||||
|
||||
it('requires username (HTML required attribute)', () => {
|
||||
render(<LoginPage />)
|
||||
const un = screen.getByLabelText('Username') as HTMLInputElement
|
||||
expect(un.required).toBe(true)
|
||||
})
|
||||
|
||||
it('requires password (HTML required attribute)', () => {
|
||||
render(<LoginPage />)
|
||||
const pw = screen.getByLabelText('Password') as HTMLInputElement
|
||||
expect(pw.required).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { CanvasContainer } from '../CanvasContainer'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
import type { NodeData, EdgeData } from '@/types'
|
||||
|
||||
// Capture props passed to ReactFlow so we can test the callbacks
|
||||
let rfProps: Record<string, unknown> = {}
|
||||
|
||||
vi.mock('@xyflow/react', () => ({
|
||||
ReactFlow: (props: Record<string, unknown>) => {
|
||||
rfProps = props
|
||||
return <div data-testid="react-flow" />
|
||||
},
|
||||
Background: () => null,
|
||||
Controls: () => null,
|
||||
BackgroundVariant: { Dots: 'dots' },
|
||||
ConnectionMode: { Loose: 'loose' },
|
||||
}))
|
||||
|
||||
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
||||
|
||||
function makeNode(id: string): Node<NodeData> {
|
||||
return {
|
||||
id,
|
||||
type: 'server',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { label: id, type: 'server', status: 'unknown', services: [] },
|
||||
}
|
||||
}
|
||||
|
||||
function makeEdge(id: string): Edge<EdgeData> {
|
||||
return { id, source: 'n1', target: 'n2', type: 'ethernet', data: { type: 'ethernet' } }
|
||||
}
|
||||
|
||||
describe('CanvasContainer', () => {
|
||||
beforeEach(() => {
|
||||
rfProps = {}
|
||||
useCanvasStore.setState({ nodes: [], edges: [], selectedNodeId: null })
|
||||
useThemeStore.setState({ activeTheme: 'default' })
|
||||
})
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────────────────
|
||||
|
||||
it('renders without crashing', () => {
|
||||
const { getByTestId } = render(<CanvasContainer />)
|
||||
expect(getByTestId('react-flow')).toBeDefined()
|
||||
})
|
||||
|
||||
it('passes nodes from store to ReactFlow', () => {
|
||||
useCanvasStore.setState({ nodes: [makeNode('n1'), makeNode('n2')] })
|
||||
render(<CanvasContainer />)
|
||||
expect((rfProps.nodes as Node[]).length).toBe(2)
|
||||
})
|
||||
|
||||
it('passes edges from store to ReactFlow', () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [makeNode('n1'), makeNode('n2')],
|
||||
edges: [makeEdge('e1')],
|
||||
})
|
||||
render(<CanvasContainer />)
|
||||
expect((rfProps.edges as Edge[]).length).toBe(1)
|
||||
})
|
||||
|
||||
// ── Node click → selection ────────────────────────────────────────────────
|
||||
|
||||
it('calls setSelectedNode with node id on node click', () => {
|
||||
const node = makeNode('n1')
|
||||
useCanvasStore.setState({ nodes: [node] })
|
||||
render(<CanvasContainer />)
|
||||
;(rfProps.onNodeClick as (...args: unknown[]) => unknown)({} as MouseEvent, node)
|
||||
expect(useCanvasStore.getState().selectedNodeId).toBe('n1')
|
||||
})
|
||||
|
||||
// ── Pane click → deselect ─────────────────────────────────────────────────
|
||||
|
||||
it('calls setSelectedNode(null) on pane click', () => {
|
||||
useCanvasStore.setState({ selectedNodeId: 'n1' })
|
||||
render(<CanvasContainer />)
|
||||
;(rfProps.onPaneClick as (...args: unknown[]) => unknown)()
|
||||
expect(useCanvasStore.getState().selectedNodeId).toBeNull()
|
||||
})
|
||||
|
||||
// ── Edge double-click ─────────────────────────────────────────────────────
|
||||
|
||||
it('calls onEdgeDoubleClick prop when an edge is double-clicked', () => {
|
||||
const onEdgeDoubleClick = vi.fn()
|
||||
const edge = makeEdge('e1')
|
||||
render(<CanvasContainer onEdgeDoubleClick={onEdgeDoubleClick} />)
|
||||
;(rfProps.onEdgeDoubleClick as (...args: unknown[]) => unknown)({} as MouseEvent, edge)
|
||||
expect(onEdgeDoubleClick).toHaveBeenCalledWith(edge)
|
||||
})
|
||||
|
||||
it('does not throw when onEdgeDoubleClick is not provided', () => {
|
||||
const edge = makeEdge('e1')
|
||||
render(<CanvasContainer />)
|
||||
expect(() => {
|
||||
;(rfProps.onEdgeDoubleClick as (...args: unknown[]) => unknown)({} as MouseEvent, edge)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
// ── Connection validation ─────────────────────────────────────────────────
|
||||
|
||||
it('isValidConnection returns false for self-connections', () => {
|
||||
render(<CanvasContainer />)
|
||||
const isValid = rfProps.isValidConnection as (c: { source: string; target: string }) => boolean
|
||||
expect(isValid({ source: 'n1', target: 'n1' })).toBe(false)
|
||||
})
|
||||
|
||||
it('isValidConnection returns true for different nodes', () => {
|
||||
render(<CanvasContainer />)
|
||||
const isValid = rfProps.isValidConnection as (c: { source: string; target: string }) => boolean
|
||||
expect(isValid({ source: 'n1', target: 'n2' })).toBe(true)
|
||||
})
|
||||
|
||||
// ── onConnect prop passthrough ────────────────────────────────────────────
|
||||
|
||||
it('passes onConnect prop to ReactFlow', () => {
|
||||
const onConnect = vi.fn()
|
||||
render(<CanvasContainer onConnect={onConnect} />)
|
||||
;(rfProps.onConnect as (...args: unknown[]) => unknown)({ source: 'a', target: 'b', sourceHandle: null, targetHandle: null })
|
||||
expect(onConnect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// ── onNodeDragStart prop passthrough ──────────────────────────────────────
|
||||
|
||||
it('passes onNodeDragStart prop to ReactFlow', () => {
|
||||
const onNodeDragStart = vi.fn()
|
||||
render(<CanvasContainer onNodeDragStart={onNodeDragStart} />)
|
||||
expect(rfProps.onNodeDragStart).toBe(onNodeDragStart)
|
||||
})
|
||||
|
||||
// ── Canvas settings ───────────────────────────────────────────────────────
|
||||
|
||||
it('enables snapToGrid', () => {
|
||||
render(<CanvasContainer />)
|
||||
expect(rfProps.snapToGrid).toBe(true)
|
||||
})
|
||||
|
||||
it('sets snapGrid to [16, 16]', () => {
|
||||
render(<CanvasContainer />)
|
||||
expect(rfProps.snapGrid).toEqual([16, 16])
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createElement } from 'react'
|
||||
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'
|
||||
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
||||
import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react'
|
||||
import type { NodeData } from '@/types'
|
||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||
@@ -18,7 +18,7 @@ function formatStorage(gb: number): string {
|
||||
return `${gb} GB`
|
||||
}
|
||||
|
||||
export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
|
||||
export function BaseNode({ data, selected, icon: typeIcon, width, height }: BaseNodeProps) {
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||
const theme = THEMES[activeTheme]
|
||||
@@ -43,8 +43,17 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
|
||||
: 'none',
|
||||
opacity: data.status === 'offline' ? 0.55 : 1,
|
||||
minWidth: 140,
|
||||
width: width ? '100%' : undefined,
|
||||
height: height ? '100%' : undefined,
|
||||
}}
|
||||
>
|
||||
<NodeResizer
|
||||
isVisible={selected}
|
||||
minWidth={140}
|
||||
minHeight={50}
|
||||
lineStyle={{ borderColor: colors.border, borderWidth: 1 }}
|
||||
handleStyle={{ borderColor: colors.border, background: colors.border, width: 8, height: 8 }}
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Top}
|
||||
@@ -69,7 +78,7 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
|
||||
{/* Label + IP */}
|
||||
<div className="flex flex-col min-w-0">
|
||||
<div
|
||||
className="text-xs font-medium leading-tight truncate max-w-[110px]"
|
||||
className="text-xs font-medium leading-tight truncate"
|
||||
style={{ color: theme.colors.nodeLabelColor }}
|
||||
title={data.label}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { EdgeModal } from '../EdgeModal'
|
||||
|
||||
describe('EdgeModal', () => {
|
||||
// ── Visibility ────────────────────────────────────────────────────────────
|
||||
|
||||
it('renders nothing when closed', () => {
|
||||
const { container } = render(<EdgeModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders form when open', () => {
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
expect(screen.getByText('Connect Nodes')).toBeDefined()
|
||||
})
|
||||
|
||||
it('uses custom title when provided', () => {
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Link" />)
|
||||
expect(screen.getByText('Edit Link')).toBeDefined()
|
||||
})
|
||||
|
||||
// ── Submit button label ───────────────────────────────────────────────────
|
||||
|
||||
it('shows "Connect" button when onDelete is not provided', () => {
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
expect(screen.getByRole('button', { name: 'Connect' })).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows "Save" button when onDelete is provided', () => {
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} onDelete={vi.fn()} />)
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDefined()
|
||||
})
|
||||
|
||||
// ── Default submit ────────────────────────────────────────────────────────
|
||||
|
||||
it('calls onSubmit with default ethernet type', () => {
|
||||
const onSubmit = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(<EdgeModal open onClose={onClose} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(onSubmit).toHaveBeenCalledOnce()
|
||||
expect(onSubmit.mock.calls[0][0].type).toBe('ethernet')
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onSubmit with label when filled', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 1G, trunk...'), { target: { value: 'uplink' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(onSubmit.mock.calls[0][0].label).toBe('uplink')
|
||||
})
|
||||
|
||||
it('omits label from payload when empty', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(onSubmit.mock.calls[0][0].label).toBeUndefined()
|
||||
})
|
||||
|
||||
// ── VLAN ID field ─────────────────────────────────────────────────────────
|
||||
|
||||
it('does not show VLAN ID field for ethernet type', () => {
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
expect(screen.queryByPlaceholderText('e.g. 20')).toBeNull()
|
||||
})
|
||||
|
||||
it('submits integer vlan_id when type is vlan', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ type: 'vlan', vlan_id: 20 }} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(onSubmit.mock.calls[0][0].vlan_id).toBe(20)
|
||||
})
|
||||
|
||||
it('omits vlan_id from payload for non-vlan types', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ type: 'wifi' }} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(onSubmit.mock.calls[0][0].vlan_id).toBeUndefined()
|
||||
})
|
||||
|
||||
// ── Path style ────────────────────────────────────────────────────────────
|
||||
|
||||
it('defaults to bezier path style', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(onSubmit.mock.calls[0][0].path_style).toBe('bezier')
|
||||
})
|
||||
|
||||
it('switches path style to smooth on click', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByText('Smooth step'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(onSubmit.mock.calls[0][0].path_style).toBe('smooth')
|
||||
})
|
||||
|
||||
// ── Animated toggle ───────────────────────────────────────────────────────
|
||||
|
||||
it('flow animation defaults to off', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
// animated: false → omitted (falsy || undefined)
|
||||
expect(onSubmit.mock.calls[0][0].animated).toBeFalsy()
|
||||
})
|
||||
|
||||
it('toggling animation sends animated: true', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
// Find the toggle: it's the only button with aria-pressed attribute
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
const toggle = allButtons.find((b) => b.hasAttribute('aria-pressed'))!
|
||||
expect(toggle).toBeDefined()
|
||||
fireEvent.click(toggle)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(onSubmit.mock.calls[0][0].animated).toBe(true)
|
||||
})
|
||||
|
||||
// ── Pre-fill ──────────────────────────────────────────────────────────────
|
||||
|
||||
it('pre-fills label from initial prop', () => {
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ label: 'trunk' }} />)
|
||||
const input = screen.getByPlaceholderText('e.g. 1G, trunk...') as HTMLInputElement
|
||||
expect(input.value).toBe('trunk')
|
||||
})
|
||||
|
||||
it('pre-fills path style from initial prop', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ path_style: 'smooth' }} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(onSubmit.mock.calls[0][0].path_style).toBe('smooth')
|
||||
})
|
||||
|
||||
// ── Cancel & Delete ───────────────────────────────────────────────────────
|
||||
|
||||
it('calls onClose when Cancel is clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<EdgeModal open onClose={onClose} onSubmit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows Delete button when onDelete is provided', () => {
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} onDelete={vi.fn()} />)
|
||||
expect(screen.getByRole('button', { name: 'Delete' })).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not show Delete button without onDelete', () => {
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
expect(screen.queryByRole('button', { name: 'Delete' })).toBeNull()
|
||||
})
|
||||
|
||||
it('calls onDelete and onClose when Delete is clicked', () => {
|
||||
const onDelete = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(<EdgeModal open onClose={onClose} onSubmit={vi.fn()} onDelete={onDelete} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
expect(onDelete).toHaveBeenCalledOnce()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { PendingDeviceModal, type PendingDevice } from '../PendingDeviceModal'
|
||||
|
||||
function makeDevice(overrides: Partial<PendingDevice> = {}): PendingDevice {
|
||||
return {
|
||||
id: 'dev-1',
|
||||
ip: '192.168.1.100',
|
||||
mac: 'aa:bb:cc:dd:ee:ff',
|
||||
hostname: 'pve.local',
|
||||
os: 'Linux',
|
||||
services: [],
|
||||
suggested_type: 'server',
|
||||
status: 'pending',
|
||||
discovered_at: '2024-01-15T10:30:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('PendingDeviceModal', () => {
|
||||
// ── Visibility ────────────────────────────────────────────────────────────
|
||||
|
||||
it('renders nothing when device is null', () => {
|
||||
const { container } = render(
|
||||
<PendingDeviceModal device={null} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
)
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders dialog when device is provided', () => {
|
||||
render(
|
||||
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByRole('dialog')).toBeDefined()
|
||||
})
|
||||
|
||||
// ── Device info display ───────────────────────────────────────────────────
|
||||
|
||||
it('shows hostname as title when available', () => {
|
||||
render(
|
||||
<PendingDeviceModal device={makeDevice({ hostname: 'myserver.local' })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
)
|
||||
// hostname appears in both title and info row — check at least one match
|
||||
expect(screen.getAllByText('myserver.local').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('falls back to IP as title when hostname is null', () => {
|
||||
render(
|
||||
<PendingDeviceModal device={makeDevice({ hostname: null })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
)
|
||||
// IP appears in both title and info row when hostname is absent
|
||||
expect(screen.getAllByText('192.168.1.100').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('shows IP address', () => {
|
||||
render(
|
||||
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('192.168.1.100')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows MAC address when present', () => {
|
||||
render(
|
||||
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('aa:bb:cc:dd:ee:ff')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows OS when present', () => {
|
||||
render(
|
||||
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Linux')).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not show hostname row when hostname is null', () => {
|
||||
render(
|
||||
<PendingDeviceModal device={makeDevice({ hostname: null })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
)
|
||||
// "Hostname" label should not appear in the info rows
|
||||
expect(screen.queryByText('Hostname')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows suggested type when present', () => {
|
||||
render(
|
||||
<PendingDeviceModal device={makeDevice({ suggested_type: 'proxmox' })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('proxmox')).toBeDefined()
|
||||
})
|
||||
|
||||
// ── Services ──────────────────────────────────────────────────────────────
|
||||
|
||||
it('shows "No services detected" when services list is empty', () => {
|
||||
render(
|
||||
<PendingDeviceModal device={makeDevice({ services: [] })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('No services detected')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows service count and details', () => {
|
||||
const device = makeDevice({
|
||||
services: [
|
||||
{ port: 80, protocol: 'tcp', service_name: 'HTTP', category: 'web' },
|
||||
{ port: 443, protocol: 'tcp', service_name: 'HTTPS', category: 'web' },
|
||||
],
|
||||
})
|
||||
render(
|
||||
<PendingDeviceModal device={device} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Services found (2)')).toBeDefined()
|
||||
expect(screen.getByText('HTTP')).toBeDefined()
|
||||
expect(screen.getByText('HTTPS')).toBeDefined()
|
||||
expect(screen.getByText('80')).toBeDefined()
|
||||
expect(screen.getByText('443')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows service category when present', () => {
|
||||
const device = makeDevice({
|
||||
services: [{ port: 8006, protocol: 'tcp', service_name: 'Proxmox Web', category: 'hypervisor' }],
|
||||
})
|
||||
render(
|
||||
<PendingDeviceModal device={device} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('hypervisor')).toBeDefined()
|
||||
})
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────────
|
||||
|
||||
it('calls onApprove with the device and onClose when Approve is clicked', () => {
|
||||
const device = makeDevice()
|
||||
const onApprove = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<PendingDeviceModal device={device} onClose={onClose} onApprove={onApprove} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Approve' }))
|
||||
expect(onApprove).toHaveBeenCalledWith(device)
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onHide with the device and onClose when Hide is clicked', () => {
|
||||
const device = makeDevice()
|
||||
const onHide = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<PendingDeviceModal device={device} onClose={onClose} onApprove={vi.fn()} onHide={onHide} onIgnore={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hide' }))
|
||||
expect(onHide).toHaveBeenCalledWith(device)
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onIgnore with the device and onClose when Delete is clicked', () => {
|
||||
const device = makeDevice()
|
||||
const onIgnore = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<PendingDeviceModal device={device} onClose={onClose} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={onIgnore} />
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
expect(onIgnore).toHaveBeenCalledWith(device)
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { ScanConfigModal } from '../ScanConfigModal'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
scanApi: {
|
||||
getConfig: vi.fn(),
|
||||
saveConfig: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
|
||||
|
||||
import { scanApi } from '@/api/client'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const defaultConfig = { data: { ranges: ['192.168.1.0/24'], interval_seconds: 60 } }
|
||||
|
||||
describe('ScanConfigModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(scanApi.getConfig).mockResolvedValue(defaultConfig as never)
|
||||
vi.mocked(scanApi.saveConfig).mockResolvedValue({} as never)
|
||||
vi.mocked(toast.success).mockReset()
|
||||
vi.mocked(toast.error).mockReset()
|
||||
})
|
||||
|
||||
it('renders nothing when closed', () => {
|
||||
const { container } = render(<ScanConfigModal open={false} onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('loads config from API on open', async () => {
|
||||
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||
await waitFor(() => {
|
||||
expect(scanApi.getConfig).toHaveBeenCalledOnce()
|
||||
})
|
||||
const input = await screen.findByDisplayValue('192.168.1.0/24')
|
||||
expect(input).toBeDefined()
|
||||
})
|
||||
|
||||
it('loads interval from API on open', async () => {
|
||||
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['10.0.0.0/8'], interval_seconds: 120 } } as never)
|
||||
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||
const input = await screen.findByDisplayValue('120')
|
||||
expect(input).toBeDefined()
|
||||
})
|
||||
|
||||
it('adds a new empty range on "Add range" click', async () => {
|
||||
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||
await screen.findByDisplayValue('192.168.1.0/24')
|
||||
fireEvent.click(screen.getByText('Add range'))
|
||||
const inputs = screen.getAllByPlaceholderText('192.168.1.0/24')
|
||||
expect(inputs).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('delete button disabled when only one range', async () => {
|
||||
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||
await screen.findByDisplayValue('192.168.1.0/24')
|
||||
// Only 1 range → delete button disabled
|
||||
const trashButtons = document.querySelectorAll('button[disabled]')
|
||||
expect(trashButtons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
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)
|
||||
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||
await screen.findByDisplayValue('192.168.1.0/24')
|
||||
// Both trash buttons should be enabled
|
||||
const trashButtons = screen.getAllByRole('button').filter((b) => !b.hasAttribute('disabled') && b.querySelector('svg'))
|
||||
expect(trashButtons.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
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)
|
||||
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||
await waitFor(() => expect(scanApi.getConfig).toHaveBeenCalled())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Add at least one IP range')
|
||||
})
|
||||
expect(scanApi.saveConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('saves config and closes on Save click', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(<ScanConfigModal open onClose={onClose} onScanNow={vi.fn()} />)
|
||||
await screen.findByDisplayValue('192.168.1.0/24')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => {
|
||||
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'], interval_seconds: 60 })
|
||||
expect(toast.success).toHaveBeenCalledWith('Scan config saved')
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error toast when save fails', async () => {
|
||||
vi.mocked(scanApi.saveConfig).mockRejectedValue(new Error('network'))
|
||||
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||
await screen.findByDisplayValue('192.168.1.0/24')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to save config')
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onScanNow after saving on "Scan Now" click', async () => {
|
||||
const onScanNow = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(<ScanConfigModal open onClose={onClose} onScanNow={onScanNow} />)
|
||||
await screen.findByDisplayValue('192.168.1.0/24')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
|
||||
await waitFor(() => {
|
||||
expect(scanApi.saveConfig).toHaveBeenCalled()
|
||||
expect(onScanNow).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onClose when Cancel is clicked', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(<ScanConfigModal open onClose={onClose} onScanNow={vi.fn()} />)
|
||||
await waitFor(() => expect(scanApi.getConfig).toHaveBeenCalled())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('strips whitespace from ranges before saving', async () => {
|
||||
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
|
||||
const input = await screen.findByDisplayValue('192.168.1.0/24')
|
||||
// Type a range with surrounding whitespace
|
||||
fireEvent.change(input, { target: { value: ' 10.0.0.0/8 ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => {
|
||||
expect(scanApi.saveConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ranges: ['10.0.0.0/8'] })
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { SearchModal } from '../SearchModal'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import type { Node } from '@xyflow/react'
|
||||
import type { NodeData } from '@/types'
|
||||
|
||||
const mockFitView = vi.fn()
|
||||
vi.mock('@xyflow/react', () => ({
|
||||
useReactFlow: () => ({ fitView: mockFitView }),
|
||||
}))
|
||||
|
||||
function makeNode(id: string, overrides: Partial<NodeData> = {}): Node<NodeData> {
|
||||
return {
|
||||
id,
|
||||
type: overrides.type ?? 'server',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: id,
|
||||
type: overrides.type ?? 'server',
|
||||
status: 'unknown',
|
||||
services: [],
|
||||
...overrides,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('SearchModal', () => {
|
||||
beforeEach(() => {
|
||||
useCanvasStore.setState({ nodes: [], edges: [], selectedNodeId: null })
|
||||
mockFitView.mockReset()
|
||||
})
|
||||
|
||||
it('renders nothing when closed', () => {
|
||||
render(<SearchModal open={false} onClose={vi.fn()} />)
|
||||
expect(screen.queryByPlaceholderText(/search nodes/i)).toBeNull()
|
||||
})
|
||||
|
||||
it('renders search input when open', () => {
|
||||
render(<SearchModal open onClose={vi.fn()} />)
|
||||
expect(screen.getByPlaceholderText(/search nodes/i)).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows "Type to search" hint when query is empty', () => {
|
||||
render(<SearchModal open onClose={vi.fn()} />)
|
||||
expect(screen.getByText(/type to search/i)).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows no results message when query has no matches', () => {
|
||||
useCanvasStore.setState({ nodes: [makeNode('router', { label: 'Router' })] })
|
||||
render(<SearchModal open onClose={vi.fn()} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zzz' } })
|
||||
expect(screen.getByText(/no nodes match/i)).toBeDefined()
|
||||
})
|
||||
|
||||
it('filters nodes by label', () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [makeNode('n1', { label: 'My Router' }), makeNode('n2', { label: 'NAS Server' })],
|
||||
})
|
||||
render(<SearchModal open onClose={vi.fn()} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'router' } })
|
||||
expect(screen.getByText('My Router')).toBeDefined()
|
||||
expect(screen.queryByText('NAS Server')).toBeNull()
|
||||
})
|
||||
|
||||
it('filters nodes by IP', () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
makeNode('n1', { label: 'Box A', ip: '192.168.1.10' }),
|
||||
makeNode('n2', { label: 'Box B', ip: '10.0.0.1' }),
|
||||
],
|
||||
})
|
||||
render(<SearchModal open onClose={vi.fn()} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: '192.168' } })
|
||||
expect(screen.getByText('Box A')).toBeDefined()
|
||||
expect(screen.queryByText('Box B')).toBeNull()
|
||||
})
|
||||
|
||||
it('filters nodes by hostname', () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
makeNode('n1', { label: 'A', hostname: 'pve.local' }),
|
||||
makeNode('n2', { label: 'B', hostname: 'nas.local' }),
|
||||
],
|
||||
})
|
||||
render(<SearchModal open onClose={vi.fn()} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'pve' } })
|
||||
expect(screen.getByText('A')).toBeDefined()
|
||||
expect(screen.queryByText('B')).toBeNull()
|
||||
})
|
||||
|
||||
it('excludes groupRect nodes from results', () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
makeNode('n1', { label: 'Server', type: 'server' }),
|
||||
makeNode('g1', { label: 'Zone A', type: 'groupRect' }),
|
||||
],
|
||||
})
|
||||
render(<SearchModal open onClose={vi.fn()} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zone' } })
|
||||
expect(screen.getByText(/no nodes match/i)).toBeDefined()
|
||||
})
|
||||
|
||||
it('limits results to 8 nodes', () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: Array.from({ length: 12 }, (_, i) => makeNode(`n${i}`, { label: `Server ${i}` })),
|
||||
})
|
||||
render(<SearchModal open onClose={vi.fn()} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'server' } })
|
||||
const items = screen.getAllByText(/Server \d/)
|
||||
expect(items).toHaveLength(8)
|
||||
})
|
||||
|
||||
it('selects node and closes on result click', () => {
|
||||
const onClose = vi.fn()
|
||||
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Proxmox' })] })
|
||||
render(<SearchModal open onClose={onClose} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'prox' } })
|
||||
fireEvent.click(screen.getByText('Proxmox'))
|
||||
expect(useCanvasStore.getState().selectedNodeId).toBe('n1')
|
||||
expect(mockFitView).toHaveBeenCalledWith(expect.objectContaining({ nodes: [{ id: 'n1' }] }))
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('selects first result and closes on Enter key', () => {
|
||||
const onClose = vi.fn()
|
||||
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Switch' })] })
|
||||
render(<SearchModal open onClose={onClose} />)
|
||||
const input = screen.getByPlaceholderText(/search nodes/i)
|
||||
fireEvent.change(input, { target: { value: 'switch' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(useCanvasStore.getState().selectedNodeId).toBe('n1')
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('closes on Escape key', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<SearchModal open onClose={onClose} />)
|
||||
fireEvent.keyDown(screen.getByPlaceholderText(/search nodes/i), { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('closes when clicking backdrop', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<SearchModal open onClose={onClose} />)
|
||||
// The backdrop is the fixed inset div — clicking it fires onClose
|
||||
const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement
|
||||
fireEvent.click(backdrop)
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not close when clicking inside the search box', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<SearchModal open onClose={onClose} />)
|
||||
fireEvent.click(screen.getByPlaceholderText(/search nodes/i))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('search is case-insensitive', () => {
|
||||
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'My NAS' })] })
|
||||
render(<SearchModal open onClose={vi.fn()} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'MY NAS' } })
|
||||
expect(screen.getByText('My NAS')).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { ThemeModal } from '../ThemeModal'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { THEME_ORDER } from '@/utils/themes'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
|
||||
import { toast } from 'sonner'
|
||||
|
||||
describe('ThemeModal', () => {
|
||||
beforeEach(() => {
|
||||
useThemeStore.setState({ activeTheme: 'default' })
|
||||
useCanvasStore.setState({ hasUnsavedChanges: false })
|
||||
vi.mocked(toast.info).mockReset()
|
||||
})
|
||||
|
||||
it('renders nothing when closed', () => {
|
||||
const { container } = render(<ThemeModal open={false} onClose={vi.fn()} />)
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders all available themes', () => {
|
||||
render(<ThemeModal open onClose={vi.fn()} />)
|
||||
// Every theme in THEME_ORDER should have a card rendered
|
||||
expect(THEME_ORDER.length).toBeGreaterThan(0)
|
||||
// At minimum the dialog title should be present
|
||||
expect(screen.getByText('Choose Canvas Style')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows Apply Style button', () => {
|
||||
render(<ThemeModal open onClose={vi.fn()} />)
|
||||
expect(screen.getByRole('button', { name: 'Apply Style' })).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows Cancel button', () => {
|
||||
render(<ThemeModal open onClose={vi.fn()} />)
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDefined()
|
||||
})
|
||||
|
||||
it('live-previews theme when a card is clicked', () => {
|
||||
render(<ThemeModal open onClose={vi.fn()} />)
|
||||
const initialTheme = useThemeStore.getState().activeTheme
|
||||
// Click a different theme card (find by button role, pick a non-default one)
|
||||
const cards = screen.getAllByRole('button').filter((b) =>
|
||||
b.className.includes('rounded-xl')
|
||||
)
|
||||
// Click the second card (first non-selected)
|
||||
fireEvent.click(cards[1])
|
||||
// Theme should have changed for live preview
|
||||
expect(useThemeStore.getState().activeTheme).not.toBe(initialTheme)
|
||||
})
|
||||
|
||||
it('Apply sets theme, marks unsaved, and closes', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<ThemeModal open onClose={onClose} />)
|
||||
// Click a non-default card first
|
||||
const cards = screen.getAllByRole('button').filter((b) => b.className.includes('rounded-xl'))
|
||||
fireEvent.click(cards[1])
|
||||
const previewTheme = useThemeStore.getState().activeTheme
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply Style' }))
|
||||
expect(useThemeStore.getState().activeTheme).toBe(previewTheme)
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('Apply shows toast asking user to save canvas', () => {
|
||||
render(<ThemeModal open onClose={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply Style' }))
|
||||
expect(toast.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('save'),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('Cancel reverts to original theme and closes', () => {
|
||||
const onClose = vi.fn()
|
||||
useThemeStore.setState({ activeTheme: 'default' })
|
||||
render(<ThemeModal open onClose={onClose} />)
|
||||
// Preview a different theme
|
||||
const cards = screen.getAllByRole('button').filter((b) => b.className.includes('rounded-xl'))
|
||||
fireEvent.click(cards[1])
|
||||
expect(useThemeStore.getState().activeTheme).not.toBe('default')
|
||||
// Cancel should revert
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(useThemeStore.getState().activeTheme).toBe('default')
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('Cancel does not mark canvas as unsaved', () => {
|
||||
render(<ThemeModal open onClose={vi.fn()} />)
|
||||
const cards = screen.getAllByRole('button').filter((b) => b.className.includes('rounded-xl'))
|
||||
fireEvent.click(cards[1])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -2,9 +2,12 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import LiveView from './components/LiveView.tsx'
|
||||
|
||||
const isLiveView = window.location.pathname === '/view'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
{isLiveView ? <LiveView /> : <App />}
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -328,4 +328,40 @@ describe('canvasStore', () => {
|
||||
useCanvasStore.getState().pasteNodes()
|
||||
expect(useCanvasStore.getState().nodes).toHaveLength(1)
|
||||
})
|
||||
|
||||
// --- Node resizing (width / height) ---
|
||||
|
||||
it('addNode preserves explicit width and height', () => {
|
||||
const node: Node<NodeData> = { ...makeNode('n1'), width: 280, height: 120 }
|
||||
useCanvasStore.getState().addNode(node)
|
||||
const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
|
||||
expect(stored?.width).toBe(280)
|
||||
expect(stored?.height).toBe(120)
|
||||
})
|
||||
|
||||
it('onNodesChange dimensions change updates width and height', () => {
|
||||
useCanvasStore.getState().addNode(makeNode('n1'))
|
||||
useCanvasStore.getState().markSaved()
|
||||
useCanvasStore.getState().onNodesChange([
|
||||
{ type: 'dimensions', id: 'n1', dimensions: { width: 320, height: 180 }, resizing: true },
|
||||
])
|
||||
const node = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
|
||||
expect(node?.measured?.width ?? node?.width).toBeDefined()
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||
})
|
||||
|
||||
it('loadCanvas preserves width and height on resized nodes', () => {
|
||||
const resized: Node<NodeData> = { ...makeNode('n1'), width: 300, height: 160 }
|
||||
useCanvasStore.getState().loadCanvas([resized], [])
|
||||
const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
|
||||
expect(stored?.width).toBe(300)
|
||||
expect(stored?.height).toBe(160)
|
||||
})
|
||||
|
||||
it('loadCanvas preserves undefined width/height for default-sized nodes', () => {
|
||||
useCanvasStore.getState().loadCanvas([makeNode('n1')], [])
|
||||
const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
|
||||
expect(stored?.width).toBeUndefined()
|
||||
expect(stored?.height).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
import type { NodeData, EdgeData } from '@/types'
|
||||
import {
|
||||
serializeNode,
|
||||
serializeEdge,
|
||||
deserializeApiNode,
|
||||
deserializeApiEdge,
|
||||
type ApiNode,
|
||||
type ApiEdge,
|
||||
} from '@/utils/canvasSerializer'
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeRfNode(overrides: Partial<Node<NodeData>> = {}): Node<NodeData> {
|
||||
return {
|
||||
id: 'n1',
|
||||
type: 'server',
|
||||
position: { x: 100, y: 200 },
|
||||
data: {
|
||||
label: 'My Server',
|
||||
type: 'server',
|
||||
status: 'online',
|
||||
services: [],
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeApiNode(overrides: Partial<ApiNode> = {}): ApiNode {
|
||||
return {
|
||||
id: 'n1',
|
||||
type: 'server',
|
||||
label: 'My Server',
|
||||
pos_x: 100,
|
||||
pos_y: 200,
|
||||
status: 'online',
|
||||
services: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeRfEdge(overrides: Partial<Edge<EdgeData>> = {}): Edge<EdgeData> {
|
||||
return {
|
||||
id: 'e1',
|
||||
source: 'n1',
|
||||
target: 'n2',
|
||||
type: 'ethernet',
|
||||
data: { type: 'ethernet' },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeApiEdge(overrides: Partial<ApiEdge> = {}): ApiEdge {
|
||||
return {
|
||||
id: 'e1',
|
||||
source: 'n1',
|
||||
target: 'n2',
|
||||
type: 'ethernet',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ── serializeNode — regular nodes ────────────────────────────────────────────
|
||||
|
||||
describe('serializeNode — regular node', () => {
|
||||
it('maps position to pos_x/pos_y', () => {
|
||||
const result = serializeNode(makeRfNode({ position: { x: 42, y: 99 } }))
|
||||
expect(result.pos_x).toBe(42)
|
||||
expect(result.pos_y).toBe(99)
|
||||
})
|
||||
|
||||
it('includes all data fields', () => {
|
||||
const node = makeRfNode({
|
||||
data: {
|
||||
label: 'Router', type: 'router', status: 'online', services: [],
|
||||
hostname: 'gw.local', ip: '192.168.1.1', mac: 'aa:bb:cc:dd:ee:ff',
|
||||
os: 'OpenWRT', check_method: 'ping', check_target: '192.168.1.1',
|
||||
notes: 'main router',
|
||||
},
|
||||
})
|
||||
const result = serializeNode(node)
|
||||
expect(result.hostname).toBe('gw.local')
|
||||
expect(result.ip).toBe('192.168.1.1')
|
||||
expect(result.mac).toBe('aa:bb:cc:dd:ee:ff')
|
||||
expect(result.os).toBe('OpenWRT')
|
||||
expect(result.check_method).toBe('ping')
|
||||
expect(result.check_target).toBe('192.168.1.1')
|
||||
expect(result.notes).toBe('main router')
|
||||
})
|
||||
|
||||
it('serializes width and height when node has been resized', () => {
|
||||
const node = makeRfNode({ width: 280, height: 120 })
|
||||
const result = serializeNode(node)
|
||||
expect(result.width).toBe(280)
|
||||
expect(result.height).toBe(120)
|
||||
})
|
||||
|
||||
it('serializes width/height as null when node has default size', () => {
|
||||
const result = serializeNode(makeRfNode())
|
||||
expect(result.width).toBeNull()
|
||||
expect(result.height).toBeNull()
|
||||
})
|
||||
|
||||
it('serializes hardware fields', () => {
|
||||
const node = makeRfNode({
|
||||
data: {
|
||||
label: 'Server', type: 'server', status: 'online', services: [],
|
||||
cpu_count: 8, cpu_model: 'Intel i7', ram_gb: 32, disk_gb: 500, show_hardware: true,
|
||||
},
|
||||
})
|
||||
const result = serializeNode(node)
|
||||
expect(result.cpu_count).toBe(8)
|
||||
expect(result.cpu_model).toBe('Intel i7')
|
||||
expect(result.ram_gb).toBe(32)
|
||||
expect(result.disk_gb).toBe(500)
|
||||
expect(result.show_hardware).toBe(true)
|
||||
})
|
||||
|
||||
it('serializes custom_colors', () => {
|
||||
const node = makeRfNode({ data: { label: 'S', type: 'server', status: 'unknown', services: [], custom_colors: { border: '#ff0000' } } })
|
||||
const result = serializeNode(node)
|
||||
expect(result.custom_colors).toEqual({ border: '#ff0000' })
|
||||
})
|
||||
|
||||
it('serializes parent_id and container_mode', () => {
|
||||
const node = makeRfNode({ data: { label: 'VM', type: 'vm', status: 'unknown', services: [], parent_id: 'px1', container_mode: false } })
|
||||
const result = serializeNode(node)
|
||||
expect(result.parent_id).toBe('px1')
|
||||
expect(result.container_mode).toBe(false)
|
||||
})
|
||||
|
||||
it('nulls optional fields when absent', () => {
|
||||
const result = serializeNode(makeRfNode())
|
||||
expect(result.hostname).toBeNull()
|
||||
expect(result.ip).toBeNull()
|
||||
expect(result.mac).toBeNull()
|
||||
expect(result.os).toBeNull()
|
||||
expect(result.check_method).toBeNull()
|
||||
expect(result.check_target).toBeNull()
|
||||
expect(result.notes).toBeNull()
|
||||
expect(result.parent_id).toBeNull()
|
||||
expect(result.cpu_count).toBeNull()
|
||||
expect(result.cpu_model).toBeNull()
|
||||
expect(result.ram_gb).toBeNull()
|
||||
expect(result.disk_gb).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ── serializeNode — groupRect ─────────────────────────────────────────────────
|
||||
|
||||
describe('serializeNode — groupRect', () => {
|
||||
it('stores dimensions inside custom_colors', () => {
|
||||
const node = makeRfNode({
|
||||
type: 'groupRect',
|
||||
data: { label: 'Zone A', type: 'groupRect', status: 'unknown', services: [] },
|
||||
width: 400,
|
||||
height: 250,
|
||||
})
|
||||
const result = serializeNode(node)
|
||||
expect((result.custom_colors as Record<string, unknown>).width).toBe(400)
|
||||
expect((result.custom_colors as Record<string, unknown>).height).toBe(250)
|
||||
})
|
||||
|
||||
it('falls back to measured dimensions over explicit width/height', () => {
|
||||
const node: Node<NodeData> = {
|
||||
...makeRfNode({ type: 'groupRect', data: { label: 'Z', type: 'groupRect', status: 'unknown', services: [] }, width: 400 }),
|
||||
measured: { width: 420, height: 260 },
|
||||
}
|
||||
const result = serializeNode(node)
|
||||
expect((result.custom_colors as Record<string, unknown>).width).toBe(420)
|
||||
expect((result.custom_colors as Record<string, unknown>).height).toBe(260)
|
||||
})
|
||||
|
||||
it('falls back to defaults when no dimensions available', () => {
|
||||
const node = makeRfNode({ type: 'groupRect', data: { label: 'Z', type: 'groupRect', status: 'unknown', services: [] } })
|
||||
const result = serializeNode(node)
|
||||
expect((result.custom_colors as Record<string, unknown>).width).toBe(360)
|
||||
expect((result.custom_colors as Record<string, unknown>).height).toBe(240)
|
||||
})
|
||||
|
||||
it('preserves existing custom_colors fields alongside dimensions', () => {
|
||||
const node = makeRfNode({
|
||||
type: 'groupRect',
|
||||
data: { label: 'Z', type: 'groupRect', status: 'unknown', services: [], custom_colors: { border: '#aaa', z_order: 2 } },
|
||||
width: 300, height: 200,
|
||||
})
|
||||
const result = serializeNode(node)
|
||||
const cc = result.custom_colors as Record<string, unknown>
|
||||
expect(cc.border).toBe('#aaa')
|
||||
expect(cc.z_order).toBe(2)
|
||||
expect(cc.width).toBe(300)
|
||||
expect(cc.height).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
// ── serializeEdge ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('serializeEdge', () => {
|
||||
it('serializes basic fields', () => {
|
||||
const result = serializeEdge(makeRfEdge())
|
||||
expect(result.id).toBe('e1')
|
||||
expect(result.source).toBe('n1')
|
||||
expect(result.target).toBe('n2')
|
||||
expect(result.type).toBe('ethernet')
|
||||
})
|
||||
|
||||
it('normalizes top-t handle to top', () => {
|
||||
const result = serializeEdge(makeRfEdge({ sourceHandle: 'top-t', targetHandle: 'bottom-t' }))
|
||||
expect(result.source_handle).toBe('top')
|
||||
expect(result.target_handle).toBe('bottom')
|
||||
})
|
||||
|
||||
it('passes through non-stub handles unchanged', () => {
|
||||
const result = serializeEdge(makeRfEdge({ sourceHandle: 'cluster-right', targetHandle: 'cluster-left' }))
|
||||
expect(result.source_handle).toBe('cluster-right')
|
||||
expect(result.target_handle).toBe('cluster-left')
|
||||
})
|
||||
|
||||
it('serializes optional edge data', () => {
|
||||
const edge = makeRfEdge({ data: { type: 'vlan', label: 'uplink', vlan_id: 10, custom_color: '#ff0', path_style: 'smooth', animated: true } })
|
||||
const result = serializeEdge(edge)
|
||||
expect(result.label).toBe('uplink')
|
||||
expect(result.vlan_id).toBe(10)
|
||||
expect(result.custom_color).toBe('#ff0')
|
||||
expect(result.path_style).toBe('smooth')
|
||||
expect(result.animated).toBe(true)
|
||||
})
|
||||
|
||||
it('nulls optional fields when absent', () => {
|
||||
const result = serializeEdge(makeRfEdge({ sourceHandle: undefined, targetHandle: undefined }))
|
||||
expect(result.source_handle).toBeNull()
|
||||
expect(result.target_handle).toBeNull()
|
||||
expect(result.label).toBeNull()
|
||||
expect(result.vlan_id).toBeNull()
|
||||
expect(result.custom_color).toBeNull()
|
||||
expect(result.path_style).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ── deserializeApiNode — regular nodes ───────────────────────────────────────
|
||||
|
||||
describe('deserializeApiNode — regular node', () => {
|
||||
const emptyMap = new Map<string, boolean>()
|
||||
|
||||
it('maps pos_x/pos_y to position', () => {
|
||||
const result = deserializeApiNode(makeApiNode({ pos_x: 50, pos_y: 75 }), emptyMap)
|
||||
expect(result.position).toEqual({ x: 50, y: 75 })
|
||||
})
|
||||
|
||||
it('restores width and height when node was resized', () => {
|
||||
const result = deserializeApiNode(makeApiNode({ width: 280, height: 120 }), emptyMap)
|
||||
expect(result.width).toBe(280)
|
||||
expect(result.height).toBe(120)
|
||||
})
|
||||
|
||||
it('leaves width/height undefined for default-sized nodes', () => {
|
||||
const result = deserializeApiNode(makeApiNode(), emptyMap)
|
||||
expect(result.width).toBeUndefined()
|
||||
expect(result.height).toBeUndefined()
|
||||
})
|
||||
|
||||
it('sets parentId and extent for children of container proxmox', () => {
|
||||
const map = new Map([['px1', true]])
|
||||
const result = deserializeApiNode(makeApiNode({ parent_id: 'px1' }), map)
|
||||
expect(result.parentId).toBe('px1')
|
||||
expect(result.extent).toBe('parent')
|
||||
})
|
||||
|
||||
it('does not set parentId when parent is not in container mode', () => {
|
||||
const map = new Map([['px1', false]])
|
||||
const result = deserializeApiNode(makeApiNode({ parent_id: 'px1' }), map)
|
||||
expect(result.parentId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('sets proxmox dimensions using saved values', () => {
|
||||
const result = deserializeApiNode(
|
||||
makeApiNode({ type: 'proxmox', container_mode: true, width: 450, height: 300 }),
|
||||
emptyMap,
|
||||
)
|
||||
expect(result.width).toBe(450)
|
||||
expect(result.height).toBe(300)
|
||||
})
|
||||
|
||||
it('falls back to 300x200 for proxmox container with no saved dimensions', () => {
|
||||
const result = deserializeApiNode(makeApiNode({ type: 'proxmox', container_mode: true }), emptyMap)
|
||||
expect(result.width).toBe(300)
|
||||
expect(result.height).toBe(200)
|
||||
})
|
||||
|
||||
it('does not set dimensions for non-container proxmox', () => {
|
||||
const result = deserializeApiNode(makeApiNode({ type: 'proxmox', container_mode: false }), emptyMap)
|
||||
expect(result.width).toBeUndefined()
|
||||
expect(result.height).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ── deserializeApiNode — groupRect ────────────────────────────────────────────
|
||||
|
||||
describe('deserializeApiNode — groupRect', () => {
|
||||
const emptyMap = new Map<string, boolean>()
|
||||
|
||||
it('restores width/height from custom_colors', () => {
|
||||
const result = deserializeApiNode(
|
||||
makeApiNode({ type: 'groupRect', custom_colors: { width: 400, height: 250, z_order: 2 } }),
|
||||
emptyMap,
|
||||
)
|
||||
expect(result.width).toBe(400)
|
||||
expect(result.height).toBe(250)
|
||||
expect(result.zIndex).toBe(-8)
|
||||
})
|
||||
|
||||
it('defaults to 360x240 when custom_colors has no dimensions', () => {
|
||||
const result = deserializeApiNode(makeApiNode({ type: 'groupRect' }), emptyMap)
|
||||
expect(result.width).toBe(360)
|
||||
expect(result.height).toBe(240)
|
||||
})
|
||||
})
|
||||
|
||||
// ── deserializeApiEdge ────────────────────────────────────────────────────────
|
||||
|
||||
describe('deserializeApiEdge', () => {
|
||||
it('maps source_handle/target_handle to sourceHandle/targetHandle', () => {
|
||||
const result = deserializeApiEdge(makeApiEdge({ source_handle: 'top', target_handle: 'bottom' }))
|
||||
expect(result.sourceHandle).toBe('top')
|
||||
expect(result.targetHandle).toBe('bottom')
|
||||
})
|
||||
|
||||
it('sets sourceHandle/targetHandle to null when absent', () => {
|
||||
const result = deserializeApiEdge(makeApiEdge())
|
||||
expect(result.sourceHandle).toBeNull()
|
||||
expect(result.targetHandle).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves id, source, target, type', () => {
|
||||
const result = deserializeApiEdge(makeApiEdge({ id: 'e99', source: 'a', target: 'b', type: 'wifi' }))
|
||||
expect(result.id).toBe('e99')
|
||||
expect(result.source).toBe('a')
|
||||
expect(result.target).toBe('b')
|
||||
expect(result.type).toBe('wifi')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Round-trip ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('round-trip: serialize → deserialize', () => {
|
||||
const emptyMap = new Map<string, boolean>()
|
||||
|
||||
it('preserves position through serialize/deserialize', () => {
|
||||
const node = makeRfNode({ position: { x: 123, y: 456 } })
|
||||
const serialized = serializeNode(node) as ApiNode
|
||||
const restored = deserializeApiNode(serialized, emptyMap)
|
||||
expect(restored.position).toEqual({ x: 123, y: 456 })
|
||||
})
|
||||
|
||||
it('preserves width/height through serialize/deserialize', () => {
|
||||
const node = makeRfNode({ width: 300, height: 160 })
|
||||
const serialized = serializeNode(node) as ApiNode
|
||||
const restored = deserializeApiNode(serialized, emptyMap)
|
||||
expect(restored.width).toBe(300)
|
||||
expect(restored.height).toBe(160)
|
||||
})
|
||||
|
||||
it('preserves null width/height for default-sized nodes', () => {
|
||||
const node = makeRfNode()
|
||||
const serialized = serializeNode(node) as ApiNode
|
||||
const restored = deserializeApiNode(serialized, emptyMap)
|
||||
expect(restored.width).toBeUndefined()
|
||||
expect(restored.height).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves groupRect dimensions through serialize/deserialize', () => {
|
||||
const node = makeRfNode({
|
||||
type: 'groupRect',
|
||||
data: { label: 'Z', type: 'groupRect', status: 'unknown', services: [] },
|
||||
width: 500,
|
||||
height: 300,
|
||||
})
|
||||
const serialized = serializeNode(node) as ApiNode
|
||||
const restored = deserializeApiNode(serialized, emptyMap)
|
||||
expect(restored.width).toBe(500)
|
||||
expect(restored.height).toBe(300)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Standalone mode save/load tests.
|
||||
*
|
||||
* In standalone mode (VITE_STANDALONE=true) the canvas is persisted directly
|
||||
* to localStorage as JSON — no backend involved. The full RF node object is
|
||||
* serialized as-is, which means width/height survive the round-trip without
|
||||
* going through serializeNode / deserializeApiNode.
|
||||
*
|
||||
* These tests verify that critical node properties (especially width/height
|
||||
* added for resizable nodes) are not lost through the localStorage cycle, and
|
||||
* that the demo data is structurally valid.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
import type { NodeData, EdgeData } from '@/types'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { demoNodes, demoEdges } from '@/utils/demoData'
|
||||
|
||||
const STORAGE_KEY = 'homelable_canvas'
|
||||
|
||||
// Simulates what App.tsx does on Ctrl+S in standalone mode
|
||||
function standaloneSerialize(nodes: Node<NodeData>[], edges: Edge<EdgeData>[], theme_id = 'default') {
|
||||
return JSON.stringify({ nodes, edges, theme_id })
|
||||
}
|
||||
|
||||
// Simulates what App.tsx does on load in standalone mode
|
||||
function standaloneDeserialize(raw: string) {
|
||||
return JSON.parse(raw) as { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[]; theme_id: string }
|
||||
}
|
||||
|
||||
function makeNode(id: string, overrides: Partial<Node<NodeData>> = {}): Node<NodeData> {
|
||||
return {
|
||||
id,
|
||||
type: 'server',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { label: id, type: 'server', status: 'unknown', services: [] },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Standalone localStorage save/load cycle', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
useCanvasStore.setState({ nodes: [], edges: [], hasUnsavedChanges: false })
|
||||
})
|
||||
|
||||
// ── width / height round-trip ─────────────────────────────────────────────
|
||||
|
||||
it('preserves width and height for resized nodes', () => {
|
||||
const nodes = [makeNode('n1', { width: 320, height: 180 })]
|
||||
const raw = standaloneSerialize(nodes, [])
|
||||
localStorage.setItem(STORAGE_KEY, raw)
|
||||
|
||||
const { nodes: loaded } = standaloneDeserialize(localStorage.getItem(STORAGE_KEY)!)
|
||||
useCanvasStore.getState().loadCanvas(loaded, [])
|
||||
|
||||
const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
|
||||
expect(stored?.width).toBe(320)
|
||||
expect(stored?.height).toBe(180)
|
||||
})
|
||||
|
||||
it('preserves undefined width/height for default-sized nodes', () => {
|
||||
const nodes = [makeNode('n1')]
|
||||
const raw = standaloneSerialize(nodes, [])
|
||||
const { nodes: loaded } = standaloneDeserialize(raw)
|
||||
useCanvasStore.getState().loadCanvas(loaded, [])
|
||||
|
||||
const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
|
||||
expect(stored?.width).toBeUndefined()
|
||||
expect(stored?.height).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves mixed: some nodes resized, others not', () => {
|
||||
const nodes = [
|
||||
makeNode('n1', { width: 280, height: 120 }),
|
||||
makeNode('n2'),
|
||||
]
|
||||
const raw = standaloneSerialize(nodes, [])
|
||||
const { nodes: loaded } = standaloneDeserialize(raw)
|
||||
useCanvasStore.getState().loadCanvas(loaded, [])
|
||||
|
||||
const { nodes: stored } = useCanvasStore.getState()
|
||||
expect(stored.find((n) => n.id === 'n1')?.width).toBe(280)
|
||||
expect(stored.find((n) => n.id === 'n2')?.width).toBeUndefined()
|
||||
})
|
||||
|
||||
// ── other node properties ─────────────────────────────────────────────────
|
||||
|
||||
it('preserves position through the round-trip', () => {
|
||||
const nodes = [makeNode('n1', { position: { x: 123, y: 456 } })]
|
||||
const raw = standaloneSerialize(nodes, [])
|
||||
const { nodes: loaded } = standaloneDeserialize(raw)
|
||||
useCanvasStore.getState().loadCanvas(loaded, [])
|
||||
|
||||
expect(useCanvasStore.getState().nodes[0].position).toEqual({ x: 123, y: 456 })
|
||||
})
|
||||
|
||||
it('preserves node data fields through the round-trip', () => {
|
||||
const nodes = [makeNode('n1', {
|
||||
data: {
|
||||
label: 'My Router', type: 'router', status: 'online', services: [],
|
||||
ip: '192.168.1.1', hostname: 'gw.local',
|
||||
},
|
||||
})]
|
||||
const raw = standaloneSerialize(nodes, [])
|
||||
const { nodes: loaded } = standaloneDeserialize(raw)
|
||||
useCanvasStore.getState().loadCanvas(loaded, [])
|
||||
|
||||
const stored = useCanvasStore.getState().nodes[0]
|
||||
expect(stored.data.label).toBe('My Router')
|
||||
expect(stored.data.ip).toBe('192.168.1.1')
|
||||
expect(stored.data.hostname).toBe('gw.local')
|
||||
expect(stored.data.status).toBe('online')
|
||||
})
|
||||
|
||||
it('preserves theme_id through the round-trip', () => {
|
||||
const raw = standaloneSerialize([], [], 'cyberpunk')
|
||||
const { theme_id } = standaloneDeserialize(raw)
|
||||
expect(theme_id).toBe('cyberpunk')
|
||||
})
|
||||
|
||||
it('preserves edge data through the round-trip', () => {
|
||||
const edges: Edge<EdgeData>[] = [{
|
||||
id: 'e1', source: 'n1', target: 'n2', type: 'vlan',
|
||||
data: { type: 'vlan', vlan_id: 20, label: 'VLAN 20' },
|
||||
}]
|
||||
const raw = standaloneSerialize([], edges)
|
||||
const { edges: loaded } = standaloneDeserialize(raw)
|
||||
useCanvasStore.getState().loadCanvas([], loaded)
|
||||
|
||||
const stored = useCanvasStore.getState().edges[0]
|
||||
expect(stored.data?.vlan_id).toBe(20)
|
||||
expect(stored.data?.label).toBe('VLAN 20')
|
||||
})
|
||||
|
||||
// ── loadCanvas marks clean ────────────────────────────────────────────────
|
||||
|
||||
it('loadCanvas sets hasUnsavedChanges to false', () => {
|
||||
useCanvasStore.setState({ hasUnsavedChanges: true })
|
||||
const { nodes: loaded } = standaloneDeserialize(standaloneSerialize([makeNode('n1')], []))
|
||||
useCanvasStore.getState().loadCanvas(loaded, [])
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Demo data validation ──────────────────────────────────────────────────────
|
||||
|
||||
describe('Demo data (standalone fallback)', () => {
|
||||
it('all demo nodes have required fields', () => {
|
||||
for (const n of demoNodes) {
|
||||
expect(n.id, `${n.id} missing id`).toBeTruthy()
|
||||
expect(n.type, `${n.id} missing type`).toBeTruthy()
|
||||
expect(n.position, `${n.id} missing position`).toBeDefined()
|
||||
expect(n.data.label, `${n.id} missing label`).toBeTruthy()
|
||||
expect(n.data.type, `${n.id} missing data.type`).toBeTruthy()
|
||||
expect(n.data.status, `${n.id} missing status`).toBeTruthy()
|
||||
expect(Array.isArray(n.data.services), `${n.id} services must be array`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('demo nodes have no explicit width/height — render at natural size', () => {
|
||||
for (const n of demoNodes) {
|
||||
expect(n.width, `${n.id} should not have explicit width`).toBeUndefined()
|
||||
expect(n.height, `${n.id} should not have explicit height`).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('all demo edges reference valid node ids', () => {
|
||||
const nodeIds = new Set(demoNodes.map((n) => n.id))
|
||||
for (const e of demoEdges) {
|
||||
expect(nodeIds.has(e.source), `edge ${e.id} source '${e.source}' not in nodes`).toBe(true)
|
||||
expect(nodeIds.has(e.target), `edge ${e.id} target '${e.target}' not in nodes`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('demo data loads into store without errors', () => {
|
||||
useCanvasStore.getState().loadCanvas(demoNodes, demoEdges)
|
||||
const { nodes, edges } = useCanvasStore.getState()
|
||||
expect(nodes).toHaveLength(demoNodes.length)
|
||||
expect(edges).toHaveLength(demoEdges.length)
|
||||
})
|
||||
|
||||
it('demo data round-trips through standalone JSON serialization', () => {
|
||||
const raw = standaloneSerialize(demoNodes, demoEdges)
|
||||
const { nodes: loaded, edges: loadedEdges } = standaloneDeserialize(raw)
|
||||
useCanvasStore.getState().loadCanvas(loaded, loadedEdges)
|
||||
|
||||
const { nodes, edges } = useCanvasStore.getState()
|
||||
expect(nodes).toHaveLength(demoNodes.length)
|
||||
expect(edges).toHaveLength(demoEdges.length)
|
||||
// Positions intact
|
||||
const router = nodes.find((n) => n.id === 'router-1')!
|
||||
expect(router.position).toEqual({ x: 300, y: 140 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
import type { NodeData, EdgeData } from '@/types'
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ApiNode extends Record<string, unknown> {
|
||||
id: string
|
||||
type: string
|
||||
label: string
|
||||
pos_x: number
|
||||
pos_y: number
|
||||
status: string
|
||||
services: unknown[]
|
||||
hostname?: string | null
|
||||
ip?: string | null
|
||||
mac?: string | null
|
||||
os?: string | null
|
||||
check_method?: string | null
|
||||
check_target?: string | null
|
||||
notes?: string | null
|
||||
parent_id?: string | null
|
||||
container_mode?: boolean
|
||||
custom_colors?: Record<string, unknown> | null
|
||||
custom_icon?: string | null
|
||||
cpu_count?: number | null
|
||||
cpu_model?: string | null
|
||||
ram_gb?: number | null
|
||||
disk_gb?: number | null
|
||||
show_hardware?: boolean
|
||||
width?: number | null
|
||||
height?: number | null
|
||||
}
|
||||
|
||||
export interface ApiEdge {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
type: string
|
||||
label?: string | null
|
||||
vlan_id?: number | null
|
||||
speed?: string | null
|
||||
custom_color?: string | null
|
||||
path_style?: string | null
|
||||
animated?: boolean
|
||||
source_handle?: string | null
|
||||
target_handle?: string | null
|
||||
}
|
||||
|
||||
// ── Serialization (RF node → API save payload) ───────────────────────────────
|
||||
|
||||
export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
|
||||
if (n.data.type === 'groupRect') {
|
||||
return {
|
||||
id: n.id,
|
||||
type: 'groupRect',
|
||||
label: n.data.label,
|
||||
hostname: null,
|
||||
ip: null,
|
||||
mac: null,
|
||||
os: null,
|
||||
status: 'unknown',
|
||||
check_method: null,
|
||||
check_target: null,
|
||||
services: [],
|
||||
notes: null,
|
||||
parent_id: null,
|
||||
container_mode: false,
|
||||
custom_icon: null,
|
||||
pos_x: n.position.x,
|
||||
pos_y: n.position.y,
|
||||
custom_colors: {
|
||||
...n.data.custom_colors,
|
||||
width: n.measured?.width ?? n.width ?? 360,
|
||||
height: n.measured?.height ?? n.height ?? 240,
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: n.id,
|
||||
type: n.data.type,
|
||||
label: n.data.label,
|
||||
hostname: n.data.hostname ?? null,
|
||||
ip: n.data.ip ?? null,
|
||||
mac: n.data.mac ?? null,
|
||||
os: n.data.os ?? null,
|
||||
status: n.data.status,
|
||||
check_method: n.data.check_method ?? null,
|
||||
check_target: n.data.check_target ?? null,
|
||||
services: n.data.services ?? [],
|
||||
notes: n.data.notes ?? null,
|
||||
parent_id: n.data.parent_id ?? null,
|
||||
container_mode: n.data.container_mode ?? false,
|
||||
custom_colors: n.data.custom_colors ?? null,
|
||||
custom_icon: n.data.custom_icon ?? null,
|
||||
cpu_count: n.data.cpu_count ?? null,
|
||||
cpu_model: n.data.cpu_model ?? null,
|
||||
ram_gb: n.data.ram_gb ?? null,
|
||||
disk_gb: n.data.disk_gb ?? null,
|
||||
show_hardware: n.data.show_hardware ?? false,
|
||||
width: n.width ?? null,
|
||||
height: n.height ?? null,
|
||||
pos_x: n.position.x,
|
||||
pos_y: n.position.y,
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeHandle = (h: string | null | undefined): string | null =>
|
||||
h === 'top-t' ? 'top' : h === 'bottom-t' ? 'bottom' : (h ?? null)
|
||||
|
||||
export function serializeEdge(e: Edge<EdgeData>): Record<string, unknown> {
|
||||
return {
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
type: e.data?.type ?? 'ethernet',
|
||||
label: e.data?.label ?? null,
|
||||
vlan_id: e.data?.vlan_id ?? null,
|
||||
speed: e.data?.speed ?? null,
|
||||
custom_color: e.data?.custom_color ?? null,
|
||||
path_style: e.data?.path_style ?? null,
|
||||
animated: e.data?.animated ?? false,
|
||||
source_handle: normalizeHandle(e.sourceHandle),
|
||||
target_handle: normalizeHandle(e.targetHandle),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Deserialization (API response → RF node/edge) ────────────────────────────
|
||||
|
||||
export function deserializeApiNode(
|
||||
n: ApiNode,
|
||||
proxmoxContainerMap: Map<string, boolean>,
|
||||
): Node<NodeData> {
|
||||
if (n.type === 'groupRect') {
|
||||
const w = (n.custom_colors?.width as number | undefined) ?? 360
|
||||
const h = (n.custom_colors?.height as number | undefined) ?? 240
|
||||
const z = (n.custom_colors?.z_order as number | undefined) ?? 1
|
||||
return {
|
||||
id: n.id,
|
||||
type: 'groupRect',
|
||||
position: { x: n.pos_x, y: n.pos_y },
|
||||
data: n as unknown as NodeData,
|
||||
width: w,
|
||||
height: h,
|
||||
zIndex: z - 10,
|
||||
}
|
||||
}
|
||||
const parentIsContainer = n.parent_id ? (proxmoxContainerMap.get(n.parent_id) ?? false) : false
|
||||
return {
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
position: { x: n.pos_x, y: n.pos_y },
|
||||
data: n as unknown as NodeData,
|
||||
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
|
||||
...(n.type === 'proxmox' && n.container_mode !== false
|
||||
? { width: n.width ?? 300, height: n.height ?? 200 }
|
||||
: {}),
|
||||
...(n.width && n.type !== 'proxmox' ? { width: n.width } : {}),
|
||||
...(n.height && n.type !== 'proxmox' ? { height: n.height } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function deserializeApiEdge(e: ApiEdge): Edge<EdgeData> {
|
||||
return {
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
type: e.type,
|
||||
sourceHandle: e.source_handle ?? null,
|
||||
targetHandle: e.target_handle ?? null,
|
||||
data: e as unknown as EdgeData,
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@ pct create "$CTID" "$TEMPLATE" \
|
||||
--rootfs "${STORAGE}:${DISK_SIZE}" \
|
||||
--memory "$RAM" \
|
||||
--cores "$CORES" \
|
||||
--net0 "name=eth0,bridge=${BRIDGE},ip=dhcp" \
|
||||
--net0 "name=eth0,bridge=${BRIDGE},ip=dhcp${VLAN_TAG:+,tag=${VLAN_TAG}}" \
|
||||
--ostype debian \
|
||||
--unprivileged 1 \
|
||||
--features "nesting=1" \
|
||||
@@ -115,7 +115,7 @@ for i in $(seq 1 20); do
|
||||
done
|
||||
|
||||
# ── Grant NET_RAW for nmap (ping-based checks) ─────────────────────────────────
|
||||
echo "lxc.cap.keep = net_raw net_bind_service" >> /etc/pve/lxc/${CTID}.conf 2>/dev/null || true
|
||||
echo "lxc.cap.keep = net_raw net_bind_service" >> "/etc/pve/lxc/${CTID}.conf" 2>/dev/null || true
|
||||
|
||||
# ── Bootstrap curl then run the installer ─────────────────────────────────────
|
||||
step "Running Homelable installer inside container $CTID..."
|
||||
|
||||
@@ -10,7 +10,6 @@ INSTALL_DIR=/opt/homelable
|
||||
DATA_DIR=/opt/homelable/data
|
||||
SERVICE_USER=homelable
|
||||
REPO_URL="https://github.com/Pouzor/homelable.git"
|
||||
RAW="https://raw.githubusercontent.com/Pouzor/homelable/main"
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||
info() { echo -e "${GREEN}[homelable]${NC} $*"; }
|
||||
@@ -20,7 +19,12 @@ error() { echo -e "${RED}[homelable]${NC} $*"; exit 1; }
|
||||
[[ $EUID -ne 0 ]] && error "Run as root (sudo bash ...)"
|
||||
|
||||
# ── Detect OS ─────────────────────────────────────────────────────────────────
|
||||
[[ -f /etc/os-release ]] && . /etc/os-release || error "Cannot detect OS"
|
||||
if [[ -f /etc/os-release ]]; then
|
||||
# shellcheck source=/dev/null
|
||||
. /etc/os-release
|
||||
else
|
||||
error "Cannot detect OS"
|
||||
fi
|
||||
info "Detected: $PRETTY_NAME"
|
||||
[[ "$ID" =~ ^(debian|ubuntu)$ ]] || error "Requires Debian or Ubuntu"
|
||||
|
||||
@@ -118,7 +122,8 @@ sed \
|
||||
|
||||
ln -sf /etc/nginx/sites-available/homelable /etc/nginx/sites-enabled/homelable
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
nginx -t && systemctl reload nginx || systemctl start nginx
|
||||
nginx -t
|
||||
systemctl reload nginx || systemctl start nginx
|
||||
|
||||
# ── Enable & start ────────────────────────────────────────────────────────────
|
||||
systemctl daemon-reload
|
||||
|
||||
Reference in New Issue
Block a user