feat(liveview): header View link opens active design as read-only canvas
Add an authenticated GET /api/v1/liveview/config endpoint exposing the configured LIVEVIEW_KEY to logged-in admins, so the UI builds a ready-to-use share link. The header View button opens /view?key=...&design=<activeDesignId>; LiveView forwards ?design=<id> to the public endpoint, which renders that design's canvas. ha-relevant: maybe
This commit is contained in:
@@ -2,9 +2,11 @@ import hmac
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.db.models import CanvasState, Design, Edge, Node
|
from app.db.models import CanvasState, Design, Edge, Node
|
||||||
@@ -15,6 +17,26 @@ from app.schemas.nodes import NodeResponse
|
|||||||
router = APIRouter()
|
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=<id>).
|
||||||
|
|
||||||
|
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)
|
@router.get("", response_model=CanvasStateResponse)
|
||||||
async def liveview_canvas(
|
async def liveview_canvas(
|
||||||
key: str | None = Query(default=None),
|
key: str | None = Query(default=None),
|
||||||
|
|||||||
@@ -146,3 +146,84 @@ async def test_liveview_disabled_after_key_cleared(client: AsyncClient):
|
|||||||
res = await client.get("/api/v1/liveview?key=was-enabled")
|
res = await client.get("/api/v1/liveview?key=was-enabled")
|
||||||
assert res.status_code == 403
|
assert res.status_code == 403
|
||||||
assert res.json()["detail"] == "Live view is disabled"
|
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=<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"]
|
||||||
|
|||||||
+24
-1
@@ -31,7 +31,7 @@ import { useCanvasStore } from '@/stores/canvasStore'
|
|||||||
import { useDesignStore } from '@/stores/designStore'
|
import { useDesignStore } from '@/stores/designStore'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
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 { demoNodes, demoEdges } from '@/utils/demoData'
|
||||||
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
||||||
import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
|
import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
|
||||||
@@ -457,6 +457,28 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved])
|
}, [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=<id>.
|
||||||
|
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 handleExport = useCallback(() => {
|
||||||
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
|
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
|
||||||
if (!el) { toast.error('Canvas not ready'); return }
|
if (!el) { toast.error('Canvas not ready'); return }
|
||||||
@@ -601,6 +623,7 @@ export default function App() {
|
|||||||
onExportMd={handleExportMd}
|
onExportMd={handleExportMd}
|
||||||
onExportYaml={handleExportYaml}
|
onExportYaml={handleExportYaml}
|
||||||
onImportYaml={handleImportYaml}
|
onImportYaml={handleImportYaml}
|
||||||
|
onViewOnly={handleViewOnly}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-1 min-h-0">
|
<div className="flex flex-1 min-h-0">
|
||||||
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
||||||
|
|||||||
@@ -158,6 +158,16 @@ describe('api/client', () => {
|
|||||||
expect(api.get).not.toHaveBeenCalled()
|
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', () => {
|
it('scanApi endpoints route correctly', () => {
|
||||||
mod.scanApi.trigger()
|
mod.scanApi.trigger()
|
||||||
expect(api.post).toHaveBeenCalledWith('/scan/trigger')
|
expect(api.post).toHaveBeenCalledWith('/scan/trigger')
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ export const edgesApi = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const liveviewApi = {
|
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 = {
|
export const scanApi = {
|
||||||
|
|||||||
@@ -68,10 +68,14 @@ function LiveViewCanvas() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Already handled synchronously in useState initializer
|
// 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
|
if (!key) return
|
||||||
|
// Optional ?design=<id> 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) => {
|
.then((res) => {
|
||||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||||
const proxmoxMap = new Map<string, boolean>(
|
const proxmoxMap = new Map<string, boolean>(
|
||||||
|
|||||||
@@ -118,7 +118,15 @@ describe('LiveView (non-standalone)', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('react-flow')).toBeDefined()
|
expect(screen.getByTestId('react-flow')).toBeDefined()
|
||||||
})
|
})
|
||||||
expect(liveviewApi.load).toHaveBeenCalledWith('correct-key')
|
expect(liveviewApi.load).toHaveBeenCalledWith('correct-key', undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('forwards ?design=<id> 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(<LiveView />)
|
||||||
|
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 () => {
|
it('allows zooming out to 0.25 so large infra fits (matches the editor)', async () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useRef } from 'react'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Logo } from '@/components/ui/Logo'
|
import { Logo } from '@/components/ui/Logo'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
@@ -15,9 +15,10 @@ interface ToolbarProps {
|
|||||||
onExportMd: () => void
|
onExportMd: () => void
|
||||||
onExportYaml: () => void
|
onExportYaml: () => void
|
||||||
onImportYaml: (content: string) => 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 { hasUnsavedChanges, past, future } = useCanvasStore()
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
@@ -81,6 +82,9 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
|||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExportMd} title="Copy inventory as Markdown table">
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExportMd} title="Copy inventory as Markdown table">
|
||||||
<Table2 size={14} /> MD
|
<Table2 size={14} /> MD
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onViewOnly} title="Open read-only live view of this canvas">
|
||||||
|
<Eye size={14} /> View
|
||||||
|
</Button>
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onShortcuts} title="Keyboard shortcuts (?)">
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onShortcuts} title="Keyboard shortcuts (?)">
|
||||||
<HelpCircle size={14} />
|
<HelpCircle size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user