diff --git a/backend/app/api/routes/liveview.py b/backend/app/api/routes/liveview.py index 137b2b9..79c4fec 100644 --- a/backend/app/api/routes/liveview.py +++ b/backend/app/api/routes/liveview.py @@ -2,9 +2,11 @@ import hmac from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.api.deps import get_current_user from app.core.config import settings from app.db.database import get_db from app.db.models import CanvasState, Design, Edge, Node @@ -15,6 +17,26 @@ from app.schemas.nodes import NodeResponse router = APIRouter() +class LiveViewConfigResponse(BaseModel): + """Whether live view is enabled, plus the key (admin-only) to build share links.""" + + enabled: bool + key: str | None = None + + +@router.get("/config", response_model=LiveViewConfigResponse) +async def liveview_config( + _: str = Depends(get_current_user), +) -> LiveViewConfigResponse: + """Authenticated: expose the configured live view key so the UI can build a + ready-to-use share link (e.g. /view?key=...&design=). + + Only reachable by a logged-in user — the key is never exposed publicly. + """ + key = settings.liveview_key or None + return LiveViewConfigResponse(enabled=bool(key), key=key) + + @router.get("", response_model=CanvasStateResponse) async def liveview_canvas( key: str | None = Query(default=None), diff --git a/backend/tests/test_liveview.py b/backend/tests/test_liveview.py index 835fbe6..b58213b 100644 --- a/backend/tests/test_liveview.py +++ b/backend/tests/test_liveview.py @@ -146,3 +146,84 @@ async def test_liveview_disabled_after_key_cleared(client: AsyncClient): res = await client.get("/api/v1/liveview?key=was-enabled") assert res.status_code == 403 assert res.json()["detail"] == "Live view is disabled" + + +# ── /config (authenticated) — key used to build share links ────────────────── + +@pytest.mark.asyncio +async def test_liveview_config_requires_auth(client: AsyncClient): + """The config endpoint exposes the key, so it must reject unauthenticated calls.""" + settings.liveview_key = "secret" + res = await client.get("/api/v1/liveview/config") + assert res.status_code == 401 + + +@pytest.mark.asyncio +async def test_liveview_config_returns_key_when_enabled(client: AsyncClient, auth_headers): + settings.liveview_key = "share-me" + headers = await auth_headers() + res = await client.get("/api/v1/liveview/config", headers=headers) + assert res.status_code == 200 + body = res.json() + assert body == {"enabled": True, "key": "share-me"} + + +@pytest.mark.asyncio +async def test_liveview_config_disabled_hides_key(client: AsyncClient, auth_headers): + settings.liveview_key = None + headers = await auth_headers() + res = await client.get("/api/v1/liveview/config", headers=headers) + assert res.status_code == 200 + assert res.json() == {"enabled": False, "key": None} + + +@pytest.mark.asyncio +async def test_liveview_config_empty_key_disabled(client: AsyncClient, auth_headers): + settings.liveview_key = "" + headers = await auth_headers() + res = await client.get("/api/v1/liveview/config", headers=headers) + assert res.status_code == 200 + assert res.json() == {"enabled": False, "key": None} + + +# ── design_id selects which canvas is rendered ─────────────────────────────── + +@pytest.mark.asyncio +async def test_liveview_design_id_selects_canvas(client: AsyncClient, auth_headers): + """?design_id= renders that design's canvas, not the first one.""" + settings.liveview_key = "test-key" + headers = await auth_headers() + + # Create two designs + d1 = (await client.post("/api/v1/designs", json={"name": "Network"}, headers=headers)).json() + d2 = (await client.post("/api/v1/designs", json={"name": "Electrical"}, headers=headers)).json() + + # Save a distinct node into each design + for design, node_id, label in ((d1, "n-net", "Net Node"), (d2, "n-elec", "Elec Node")): + payload = { + "nodes": [{ + "id": node_id, + "type": "server", + "label": label, + "status": "online", + "services": [], + "pos_x": 0, + "pos_y": 0, + }], + "edges": [], + "viewport": {"x": 0, "y": 0, "zoom": 1}, + "design_id": design["id"], + } + await client.post("/api/v1/canvas/save", json=payload, headers=headers) + + # Requesting d2 returns only the electrical node + res = await client.get(f"/api/v1/liveview?key=test-key&design_id={d2['id']}") + assert res.status_code == 200 + nodes = res.json()["nodes"] + assert [n["id"] for n in nodes] == ["n-elec"] + + # Requesting d1 returns only the network node + res = await client.get(f"/api/v1/liveview?key=test-key&design_id={d1['id']}") + assert res.status_code == 200 + nodes = res.json()["nodes"] + assert [n["id"] for n in nodes] == ["n-net"] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7e4d800..1d13d6e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -31,7 +31,7 @@ import { useCanvasStore } from '@/stores/canvasStore' import { useDesignStore } from '@/stores/designStore' import { useAuthStore } from '@/stores/authStore' import { useThemeStore } from '@/stores/themeStore' -import { canvasApi, designsApi } from '@/api/client' +import { canvasApi, designsApi, liveviewApi } from '@/api/client' import { demoNodes, demoEdges } from '@/utils/demoData' import { useStatusPolling } from '@/hooks/useStatusPolling' import type { NodeData, EdgeData, CustomStyleDef } from '@/types' @@ -457,6 +457,28 @@ export default function App() { } }, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved]) + // Open the read-only live view of the currently active design in a new tab. + // Standalone has no backend/key — it reads localStorage, so just open /view. + // Otherwise fetch the configured live view key and build /view?key=...&design=. + const handleViewOnly = useCallback(async () => { + if (STANDALONE) { + window.open('/view', '_blank', 'noopener,noreferrer') + return + } + try { + const res = await liveviewApi.getConfig() + if (!res.data.enabled || !res.data.key) { + toast.error('Live view is disabled — set LIVEVIEW_KEY in the backend .env') + return + } + const params = new URLSearchParams({ key: res.data.key }) + if (activeDesignId) params.set('design', activeDesignId) + window.open(`/view?${params.toString()}`, '_blank', 'noopener,noreferrer') + } catch { + toast.error('Failed to open live view') + } + }, [activeDesignId]) + const handleExport = useCallback(() => { const el = canvasRef.current?.querySelector('.react-flow') if (!el) { toast.error('Canvas not ready'); return } @@ -601,6 +623,7 @@ export default function App() { onExportMd={handleExportMd} onExportYaml={handleExportYaml} onImportYaml={handleImportYaml} + onViewOnly={handleViewOnly} />
diff --git a/frontend/src/api/__tests__/client.test.ts b/frontend/src/api/__tests__/client.test.ts index fa8f2ff..91af73b 100644 --- a/frontend/src/api/__tests__/client.test.ts +++ b/frontend/src/api/__tests__/client.test.ts @@ -158,6 +158,16 @@ describe('api/client', () => { expect(api.get).not.toHaveBeenCalled() }) + it('liveviewApi.load forwards design as design_id when provided', () => { + mod.liveviewApi.load('k-1', 'design-9') + expect(publicApi.get).toHaveBeenCalledWith('/liveview', { params: { key: 'k-1', design_id: 'design-9' } }) + }) + + it('liveviewApi.getConfig hits the authenticated config endpoint', () => { + mod.liveviewApi.getConfig() + expect(api.get).toHaveBeenCalledWith('/liveview/config') + }) + it('scanApi endpoints route correctly', () => { mod.scanApi.trigger() expect(api.post).toHaveBeenCalledWith('/scan/trigger') diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index fb8eb34..a648711 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -53,7 +53,9 @@ export const edgesApi = { } export const liveviewApi = { - load: (key: string) => publicApi.get('/liveview', { params: { key } }), + load: (key: string, design?: string) => + publicApi.get('/liveview', { params: { key, ...(design ? { design_id: design } : {}) } }), + getConfig: () => api.get<{ enabled: boolean; key: string | null }>('/liveview/config'), } export const scanApi = { diff --git a/frontend/src/components/LiveView.tsx b/frontend/src/components/LiveView.tsx index e366a61..5533725 100644 --- a/frontend/src/components/LiveView.tsx +++ b/frontend/src/components/LiveView.tsx @@ -68,10 +68,14 @@ function LiveViewCanvas() { } // Already handled synchronously in useState initializer - const key = new URLSearchParams(window.location.search).get('key') + const search = new URLSearchParams(window.location.search) + const key = search.get('key') if (!key) return + // Optional ?design= selects which canvas to render; backend falls back + // to the first design when omitted. + const design = search.get('design') ?? undefined - liveviewApi.load(key) + liveviewApi.load(key, design) .then((res) => { const { nodes: apiNodes, edges: apiEdges } = res.data const proxmoxMap = new Map( diff --git a/frontend/src/components/__tests__/LiveView.test.tsx b/frontend/src/components/__tests__/LiveView.test.tsx index c77ac33..8a8bd4f 100644 --- a/frontend/src/components/__tests__/LiveView.test.tsx +++ b/frontend/src/components/__tests__/LiveView.test.tsx @@ -118,7 +118,15 @@ describe('LiveView (non-standalone)', () => { await waitFor(() => { expect(screen.getByTestId('react-flow')).toBeDefined() }) - expect(liveviewApi.load).toHaveBeenCalledWith('correct-key') + expect(liveviewApi.load).toHaveBeenCalledWith('correct-key', undefined) + }) + + it('forwards ?design= to the API so a specific canvas is loaded', async () => { + setSearch('?key=correct-key&design=elec-123') + vi.mocked(liveviewApi.load).mockResolvedValue(canvasPayload as never) + render() + await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined()) + expect(liveviewApi.load).toHaveBeenCalledWith('correct-key', 'elec-123') }) it('allows zooming out to 0.25 so large infra fits (matches the editor)', async () => { diff --git a/frontend/src/components/panels/Toolbar.tsx b/frontend/src/components/panels/Toolbar.tsx index 1042043..a72567a 100644 --- a/frontend/src/components/panels/Toolbar.tsx +++ b/frontend/src/components/panels/Toolbar.tsx @@ -1,5 +1,5 @@ import { useRef } from 'react' -import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Table2, FileDown, Upload } from 'lucide-react' +import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Table2, FileDown, Upload, Eye } from 'lucide-react' import { Button } from '@/components/ui/button' import { Logo } from '@/components/ui/Logo' import { useCanvasStore } from '@/stores/canvasStore' @@ -15,9 +15,10 @@ interface ToolbarProps { onExportMd: () => void onExportYaml: () => void onImportYaml: (content: string) => void + onViewOnly: () => void } -export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd, onExportYaml, onImportYaml }: ToolbarProps) { +export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd, onExportYaml, onImportYaml, onViewOnly }: ToolbarProps) { const { hasUnsavedChanges, past, future } = useCanvasStore() const fileInputRef = useRef(null) @@ -81,6 +82,9 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, +