diff --git a/.github/workflows/docker-ci.yml b/.github/workflows/docker-ci.yml new file mode 100644 index 0000000..227be16 --- /dev/null +++ b/.github/workflows/docker-ci.yml @@ -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 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index f173891..b6a2bd4 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -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: diff --git a/.hadolint.yaml b/.hadolint.yaml new file mode 100644 index 0000000..f3991ca --- /dev/null +++ b/.hadolint.yaml @@ -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 diff --git a/backend/app/db/database.py b/backend/app/db/database.py index a2a57a5..40fd629 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -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]: diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 8438e16..2654f8a 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -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) diff --git a/backend/app/schemas/canvas.py b/backend/app/schemas/canvas.py index 348c19e..9c768d4 100644 --- a/backend/app/schemas/canvas.py +++ b/backend/app/schemas/canvas.py @@ -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 diff --git a/backend/app/schemas/nodes.py b/backend/app/schemas/nodes.py index afe7f2e..caaad34 100644 --- a/backend/app/schemas/nodes.py +++ b/backend/app/schemas/nodes.py @@ -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): diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py index 8519923..8187e06 100644 --- a/backend/tests/test_canvas.py +++ b/backend/tests/test_canvas.py @@ -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 diff --git a/backend/tests/test_integration.py b/backend/tests/test_integration.py new file mode 100644 index 0000000..6bd8a17 --- /dev/null +++ b/backend/tests/test_integration.py @@ -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" diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml new file mode 100644 index 0000000..4c995dd --- /dev/null +++ b/docker-compose.ci.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 631125c..613d572 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 762525b..d2662b8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "1.0.0", + "version": "1.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "1.0.0", + "version": "1.3.3", "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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index feac20a..2530d25 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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( - 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) diff --git a/frontend/src/components/__tests__/LoginPage.test.tsx b/frontend/src/components/__tests__/LoginPage.test.tsx new file mode 100644 index 0000000..9b617a6 --- /dev/null +++ b/frontend/src/components/__tests__/LoginPage.test.tsx @@ -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() + expect(screen.getByLabelText('Username')).toBeDefined() + expect(screen.getByLabelText('Password')).toBeDefined() + }) + + it('renders a Sign in button', () => { + render() + expect(screen.getByRole('button', { name: /sign in/i })).toBeDefined() + }) + + // ── Security checks ────────────────────────────────────────────────────── + + it('password field type is "password" — not rendered as plain text', () => { + render() + const pw = screen.getByLabelText('Password') as HTMLInputElement + expect(pw.type).toBe('password') + }) + + it('username field has autocomplete="username"', () => { + render() + const un = screen.getByLabelText('Username') as HTMLInputElement + expect(un.getAttribute('autocomplete')).toBe('username') + }) + + it('password field has autocomplete="current-password" (supports password managers)', () => { + render() + 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() + 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() + 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() + 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() + 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() + 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() + // 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() + expect(screen.queryByText('Invalid username or password')).toBeNull() + }) + + it('requires username (HTML required attribute)', () => { + render() + const un = screen.getByLabelText('Username') as HTMLInputElement + expect(un.required).toBe(true) + }) + + it('requires password (HTML required attribute)', () => { + render() + const pw = screen.getByLabelText('Password') as HTMLInputElement + expect(pw.required).toBe(true) + }) +}) diff --git a/frontend/src/components/canvas/__tests__/CanvasContainer.test.tsx b/frontend/src/components/canvas/__tests__/CanvasContainer.test.tsx new file mode 100644 index 0000000..b71adc8 --- /dev/null +++ b/frontend/src/components/canvas/__tests__/CanvasContainer.test.tsx @@ -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 = {} + +vi.mock('@xyflow/react', () => ({ + ReactFlow: (props: Record) => { + rfProps = props + return
+ }, + Background: () => null, + Controls: () => null, + BackgroundVariant: { Dots: 'dots' }, + ConnectionMode: { Loose: 'loose' }, +})) + +vi.mock('@xyflow/react/dist/style.css', () => ({})) + +function makeNode(id: string): Node { + return { + id, + type: 'server', + position: { x: 0, y: 0 }, + data: { label: id, type: 'server', status: 'unknown', services: [] }, + } +} + +function makeEdge(id: string): Edge { + 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() + expect(getByTestId('react-flow')).toBeDefined() + }) + + it('passes nodes from store to ReactFlow', () => { + useCanvasStore.setState({ nodes: [makeNode('n1'), makeNode('n2')] }) + render() + 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() + 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() + ;(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() + ;(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() + ;(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() + expect(() => { + ;(rfProps.onEdgeDoubleClick as (...args: unknown[]) => unknown)({} as MouseEvent, edge) + }).not.toThrow() + }) + + // ── Connection validation ───────────────────────────────────────────────── + + it('isValidConnection returns false for self-connections', () => { + render() + 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() + 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() + ;(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() + expect(rfProps.onNodeDragStart).toBe(onNodeDragStart) + }) + + // ── Canvas settings ─────────────────────────────────────────────────────── + + it('enables snapToGrid', () => { + render() + expect(rfProps.snapToGrid).toBe(true) + }) + + it('sets snapGrid to [16, 16]', () => { + render() + expect(rfProps.snapGrid).toEqual([16, 16]) + }) +}) diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index 28fc616..35ee327 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -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, }} > +
diff --git a/frontend/src/components/modals/__tests__/EdgeModal.test.tsx b/frontend/src/components/modals/__tests__/EdgeModal.test.tsx new file mode 100644 index 0000000..8918dd8 --- /dev/null +++ b/frontend/src/components/modals/__tests__/EdgeModal.test.tsx @@ -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() + expect(container.querySelector('[role="dialog"]')).toBeNull() + }) + + it('renders form when open', () => { + render() + expect(screen.getByText('Connect Nodes')).toBeDefined() + }) + + it('uses custom title when provided', () => { + render() + expect(screen.getByText('Edit Link')).toBeDefined() + }) + + // ── Submit button label ─────────────────────────────────────────────────── + + it('shows "Connect" button when onDelete is not provided', () => { + render() + expect(screen.getByRole('button', { name: 'Connect' })).toBeDefined() + }) + + it('shows "Save" button when onDelete is provided', () => { + render() + 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() + 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() + 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() + 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() + expect(screen.queryByPlaceholderText('e.g. 20')).toBeNull() + }) + + it('submits integer vlan_id when type is vlan', () => { + const onSubmit = vi.fn() + render() + 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() + 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() + 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() + 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() + 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() + // 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() + 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() + 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() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('shows Delete button when onDelete is provided', () => { + render() + expect(screen.getByRole('button', { name: 'Delete' })).toBeDefined() + }) + + it('does not show Delete button without onDelete', () => { + render() + 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() + fireEvent.click(screen.getByRole('button', { name: 'Delete' })) + expect(onDelete).toHaveBeenCalledOnce() + expect(onClose).toHaveBeenCalledOnce() + }) +}) diff --git a/frontend/src/components/modals/__tests__/PendingDeviceModal.test.tsx b/frontend/src/components/modals/__tests__/PendingDeviceModal.test.tsx new file mode 100644 index 0000000..3ec4211 --- /dev/null +++ b/frontend/src/components/modals/__tests__/PendingDeviceModal.test.tsx @@ -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 { + 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( + + ) + expect(container.querySelector('[role="dialog"]')).toBeNull() + }) + + it('renders dialog when device is provided', () => { + render( + + ) + expect(screen.getByRole('dialog')).toBeDefined() + }) + + // ── Device info display ─────────────────────────────────────────────────── + + it('shows hostname as title when available', () => { + render( + + ) + // 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( + + ) + // 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( + + ) + expect(screen.getByText('192.168.1.100')).toBeDefined() + }) + + it('shows MAC address when present', () => { + render( + + ) + expect(screen.getByText('aa:bb:cc:dd:ee:ff')).toBeDefined() + }) + + it('shows OS when present', () => { + render( + + ) + expect(screen.getByText('Linux')).toBeDefined() + }) + + it('does not show hostname row when hostname is null', () => { + render( + + ) + // "Hostname" label should not appear in the info rows + expect(screen.queryByText('Hostname')).toBeNull() + }) + + it('shows suggested type when present', () => { + render( + + ) + expect(screen.getByText('proxmox')).toBeDefined() + }) + + // ── Services ────────────────────────────────────────────────────────────── + + it('shows "No services detected" when services list is empty', () => { + render( + + ) + 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( + + ) + 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( + + ) + 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( + + ) + 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( + + ) + 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( + + ) + fireEvent.click(screen.getByRole('button', { name: 'Delete' })) + expect(onIgnore).toHaveBeenCalledWith(device) + expect(onClose).toHaveBeenCalledOnce() + }) +}) diff --git a/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx b/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx new file mode 100644 index 0000000..5eaca0c --- /dev/null +++ b/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx @@ -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() + expect(container.querySelector('[role="dialog"]')).toBeNull() + }) + + it('loads config from API on open', async () => { + render() + 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() + const input = await screen.findByDisplayValue('120') + expect(input).toBeDefined() + }) + + it('adds a new empty range on "Add range" click', async () => { + render() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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'] }) + ) + }) + }) +}) diff --git a/frontend/src/components/modals/__tests__/SearchModal.test.tsx b/frontend/src/components/modals/__tests__/SearchModal.test.tsx new file mode 100644 index 0000000..1897db6 --- /dev/null +++ b/frontend/src/components/modals/__tests__/SearchModal.test.tsx @@ -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 = {}): Node { + 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() + expect(screen.queryByPlaceholderText(/search nodes/i)).toBeNull() + }) + + it('renders search input when open', () => { + render() + expect(screen.getByPlaceholderText(/search nodes/i)).toBeDefined() + }) + + it('shows "Type to search" hint when query is empty', () => { + render() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + fireEvent.keyDown(screen.getByPlaceholderText(/search nodes/i), { key: 'Escape' }) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('closes when clicking backdrop', () => { + const onClose = vi.fn() + render() + // 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() + 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() + fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'MY NAS' } }) + expect(screen.getByText('My NAS')).toBeDefined() + }) +}) diff --git a/frontend/src/components/modals/__tests__/ThemeModal.test.tsx b/frontend/src/components/modals/__tests__/ThemeModal.test.tsx new file mode 100644 index 0000000..1e5b9c3 --- /dev/null +++ b/frontend/src/components/modals/__tests__/ThemeModal.test.tsx @@ -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() + expect(container.querySelector('[role="dialog"]')).toBeNull() + }) + + it('renders all available themes', () => { + render() + // 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() + expect(screen.getByRole('button', { name: 'Apply Style' })).toBeDefined() + }) + + it('shows Cancel button', () => { + render() + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDefined() + }) + + it('live-previews theme when a card is clicked', () => { + render() + 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() + // 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() + 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() + // 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() + 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) + }) +}) diff --git a/frontend/src/stores/__tests__/canvasStore.test.ts b/frontend/src/stores/__tests__/canvasStore.test.ts index 6c508bb..f824ed7 100644 --- a/frontend/src/stores/__tests__/canvasStore.test.ts +++ b/frontend/src/stores/__tests__/canvasStore.test.ts @@ -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 = { ...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 = { ...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() + }) }) diff --git a/frontend/src/utils/__tests__/canvasSerializer.test.ts b/frontend/src/utils/__tests__/canvasSerializer.test.ts new file mode 100644 index 0000000..42b0bac --- /dev/null +++ b/frontend/src/utils/__tests__/canvasSerializer.test.ts @@ -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 { + 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 { + return { + id: 'n1', + type: 'server', + label: 'My Server', + pos_x: 100, + pos_y: 200, + status: 'online', + services: [], + ...overrides, + } +} + +function makeRfEdge(overrides: Partial> = {}): Edge { + return { + id: 'e1', + source: 'n1', + target: 'n2', + type: 'ethernet', + data: { type: 'ethernet' }, + ...overrides, + } +} + +function makeApiEdge(overrides: Partial = {}): 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).width).toBe(400) + expect((result.custom_colors as Record).height).toBe(250) + }) + + it('falls back to measured dimensions over explicit width/height', () => { + const node: Node = { + ...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).width).toBe(420) + expect((result.custom_colors as Record).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).width).toBe(360) + expect((result.custom_colors as Record).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 + 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() + + 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() + + 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() + + 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) + }) +}) diff --git a/frontend/src/utils/__tests__/standaloneStorage.test.ts b/frontend/src/utils/__tests__/standaloneStorage.test.ts new file mode 100644 index 0000000..f1ad38b --- /dev/null +++ b/frontend/src/utils/__tests__/standaloneStorage.test.ts @@ -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[], edges: Edge[], 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[]; edges: Edge[]; theme_id: string } +} + +function makeNode(id: string, overrides: Partial> = {}): Node { + 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[] = [{ + 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 }) + }) +}) diff --git a/frontend/src/utils/canvasSerializer.ts b/frontend/src/utils/canvasSerializer.ts new file mode 100644 index 0000000..84b7029 --- /dev/null +++ b/frontend/src/utils/canvasSerializer.ts @@ -0,0 +1,172 @@ +import type { Node, Edge } from '@xyflow/react' +import type { NodeData, EdgeData } from '@/types' + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface ApiNode extends Record { + 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 | 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): Record { + 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): Record { + 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, +): Node { + 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 { + 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, + } +} diff --git a/scripts/install-proxmox.sh b/scripts/install-proxmox.sh index ce16fa8..34a8c0b 100755 --- a/scripts/install-proxmox.sh +++ b/scripts/install-proxmox.sh @@ -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..." diff --git a/scripts/lxc-install.sh b/scripts/lxc-install.sh index 3798939..1eed19a 100755 --- a/scripts/lxc-install.sh +++ b/scripts/lxc-install.sh @@ -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