Compare commits

..

13 Commits

Author SHA1 Message Date
Rémy 332b6fb4f3 Merge pull request #246 from Pouzor/release/2.6.1
Release 2.6.1: Z-Wave manual node type + standalone multi-canvas
2026-06-30 14:45:53 +02:00
Rémy a8d365c1ee Merge pull request #245 from Pouzor/feat/standalone-multi-canvas
build: typecheck with tsc -b to match the production build
2026-06-30 14:45:36 +02:00
Pouzor 6e21218f05 chore: bump version to 2.6.1 2026-06-30 14:39:06 +02:00
Pouzor fb33e5cb16 feat: add Z-Wave node types to add/edit node modal
Z-Wave (controller/router/end device) types existed in the model, theme
accents, default icons and the custom-style editor, but were missing from
the node Add/Edit type selector, so they could not be placed manually like
Zigbee nodes. Add a Z-Wave group and default mesh-radio nodes to no status
check (not IP-reachable), matching Zigbee behaviour.
2026-06-30 14:38:02 +02:00
Pouzor 52de302f2b build: typecheck with tsc -b to match the production build
The `typecheck` script ran `tsc --noEmit` against the root tsconfig, which has
`files: []` and only project references — so without `-b` it checked nothing in
src and let real type errors (e.g. theme_id) reach Docker CI. Switch to `tsc -b`
(same as the build) so pre-commit and the Quality job catch them locally.

ha-relevant: no
2026-06-30 09:49:38 +02:00
Rémy f2162a663a Merge pull request #244 from Pouzor/feat/standalone-multi-canvas
feat: multi-canvas (designs) support in frontend-only standalone mode
2026-06-30 01:10:52 +02:00
Pouzor 1f2d298419 fix: type standalone canvas theme_id as ThemeId
The Docker CI build (tsc -b, stricter than the local tsc --noEmit) rejected
passing the storage theme_id (string) to setTheme/setCustomStyle which expect
ThemeId. Type StandaloneCanvas.theme_id as ThemeId so the standalone load path
matches the theme store API.

ha-relevant: no
2026-06-30 00:58:42 +02:00
Pouzor d1c052c9e8 feat: hide header View link in standalone mode
The "View" link opens the read-only live view of the canvas. In frontend-only
standalone mode the editor already renders the only (localStorage) copy, so the
live view adds nothing — hide the link. Kept in full mode.

ha-relevant: no
2026-06-30 00:48:56 +02:00
Pouzor 79cac7d80f feat: hide node stats footer in standalone mode
The Total/Online/Offline counts reflect live status-check results, which
require a backend scanner. In frontend-only standalone mode there is nothing to
populate them, so hide the footer (kept in full mode and live view).

ha-relevant: no
2026-06-30 00:33:42 +02:00
Pouzor c356a65a5f 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
2026-06-30 00:05:13 +02:00
Pouzor ca171089c2 feat: support multi-canvas (designs) in frontend-only standalone mode
Standalone mode (VITE_STANDALONE=true) skipped the designs system entirely:
the design list stayed empty (switcher hidden) and all canvases collapsed onto
a single localStorage key. Add a localStorage-backed design layer mirroring the
backend designs API.

- standaloneStorage util: list/create/update/delete designs + per-design canvas
  storage (homelable_designs + homelable_canvas:<id>). ensureSeed migrates a
  legacy single-canvas install into a default design so existing data survives.
- App.tsx: load/seed designs, save + switch canvases per design id in standalone.
- Sidebar.tsx: create/update/delete dispatch to standaloneStorage when standalone.
- Tests for the new storage util.

ha-relevant: no
2026-06-29 23:13:56 +02:00
Rémy e153b42b2e Merge pull request #241 from Pouzor/docs/zwave-import
docs: add Z-Wave import documentation
2026-06-29 02:10:46 +02:00
Pouzor 20dc9069d7 docs: add Z-Wave import section to README and full guide 2026-06-29 02:08:06 +02:00
16 changed files with 691 additions and 61 deletions
+31
View File
@@ -123,6 +123,37 @@ LQI (Link Quality Indicator) is stored as a node property.
---
## Z-Wave Import
Homelable can also import your **Z-Wave** network from **Z-Wave JS UI** (formerly `zwavejs2mqtt`) over the same MQTT broker, dropping each node on the canvas as a typed node.
### Prerequisites
- A running **MQTT broker** (e.g. Mosquitto) accessible from the Homelable host
- **Z-Wave JS UI** connected to the broker with its MQTT gateway enabled and at least one node included
### Usage
1. Click **Z-Wave Import** in the left sidebar (below "Zigbee Import")
2. Enter your broker host, port (default `1883`), optional credentials, MQTT prefix (default `zwave`), and gateway name (default `zwavejs2mqtt`)
3. Click **Test Connection** to verify reachability
4. Choose a target — **Pending section** or **Canvas directly** — then **Import to Pending** / **Fetch Devices**
5. Select the devices you want from the grouped list (Controller / Router / End Device) and click **Add N to Canvas**
### Node Types
| Type | Z-Wave Role | Icon |
|------|-------------|------|
| `zwave_coordinator` | Controller | Network hub |
| `zwave_router` | Routing (mains-powered) node | Radio |
| `zwave_enddevice` | End Device (battery) | Antenna |
Hierarchy is set automatically: controller → routers → end devices (`parent_id`), derived from each node's neighbor list. Z-Wave has no LQI, so that property is omitted.
> **Full documentation:** [docs/zwave-import.md](./docs/zwave-import.md)
---
## Live View (read-only public canvas)
Live View lets you share a read-only snapshot of your canvas with anyone on your network — no login required. It is disabled by default.
+1 -1
View File
@@ -1 +1 @@
2.6.0
2.6.1
+161
View File
@@ -0,0 +1,161 @@
# Z-Wave (Z-Wave JS UI) Network Map Importer
This feature lets you connect Homelable to your MQTT broker, fetch the Z-Wave node list from **Z-Wave JS UI** (formerly `zwavejs2mqtt`), and drop all Z-Wave devices onto the canvas as typed nodes with proper hierarchy.
It mirrors the [Zigbee2MQTT importer](./zigbee-import.md): same MQTT request/response pattern, same node-type model, same canvas behaviour.
---
## Feature Overview
- **Automatic device discovery** — Requests the node list from the Z-Wave JS UI MQTT gateway API and parses the full device list
- **Typed nodes** — Devices are mapped to three homelable node types:
- `zwave_coordinator` — The Z-Wave controller (hub)
- `zwave_router` — Mains-powered / routing nodes
- `zwave_enddevice` — Battery-powered end devices (sensors, locks, etc.)
- **Hierarchy** — `parent_id` is set automatically: controller → routers → end devices, derived from each node's neighbor list
- **IoT edges** — Links between devices are added as `IoT / Zigbee` edge type
- **Import targets** — Send discovered devices to the **Pending** section (review before adding) or drop them on the **Canvas** directly
> **Note:** Z-Wave does not expose an LQI value the way Zigbee does, so the LQI property is omitted. (RSSI may be surfaced in a future release.)
---
## Prerequisites
1. A running **MQTT broker** (e.g. Mosquitto) accessible from your Homelable host
2. **Z-Wave JS UI** connected to the broker with the **MQTT gateway** enabled
3. The gateway must respond to `getNodes` requests on:
- **Request topic:** `<prefix>/_CLIENTS/ZWAVE_GATEWAY-<gateway>/api/getNodes/set`
- **Response topic:** `<prefix>/_CLIENTS/ZWAVE_GATEWAY-<gateway>/api/getNodes`
- The default prefix is `zwave` and the default gateway name is `zwavejs2mqtt`
---
## Step-by-step Usage
### 1. Open the Z-Wave Import dialog
Click **Z-Wave Import** in the left sidebar (below "Zigbee Import").
### 2. Configure the MQTT connection
| Field | Default | Description |
|---|---|---|
| Broker Host | — | IP or hostname of your MQTT broker |
| Port | 1883 | MQTT broker port (auto-switches to 8883 when TLS is enabled) |
| MQTT Prefix | `zwave` | Z-Wave JS UI MQTT prefix (Settings → MQTT → "Prefix") |
| Gateway Name | `zwavejs2mqtt` | Z-Wave JS UI gateway name (Settings → MQTT → "Name") |
| Username | _(optional)_ | MQTT username if authentication is enabled |
| Password | _(optional)_ | MQTT password |
| Use TLS | off | Connect over TLS (typically port 8883) |
| Skip cert verify | off | Accept self-signed certificates (TLS only) |
> The **Prefix** and **Gateway Name** together form the MQTT topic the importer talks to. They must match your Z-Wave JS UI **Settings → MQTT** configuration exactly, or the request will time out.
### 3. Test the connection (optional)
Click **Test Connection** to verify broker reachability before fetching devices.
A green indicator confirms success; red shows the error message from the broker.
### 4. Choose an import target
Pick where discovered devices should go:
- **Pending section** — Devices are queued for review in the Pending list (and tracked as a scan run in Scan History). The controller is auto-approved as a canvas node; the rest wait for you to approve, hide, or delete them.
- **Canvas directly** — Devices are fetched and shown grouped in the dialog so you can pick which ones to add immediately.
### 5. Fetch devices
Click **Import to Pending** (or **Fetch Devices** in canvas mode). Homelable will:
1. Connect to the broker
2. Subscribe to the response topic
3. Publish a `getNodes` request to the gateway request topic
4. Wait for the node-list response
5. Parse and group devices by type
### 6. Select and add to canvas
(Canvas mode) Devices are grouped by type (Controller / Router / End Device).
Use the checkboxes to select which devices to add, then click **Add N to Canvas**.
> **Tip:** All devices are selected by default. Uncheck any you don't want.
### 7. Arrange on the canvas
Devices are placed in a grid at the top-right of the canvas.
Use **Auto Layout** (toolbar) to re-arrange the full canvas, or drag nodes manually.
---
## Node Type Mapping
The importer reads each Z-Wave node's role flags from the gateway and maps them as follows:
| Z-Wave JS UI flag | homelable type | Role label |
|---|---|---|
| `isControllerNode` | `zwave_coordinator` | Controller |
| `isRouting` | `zwave_router` | Router |
| _(everything else)_ | `zwave_enddevice` | EndDevice |
Each node keeps its name (`name``loc``Node <id>` fallback), vendor (`manufacturer`), and model (`productLabel` / `productDescription`) where available.
---
## MQTT Configuration Tips
### Mosquitto without authentication
```
listener 1883
allow_anonymous true
```
### Mosquitto with password file
```
listener 1883
password_file /etc/mosquitto/passwd
```
Create a user:
```bash
mosquitto_passwd -c /etc/mosquitto/passwd <username>
```
### Z-Wave JS UI MQTT settings
In **Settings → MQTT**, make sure the gateway is enabled and note these two values — they must match the importer fields:
| Z-Wave JS UI setting | Importer field | Default |
|---|---|---|
| Name | Gateway Name | `zwavejs2mqtt` |
| Prefix | MQTT Prefix | `zwave` |
| Host / Port | Broker Host / Port | `localhost` / `1883` |
The gateway must be in **"Named topics"** mode (the default) so the `getNodes` API topic is exposed.
---
## Supported Versions
The `getNodes` MQTT gateway API is provided by **Z-Wave JS UI** (and its predecessor `zwavejs2mqtt`). Any recent release with the MQTT gateway enabled is supported.
---
## Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| "Connection refused" | Broker unreachable | Check host/port, firewall rules |
| "Timed out waiting for response" | Gateway not running, or wrong prefix/gateway name | Verify Z-Wave JS UI is connected to MQTT; match **Prefix** and **Gateway Name** to Settings → MQTT |
| 0 devices returned | No nodes included in the Z-Wave network | Include at least one device first |
| "Z-Wave gateway reported failure" | Gateway returned `success: false` | Check the Z-Wave JS UI logs |
| "Malformed getNodes response" | Gateway returned an unexpected format | Check the Z-Wave JS UI version; open an issue |
| TLS errors with a self-signed cert | Certificate not trusted | Enable **Use TLS** + **Skip cert verify** |
---
## Screenshots
_(Screenshots will be added in a future release)_
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "2.6.0",
"version": "2.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "2.6.0",
"version": "2.6.1",
"dependencies": {
"@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4",
+2 -2
View File
@@ -1,13 +1,13 @@
{
"name": "frontend",
"private": true,
"version": "2.6.0",
"version": "2.6.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"typecheck": "tsc -b",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
+50 -27
View File
@@ -38,6 +38,7 @@ import { useDesignStore } from '@/stores/designStore'
import { useAuthStore } from '@/stores/authStore'
import { useThemeStore } from '@/stores/themeStore'
import { canvasApi, designsApi, liveviewApi } from '@/api/client'
import * as standaloneStorage from '@/utils/standaloneStorage'
import { demoNodes, demoEdges } from '@/utils/demoData'
import { useStatusPolling } from '@/hooks/useStatusPolling'
import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
@@ -45,7 +46,6 @@ import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
import type { ZwaveNode, ZwaveEdge } from '@/components/zwave/types'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
export default function App() {
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer } = useCanvasStore()
@@ -90,7 +90,8 @@ export default function App() {
try {
const saveDesignId = designIdOverride ?? activeDesignId
if (STANDALONE) {
localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme, custom_style: customStyle }))
if (!saveDesignId) return false
standaloneStorage.saveCanvas(saveDesignId, { nodes, edges, theme_id: activeTheme, custom_style: customStyle })
markSaved()
toast.success('Canvas saved')
return true
@@ -135,8 +136,30 @@ export default function App() {
}
}, [loadCanvas, setTheme, setCustomStyle])
// Standalone counterpart of loadCanvasFromApi — reads a design's canvas from
// localStorage, falling back to the demo canvas when it has never been saved.
const loadStandaloneCanvas = useCallback((designId: string) => {
const saved = standaloneStorage.loadCanvas(designId)
if (saved && saved.nodes.length > 0) {
if (saved.theme_id) setTheme(saved.theme_id)
if (saved.custom_style) setCustomStyle(saved.custom_style)
loadCanvas(saved.nodes, saved.edges)
} else {
loadCanvas(demoNodes, demoEdges)
}
}, [loadCanvas, setTheme, setCustomStyle])
const loadDesignsAndCanvas = useCallback(async () => {
if (STANDALONE) return
if (STANDALONE) {
const designs = standaloneStorage.ensureSeed()
setDesigns(designs)
const targetId = activeDesignId ?? designs[0]?.id
if (targetId) {
setActiveDesign(targetId)
loadStandaloneCanvas(targetId)
}
return
}
try {
const res = await designsApi.list()
const loadedDesigns = res.data
@@ -150,29 +173,23 @@ export default function App() {
// If API fails (e.g. fresh DB with no designs), fall back to demo data
loadCanvas(demoNodes, demoEdges)
}
}, [setDesigns, setActiveDesign, loadCanvasFromApi, activeDesignId, loadCanvas])
}, [setDesigns, setActiveDesign, loadCanvasFromApi, loadStandaloneCanvas, activeDesignId, loadCanvas])
// Load canvas on auth (or immediately in standalone mode)
// Keep a ref so the auth effect can call the latest loader without listing it
// as a dependency (which would re-fire on every design switch).
const loadDesignsAndCanvasRef = useRef(loadDesignsAndCanvas)
useEffect(() => { loadDesignsAndCanvasRef.current = loadDesignsAndCanvas }, [loadDesignsAndCanvas])
// Load designs + canvas on auth (or immediately in standalone mode, which has
// no auth gate).
useEffect(() => {
if (STANDALONE) {
try {
const saved = localStorage.getItem(STANDALONE_STORAGE_KEY)
if (saved) {
const { nodes: savedNodes, edges: savedEdges, theme_id, custom_style } = JSON.parse(saved)
if (theme_id) setTheme(theme_id)
if (custom_style) setCustomStyle(custom_style)
loadCanvas(savedNodes, savedEdges)
} else {
loadCanvas(demoNodes, demoEdges)
}
} catch {
loadCanvas(demoNodes, demoEdges)
}
loadDesignsAndCanvasRef.current()
return
}
if (!isAuthenticated) return
loadDesignsAndCanvas()
}, [isAuthenticated, loadCanvas, setTheme, setCustomStyle]) // only on auth change, not design change
loadDesignsAndCanvasRef.current()
}, [isAuthenticated]) // only on auth change, not design change
// Reload canvas when active design changes (after initial load)
const initialLoadDone = useRef(false)
@@ -186,7 +203,10 @@ export default function App() {
prevDesignRef.current = activeDesignId
return
}
if (!STANDALONE && isAuthenticated && activeDesignId && initialLoadDone.current) {
// Standalone has no auth gate; backed mode requires authentication.
const ready = STANDALONE || isAuthenticated
const loadForDesign = STANDALONE ? loadStandaloneCanvas : loadCanvasFromApi
if (ready && activeDesignId && initialLoadDone.current) {
const oldId = prevDesignRef.current
// If the previous design was deleted (no longer in the list), don't try to
// save into it — just load the newly-selected design.
@@ -199,7 +219,7 @@ export default function App() {
const targetId = activeDesignId
handleSave(oldId).then((ok) => {
if (ok) {
loadCanvasFromApi(targetId)
loadForDesign(targetId)
} else {
// Save failed: don't load the new design — that would overwrite the
// unsaved in-memory canvas. Revert the selection back to the old
@@ -210,7 +230,7 @@ export default function App() {
}
})
} else {
loadCanvasFromApi(activeDesignId)
loadForDesign(activeDesignId)
}
}
if (activeDesignId) {
@@ -478,7 +498,10 @@ export default function App() {
// 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')
// 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
}
try {
@@ -913,9 +936,9 @@ export default function App() {
onCancel={() => setPendingContainerAdd(null)}
/>
{!STANDALONE && (
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
)}
{/* Mounted in standalone too: status-check settings are hidden inside,
but canvas prefs (snap, hide-IP) still apply. */}
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
<PendingDevicesModal
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 { computeCollapseInfo, rewireEdgesForCollapse } from '@/utils/collapseFilter'
import { liveviewApi } from '@/api/client'
import * as standaloneStorage from '@/utils/standaloneStorage'
import type { NodeData, CustomStyleDef } from '@/types'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const STORAGE_KEY = 'homelable_canvas'
type ViewState = 'loading' | 'disabled' | 'invalid-key' | 'no-key' | 'network-error' | 'ready'
@@ -55,14 +55,16 @@ function LiveViewCanvas() {
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
// ?design=<id> selects which canvas to render; fall back to the first
// design when omitted. Standalone stores full React Flow nodes/edges, so
// no API deserialization is needed.
const designId = new URLSearchParams(window.location.search).get('design')
?? standaloneStorage.listDesigns()[0]?.id
const saved = designId ? standaloneStorage.loadCanvas(designId) : null
if (saved) {
if (saved.theme_id) setTheme(saved.theme_id)
if (saved.custom_style) setCustomStyle(saved.custom_style)
loadCanvas(saved.nodes, saved.edges)
}
return
}
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { useCanvasStore } from '@/stores/canvasStore'
import { useThemeStore } from '@/stores/themeStore'
import * as standaloneStorage from '@/utils/standaloneStorage'
// ── Mock heavy dependencies ────────────────────────────────────────────────
@@ -213,9 +214,13 @@ describe('LiveView (non-standalone)', () => {
// ── 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 = {
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,
Controls: () => null,
BackgroundVariant: { Dots: 'dots' },
@@ -235,16 +240,45 @@ describe('LiveView (standalone — localStorage)', () => {
vi.unstubAllEnvs()
})
it('loads canvas from localStorage without calling the API', async () => {
const stored = {
it('loads the active design canvas from localStorage without calling the API', async () => {
const design = standaloneStorage.createDesign('Main')
standaloneStorage.saveCanvas(design.id, {
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))
})
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.resetModules()
@@ -260,6 +294,7 @@ describe('LiveView (standalone — localStorage)', () => {
await waitFor(() => {
expect(screen.getByTestId('react-flow')).toBeDefined()
})
expect((standaloneRfProps.nodes as { id: string }[]).map((n) => n.id)).toContain('fb-node')
expect(mockLoad).not.toHaveBeenCalled()
})
+6 -2
View File
@@ -18,6 +18,7 @@ const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
{ label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] },
{ label: 'Z-Wave', types: ['zwave_coordinator', 'zwave_router', 'zwave_enddevice'] },
{ label: 'Personal', types: ['computer', 'laptop', 'mobile'] },
{ label: 'Electrical', types: ['grid', 'ups', 'battery', 'generator', 'solar_panel', 'inverter', 'circuit_breaker', 'contactor', 'electrical_switch', 'socket', 'light', 'meter', 'transformer', 'load'] },
{ label: 'Generic', types: ['generic', 'groupRect'] },
@@ -26,6 +27,9 @@ const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
const CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host']
const ZIGBEE_TYPES: NodeType[] = ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice']
const ZWAVE_TYPES: NodeType[] = ['zwave_coordinator', 'zwave_router', 'zwave_enddevice']
// Mesh radio devices aren't IP-reachable, so they default to no status check.
const MESH_TYPES: NodeType[] = [...ZIGBEE_TYPES, ...ZWAVE_TYPES]
const CHECK_METHOD_LABELS: Record<CheckMethod, string> = {
none: 'None',
@@ -73,7 +77,7 @@ interface NodeModalProps {
// initial value is enough - no need for a reset effect.
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentCandidates = [], currentNodeId }: NodeModalProps) {
const merged = { ...DEFAULT_DATA, ...initial }
if (ZIGBEE_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
if (MESH_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
const [form, setForm] = useState<Partial<NodeData>>(merged)
const [iconSearch, setIconSearch] = useState('')
const [iconPickerOpen, setIconPickerOpen] = useState(false)
@@ -133,7 +137,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
const t = v as NodeType
setForm((f) => {
const next: Partial<NodeData> = { ...f, type: t }
if (ZIGBEE_TYPES.includes(t)) next.check_method = 'none' as CheckMethod
if (MESH_TYPES.includes(t)) next.check_method = 'none' as CheckMethod
// Drop the parent only if it's no longer a valid target for the
// new type — keep container-mode parents (any node can nest).
const parent = parentCandidates.find((n) => n.id === f.parent_id)
@@ -188,6 +188,20 @@ describe('NodeModal', () => {
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).type).toBe('nas')
})
it('offers Z-Wave node types and submits one', () => {
const { onSubmit } = renderModal({ initial: BASE })
fireEvent.change(selects()[0], { target: { value: 'zwave_enddevice' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).type).toBe('zwave_enddevice')
})
it('forces check_method to none when a Z-Wave type is selected', () => {
const { onSubmit } = renderModal({ initial: { ...BASE, check_method: 'ping' } })
fireEvent.change(selects()[0], { target: { value: 'zwave_coordinator' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).check_method).toBe('none')
})
// ── Check method ──────────────────────────────────────────────────────
it('pre-fills check_method from initial', () => {
+16 -7
View File
@@ -6,6 +6,7 @@ import { useCanvasStore } from '@/stores/canvasStore'
import { useDesignStore } from '@/stores/designStore'
import { useAuthStore } from '@/stores/authStore'
import { designsApi } from '@/api/client'
import * as standaloneStorage from '@/utils/standaloneStorage'
import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal'
import type { Design } from '@/types'
@@ -43,11 +44,15 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
if (!designModal) return
try {
if (designModal.mode === 'create') {
const res = await designsApi.create({ name: data.name, icon: data.icon })
addDesign(res.data)
const created = STANDALONE
? standaloneStorage.createDesign(data.name, data.icon)
: (await designsApi.create({ name: data.name, icon: data.icon })).data
addDesign(created)
} else if (designModal.design) {
const res = await designsApi.update(designModal.design.id, { name: data.name, icon: data.icon })
updateDesign(res.data.id, { name: res.data.name, icon: res.data.icon })
const updated = STANDALONE
? standaloneStorage.updateDesign(designModal.design.id, { name: data.name, icon: data.icon })
: (await designsApi.update(designModal.design.id, { name: data.name, icon: data.icon })).data
if (updated) updateDesign(updated.id, { name: updated.name, icon: updated.icon })
}
setDesignModal(null)
} catch {
@@ -59,7 +64,11 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
if (designs.length <= 1) { toast.error('Cannot delete the only canvas'); return }
if (!window.confirm(`Delete canvas "${d.name}"? Its nodes and links will be removed.`)) return
try {
await designsApi.delete(d.id)
if (STANDALONE) {
standaloneStorage.deleteDesign(d.id)
} else {
await designsApi.delete(d.id)
}
removeDesign(d.id)
toast.success('Canvas deleted')
} catch {
@@ -193,8 +202,8 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
{!collapsed && <div className="flex-1" />}
{/* Stats footer */}
{!collapsed && (
{/* Stats footer — hidden in standalone (no scan / live status to count) */}
{!collapsed && !STANDALONE && (
<div className="px-3 py-2 border-t border-border text-xs text-muted-foreground space-y-0.5">
<div className="flex justify-between">
<span>Total</span>
+9 -3
View File
@@ -4,6 +4,8 @@ import { Button } from '@/components/ui/button'
import { Logo } from '@/components/ui/Logo'
import { useCanvasStore } from '@/stores/canvasStore'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
interface ToolbarProps {
onSave: () => void
onAutoLayout: () => void
@@ -82,9 +84,13 @@ 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>
{/* Live view reads backend/localStorage canvas; pointless in standalone
where the editor already shows the only (localStorage) copy. */}
{!STANDALONE && (
<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>
@@ -1,10 +1,11 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { Sidebar } from '../Sidebar'
import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore'
import type { Node } from '@xyflow/react'
import type { NodeData } from '@/types'
import type { NodeData, Design } from '@/types'
import * as standaloneStorage from '@/utils/standaloneStorage'
// ── Mocks ────────────────────────────────────────────────────────────────────
@@ -277,3 +278,103 @@ describe('Sidebar', () => {
expect(mockLogout).toHaveBeenCalledOnce()
})
})
// ── Standalone mode ────────────────────────────────────────────────────────────
// VITE_STANDALONE is read at module load, so re-import Sidebar after stubbing it.
// The hoisted vi.mock auto-mocks re-apply on re-import; configure the fresh mock
// instances after the dynamic import.
describe('Sidebar (standalone)', () => {
const makeDesign = (id: string, name: string): Design => ({
id, name, design_type: 'network', icon: null,
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
})
beforeEach(() => {
localStorage.clear()
})
afterEach(() => {
vi.unstubAllEnvs()
})
// Re-import Sidebar after stubbing VITE_STANDALONE. Returns the design-store
// instance the re-imported Sidebar uses, seeded with `designs` so we can drive
// and assert the switcher.
async function renderStandalone(nodes: Node<NodeData>[] = [], designs: Design[] = []) {
vi.stubEnv('VITE_STANDALONE', 'true')
vi.resetModules()
const { useCanvasStore: cs } = await import('@/stores/canvasStore')
const { useAuthStore: as } = await import('@/stores/authStore')
const { useDesignStore: ds } = await import('@/stores/designStore')
vi.mocked(cs).mockReturnValue({
nodes, hasUnsavedChanges: false, addNode: vi.fn(), scanEventTs: 0,
} as ReturnType<typeof useCanvasStore>)
vi.mocked(as).mockImplementation((selector: (s: { logout: () => void }) => unknown) =>
selector({ logout: mockLogout }) as ReturnType<typeof useAuthStore>
)
ds.setState({ designs, activeDesignId: designs[0]?.id ?? null, loaded: true })
const { Sidebar: SB } = await import('../Sidebar')
render(<SB {...defaultProps} />)
return ds
}
it('hides the Total/Online/Offline stats footer', async () => {
await renderStandalone([makeNode('n1', 'online'), makeNode('n2', 'offline')])
expect(screen.queryByText('Total')).not.toBeInTheDocument()
expect(screen.queryByText('Online')).not.toBeInTheDocument()
expect(screen.queryByText('Offline')).not.toBeInTheDocument()
})
it('hides scan-dependent items but keeps canvas actions', async () => {
await renderStandalone()
expect(screen.queryByText('Scan Network')).not.toBeInTheDocument()
expect(screen.queryByText('Device Inventory')).not.toBeInTheDocument()
expect(screen.queryByText('Logout')).not.toBeInTheDocument()
expect(screen.getByText('Add Node')).toBeInTheDocument()
expect(screen.getByText('Save Canvas')).toBeInTheDocument()
})
it('creates a canvas via localStorage (no API) and adds it to the store', async () => {
const ds = await renderStandalone([], [makeDesign('d1', 'Main')])
fireEvent.click(screen.getByText('Main')) // open switcher
fireEvent.click(screen.getByText('New Canvas')) // open create modal
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Garage' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
await screen.findByText('Garage')
expect(ds.getState().designs.map((d) => d.name)).toContain('Garage')
expect(standaloneStorage.listDesigns().map((d) => d.name)).toContain('Garage')
})
it('renames a canvas via localStorage (no API)', async () => {
standaloneStorage.createDesign('Old')
const seeded = standaloneStorage.listDesigns()
const ds = await renderStandalone([], seeded)
fireEvent.click(screen.getByText('Old'))
fireEvent.click(screen.getByLabelText('Edit Old'))
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Renamed' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await screen.findByText('Renamed')
expect(ds.getState().designs.map((d) => d.name)).toContain('Renamed')
expect(standaloneStorage.listDesigns()[0].name).toBe('Renamed')
})
it('deletes a canvas via localStorage (no API)', async () => {
standaloneStorage.createDesign('Keep')
standaloneStorage.createDesign('Drop')
const seeded = standaloneStorage.listDesigns()
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
const ds = await renderStandalone([], seeded)
fireEvent.click(screen.getByText('Keep')) // open switcher
fireEvent.click(screen.getByLabelText('Delete Drop'))
expect(confirmSpy).toHaveBeenCalled()
expect(standaloneStorage.listDesigns().map((d) => d.name)).toEqual(['Keep'])
expect(ds.getState().designs.map((d) => d.name)).toEqual(['Keep'])
confirmSpy.mockRestore()
})
})
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { Toolbar } from '../Toolbar'
import { useCanvasStore } from '@/stores/canvasStore'
@@ -56,4 +56,33 @@ describe('Toolbar', () => {
fireEvent.click(screen.getByText('Save'))
expect(defaultProps.onSave).toHaveBeenCalledWith()
})
it('shows the View (live view) link in full mode', () => {
render(<Toolbar {...defaultProps} />)
expect(screen.getByText('View')).toBeInTheDocument()
})
})
// ── Standalone mode ────────────────────────────────────────────────────────────
// VITE_STANDALONE is read at module load, so re-import Toolbar after stubbing it.
describe('Toolbar (standalone)', () => {
afterEach(() => {
vi.unstubAllEnvs()
})
it('hides the View link (live view is pointless without a backend)', async () => {
vi.stubEnv('VITE_STANDALONE', 'true')
vi.resetModules()
const { useCanvasStore: cs } = await import('@/stores/canvasStore')
vi.mocked(cs).mockReturnValue({
hasUnsavedChanges: false, past: [], future: [],
} as ReturnType<typeof useCanvasStore>)
const { Toolbar: TB } = await import('../Toolbar')
render(<TB {...defaultProps} />)
expect(screen.queryByText('View')).not.toBeInTheDocument()
// Other actions remain.
expect(screen.getByText('Save')).toBeInTheDocument()
expect(screen.getByText('MD')).toBeInTheDocument()
})
})
@@ -0,0 +1,102 @@
/**
* Standalone multi-canvas (designs) persistence tests.
*
* Verifies the localStorage-backed design list + per-design canvas storage used
* when VITE_STANDALONE=true, including migration of a legacy single-canvas
* install into a default design.
*/
import { describe, it, expect, beforeEach } from 'vitest'
import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types'
import * as ss from '@/utils/standaloneStorage'
const DESIGNS_KEY = 'homelable_designs'
const LEGACY_CANVAS_KEY = 'homelable_canvas'
const canvasKey = (id: string) => `${LEGACY_CANVAS_KEY}:${id}`
function node(id: string): Node<NodeData> {
return { id, type: 'server', position: { x: 0, y: 0 }, data: { label: id, type: 'server', status: 'unknown', services: [] } }
}
const noEdges: Edge<EdgeData>[] = []
beforeEach(() => {
localStorage.clear()
})
describe('standaloneStorage designs', () => {
it('listDesigns returns empty before any seed', () => {
expect(ss.listDesigns()).toEqual([])
})
it('ensureSeed creates a default design once and is idempotent', () => {
const first = ss.ensureSeed()
expect(first).toHaveLength(1)
expect(first[0].name).toBe('My Homelab')
expect(first[0].design_type).toBe('network')
const second = ss.ensureSeed()
expect(second).toHaveLength(1)
expect(second[0].id).toBe(first[0].id) // same design, not recreated
})
it('ensureSeed migrates legacy single-canvas data into the default design', () => {
const legacy = { nodes: [node('a'), node('b')], edges: noEdges, theme_id: 'dark', custom_style: null }
localStorage.setItem(LEGACY_CANVAS_KEY, JSON.stringify(legacy))
const [design] = ss.ensureSeed()
const migrated = ss.loadCanvas(design.id)
expect(migrated?.nodes).toHaveLength(2)
expect(migrated?.theme_id).toBe('dark')
// Legacy bare key is consumed so it can't shadow per-design data later.
expect(localStorage.getItem(LEGACY_CANVAS_KEY)).toBeNull()
})
it('createDesign appends and persists a new design', () => {
ss.ensureSeed()
const created = ss.createDesign('Garage', 'network')
expect(ss.listDesigns().map((d) => d.id)).toContain(created.id)
expect(ss.listDesigns()).toHaveLength(2)
})
it('saveCanvas / loadCanvas round-trips per design without cross-talk', () => {
const a = ss.createDesign('A')
const b = ss.createDesign('B')
ss.saveCanvas(a.id, { nodes: [node('a1')], edges: noEdges, theme_id: 'default' })
ss.saveCanvas(b.id, { nodes: [node('b1'), node('b2')], edges: noEdges, theme_id: 'default' })
expect(ss.loadCanvas(a.id)?.nodes).toHaveLength(1)
expect(ss.loadCanvas(b.id)?.nodes).toHaveLength(2)
})
it('loadCanvas returns null for an unsaved design', () => {
const d = ss.createDesign('Empty')
expect(ss.loadCanvas(d.id)).toBeNull()
})
it('updateDesign patches name/icon and bumps updated_at', () => {
const d = ss.createDesign('Old')
const updated = ss.updateDesign(d.id, { name: 'New', icon: 'router' })
expect(updated?.name).toBe('New')
expect(updated?.icon).toBe('router')
expect(ss.listDesigns()[0].name).toBe('New')
})
it('updateDesign returns null for an unknown id', () => {
expect(ss.updateDesign('nope', { name: 'x' })).toBeNull()
})
it('deleteDesign removes the design and its canvas data', () => {
const a = ss.createDesign('A')
const b = ss.createDesign('B')
ss.saveCanvas(a.id, { nodes: [node('a1')], edges: noEdges })
ss.deleteDesign(a.id)
expect(ss.listDesigns().map((d) => d.id)).toEqual([b.id])
expect(localStorage.getItem(canvasKey(a.id))).toBeNull()
})
it('tolerates corrupt JSON in the designs key', () => {
localStorage.setItem(DESIGNS_KEY, '{not valid')
expect(ss.listDesigns()).toEqual([])
})
})
+113
View File
@@ -0,0 +1,113 @@
/**
* Standalone-mode persistence (VITE_STANDALONE=true).
*
* No backend is available, so designs (multi-canvas) and their canvas data are
* persisted directly to localStorage:
* - `homelable_designs` → Design[] (the canvas list)
* - `homelable_canvas:<id>` → { nodes, edges, theme_id, custom_style } per design
*
* Legacy single-canvas installs stored everything under `homelable_canvas`
* (no per-design key, no design list). `ensureSeed()` migrates that data into a
* default design on first run so existing users keep their canvas.
*/
import type { Node, Edge } from '@xyflow/react'
import type { Design, DesignType, NodeData, EdgeData, CustomStyleDef } from '@/types'
import type { ThemeId } from '@/utils/themes'
import { generateUUID } from '@/utils/uuid'
const DESIGNS_KEY = 'homelable_designs'
const LEGACY_CANVAS_KEY = 'homelable_canvas'
const canvasKey = (designId: string) => `${LEGACY_CANVAS_KEY}:${designId}`
export interface StandaloneCanvas {
nodes: Node<NodeData>[]
edges: Edge<EdgeData>[]
theme_id?: ThemeId
custom_style?: CustomStyleDef | null
}
function readJSON<T>(key: string): T | null {
try {
const raw = localStorage.getItem(key)
return raw ? (JSON.parse(raw) as T) : null
} catch {
return null
}
}
function nowIso(): string {
return new Date().toISOString()
}
/** Read the design list. Returns [] when none have been created yet. */
export function listDesigns(): Design[] {
return readJSON<Design[]>(DESIGNS_KEY) ?? []
}
function writeDesigns(designs: Design[]): void {
localStorage.setItem(DESIGNS_KEY, JSON.stringify(designs))
}
/**
* Guarantee at least one design exists and return the full list.
* Migrates a legacy single-canvas install into a default design on first run.
*/
export function ensureSeed(): Design[] {
const existing = listDesigns()
if (existing.length > 0) return existing
const design: Design = {
id: generateUUID(),
name: 'My Homelab',
design_type: 'network',
icon: null,
created_at: nowIso(),
updated_at: nowIso(),
}
writeDesigns([design])
// Migrate legacy canvas data (stored under the bare key) into this design.
const legacy = readJSON<StandaloneCanvas>(LEGACY_CANVAS_KEY)
if (legacy && localStorage.getItem(canvasKey(design.id)) === null) {
localStorage.setItem(canvasKey(design.id), JSON.stringify(legacy))
localStorage.removeItem(LEGACY_CANVAS_KEY)
}
return [design]
}
export function createDesign(name: string, icon?: string | null, design_type: DesignType = 'network'): Design {
const design: Design = {
id: generateUUID(),
name,
design_type,
icon: icon ?? null,
created_at: nowIso(),
updated_at: nowIso(),
}
writeDesigns([...listDesigns(), design])
return design
}
export function updateDesign(id: string, patch: Partial<Pick<Design, 'name' | 'icon'>>): Design | null {
const designs = listDesigns()
const idx = designs.findIndex((d) => d.id === id)
if (idx === -1) return null
const updated: Design = { ...designs[idx], ...patch, updated_at: nowIso() }
designs[idx] = updated
writeDesigns(designs)
return updated
}
export function deleteDesign(id: string): void {
writeDesigns(listDesigns().filter((d) => d.id !== id))
localStorage.removeItem(canvasKey(id))
}
/** Load a design's canvas. Returns null when the design has never been saved. */
export function loadCanvas(designId: string): StandaloneCanvas | null {
return readJSON<StandaloneCanvas>(canvasKey(designId))
}
export function saveCanvas(designId: string, data: StandaloneCanvas): void {
localStorage.setItem(canvasKey(designId), JSON.stringify(data))
}