fix: repair live view + settings in standalone multi-canvas mode

Two gaps surfaced after adding per-design localStorage storage:

- Live view (/view) still read the legacy bare `homelable_canvas` key, which
  no longer exists once canvases are keyed per design — the read-only tab
  rendered empty. Now passes the active design id (?design=<id>) and reads that
  design's canvas, falling back to the first design.
- The Settings modal was gated out entirely in standalone, leaving the Settings
  button dead even though canvas prefs (snap, hide-IP) work without a backend.
  Mount it in standalone; only the backend status-check section stays hidden.

ha-relevant: no
This commit is contained in:
Pouzor
2026-06-30 00:05:13 +02:00
parent ca171089c2
commit c356a65a5f
3 changed files with 58 additions and 18 deletions
+7 -4
View File
@@ -498,7 +498,10 @@ export default function App() {
// Otherwise fetch the configured live view key and build /view?key=...&design=<id>. // Otherwise fetch the configured live view key and build /view?key=...&design=<id>.
const handleViewOnly = useCallback(async () => { const handleViewOnly = useCallback(async () => {
if (STANDALONE) { if (STANDALONE) {
window.open('/view', '_blank', 'noopener,noreferrer') // Standalone reads canvas from localStorage; pass the active design id so
// the read-only tab renders the same canvas the user is viewing.
const url = activeDesignId ? `/view?design=${encodeURIComponent(activeDesignId)}` : '/view'
window.open(url, '_blank', 'noopener,noreferrer')
return return
} }
try { try {
@@ -933,9 +936,9 @@ export default function App() {
onCancel={() => setPendingContainerAdd(null)} onCancel={() => setPendingContainerAdd(null)}
/> />
{!STANDALONE && ( {/* Mounted in standalone too: status-check settings are hidden inside,
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} /> but canvas prefs (snap, hide-IP) still apply. */}
)} <SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
<PendingDevicesModal <PendingDevicesModal
open={pendingModalOpen} open={pendingModalOpen}
+11 -9
View File
@@ -30,10 +30,10 @@ import { edgeTypes } from '@/components/canvas/edges/edgeTypes'
import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer' import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
import { computeCollapseInfo, rewireEdgesForCollapse } from '@/utils/collapseFilter' import { computeCollapseInfo, rewireEdgesForCollapse } from '@/utils/collapseFilter'
import { liveviewApi } from '@/api/client' import { liveviewApi } from '@/api/client'
import * as standaloneStorage from '@/utils/standaloneStorage'
import type { NodeData, CustomStyleDef } from '@/types' import type { NodeData, CustomStyleDef } from '@/types'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const STORAGE_KEY = 'homelable_canvas'
type ViewState = 'loading' | 'disabled' | 'invalid-key' | 'no-key' | 'network-error' | 'ready' type ViewState = 'loading' | 'disabled' | 'invalid-key' | 'no-key' | 'network-error' | 'ready'
@@ -55,14 +55,16 @@ function LiveViewCanvas() {
useEffect(() => { useEffect(() => {
if (STANDALONE) { if (STANDALONE) {
try { // ?design=<id> selects which canvas to render; fall back to the first
const saved = localStorage.getItem(STORAGE_KEY) // design when omitted. Standalone stores full React Flow nodes/edges, so
if (saved) { // no API deserialization is needed.
const { nodes: savedNodes, edges: savedEdges } = JSON.parse(saved) const designId = new URLSearchParams(window.location.search).get('design')
loadCanvas(savedNodes, savedEdges) ?? standaloneStorage.listDesigns()[0]?.id
} const saved = designId ? standaloneStorage.loadCanvas(designId) : null
} catch { if (saved) {
// empty canvas on parse error — show empty canvas if (saved.theme_id) setTheme(saved.theme_id)
if (saved.custom_style) setCustomStyle(saved.custom_style)
loadCanvas(saved.nodes, saved.edges)
} }
return return
} }
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react' import { render, screen, waitFor } from '@testing-library/react'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { useThemeStore } from '@/stores/themeStore' import { useThemeStore } from '@/stores/themeStore'
import * as standaloneStorage from '@/utils/standaloneStorage'
// ── Mock heavy dependencies ──────────────────────────────────────────────── // ── Mock heavy dependencies ────────────────────────────────────────────────
@@ -213,9 +214,13 @@ describe('LiveView (non-standalone)', () => {
// ── Standalone mode ──────────────────────────────────────────────────────── // ── Standalone mode ────────────────────────────────────────────────────────
// Captures props from the re-imported (resetModules) ReactFlow so standalone
// tests can assert which nodes were rendered without reaching into the fresh
// canvas-store module instance.
let standaloneRfProps: Record<string, unknown> = {}
const XYFLOW_MOCK = { const XYFLOW_MOCK = {
ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>, ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
ReactFlow: () => <div data-testid="react-flow" />, ReactFlow: (props: Record<string, unknown>) => { standaloneRfProps = props; return <div data-testid="react-flow" /> },
Background: () => null, Background: () => null,
Controls: () => null, Controls: () => null,
BackgroundVariant: { Dots: 'dots' }, BackgroundVariant: { Dots: 'dots' },
@@ -235,16 +240,45 @@ describe('LiveView (standalone — localStorage)', () => {
vi.unstubAllEnvs() vi.unstubAllEnvs()
}) })
it('loads canvas from localStorage without calling the API', async () => { it('loads the active design canvas from localStorage without calling the API', async () => {
const stored = { const design = standaloneStorage.createDesign('Main')
standaloneStorage.saveCanvas(design.id, {
nodes: [{ nodes: [{
id: 'ls-node', type: 'router', id: 'ls-node', type: 'router',
position: { x: 10, y: 20 }, position: { x: 10, y: 20 },
data: { label: 'Router', type: 'router', status: 'unknown', services: [] }, data: { label: 'Router', type: 'router', status: 'unknown', services: [] },
}], }],
edges: [], edges: [],
} })
localStorage.setItem('homelable_canvas', JSON.stringify(stored))
vi.stubEnv('VITE_STANDALONE', 'true')
vi.resetModules()
const mockLoad = vi.fn()
vi.doMock('@xyflow/react', () => XYFLOW_MOCK)
vi.doMock('@xyflow/react/dist/style.css', () => ({}))
vi.doMock('@/api/client', () => ({ liveviewApi: { load: mockLoad } }))
const { default: LiveViewStandalone } = await import('../LiveView')
setSearch(`?design=${design.id}`)
render(<LiveViewStandalone />)
await waitFor(() => {
expect(screen.getByTestId('react-flow')).toBeDefined()
})
expect((standaloneRfProps.nodes as { id: string }[]).map((n) => n.id)).toContain('ls-node')
expect(mockLoad).not.toHaveBeenCalled()
})
it('falls back to the first design when no ?design= param is given', async () => {
const design = standaloneStorage.createDesign('Only')
standaloneStorage.saveCanvas(design.id, {
nodes: [{
id: 'fb-node', type: 'server',
position: { x: 0, y: 0 },
data: { label: 'Srv', type: 'server', status: 'unknown', services: [] },
}],
edges: [],
})
vi.stubEnv('VITE_STANDALONE', 'true') vi.stubEnv('VITE_STANDALONE', 'true')
vi.resetModules() vi.resetModules()
@@ -260,6 +294,7 @@ describe('LiveView (standalone — localStorage)', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('react-flow')).toBeDefined() expect(screen.getByTestId('react-flow')).toBeDefined()
}) })
expect((standaloneRfProps.nodes as { id: string }[]).map((n) => n.id)).toContain('fb-node')
expect(mockLoad).not.toHaveBeenCalled() expect(mockLoad).not.toHaveBeenCalled()
}) })