feat: read-only live view at /view?key=<LIVEVIEW_KEY>
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.
This commit is contained in:
@@ -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'),
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* LiveView — read-only canvas accessible at /view?key=<LIVEVIEW_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://<ip> 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<ViewState>(() => {
|
||||
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<string, boolean>(
|
||||
(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<NodeData>) => {
|
||||
const ip = node.data.ip
|
||||
if (ip) window.open(`http://${ip}`, '_blank', 'noopener,noreferrer')
|
||||
}, [])
|
||||
|
||||
if (viewState === 'loading') {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-[#0d1117] text-[#8b949e]">
|
||||
Loading…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (viewState !== 'ready') {
|
||||
const messages: Record<Exclude<ViewState, 'loading' | 'ready'>, 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 (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-[#0d1117]">
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-[#f85149] text-lg font-medium">Access Denied</p>
|
||||
<p className="text-[#8b949e] text-sm">{messages[viewState]}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-screen" style={{ background: theme.colors.canvasBackground }}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
panOnDrag
|
||||
zoomOnScroll
|
||||
fitView
|
||||
colorMode={theme.colors.reactFlowColorMode}
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
onNodeClick={onNodeClick}
|
||||
>
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={24}
|
||||
size={1}
|
||||
color={theme.colors.canvasDotColor}
|
||||
/>
|
||||
<Controls showInteractive={false} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LiveView() {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<LiveViewCanvas />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
@@ -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: () => <div data-testid="react-flow" />,
|
||||
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(<LiveView />)
|
||||
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(<LiveView />)
|
||||
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(<LiveView />)
|
||||
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(<LiveView />)
|
||||
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(<LiveView />)
|
||||
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(<LiveView />)
|
||||
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(<LiveView />)
|
||||
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(<LiveViewStandalone />)
|
||||
|
||||
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(<LiveViewStandalone />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('react-flow')).toBeDefined()
|
||||
})
|
||||
expect(liveviewApi.load).not.toHaveBeenCalled()
|
||||
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
{isLiveView ? <LiveView /> : <App />}
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user