From 210304394e63a79ae02d97178fc31ea90a6b09cf Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sat, 28 Mar 2026 15:27:54 +0100 Subject: [PATCH] feat: read-only live view at /view?key= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements issue #5. Off by default; set LIVEVIEW_KEY in .env to enable. No JWT required — key-based auth via ?key= query param. Returns 403 when disabled or key is wrong. Read-only ReactFlow canvas (pan/zoom, no editing). Standalone mode loads from localStorage without a key. Includes 8 backend tests and 9 frontend tests. --- .env.example | 5 + backend/app/api/routes/liveview.py | 40 ++++ backend/app/core/config.py | 5 + backend/app/main.py | 3 +- backend/tests/test_liveview.py | 126 ++++++++++++ frontend/src/api/client.ts | 7 + frontend/src/components/LiveView.tsx | 153 +++++++++++++++ .../components/__tests__/LiveView.test.tsx | 185 ++++++++++++++++++ frontend/src/main.tsx | 5 +- 9 files changed, 527 insertions(+), 2 deletions(-) create mode 100644 backend/app/api/routes/liveview.py create mode 100644 backend/tests/test_liveview.py create mode 100644 frontend/src/components/LiveView.tsx create mode 100644 frontend/src/components/__tests__/LiveView.test.tsx diff --git a/.env.example b/.env.example index 11a238b..f9affad 100644 --- a/.env.example +++ b/.env.example @@ -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= +# Off by default. Set to a random secret to enable. +# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))" +# LIVEVIEW_KEY= diff --git a/backend/app/api/routes/liveview.py b/backend/app/api/routes/liveview.py new file mode 100644 index 0000000..ce6492f --- /dev/null +++ b/backend/app/api/routes/liveview.py @@ -0,0 +1,40 @@ +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 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, + ) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 80d51a2..7d6a1c6 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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=. + # 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" diff --git a/backend/app/main.py b/backend/app/main.py index 9767ec6..9cd067d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 @@ -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") diff --git a/backend/tests/test_liveview.py b/backend/tests/test_liveview.py new file mode 100644 index 0000000..bf27b85 --- /dev/null +++ b/backend/tests/test_liveview.py @@ -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" diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e8d1145..bf4f566 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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'), diff --git a/frontend/src/components/LiveView.tsx b/frontend/src/components/LiveView.tsx new file mode 100644 index 0000000..ac0afa8 --- /dev/null +++ b/frontend/src/components/LiveView.tsx @@ -0,0 +1,153 @@ +/** + * LiveView — read-only canvas accessible at /view?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:// 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' | '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(() => { + 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( + (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) => { + const detail: string = err.response?.data?.detail ?? '' + setViewState(detail === 'Live view is disabled' ? 'disabled' : 'invalid-key') + }) + }, [loadCanvas]) + + const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => { + const ip = node.data.ip + if (ip) window.open(`http://${ip}`, '_blank', 'noopener,noreferrer') + }, []) + + if (viewState === 'loading') { + return ( +
+ Loading… +
+ ) + } + + if (viewState !== 'ready') { + const messages: Record, 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.', + } + return ( +
+
+

Access Denied

+

{messages[viewState]}

+
+
+ ) + } + + return ( +
+ + + + +
+ ) +} + +export default function LiveView() { + return ( + + + + ) +} diff --git a/frontend/src/components/__tests__/LiveView.test.tsx b/frontend/src/components/__tests__/LiveView.test.tsx new file mode 100644 index 0000000..0b63af7 --- /dev/null +++ b/frontend/src/components/__tests__/LiveView.test.tsx @@ -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: () =>
, + 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() + 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() + 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() + await waitFor(() => { + expect(screen.getByText(/Invalid or expired/)).toBeDefined() + }) + }) + + it('shows invalid-key error for unexpected API errors', async () => { + setSearch('?key=anything') + vi.mocked(liveviewApi.load).mockRejectedValue(new Error('network')) + render() + await waitFor(() => { + expect(screen.getByText(/Invalid or expired/)).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() + 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() + 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() + 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() + + 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() + + await waitFor(() => { + expect(screen.getByTestId('react-flow')).toBeDefined() + }) + expect(liveviewApi.load).not.toHaveBeenCalled() + + vi.unstubAllEnvs() + }) +}) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202..ad53488 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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( - + {isLiveView ? : } , )