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:
+24
-1
@@ -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=<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 el = canvasRef.current?.querySelector<HTMLElement>('.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}
|
||||
/>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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=<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) => {
|
||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||
const proxmoxMap = new Map<string, boolean>(
|
||||
|
||||
@@ -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=<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 () => {
|
||||
|
||||
@@ -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<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">
|
||||
<Table2 size={14} /> MD
|
||||
</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 (?)">
|
||||
<HelpCircle size={14} />
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user