Merge branch 'main' into fix/markdown-copy

This commit is contained in:
Pouzor
2026-06-26 23:28:30 +02:00
51 changed files with 4816 additions and 366 deletions
+97 -87
View File
@@ -118,12 +118,12 @@
"license": "MIT"
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@@ -132,29 +132,29 @@
}
},
"node_modules/@babel/compat-data": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/core": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-compilation-targets": "^7.28.6",
"@babel/helper-module-transforms": "^7.28.6",
"@babel/helpers": "^7.28.6",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/traverse": "^7.29.0",
"@babel/types": "^7.29.0",
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-compilation-targets": "^7.29.7",
"@babel/helper-module-transforms": "^7.29.7",
"@babel/helpers": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
@@ -171,13 +171,13 @@
}
},
"node_modules/@babel/generator": {
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -199,13 +199,13 @@
}
},
"node_modules/@babel/helper-compilation-targets": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.28.6",
"@babel/helper-validator-option": "^7.27.1",
"@babel/compat-data": "^7.29.7",
"@babel/helper-validator-option": "^7.29.7",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
@@ -236,9 +236,9 @@
}
},
"node_modules/@babel/helper-globals": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -258,27 +258,27 @@
}
},
"node_modules/@babel/helper-module-imports": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.28.6",
"@babel/types": "^7.28.6"
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.28.6",
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/traverse": "^7.28.6"
"@babel/helper-module-imports": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7",
"@babel/traverse": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -339,52 +339,52 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-option": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helpers": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"license": "MIT",
"dependencies": {
"@babel/template": "^7.28.6",
"@babel/types": "^7.29.0"
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
"@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -519,31 +519,31 @@
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/parser": "^7.28.6",
"@babel/types": "^7.28.6"
"@babel/code-frame": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/types": "^7.29.0",
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-globals": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7",
"debug": "^4.3.1"
},
"engines": {
@@ -551,13 +551,13 @@
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -6638,9 +6638,19 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -9138,9 +9148,9 @@
}
},
"node_modules/undici": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz",
"integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"dev": true,
"license": "MIT",
"engines": {
+59
View File
@@ -22,6 +22,7 @@ import { EdgeModal } from '@/components/modals/EdgeModal'
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
import { SettingsModal } from '@/components/modals/SettingsModal'
import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
import { ZwaveImportModal } from '@/components/zwave/ZwaveImportModal'
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
import { TextModal, type TextFormData } from '@/components/modals/TextModal'
import { ThemeModal } from '@/components/modals/ThemeModal'
@@ -39,6 +40,7 @@ import { demoNodes, demoEdges } from '@/utils/demoData'
import { useStatusPolling } from '@/hooks/useStatusPolling'
import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
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'
@@ -77,6 +79,7 @@ export default function App() {
const [settingsOpen, setSettingsOpen] = useState(false)
const [exportModalOpen, setExportModalOpen] = useState(false)
const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false)
const [zwaveImportOpen, setZwaveImportOpen] = useState(false)
// Declare handleSave before the Ctrl+S effect so it is in scope.
// Returns true on success, false on failure — the design-switch effect relies
@@ -560,6 +563,50 @@ export default function App() {
markUnsaved()
}, [addNode, onConnect, snapshotHistory, markUnsaved])
const handleZwaveAddToCanvas = useCallback((zwaveNodes: ZwaveNode[], zwaveEdges: ZwaveEdge[]) => {
snapshotHistory()
const COLS = 4
const SPACING_X = 170
const SPACING_Y = 100
zwaveNodes.forEach((zn, i) => {
const id = zn.id
const col = i % COLS
const row = Math.floor(i / COLS)
const position = { x: 500 + col * SPACING_X, y: 100 + row * SPACING_Y }
const newNode: import('@xyflow/react').Node<NodeData> = {
id,
type: zn.type,
position,
data: {
label: zn.friendly_name,
type: zn.type as NodeData['type'],
status: 'unknown' as const,
services: [],
...(zn.model ? { os: zn.model } : {}),
...(zn.parent_id ? { parent_id: zn.parent_id } : {}),
},
}
addNode(newNode)
})
// Add IoT edges between Z-Wave devices: parent bottom -> child top
zwaveEdges.forEach((ze) => {
onConnect({
source: ze.source,
sourceHandle: 'bottom',
target: ze.target,
targetHandle: 'top-t',
type: 'iot',
} as unknown as import('@xyflow/react').Connection)
})
const importedIds = new Set(zwaveNodes.map((zn) => zn.id))
useCanvasStore.setState((state) => ({
nodes: state.nodes.map((n) => ({ ...n, selected: importedIds.has(n.id) })),
selectedNodeIds: Array.from(importedIds),
selectedNodeId: importedIds.size === 1 ? Array.from(importedIds)[0] : null,
}))
markUnsaved()
}, [addNode, onConnect, snapshotHistory, markUnsaved])
const handleEdgeConnect = useCallback((connection: Connection) => {
setPendingConnection(connection)
}, [])
@@ -634,6 +681,7 @@ export default function App() {
onAddText={() => setAddTextOpen(true)}
onScan={() => setScanConfigOpen(true)}
onZigbeeImport={() => setZigbeeImportOpen(true)}
onZwaveImport={() => setZwaveImportOpen(true)}
onSave={handleSave}
onOpenSettings={() => setSettingsOpen(true)}
onOpenHistory={() => setScanHistoryOpen(true)}
@@ -752,6 +800,17 @@ export default function App() {
/>
)}
{!STANDALONE && (
<ZwaveImportModal
open={zwaveImportOpen}
onClose={() => setZwaveImportOpen(false)}
onAddToCanvas={handleZwaveAddToCanvas}
onPendingImported={() => {
toast.success('Z-Wave import started — check Scan History for results')
}}
/>
)}
{!STANDALONE && (
<ScanHistoryModal
open={scanHistoryOpen}
+14 -2
View File
@@ -170,7 +170,7 @@ describe('api/client', () => {
it('scanApi endpoints route correctly', () => {
mod.scanApi.trigger()
expect(api.post).toHaveBeenCalledWith('/scan/trigger')
expect(api.post).toHaveBeenCalledWith('/scan/trigger', {})
mod.scanApi.pending()
expect(api.get).toHaveBeenCalledWith('/scan/pending')
mod.scanApi.hidden()
@@ -186,7 +186,9 @@ describe('api/client', () => {
mod.scanApi.ignore('d1')
expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/ignore')
mod.scanApi.bulkApprove(['a', 'b'])
expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-approve', { device_ids: ['a', 'b'] })
expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-approve', { device_ids: ['a', 'b'], design_id: undefined })
mod.scanApi.bulkApprove(['a'], 'design-9')
expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-approve', { device_ids: ['a'], design_id: 'design-9' })
mod.scanApi.bulkHide(['a'])
expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-hide', { device_ids: ['a'] })
mod.scanApi.restore('d1')
@@ -217,4 +219,14 @@ describe('api/client', () => {
mod.zigbeeApi.importToPending(cfg)
expect(api.post).toHaveBeenCalledWith('/zigbee/import-pending', cfg)
})
it('zwaveApi.testConnection/importNetwork/importToPending', () => {
const cfg = { mqtt_host: 'h', mqtt_port: 1883, prefix: 'zwave', gateway_name: 'zwavejs2mqtt' }
mod.zwaveApi.testConnection(cfg)
expect(api.post).toHaveBeenCalledWith('/zwave/test-connection', cfg)
mod.zwaveApi.importNetwork(cfg)
expect(api.post).toHaveBeenCalledWith('/zwave/import', cfg)
mod.zwaveApi.importToPending(cfg)
expect(api.post).toHaveBeenCalledWith('/zwave/import-pending', cfg)
})
})
+62 -5
View File
@@ -58,8 +58,16 @@ export const liveviewApi = {
getConfig: () => api.get<{ enabled: boolean; key: string | null }>('/liveview/config'),
}
export interface DeepScanConfig {
http_ranges: string[]
http_probe_enabled: boolean
verify_tls: boolean
}
export type ScanConfigData = { ranges: string[] } & DeepScanConfig
export const scanApi = {
trigger: () => api.post('/scan/trigger'),
trigger: (deepScan?: Partial<DeepScanConfig>) => api.post('/scan/trigger', deepScan ?? {}),
pending: () => api.get('/scan/pending'),
hidden: () => api.get('/scan/hidden'),
runs: () => api.get('/scan/runs'),
@@ -73,7 +81,7 @@ export const scanApi = {
}>(`/scan/pending/${id}/approve`, nodeData),
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
bulkApprove: (ids: string[]) =>
bulkApprove: (ids: string[], designId?: string | null) =>
api.post<{
approved: number
node_ids: string[]
@@ -81,13 +89,13 @@ export const scanApi = {
edges_created: number
edges: { id: string; source: string; target: string }[]
skipped: number
}>('/scan/pending/bulk-approve', { device_ids: ids }),
}>('/scan/pending/bulk-approve', { device_ids: ids, design_id: designId ?? undefined }),
bulkHide: (ids: string[]) => api.post<{ hidden: number; skipped: number }>('/scan/pending/bulk-hide', { device_ids: ids }),
restore: (id: string) => api.post<{ restored: boolean; device_id: string }>(`/scan/pending/${id}/restore`),
bulkRestore: (ids: string[]) => api.post<{ restored: number; skipped: number }>('/scan/pending/bulk-restore', { device_ids: ids }),
stop: (runId: string) => api.post(`/scan/${runId}/stop`),
getConfig: () => api.get<{ ranges: string[] }>('/scan/config'),
saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
getConfig: () => api.get<ScanConfigData>('/scan/config'),
saveConfig: (data: ScanConfigData) => api.post('/scan/config', data),
}
export interface AppSettings {
@@ -156,3 +164,52 @@ export const zigbeeApi = {
error: string | null
}>('/zigbee/import-pending', data),
}
export const zwaveApi = {
testConnection: (data: {
mqtt_host: string
mqtt_port: number
mqtt_username?: string
mqtt_password?: string
mqtt_tls?: boolean
mqtt_tls_insecure?: boolean
}) =>
api.post<{ connected: boolean; message: string }>('/zwave/test-connection', data),
importNetwork: (data: {
mqtt_host: string
mqtt_port: number
mqtt_username?: string
mqtt_password?: string
prefix?: string
gateway_name?: string
mqtt_tls?: boolean
mqtt_tls_insecure?: boolean
}) =>
api.post<{
nodes: import('@/components/zwave/types').ZwaveNode[]
edges: import('@/components/zwave/types').ZwaveEdge[]
device_count: number
}>('/zwave/import', data),
importToPending: (data: {
mqtt_host: string
mqtt_port: number
mqtt_username?: string
mqtt_password?: string
prefix?: string
gateway_name?: string
mqtt_tls?: boolean
mqtt_tls_insecure?: boolean
}) =>
api.post<{
id: string
status: string
kind: string
ranges: string[]
devices_found: number
started_at: string
finished_at: string | null
error: string | null
}>('/zwave/import-pending', data),
}
@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest'
import { nodeTypes } from '../nodeTypes'
describe('nodeTypes registry', () => {
it('registers a component for every wireless mesh node type', () => {
// Regression: zwave_* types were missing, so React Flow fell back to the
// default (unstyled) node — no icon, no accent. (Zigbee covered too.)
for (const t of [
'zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice',
'zwave_coordinator', 'zwave_router', 'zwave_enddevice',
]) {
expect(nodeTypes[t as keyof typeof nodeTypes], `missing nodeType: ${t}`).toBeDefined()
}
})
})
@@ -1,7 +1,7 @@
import { type NodeProps, type Node } from '@xyflow/react'
import {
Globe, Router, Network, Server, Layers, Box, Container,
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Flame, Radio, Antenna,
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Flame, Radio, Antenna, RadioTower, Share2,
Grid3x3, Battery, Fuel, Sun, Repeat2, Split, ToggleLeft, Lightbulb, Gauge, Combine, Cable, Zap,
} from 'lucide-react'
import { BaseNode } from './BaseNode'
@@ -34,6 +34,11 @@ export const ZigbeeCoordinatorNode = (props: N) => <BaseNode {...props} icon={Ne
export const ZigbeeRouterNode = (props: N) => <BaseNode {...props} icon={Radio} />
export const ZigbeeEndDeviceNode = (props: N) => <BaseNode {...props} icon={Antenna} />
// Z-Wave node types
export const ZwaveCoordinatorNode = (props: N) => <BaseNode {...props} icon={RadioTower} />
export const ZwaveRouterNode = (props: N) => <BaseNode {...props} icon={Share2} />
export const ZwaveEndDeviceNode = (props: N) => <BaseNode {...props} icon={Antenna} />
// Electrical node types
export const GridNode = (props: N) => <BaseNode {...props} icon={Grid3x3} />
export const UpsNode = (props: N) => <BaseNode {...props} icon={Battery} />
@@ -3,6 +3,7 @@ import {
NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, LaptopNode,
MobileNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode,
ZigbeeCoordinatorNode, ZigbeeRouterNode, ZigbeeEndDeviceNode,
ZwaveCoordinatorNode, ZwaveRouterNode, ZwaveEndDeviceNode,
GridNode, UpsNode, BatteryNode, GeneratorNode, SolarPanelNode, InverterNode,
CircuitBreakerNode, ContactorNode, ElectricalSwitchNode, SocketNode,
LightNode, MeterNode, TransformerNode, LoadNode,
@@ -39,6 +40,9 @@ export const nodeTypes = {
zigbee_coordinator: ZigbeeCoordinatorNode,
zigbee_router: ZigbeeRouterNode,
zigbee_enddevice: ZigbeeEndDeviceNode,
zwave_coordinator: ZwaveCoordinatorNode,
zwave_router: ZwaveRouterNode,
zwave_enddevice: ZwaveEndDeviceNode,
grid: GridNode,
ups: UpsNode,
battery: BatteryNode,
@@ -1,9 +1,9 @@
import { useState, useCallback } from 'react'
import { Fragment, useState, useEffect, useCallback } from 'react'
import { toast } from 'sonner'
import {
Globe, Router, Network, Server, Layers, Box, Container, HardDrive,
Cpu, Wifi, Camera, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Circle, Flame,
Radio, Zap, Lightbulb,
Radio, Zap, Lightbulb, RadioTower, Share2,
type LucideIcon,
} from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
@@ -17,13 +17,16 @@ import type {
} from '@/types'
import { NODE_TYPE_LABELS, EDGE_TYPE_LABELS } from '@/types'
// ── Node types exposed for custom style (skip groupRect/group) ───────────────
// ── Node types exposed for custom style, grouped by category (skip groupRect/group) ──
const EDITABLE_NODE_TYPES: NodeType[] = [
'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas',
'iot', 'ap', 'camera', 'printer', 'computer', 'laptop', 'mobile', 'cpl', 'docker_host',
'docker_container', 'zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice',
'generic',
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
{ label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] },
{ 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: 'Generic', types: ['generic'] },
]
const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre', 'electrical']
@@ -34,6 +37,7 @@ const NODE_ICONS: Record<string, LucideIcon> = {
camera: Camera, printer: Printer, computer: Monitor, laptop: Laptop, mobile: Smartphone, cpl: PlugZap,
docker_host: Anchor, docker_container: Package,
zigbee_coordinator: Radio, zigbee_router: Zap, zigbee_enddevice: Lightbulb,
zwave_coordinator: RadioTower, zwave_router: Share2, zwave_enddevice: Lightbulb,
generic: Circle,
}
@@ -156,7 +160,7 @@ function NodeEditor({ nodeType, style, onChange, onApplyToExisting }: NodeEditor
min={0}
step={10}
value={style.width}
onChange={(e) => set('width', parseInt(e.target.value) || 0)}
onChange={(e) => set('width', parseInt(e.target.value, 10) || 0)}
className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
/>
</div>
@@ -167,7 +171,7 @@ function NodeEditor({ nodeType, style, onChange, onApplyToExisting }: NodeEditor
min={0}
step={10}
value={style.height}
onChange={(e) => set('height', parseInt(e.target.value) || 0)}
onChange={(e) => set('height', parseInt(e.target.value, 10) || 0)}
className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
/>
</div>
@@ -281,14 +285,22 @@ export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) {
edges: { ...customStyle.edges },
}))
const handleOpen = (isOpen: boolean) => {
if (isOpen) {
// Reset draft to current saved customStyle on open
// Reset the draft to the saved customStyle whenever the modal is (re)opened.
// The parent keeps this component mounted and only toggles `open`, so Radix's
// onOpenChange never fires for a parent-driven open — we key off the prop edge
// instead. Without this, abandoned edits (Cancel) would leak into the next open.
useEffect(() => {
if (open) {
setDraft({ nodes: { ...customStyle.nodes }, edges: { ...customStyle.edges } })
setSelection(null)
} else {
onClose()
}
// Intentional snapshot-on-open: we don't want live customStyle changes to
// clobber an in-progress edit, only a fresh open should reset.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
const handleOpen = (isOpen: boolean) => {
if (!isOpen) onClose()
}
const getNodeStyle = (t: NodeType): NodeTypeStyle =>
@@ -363,34 +375,41 @@ export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) {
{/* Type list */}
<div className="flex-1 overflow-y-auto py-1">
{tab === 'nodes' && EDITABLE_NODE_TYPES.map((t) => {
const Icon = NODE_ICONS[t] ?? Circle
const style = draft.nodes[t]
const isSelected = selection?.kind === 'node' && selection.type === t
const swatchColor = style
? applyOpacity(style.borderColor, style.borderOpacity)
: THEMES.default.colors.nodeAccents[t]?.border ?? '#8b949e'
{tab === 'nodes' && NODE_TYPE_GROUPS.map((group) => (
<Fragment key={group.label}>
<div className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-[#8b949e]/60">
{group.label}
</div>
{group.types.map((t) => {
const Icon = NODE_ICONS[t] ?? Circle
const style = draft.nodes[t]
const isSelected = selection?.kind === 'node' && selection.type === t
const swatchColor = style
? applyOpacity(style.borderColor, style.borderOpacity)
: THEMES.default.colors.nodeAccents[t]?.border ?? '#8b949e'
return (
<button
key={t}
type="button"
onClick={() => setSelection({ kind: 'node', type: t })}
className="w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left"
style={{
background: isSelected ? '#21262d' : 'transparent',
color: isSelected ? '#e6edf3' : '#8b949e',
}}
>
<Icon size={13} />
<span className="flex-1 truncate">{NODE_TYPE_LABELS[t]}</span>
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ background: swatchColor }}
/>
</button>
)
})}
return (
<button
key={t}
type="button"
onClick={() => setSelection({ kind: 'node', type: t })}
className="w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left"
style={{
background: isSelected ? '#21262d' : 'transparent',
color: isSelected ? '#e6edf3' : '#8b949e',
}}
>
<Icon size={13} />
<span className="flex-1 truncate">{NODE_TYPE_LABELS[t]}</span>
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ background: swatchColor }}
/>
</button>
)
})}
</Fragment>
))}
{tab === 'edges' && EDITABLE_EDGE_TYPES.map((t) => {
const style = draft.edges[t]
@@ -27,6 +27,8 @@ export interface PendingDevice {
vendor?: string | null
lqi?: number | null
discovered_at: string
// How many canvases (designs) this device already appears on. Computed server-side.
canvas_count?: number
}
interface PendingDeviceModalProps {
@@ -1,15 +1,17 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import {
Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network,
Search, RefreshCw, X, CheckCircle2, EyeOff, Trash2, Loader2,
Search, RefreshCw, X, CheckCircle2, EyeOff, Trash2, Loader2, ServerCog,
} from 'lucide-react'
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { scanApi } from '@/api/client'
import { useCanvasStore } from '@/stores/canvasStore'
import { useDesignStore } from '@/stores/designStore'
import { toast } from 'sonner'
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
import type { NodeType, ServiceInfo } from '@/types'
import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties'
import { buildZwaveProperties, isZwaveType } from '@/utils/zwaveProperties'
import { buildMacProperty } from '@/utils/macProperty'
interface PendingDevicesModalProps {
@@ -67,11 +69,13 @@ const TYPE_ICONS: Record<string, React.ElementType> = {
generic: Circle,
}
type SourceFilter = 'all' | 'ip' | 'zigbee'
type SourceFilter = 'all' | 'ip' | 'zigbee' | 'zwave'
type StatusFilter = 'pending' | 'hidden'
function inferSource(d: PendingDevice): 'zigbee' | 'ip' {
if (d.discovery_source === 'zigbee' || d.ieee_address) return 'zigbee'
function inferSource(d: PendingDevice): 'zigbee' | 'zwave' | 'ip' {
if (d.discovery_source === 'zwave') return 'zwave'
if (d.discovery_source === 'zigbee') return 'zigbee'
if (d.ieee_address) return 'zigbee'
return 'ip'
}
@@ -119,7 +123,12 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all')
const [typeFilter, setTypeFilter] = useState<string>('all')
const [statusFilter, setStatusFilter] = useState<StatusFilter>(initialStatus)
// Inventory shows on-canvas devices by default; toggle off to hide them.
const [showOnCanvas, setShowOnCanvas] = useState(true)
// Optionally restrict to devices that have at least one detected service.
const [withServicesOnly, setWithServicesOnly] = useState(false)
const { addNode, scanEventTs } = useCanvasStore()
const activeDesignId = useDesignStore((s) => s.activeDesignId)
const highlightRef = useRef<HTMLButtonElement>(null)
const load = useCallback(async () => {
@@ -159,6 +168,9 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
return devices.filter((d) => {
if (sourceFilter !== 'all' && inferSource(d) !== sourceFilter) return false
if (typeFilter !== 'all' && d.suggested_type !== typeFilter) return false
// Inventory-only: optionally hide devices already placed on a canvas.
if (statusFilter === 'pending' && !showOnCanvas && (d.canvas_count ?? 0) > 0) return false
if (withServicesOnly && (d.services?.length ?? 0) === 0) return false
if (q) {
const hay = [
d.friendly_name, d.hostname, d.ip, d.mac, d.ieee_address, d.vendor, d.model,
@@ -168,7 +180,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
}
return true
})
}, [devices, search, sourceFilter, typeFilter])
}, [devices, search, sourceFilter, typeFilter, statusFilter, showOnCanvas, withServicesOnly])
useEffect(() => {
if (!highlightId || loading || !open) return
@@ -241,9 +253,11 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
if (failed > 0) toast.error(`Removed ${removedIds.size}, ${failed} failed`)
else toast.success(`Removed ${removedIds.size} device${removedIds.size !== 1 ? 's' : ''}`)
} else {
// Clears only pending rows server-side; approved/on-canvas devices stay,
// so reload rather than blanking the whole inventory.
await scanApi.clearPending()
setDevices([])
setSelectedIds(new Set())
await load()
toast.success('Pending devices cleared')
}
} catch {
@@ -255,17 +269,24 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
try {
const fallbackLabel = deviceLabel(device)
const type = (device.suggested_type ?? 'generic') as NodeType
const zigbee = isZigbeeType(type)
const properties = zigbee ? buildZigbeeProperties(device) : buildMacProperty(device.mac)
const zwave = isZwaveType(type)
const wireless = isZigbeeType(type) || zwave
const properties = zwave
? buildZwaveProperties(device)
: isZigbeeType(type)
? buildZigbeeProperties(device)
: buildMacProperty(device.mac)
const nodeData = {
label: fallbackLabel,
type,
ip: device.ip ?? undefined,
mac: device.mac ?? undefined,
hostname: device.hostname ?? undefined,
status: zigbee ? 'online' : 'unknown',
status: wireless ? 'online' : 'unknown',
services: (device.services ?? []) as ServiceInfo[],
properties,
// Approve onto the design the user is viewing, not the first design.
design_id: activeDesignId ?? undefined,
}
const res = await scanApi.approve(device.id, nodeData)
const nodeId = res.data.node_id
@@ -273,7 +294,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
id: nodeId,
type: nodeData.type,
position: { x: 400, y: 300 },
data: { ...nodeData, status: zigbee ? ('online' as const) : ('unknown' as const) },
data: { ...nodeData, status: wireless ? ('online' as const) : ('unknown' as const) },
})
injectAutoEdges(res.data.edges)
const extra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
@@ -310,7 +331,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
const ids = [...selectedIds]
if (ids.length === 0) return
try {
const res = await scanApi.bulkApprove(ids)
const res = await scanApi.bulkApprove(ids, activeDesignId)
const deviceToNode: Record<string, string> = {}
res.data.device_ids.forEach((did, i) => { deviceToNode[did] = res.data.node_ids[i] })
const approvedDevices = devices.filter((d) => ids.includes(d.id))
@@ -318,7 +339,8 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
const nodeId = deviceToNode[d.id]
if (!nodeId) return
const type = (d.suggested_type ?? 'generic') as NodeType
const zigbee = isZigbeeType(type)
const zwave = isZwaveType(type)
const wireless = isZigbeeType(type) || zwave
addNode({
id: nodeId,
type,
@@ -329,9 +351,13 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
ip: d.ip ?? undefined,
mac: d.mac ?? undefined,
hostname: d.hostname ?? undefined,
status: zigbee ? ('online' as const) : ('unknown' as const),
status: wireless ? ('online' as const) : ('unknown' as const),
services: (d.services ?? []) as ServiceInfo[],
properties: zigbee ? buildZigbeeProperties(d) : buildMacProperty(d.mac),
properties: zwave
? buildZwaveProperties(d)
: isZigbeeType(type)
? buildZigbeeProperties(d)
: buildMacProperty(d.mac),
},
})
})
@@ -398,7 +424,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
<DialogHeader className="px-4 py-3 border-b border-border shrink-0">
<div className="flex items-center justify-between gap-3">
<DialogTitle className="text-base font-semibold flex items-center gap-2">
{statusFilter === 'pending' ? 'Pending Devices' : 'Hidden Devices'}
{statusFilter === 'pending' ? 'Device Inventory' : 'Hidden Devices'}
<span className="text-muted-foreground font-normal text-xs">
({filtered.length}{filtered.length !== devices.length && ` of ${devices.length}`})
</span>
@@ -464,6 +490,12 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
>
Zigbee
</button>
<button
onClick={() => setSourceFilter('zwave')}
className={`px-2.5 py-1.5 transition-colors border-l border-border ${sourceFilter === 'zwave' ? 'bg-[#ff6e00]/20 text-[#ff6e00]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
>
Z-Wave
</button>
</div>
<select
value={typeFilter}
@@ -479,7 +511,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
onClick={() => setStatusFilter('pending')}
className={`px-2.5 py-1.5 transition-colors ${statusFilter === 'pending' ? 'bg-[#00d4ff]/20 text-[#00d4ff]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
>
Pending
Inventory
</button>
<button
onClick={() => setStatusFilter('hidden')}
@@ -488,6 +520,26 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
Hidden
</button>
</div>
{statusFilter === 'pending' && (
<button
onClick={() => setShowOnCanvas((v) => !v)}
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded border transition-colors ${showOnCanvas ? 'bg-[#0d1117] text-muted-foreground border-border hover:text-foreground' : 'bg-[#00d4ff]/20 text-[#00d4ff] border-[#00d4ff]/50'}`}
title="Show or hide devices already on a canvas"
aria-pressed={!showOnCanvas}
>
<Layers size={12} />
{showOnCanvas ? 'Hide on-canvas' : 'Show on-canvas'}
</button>
)}
<button
onClick={() => setWithServicesOnly((v) => !v)}
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded border transition-colors ${withServicesOnly ? 'bg-[#00d4ff]/20 text-[#00d4ff] border-[#00d4ff]/50' : 'bg-[#0d1117] text-muted-foreground border-border hover:text-foreground'}`}
title="Only show devices with at least one detected service"
aria-pressed={withServicesOnly}
>
<ServerCog size={12} />
With services
</button>
<button
onClick={() => selectMode ? exitSelectMode() : enterSelectMode()}
className={`text-xs px-2.5 py-1.5 rounded border transition-colors ${selectMode ? 'bg-[#00d4ff]/20 text-[#00d4ff] border-[#00d4ff]/50' : 'bg-[#0d1117] text-muted-foreground border-border hover:text-foreground'}`}
@@ -602,8 +654,11 @@ function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRe
const source = inferSource(device)
const Icon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
const label = deviceLabel(device)
const sourceColor = source === 'zigbee' ? '#00d4ff' : '#a855f7'
const sourceLabel = source === 'zigbee' ? 'ZIGBEE' : (device.discovery_source ?? 'IP').toUpperCase()
const sourceColor = source === 'zigbee' ? '#00d4ff' : source === 'zwave' ? '#ff6e00' : '#a855f7'
const sourceLabel =
source === 'zigbee' ? 'ZIGBEE'
: source === 'zwave' ? 'Z-WAVE'
: (device.discovery_source ?? 'IP').toUpperCase()
const services = device.services ?? []
const visibleServices = services.slice(0, 4)
const moreServices = services.length - visibleServices.length
@@ -624,12 +679,24 @@ function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRe
{selectMode && selected && (
<CheckCircle2
size={18}
className="absolute top-2 right-2 text-[#00d4ff] fill-[#0d1117]"
className="absolute top-2 right-2 text-[#00d4ff] fill-[#0d1117] z-10"
/>
)}
{!selectMode && device.status === 'hidden' && (
<EyeOff size={14} className="absolute top-2 right-2 text-muted-foreground" />
)}
{/* Canvas-presence corner: how many canvases this device already sits on.
Hidden while a select-mode checkmark occupies the same corner. */}
{device.status !== 'hidden' && (device.canvas_count ?? 0) > 0 && !(selectMode && selected) && (
<div
className="absolute top-0 right-0 flex items-center gap-1 rounded-bl-lg rounded-tr-lg bg-[#00d4ff] text-[#0d1117] text-xs font-bold px-2 py-1 shadow-md"
title={`On ${device.canvas_count} canvas${device.canvas_count !== 1 ? 'es' : ''}`}
aria-label={`On ${device.canvas_count} canvas${device.canvas_count !== 1 ? 'es' : ''}`}
>
<Layers size={12} strokeWidth={2.5} />
{device.canvas_count}
</div>
)}
{/* Header */}
<div className="flex items-start gap-2 mb-2">
@@ -1,10 +1,10 @@
import { useState, useEffect } from 'react'
import { Plus, Trash2, Settings } from 'lucide-react'
import { Plus, Trash2, Settings, ChevronRight, ChevronDown } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { scanApi } from '@/api/client'
import { scanApi, type DeepScanConfig } from '@/api/client'
import { toast } from 'sonner'
interface ScanConfigModalProps {
@@ -13,24 +13,56 @@ interface ScanConfigModalProps {
onScanNow: () => void
}
const DEEP_DEFAULTS: DeepScanConfig = { http_ranges: [], http_probe_enabled: false, verify_tls: false }
export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalProps) {
const [ranges, setRanges] = useState<string[]>([''])
const [saving, setSaving] = useState(false)
// Deep-scan section. Pre-filled from persisted defaults; edits here are a
// per-scan override passed to trigger() — they do NOT change the saved defaults.
const [deepOpen, setDeepOpen] = useState(false)
const [deepDefaults, setDeepDefaults] = useState<DeepScanConfig>(DEEP_DEFAULTS)
const [httpProbe, setHttpProbe] = useState(false)
const [verifyTls, setVerifyTls] = useState(false)
const [httpRangesText, setHttpRangesText] = useState('')
useEffect(() => {
if (!open) return
scanApi.getConfig()
.then((res) => setRanges(res.data.ranges.length > 0 ? res.data.ranges : ['']))
.then((res) => {
const d = res.data
setRanges(d.ranges.length > 0 ? d.ranges : [''])
const deep: DeepScanConfig = {
http_ranges: d.http_ranges ?? [],
http_probe_enabled: d.http_probe_enabled ?? false,
verify_tls: d.verify_tls ?? false,
}
setDeepDefaults(deep)
setHttpProbe(deep.http_probe_enabled)
setVerifyTls(deep.verify_tls)
setHttpRangesText(deep.http_ranges.join(', '))
setDeepOpen(deep.http_probe_enabled || deep.http_ranges.length > 0)
})
.catch(() => {/* use defaults */})
}, [open])
const parseHttpRanges = () =>
httpRangesText.split(',').map((r) => r.trim()).filter(Boolean)
const handleScanNow = async () => {
const cleaned = ranges.map((r) => r.trim()).filter(Boolean)
if (cleaned.length === 0) { toast.error('Add at least one IP range'); return }
setSaving(true)
try {
await scanApi.saveConfig({ ranges: cleaned })
await scanApi.trigger()
// Persist IP ranges; leave deep-scan defaults as configured in Options.
await scanApi.saveConfig({ ranges: cleaned, ...deepDefaults })
// Per-scan deep-scan override from this dialog.
await scanApi.trigger({
http_ranges: parseHttpRanges(),
http_probe_enabled: httpProbe,
verify_tls: verifyTls,
})
onScanNow()
onClose()
} catch {
@@ -84,6 +116,57 @@ export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalPro
</Button>
</div>
{/* Deep Scan (opt-in) */}
<div className="space-y-2 border-t border-border pt-3">
<button
type="button"
onClick={() => setDeepOpen((v) => !v)}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
>
{deepOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
Deep Scan
</button>
{deepOpen && (
<div className="space-y-3 pl-1">
<p className="text-xs text-muted-foreground">
Scan extra ports and probe HTTP services to identify apps on custom ports.
Overrides the saved defaults for this scan only.
</p>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Extra port ranges</Label>
<Input
value={httpRangesText}
onChange={(e) => setHttpRangesText(e.target.value)}
placeholder="8000-8100, 9000-9100"
className="font-mono text-sm bg-[#0d1117] border-border"
/>
</div>
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
<input
type="checkbox"
checked={httpProbe}
onChange={(e) => setHttpProbe(e.target.checked)}
className="accent-[#00d4ff]"
/>
Enable HTTP probe
</label>
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
<input
type="checkbox"
checked={verifyTls}
onChange={(e) => setVerifyTls(e.target.checked)}
className="accent-[#00d4ff]"
/>
Verify TLS certificates
</label>
</div>
)}
</div>
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
<Settings size={11} />
Status check interval can be configured in the sidebar Settings.
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { RefreshCw, X, Loader2, StopCircle, Clock, ScanLine, Network, Inbox } from 'lucide-react'
import { RefreshCw, X, Loader2, StopCircle, Clock, ScanLine, Network, RadioTower, Inbox } from 'lucide-react'
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { scanApi } from '@/api/client'
@@ -22,7 +22,18 @@ interface ScanHistoryModalProps {
onClose: () => void
}
type KindFilter = 'all' | 'ip' | 'zigbee'
type KindFilter = 'all' | 'ip' | 'zigbee' | 'zwave'
/** Normalise a ScanRun.kind into one of the known display kinds. */
function runKind(kind: string | undefined): 'ip' | 'zigbee' | 'zwave' {
return kind === 'zigbee' ? 'zigbee' : kind === 'zwave' ? 'zwave' : 'ip'
}
const KIND_META = {
ip: { label: 'IP', color: '#a855f7' },
zigbee: { label: 'Zigbee', color: '#00d4ff' },
zwave: { label: 'Z-Wave', color: '#ff6e00' },
} as const
type StatusFilter = 'all' | 'running' | 'done' | 'error' | 'cancelled'
const STATUS_FILTERS: { key: StatusFilter; label: string }[] = [
@@ -37,6 +48,7 @@ const KIND_FILTERS: { key: KindFilter; label: string }[] = [
{ key: 'all', label: 'All' },
{ key: 'ip', label: 'IP' },
{ key: 'zigbee', label: 'Zigbee' },
{ key: 'zwave', label: 'Z-Wave' },
]
function statusColor(s: string): string {
@@ -90,8 +102,9 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
toast.error(`Scan failed: ${run.error ?? 'unknown error'}`)
}
if (prev?.status === 'running' && run.status === 'done') {
if (run.kind === 'zigbee') {
toast.success(`Zigbee import done — ${run.devices_found} device${run.devices_found !== 1 ? 's' : ''}`)
if (run.kind === 'zigbee' || run.kind === 'zwave') {
const label = run.kind === 'zwave' ? 'Z-Wave' : 'Zigbee'
toast.success(`${label} import done — ${run.devices_found} device${run.devices_found !== 1 ? 's' : ''}`)
}
useCanvasStore.getState().notifyScanDeviceFound()
}
@@ -143,7 +156,7 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
}
const filtered = runs.filter((r) => {
const k = r.kind === 'zigbee' ? 'zigbee' : 'ip'
const k = runKind(r.kind)
if (kindFilter !== 'all' && k !== kindFilter) return false
if (statusFilter !== 'all' && r.status !== statusFilter) return false
return true
@@ -216,7 +229,9 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
</div>
)}
{filtered.map((r) => {
const isZigbee = r.kind === 'zigbee'
const kind = runKind(r.kind)
const meta = KIND_META[kind]
const KindIcon = kind === 'zigbee' ? Network : kind === 'zwave' ? RadioTower : ScanLine
return (
<div key={r.id} className="rounded-lg border border-border bg-[#161b22] p-3">
<div className="flex items-center gap-2">
@@ -225,12 +240,10 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
{r.status === 'running' && <Loader2 size={12} className="animate-spin text-[#e3b341]" />}
<span
className="inline-flex items-center gap-1 text-[10px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
style={isZigbee
? { background: '#00d4ff22', color: '#00d4ff' }
: { background: '#a855f722', color: '#a855f7' }}
style={{ background: `${meta.color}22`, color: meta.color }}
>
{isZigbee ? <Network size={10} /> : <ScanLine size={10} />}
{isZigbee ? 'Zigbee' : 'IP'}
<KindIcon size={10} />
{meta.label}
</span>
<span className="ml-auto text-xs text-muted-foreground font-mono">
{r.devices_found} found
@@ -37,6 +37,16 @@ describe('CustomStyleModal', () => {
expect(screen.getByText(/edge type from the list/i)).toBeDefined()
})
it('groups node types under category headers (incl. Zigbee and Z-Wave)', () => {
render(<CustomStyleModal open onClose={vi.fn()} />)
expect(screen.getByText('Hardware')).toBeDefined()
expect(screen.getByText('Zigbee')).toBeDefined()
expect(screen.getByText('Z-Wave')).toBeDefined()
// A Z-Wave node type is selectable from its category.
fireEvent.click(screen.getByRole('button', { name: /Z-Wave Controller/ }))
expect(screen.getByText(/Apply to existing Z-Wave Controller/)).toBeDefined()
})
it('selecting a node type opens the node editor', () => {
render(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
@@ -124,4 +134,23 @@ describe('CustomStyleModal', () => {
fireEvent.change(widthInputs[0], { target: { value: '250' } })
expect((widthInputs[0] as HTMLInputElement).value).toBe('250')
})
it('resets abandoned edits when reopened after cancel (mounted parent)', () => {
// Parent keeps the modal mounted and only toggles `open`, so the reset must
// happen on the open-prop edge, not via Radix onOpenChange.
const { rerender } = render(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
const widthInput = screen.getAllByRole('spinbutton')[0]
fireEvent.change(widthInput, { target: { value: '250' } })
expect((widthInput as HTMLInputElement).value).toBe('250')
// Cancel = parent flips open → false, then later → true again.
rerender(<CustomStyleModal open={false} onClose={vi.fn()} />)
rerender(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
const reopenedWidth = screen.getAllByRole('spinbutton')[0]
// Draft was reset to saved style (default width 0) — edit did not leak.
expect((reopenedWidth as HTMLInputElement).value).toBe('0')
})
})
@@ -67,6 +67,23 @@ const DEVICE_ZIGBEE = {
discovered_at: '2026-01-02T00:00:00Z',
}
const DEVICE_ZWAVE = {
id: 'dev-c',
ip: null,
hostname: null,
mac: null,
os: null,
services: [],
suggested_type: 'zwave_router',
status: 'pending',
discovery_source: 'zwave',
ieee_address: 'zwave-0xh-2',
friendly_name: 'wall-plug',
vendor: 'Aeotec',
model: 'ZW100',
discovered_at: '2026-01-03T00:00:00Z',
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useCanvasStore).mockReturnValue({
@@ -129,6 +146,22 @@ describe('PendingDevicesModal', () => {
expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument()
})
it('shows source chip Z-WAVE for zwave device', async () => {
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZWAVE] })
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-c')).toBeInTheDocument())
expect(screen.getByText('Z-WAVE')).toBeInTheDocument()
})
it('filters by source (zwave only)', async () => {
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZWAVE] })
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Z-Wave' }))
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
expect(screen.getByTestId('pending-card-dev-c')).toBeInTheDocument()
})
it('filters by suggested type', async () => {
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
@@ -180,7 +213,7 @@ describe('PendingDevicesModal', () => {
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
fireEvent.click(screen.getByTestId('pending-card-dev-b'))
fireEvent.click(screen.getByRole('button', { name: /Approve \(2\)/ }))
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b']))
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b'], null))
})
it('bulk approve carries the scanned MAC onto the canvas node (#168)', async () => {
@@ -257,7 +290,7 @@ describe('PendingDevicesModal', () => {
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
fireEvent.keyDown(window, { key: 'Enter' })
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a']))
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a'], null))
expect(mockBulkRestore).not.toHaveBeenCalled()
})
@@ -271,4 +304,64 @@ describe('PendingDevicesModal', () => {
await waitFor(() => expect(mockBulkRestore).toHaveBeenCalledWith(['dev-a']))
expect(mockBulkApprove).not.toHaveBeenCalled()
})
// --- Device Inventory: rename, canvas badge, on-canvas filter ---
it('titles the pending view "Device Inventory"', async () => {
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.getByText('Device Inventory')).toBeInTheDocument()
})
it('shows "Hidden Devices" title in hidden mode', async () => {
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] })
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.getByText('Hidden Devices')).toBeInTheDocument()
})
it('renders a corner canvas-count when canvas_count > 0', async () => {
mockPending.mockResolvedValue({ data: [{ ...DEVICE_IP, canvas_count: 2 }] })
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
const corner = screen.getByLabelText('On 2 canvases')
expect(corner).toHaveTextContent('2')
})
it('uses singular "canvas" for a single canvas', async () => {
mockPending.mockResolvedValue({ data: [{ ...DEVICE_IP, canvas_count: 1 }] })
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.getByLabelText('On 1 canvas')).toHaveTextContent('1')
})
it('does not render the canvas-count corner when canvas_count is 0', async () => {
mockPending.mockResolvedValue({ data: [{ ...DEVICE_IP, canvas_count: 0 }] })
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.queryByLabelText(/On \d+ canvas/)).not.toBeInTheDocument()
})
it('filters to devices with detected services when "With services" is on', async () => {
// dev-a has an http service; dev-b (zigbee) has none.
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: /With services/ }))
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
expect(screen.queryByTestId('pending-card-dev-b')).not.toBeInTheDocument()
})
it('shows on-canvas devices by default and hides them when toggled off', async () => {
mockPending.mockResolvedValue({
data: [DEVICE_IP, { ...DEVICE_ZIGBEE, canvas_count: 1 }],
})
render(<PendingDevicesModal {...baseProps} />)
// Default: both visible (on-canvas shown).
await waitFor(() => expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument())
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
// Toggle off → the on-canvas device (dev-b) disappears.
fireEvent.click(screen.getByRole('button', { name: /Hide on-canvas/ }))
expect(screen.queryByTestId('pending-card-dev-b')).not.toBeInTheDocument()
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
})
})
@@ -82,7 +82,12 @@ describe('ScanConfigModal', () => {
await screen.findByDisplayValue('192.168.1.0/24')
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
await waitFor(() => {
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'] })
expect(scanApi.saveConfig).toHaveBeenCalledWith({
ranges: ['192.168.1.0/24'],
http_ranges: [],
http_probe_enabled: false,
verify_tls: false,
})
expect(scanApi.trigger).toHaveBeenCalledOnce()
expect(onScanNow).toHaveBeenCalledOnce()
expect(onClose).toHaveBeenCalledOnce()
@@ -108,4 +113,59 @@ describe('ScanConfigModal', () => {
)
})
})
// --- Deep scan ---
it('reveals deep-scan fields when the section is toggled', async () => {
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
expect(screen.queryByText('Enable HTTP probe')).toBeNull()
fireEvent.click(screen.getByText('Deep Scan'))
expect(screen.getByText('Enable HTTP probe')).toBeDefined()
})
it('passes deep-scan overrides to trigger() as a per-scan override', async () => {
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
fireEvent.click(screen.getByText('Deep Scan'))
fireEvent.change(screen.getByPlaceholderText('8000-8100, 9000-9100'), {
target: { value: '8000-8100, 9000' },
})
fireEvent.click(screen.getByLabelText('Enable HTTP probe'))
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
await waitFor(() => {
expect(scanApi.trigger).toHaveBeenCalledWith({
http_ranges: ['8000-8100', '9000'],
http_probe_enabled: true,
verify_tls: false,
})
})
})
it('auto-opens deep-scan section when a default probe is enabled', async () => {
vi.mocked(scanApi.getConfig).mockResolvedValue({
data: { ranges: ['192.168.1.0/24'], http_ranges: ['7000-7100'], http_probe_enabled: true, verify_tls: false },
} as never)
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
expect(screen.getByText('Enable HTTP probe')).toBeDefined()
expect(screen.getByDisplayValue('7000-7100')).toBeDefined()
})
it('saving keeps deep-scan defaults untouched (modal only overrides per-scan)', async () => {
vi.mocked(scanApi.getConfig).mockResolvedValue({
data: { ranges: ['192.168.1.0/24'], http_ranges: ['7000-7100'], http_probe_enabled: true, verify_tls: true },
} as never)
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
await waitFor(() => {
expect(scanApi.saveConfig).toHaveBeenCalledWith({
ranges: ['192.168.1.0/24'],
http_ranges: ['7000-7100'],
http_probe_enabled: true,
verify_tls: true,
})
})
})
})
@@ -61,6 +61,17 @@ const ZIGBEE_RUN = {
error: null,
}
const ZWAVE_RUN = {
id: 'run-5',
status: 'done',
kind: 'zwave',
ranges: [],
devices_found: 5,
started_at: new Date().toISOString(),
finished_at: new Date().toISOString(),
error: null,
}
function renderModal() {
return render(
<TooltipProvider>
@@ -155,4 +166,14 @@ describe('ScanHistoryModal', () => {
expect(screen.getByText('7 found')).toBeDefined()
expect(screen.queryByText('3 found')).toBeNull()
})
it('filters by zwave kind', async () => {
vi.mocked(scanApi.runs).mockResolvedValue({ data: [DONE_RUN, ZWAVE_RUN] } as never)
renderModal()
await waitFor(() => expect(screen.getAllByText('done').length).toBe(2))
fireEvent.click(screen.getByRole('button', { name: 'Z-Wave' }))
// Only the zwave run (5 found) remains
expect(screen.getByText('5 found')).toBeDefined()
expect(screen.queryByText('3 found')).toBeNull()
})
})
+5 -3
View File
@@ -1,5 +1,5 @@
import { useState, useCallback } from 'react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, RadioTower, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
import { Logo } from '@/components/ui/Logo'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvasStore'
@@ -15,7 +15,7 @@ import { useLatestRelease } from '@/hooks/useLatestRelease'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const PENDING_TRIGGERS: { kind: 'pending' | 'hidden'; icon: typeof ScanLine; label: string }[] = [
{ kind: 'pending', icon: ScanLine, label: 'Pending Devices' },
{ kind: 'pending', icon: ScanLine, label: 'Device Inventory' },
{ kind: 'hidden', icon: EyeOff, label: 'Hidden Devices' },
]
@@ -25,13 +25,14 @@ interface SidebarProps {
onAddText: () => void
onScan: () => void
onZigbeeImport: () => void
onZwaveImport: () => void
onSave: () => void
onOpenSettings: () => void
onOpenHistory: () => void
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
}
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onSave, onOpenSettings, onOpenHistory, onOpenPending }: SidebarProps) {
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onZwaveImport, onSave, onOpenSettings, onOpenHistory, onOpenPending }: SidebarProps) {
const [collapsed, setCollapsed] = useState(false)
const logout = useAuthStore((s) => s.logout)
const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore()
@@ -217,6 +218,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} />
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
{!STANDALONE && <SidebarItem icon={RadioTower} label="Z-Wave Import" collapsed={collapsed} onClick={onZwaveImport} />}
<SidebarItem
icon={Save}
label="Save Canvas"
@@ -70,6 +70,7 @@ const defaultProps = {
onAddText: vi.fn(),
onScan: vi.fn(),
onZigbeeImport: vi.fn(),
onZwaveImport: vi.fn(),
onSave: vi.fn(),
onOpenSettings: vi.fn(),
onOpenHistory: vi.fn(),
@@ -98,7 +99,7 @@ describe('Sidebar', () => {
it('shows all view nav items', () => {
render(<Sidebar {...defaultProps} />)
expect(screen.getByText('Canvas')).toBeInTheDocument()
expect(screen.getByText('Pending Devices')).toBeInTheDocument()
expect(screen.getByText('Device Inventory')).toBeInTheDocument()
expect(screen.getByText('Hidden Devices')).toBeInTheDocument()
expect(screen.getByText('Scan History')).toBeInTheDocument()
})
@@ -184,6 +185,12 @@ describe('Sidebar', () => {
expect(defaultProps.onAddGroupRect).toHaveBeenCalledOnce()
})
it('calls onZwaveImport when Z-Wave Import is clicked', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Z-Wave Import'))
expect(defaultProps.onZwaveImport).toHaveBeenCalledOnce()
})
it('calls onSave when Save Canvas is clicked', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Save Canvas'))
@@ -233,9 +240,9 @@ describe('Sidebar', () => {
// ── Pending / Hidden open modal ────────────────────────────────────────────
it('calls onOpenPending with pending status when Pending Devices is clicked', () => {
it('calls onOpenPending with pending status when Device Inventory is clicked', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Pending Devices'))
fireEvent.click(screen.getByText('Device Inventory'))
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'pending')
})
@@ -0,0 +1,461 @@
import { useState } from 'react'
import { RadioTower, Share2, Cpu, CheckCircle2, XCircle, Loader2, Plus } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { zwaveApi } from '@/api/client'
import { toast } from 'sonner'
import type { ZwaveNode, ZwaveEdge } from './types'
interface ZwaveImportModalProps {
open: boolean
onClose: () => void
onAddToCanvas: (nodes: ZwaveNode[], edges: ZwaveEdge[]) => void
onPendingImported?: (
coordinator?: { id: string; label: string; ieee_address: string } | null,
) => void
}
type ImportMode = 'pending' | 'canvas'
const ACCENT = '#ff6e00'
interface ConnectionForm {
mqtt_host: string
mqtt_port: string
mqtt_username: string
mqtt_password: string
prefix: string
gateway_name: string
mqtt_tls: boolean
mqtt_tls_insecure: boolean
port_user_edited: boolean
}
const DEFAULT_FORM: ConnectionForm = {
mqtt_host: '',
mqtt_port: '1883',
mqtt_username: '',
mqtt_password: '',
prefix: 'zwave',
gateway_name: 'zwavejs2mqtt',
mqtt_tls: false,
mqtt_tls_insecure: false,
port_user_edited: false,
}
const DEVICE_TYPE_ICON = {
zwave_coordinator: RadioTower,
zwave_router: Share2,
zwave_enddevice: Cpu,
} as const
const DEVICE_TYPE_LABEL = {
zwave_coordinator: 'Controller',
zwave_router: 'Router',
zwave_enddevice: 'End Device',
} as const
const DEVICE_TYPE_COLOR = {
zwave_coordinator: '#ff6e00',
zwave_router: '#39d353',
zwave_enddevice: '#e3b341',
} as const
export function ZwaveImportModal({ open, onClose, onAddToCanvas, onPendingImported }: ZwaveImportModalProps) {
const [form, setForm] = useState<ConnectionForm>(DEFAULT_FORM)
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>('idle')
const [connectionMsg, setConnectionMsg] = useState('')
const [loading, setLoading] = useState(false)
const [devices, setDevices] = useState<ZwaveNode[]>([])
const [edges, setEdges] = useState<ZwaveEdge[]>([])
const [checked, setChecked] = useState<Set<string>>(new Set())
const [importMode, setImportMode] = useState<ImportMode>('pending')
const updateField = (field: keyof ConnectionForm, value: string) =>
setForm((f) => ({
...f,
[field]: value,
...(field === 'mqtt_port' ? { port_user_edited: true } : {}),
}))
const toggleTls = (next: boolean) =>
setForm((f) => {
const port = f.port_user_edited
? f.mqtt_port
: next
? '8883'
: '1883'
return {
...f,
mqtt_tls: next,
mqtt_tls_insecure: next ? f.mqtt_tls_insecure : false,
mqtt_port: port,
}
})
const buildPayload = () => ({
mqtt_host: form.mqtt_host.trim(),
mqtt_port: Number(form.mqtt_port) || (form.mqtt_tls ? 8883 : 1883),
mqtt_username: form.mqtt_username.trim() || undefined,
mqtt_password: form.mqtt_password || undefined,
prefix: form.prefix.trim() || 'zwave',
gateway_name: form.gateway_name.trim() || 'zwavejs2mqtt',
mqtt_tls: form.mqtt_tls,
mqtt_tls_insecure: form.mqtt_tls_insecure,
})
const handleTestConnection = async () => {
if (!form.mqtt_host.trim()) { toast.error('Enter a broker hostname'); return }
setConnectionStatus('testing')
try {
const res = await zwaveApi.testConnection({
mqtt_host: form.mqtt_host.trim(),
mqtt_port: Number(form.mqtt_port) || (form.mqtt_tls ? 8883 : 1883),
mqtt_username: form.mqtt_username.trim() || undefined,
mqtt_password: form.mqtt_password || undefined,
mqtt_tls: form.mqtt_tls,
mqtt_tls_insecure: form.mqtt_tls_insecure,
})
if (res.data.connected) {
setConnectionStatus('ok')
setConnectionMsg(res.data.message)
} else {
setConnectionStatus('fail')
setConnectionMsg(res.data.message)
}
} catch {
setConnectionStatus('fail')
setConnectionMsg('Request failed — check broker address')
}
}
const extractError = (err: unknown): string | undefined => {
if (err && typeof err === 'object' && 'response' in err) {
return (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
}
return undefined
}
const handleFetchDevices = async () => {
if (!form.mqtt_host.trim()) { toast.error('Enter a broker hostname'); return }
setLoading(true)
try {
if (importMode === 'pending') {
await zwaveApi.importToPending(buildPayload())
toast.success('Z-Wave import started — track progress in Scan History')
onPendingImported?.(null)
handleClose()
} else {
const res = await zwaveApi.importNetwork(buildPayload())
setDevices(res.data.nodes)
setEdges(res.data.edges)
setChecked(new Set(res.data.nodes.map((n) => n.id)))
if (res.data.device_count === 0) {
toast.info('No Z-Wave devices found')
} else {
toast.success(`Found ${res.data.device_count} device${res.data.device_count !== 1 ? 's' : ''}`)
}
}
} catch (err: unknown) {
toast.error(extractError(err) ?? 'Failed to fetch Z-Wave devices')
} finally {
setLoading(false)
}
}
const toggleCheck = (id: string) =>
setChecked((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
const toggleAll = () => {
setChecked(checked.size === devices.length ? new Set() : new Set(devices.map((d) => d.id)))
}
const handleAddToCanvas = () => {
const selectedDevices = devices.filter((d) => checked.has(d.id))
const selectedIds = new Set(selectedDevices.map((d) => d.id))
const selectedEdges = edges.filter((e) => selectedIds.has(e.source) && selectedIds.has(e.target))
onAddToCanvas(selectedDevices, selectedEdges)
toast.success(`Added ${selectedDevices.length} device${selectedDevices.length !== 1 ? 's' : ''} to canvas`)
onClose()
}
const handleClose = () => {
setDevices([])
setEdges([])
setChecked(new Set())
setConnectionStatus('idle')
setConnectionMsg('')
setImportMode('pending')
onClose()
}
const groupedDevices = {
zwave_coordinator: devices.filter((d) => d.type === 'zwave_coordinator'),
zwave_router: devices.filter((d) => d.type === 'zwave_router'),
zwave_enddevice: devices.filter((d) => d.type === 'zwave_enddevice'),
} as const
return (
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
<DialogContent className="bg-[#161b22] border-border max-w-xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="text-foreground flex items-center gap-2">
<RadioTower size={16} style={{ color: ACCENT }} />
Z-Wave Import
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-4 py-2 min-h-0">
{/* Connection Form */}
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 space-y-1">
<Label className="text-xs text-muted-foreground">Broker Host</Label>
<Input
value={form.mqtt_host}
onChange={(e) => updateField('mqtt_host', e.target.value)}
placeholder="192.168.1.x or mqtt.local"
className="font-mono text-sm bg-[#0d1117] border-border"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Port</Label>
<Input
value={form.mqtt_port}
onChange={(e) => updateField('mqtt_port', e.target.value)}
placeholder="1883"
type="number"
className="font-mono text-sm bg-[#0d1117] border-border"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">MQTT Prefix</Label>
<Input
value={form.prefix}
onChange={(e) => updateField('prefix', e.target.value)}
placeholder="zwave"
className="font-mono text-sm bg-[#0d1117] border-border"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Gateway Name</Label>
<Input
value={form.gateway_name}
onChange={(e) => updateField('gateway_name', e.target.value)}
placeholder="zwavejs2mqtt"
className="font-mono text-sm bg-[#0d1117] border-border"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Username (optional)</Label>
<Input
value={form.mqtt_username}
onChange={(e) => updateField('mqtt_username', e.target.value)}
placeholder="mqtt_user"
className="text-sm bg-[#0d1117] border-border"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Password (optional)</Label>
<Input
value={form.mqtt_password}
onChange={(e) => updateField('mqtt_password', e.target.value)}
placeholder="••••••••"
type="password"
autoComplete="new-password"
className="text-sm bg-[#0d1117] border-border"
/>
</div>
<div className="col-span-2 flex items-center gap-4 pt-1">
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={form.mqtt_tls}
onChange={(e) => toggleTls(e.target.checked)}
className="w-3 h-3 cursor-pointer"
style={{ accentColor: ACCENT }}
/>
Use TLS (port 8883)
</label>
<label
className={`flex items-center gap-1.5 text-xs cursor-pointer ${
form.mqtt_tls ? 'text-[#f85149]' : 'text-muted-foreground/40 cursor-not-allowed'
}`}
>
<input
type="checkbox"
checked={form.mqtt_tls_insecure}
disabled={!form.mqtt_tls}
onChange={(e) =>
setForm((f) => ({ ...f, mqtt_tls_insecure: e.target.checked }))
}
className="w-3 h-3 accent-[#f85149] cursor-pointer disabled:cursor-not-allowed"
/>
Skip cert verify (self-signed only)
</label>
</div>
</div>
{/* Connection status indicator */}
{connectionStatus !== 'idle' && (
<div className={`flex items-center gap-1.5 text-xs px-2 py-1.5 rounded-md border ${
connectionStatus === 'ok'
? 'bg-[#39d353]/10 border-[#39d353]/30 text-[#39d353]'
: connectionStatus === 'fail'
? 'bg-[#f85149]/10 border-[#f85149]/30 text-[#f85149]'
: 'bg-[#e3b341]/10 border-[#e3b341]/30 text-[#e3b341]'
}`}>
{connectionStatus === 'testing' && <Loader2 size={12} className="animate-spin" />}
{connectionStatus === 'ok' && <CheckCircle2 size={12} />}
{connectionStatus === 'fail' && <XCircle size={12} />}
<span>{connectionStatus === 'testing' ? 'Testing…' : connectionMsg}</span>
</div>
)}
<div className="flex items-center gap-3 text-xs">
<span className="text-muted-foreground">Send devices to:</span>
<label className="flex items-center gap-1.5 cursor-pointer text-foreground">
<input
type="radio"
name="zwave-import-mode"
checked={importMode === 'pending'}
onChange={() => setImportMode('pending')}
className="cursor-pointer"
style={{ accentColor: ACCENT }}
/>
Pending section
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-foreground">
<input
type="radio"
name="zwave-import-mode"
checked={importMode === 'canvas'}
onChange={() => setImportMode('canvas')}
className="cursor-pointer"
style={{ accentColor: ACCENT }}
/>
Canvas directly
</label>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="ghost"
className="gap-1.5 text-muted-foreground hover:text-foreground border border-border hover:bg-[#21262d]"
onClick={handleTestConnection}
disabled={connectionStatus === 'testing' || loading}
>
{connectionStatus === 'testing'
? <Loader2 size={13} className="animate-spin" />
: <CheckCircle2 size={13} />}
Test Connection
</Button>
<Button
size="sm"
style={{ background: ACCENT, color: '#0d1117' }}
className="gap-1.5"
onClick={handleFetchDevices}
disabled={loading || connectionStatus === 'testing'}
>
{loading ? <Loader2 size={13} className="animate-spin" /> : <RadioTower size={13} />}
{importMode === 'pending' ? 'Import to Pending' : 'Fetch Devices'}
</Button>
</div>
<p className="text-[11px] text-muted-foreground italic">
Make sure the gateway name matches your Z-Wave JS UI configuration.
</p>
</div>
{/* Device List */}
{devices.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<input
type="checkbox"
checked={checked.size === devices.length}
ref={(el) => { if (el) el.indeterminate = checked.size > 0 && checked.size < devices.length }}
onChange={toggleAll}
className="w-3 h-3 cursor-pointer"
style={{ accentColor: ACCENT }}
title="Select all"
/>
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Devices ({checked.size}/{devices.length} selected)
</span>
</div>
</div>
{(Object.entries(groupedDevices) as [keyof typeof groupedDevices, ZwaveNode[]][])
.filter(([, group]) => group.length > 0)
.map(([type, group]) => {
const Icon = DEVICE_TYPE_ICON[type]
const color = DEVICE_TYPE_COLOR[type]
return (
<div key={type}>
<div className="flex items-center gap-1.5 mb-1">
<Icon size={11} style={{ color }} />
<span className="text-[10px] font-medium uppercase tracking-wider" style={{ color }}>
{DEVICE_TYPE_LABEL[type]} ({group.length})
</span>
</div>
{group.map((device) => (
<div
key={device.id}
className={`flex items-start gap-2 p-2 mb-1 rounded-md text-xs cursor-pointer transition-colors border ${
checked.has(device.id)
? 'bg-[#21262d] border-[#ff6e00]/40'
: 'bg-[#21262d] border-transparent hover:bg-[#30363d]'
}`}
onClick={() => toggleCheck(device.id)}
>
<input
type="checkbox"
checked={checked.has(device.id)}
onChange={() => toggleCheck(device.id)}
onClick={(e) => e.stopPropagation()}
className="w-3 h-3 mt-0.5 cursor-pointer shrink-0"
style={{ accentColor: ACCENT }}
/>
<div className="flex-1 min-w-0">
<div className="text-foreground font-medium truncate">{device.friendly_name}</div>
<div className="font-mono text-[10px] text-muted-foreground truncate">{device.ieee_address}</div>
{(device.model || device.vendor) && (
<div className="text-[10px] text-muted-foreground truncate">
{[device.vendor, device.model].filter(Boolean).join(' · ')}
</div>
)}
</div>
</div>
))}
</div>
)
})}
</div>
)}
</div>
<DialogFooter className="gap-2 shrink-0 pt-2 border-t border-border">
<Button variant="ghost" onClick={handleClose}>Cancel</Button>
{devices.length > 0 && (
<Button
onClick={handleAddToCanvas}
disabled={checked.size === 0}
style={{ background: ACCENT, color: '#0d1117' }}
className="gap-1.5"
>
<Plus size={13} />
Add {checked.size} to Canvas
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,198 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { ZwaveImportModal } from '../ZwaveImportModal'
vi.mock('@/api/client', () => ({
zwaveApi: {
testConnection: vi.fn(),
importNetwork: vi.fn(),
importToPending: vi.fn(),
},
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
import { zwaveApi } from '@/api/client'
import { toast } from 'sonner'
const defaultProps = {
open: true,
onClose: vi.fn(),
onAddToCanvas: vi.fn(),
}
const sampleNodes = [
{
id: 'zwave-0xh-1',
label: 'Controller',
type: 'zwave_coordinator' as const,
ieee_address: 'zwave-0xh-1',
friendly_name: 'Controller',
device_type: 'Controller',
model: null,
vendor: null,
lqi: null,
parent_id: null,
},
{
id: 'zwave-0xh-2',
label: 'Wall Plug',
type: 'zwave_router' as const,
ieee_address: 'zwave-0xh-2',
friendly_name: 'Wall Plug',
device_type: 'Router',
model: 'ZW100',
vendor: 'Aeotec',
lqi: null,
parent_id: 'zwave-0xh-1',
},
]
describe('ZwaveImportModal', () => {
beforeEach(() => {
vi.mocked(zwaveApi.testConnection).mockReset()
vi.mocked(zwaveApi.importNetwork).mockReset()
vi.mocked(zwaveApi.importToPending).mockReset()
vi.mocked(toast.success).mockReset()
vi.mocked(toast.error).mockReset()
vi.mocked(toast.info).mockReset()
defaultProps.onClose.mockReset()
defaultProps.onAddToCanvas.mockReset()
})
it('renders nothing when closed', () => {
const { container } = render(<ZwaveImportModal {...defaultProps} open={false} />)
expect(container.querySelector('[role="dialog"]')).toBeNull()
})
it('renders the modal with prefix and gateway fields when open', () => {
render(<ZwaveImportModal {...defaultProps} />)
expect(screen.getByText('Z-Wave Import')).toBeDefined()
expect(screen.getByPlaceholderText('zwave')).toBeDefined()
expect(screen.getByPlaceholderText('zwavejs2mqtt')).toBeDefined()
})
it('shows error toast when testing connection without a host', async () => {
render(<ZwaveImportModal {...defaultProps} />)
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Enter a broker hostname')
})
expect(zwaveApi.testConnection).not.toHaveBeenCalled()
})
it('shows success status when connection test passes', async () => {
vi.mocked(zwaveApi.testConnection).mockResolvedValue({
data: { connected: true, message: 'Connection successful' },
} as never)
render(<ZwaveImportModal {...defaultProps} />)
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), {
target: { value: '192.168.1.100' },
})
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
await waitFor(() => {
expect(screen.getByText('Connection successful')).toBeDefined()
})
})
const selectCanvasMode = () => {
fireEvent.click(screen.getByRole('radio', { name: /canvas directly/i }))
}
it('fetches devices and renders them grouped by type', async () => {
vi.mocked(zwaveApi.importNetwork).mockResolvedValue({
data: { nodes: sampleNodes, edges: [], device_count: 2 },
} as never)
render(<ZwaveImportModal {...defaultProps} />)
selectCanvasMode()
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), {
target: { value: '192.168.1.100' },
})
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
await waitFor(() => {
expect(screen.getByText('Controller')).toBeDefined()
expect(screen.getByText('Wall Plug')).toBeDefined()
})
expect(toast.success).toHaveBeenCalledWith('Found 2 devices')
})
it('passes prefix and gateway_name to importNetwork', async () => {
vi.mocked(zwaveApi.importNetwork).mockResolvedValue({
data: { nodes: [], edges: [], device_count: 0 },
} as never)
render(<ZwaveImportModal {...defaultProps} />)
selectCanvasMode()
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), {
target: { value: '10.0.0.5' },
})
fireEvent.change(screen.getByPlaceholderText('zwave'), { target: { value: 'myzw' } })
fireEvent.change(screen.getByPlaceholderText('zwavejs2mqtt'), { target: { value: 'gw1' } })
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
await waitFor(() => expect(zwaveApi.importNetwork).toHaveBeenCalled())
const payload = vi.mocked(zwaveApi.importNetwork).mock.calls[0][0]
expect(payload.prefix).toBe('myzw')
expect(payload.gateway_name).toBe('gw1')
})
it('imports to pending by default and notifies parent', async () => {
vi.mocked(zwaveApi.importToPending).mockResolvedValue({
data: {
id: 'run-1',
status: 'running',
kind: 'zwave',
ranges: ['192.168.1.100:1883'],
devices_found: 0,
started_at: '2026-01-01T00:00:00Z',
finished_at: null,
error: null,
},
} as never)
const onPendingImported = vi.fn()
render(<ZwaveImportModal {...defaultProps} onPendingImported={onPendingImported} />)
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), {
target: { value: '192.168.1.100' },
})
fireEvent.click(screen.getByRole('button', { name: /import to pending/i }))
await waitFor(() => {
expect(zwaveApi.importToPending).toHaveBeenCalled()
expect(onPendingImported).toHaveBeenCalled()
expect(defaultProps.onClose).toHaveBeenCalled()
})
expect(zwaveApi.importNetwork).not.toHaveBeenCalled()
})
it('calls onAddToCanvas with selected devices and closes modal', async () => {
vi.mocked(zwaveApi.importNetwork).mockResolvedValue({
data: { nodes: sampleNodes, edges: [{ source: 'zwave-0xh-1', target: 'zwave-0xh-2' }], device_count: 2 },
} as never)
render(<ZwaveImportModal {...defaultProps} />)
selectCanvasMode()
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), {
target: { value: '192.168.1.100' },
})
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
await waitFor(() => screen.getByText('Controller'))
fireEvent.click(screen.getByRole('button', { name: /add.*canvas/i }))
await waitFor(() => {
expect(defaultProps.onAddToCanvas).toHaveBeenCalledOnce()
expect(defaultProps.onClose).toHaveBeenCalledOnce()
})
})
it('calls onClose when Cancel is clicked', () => {
render(<ZwaveImportModal {...defaultProps} />)
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(defaultProps.onClose).toHaveBeenCalledOnce()
})
})
+37
View File
@@ -0,0 +1,37 @@
/** Shared Z-Wave type definitions for the frontend. */
export interface ZwaveNode {
id: string
label: string
type: 'zwave_coordinator' | 'zwave_router' | 'zwave_enddevice'
ieee_address: string
friendly_name: string
device_type: string
model?: string | null
vendor?: string | null
lqi?: number | null
parent_id?: string | null
}
export interface ZwaveEdge {
source: string
target: string
}
export interface ZwaveImportResponse {
nodes: ZwaveNode[]
edges: ZwaveEdge[]
device_count: number
}
export interface ZwaveTestConnectionRequest {
mqtt_host: string
mqtt_port: number
mqtt_username?: string
mqtt_password?: string
}
export interface ZwaveTestConnectionResponse {
connected: boolean
message: string
}
+6
View File
@@ -37,6 +37,9 @@ export type NodeType =
| 'zigbee_coordinator'
| 'zigbee_router'
| 'zigbee_enddevice'
| 'zwave_coordinator'
| 'zwave_router'
| 'zwave_enddevice'
| 'grid'
| 'ups'
| 'battery'
@@ -187,6 +190,9 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
zigbee_coordinator: 'Zigbee Coordinator',
zigbee_router: 'Zigbee Router',
zigbee_enddevice: 'Zigbee End Device',
zwave_coordinator: 'Z-Wave Controller',
zwave_router: 'Z-Wave Router',
zwave_enddevice: 'Z-Wave End Device',
grid: 'Grid Connection',
ups: 'UPS',
battery: 'Battery',
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest'
import { isZwaveType, buildZwaveProperties } from '../zwaveProperties'
describe('isZwaveType', () => {
it('returns true for zwave types', () => {
expect(isZwaveType('zwave_coordinator')).toBe(true)
expect(isZwaveType('zwave_router')).toBe(true)
expect(isZwaveType('zwave_enddevice')).toBe(true)
})
it('returns false for non-zwave types', () => {
expect(isZwaveType('zigbee_router')).toBe(false)
expect(isZwaveType('server')).toBe(false)
expect(isZwaveType(undefined)).toBe(false)
expect(isZwaveType(null)).toBe(false)
})
})
describe('buildZwaveProperties', () => {
it('builds Z-Wave ID / Vendor / Model rows, all hidden', () => {
const props = buildZwaveProperties({ ieee_address: 'zwave-0xh-2', vendor: 'Aeotec', model: 'ZW100' })
expect(props).toEqual([
{ key: 'Z-Wave ID', value: 'zwave-0xh-2', icon: null, visible: false },
{ key: 'Vendor', value: 'Aeotec', icon: null, visible: false },
{ key: 'Model', value: 'ZW100', icon: null, visible: false },
])
})
it('omits empty fields', () => {
const props = buildZwaveProperties({ ieee_address: 'zwave-0xh-2', vendor: null, model: undefined })
expect(props.map((p) => p.key)).toEqual(['Z-Wave ID'])
})
it('never adds an LQI row', () => {
const props = buildZwaveProperties({ ieee_address: 'x', vendor: 'v', model: 'm' })
expect(props.some((p) => p.key === 'LQI')).toBe(false)
})
})
+4 -1
View File
@@ -11,7 +11,7 @@ import {
// Security & Auth
Shield, ShieldCheck, Lock, Key, Users, UserCheck, Flame,
// Automation & IoT
Zap, Workflow, Bot, Home, Thermometer, Lightbulb, Radio, BotMessageSquare, Webhook,
Zap, Workflow, Bot, Home, Thermometer, Lightbulb, Radio, RadioTower, Share2, BotMessageSquare, Webhook,
// Smart Home / Sensors
Plug, Power, BatteryCharging, Sun, DoorOpen, KeyRound, AlarmSmoke, Siren,
Radar, PersonStanding, Vibrate, Droplet, Droplets, Wind, AirVent, Fan,
@@ -178,6 +178,9 @@ export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = {
zigbee_coordinator: Radio,
zigbee_router: Zap,
zigbee_enddevice: Lightbulb,
zwave_coordinator: RadioTower,
zwave_router: Share2,
zwave_enddevice: Lightbulb,
generic: Circle,
group: Circle,
groupRect: Circle,
+18
View File
@@ -64,6 +64,9 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
zigbee_coordinator:{ border: '#ff6e00', icon: '#ff6e00' },
zigbee_router: { border: '#e3b341', icon: '#e3b341' },
zigbee_enddevice: { border: '#a855f7', icon: '#a855f7' },
zwave_coordinator: { border: '#ff6e00', icon: '#ff6e00' },
zwave_router: { border: '#e3b341', icon: '#e3b341' },
zwave_enddevice: { border: '#a855f7', icon: '#a855f7' },
generic: { border: '#8b949e', icon: '#8b949e' },
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
group: { border: '#00d4ff', icon: '#00d4ff' },
@@ -143,6 +146,9 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
zigbee_coordinator:{ border: '#fb923c', icon: '#fb923c' },
zigbee_router: { border: '#fbbf24', icon: '#fbbf24' },
zigbee_enddevice: { border: '#c084fc', icon: '#c084fc' },
zwave_coordinator: { border: '#fb923c', icon: '#fb923c' },
zwave_router: { border: '#fbbf24', icon: '#fbbf24' },
zwave_enddevice: { border: '#c084fc', icon: '#c084fc' },
generic: { border: '#94a3b8', icon: '#94a3b8' },
groupRect: { border: '#22d3ee', icon: '#22d3ee' },
group: { border: '#22d3ee', icon: '#22d3ee' },
@@ -222,6 +228,9 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
zigbee_coordinator:{ border: '#ea580c', icon: '#ea580c' },
zigbee_router: { border: '#b45309', icon: '#b45309' },
zigbee_enddevice: { border: '#7c3aed', icon: '#7c3aed' },
zwave_coordinator: { border: '#ea580c', icon: '#ea580c' },
zwave_router: { border: '#b45309', icon: '#b45309' },
zwave_enddevice: { border: '#7c3aed', icon: '#7c3aed' },
generic: { border: '#6b7280', icon: '#6b7280' },
groupRect: { border: '#0284c7', icon: '#0284c7' },
group: { border: '#0284c7', icon: '#0284c7' },
@@ -301,6 +310,9 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
zigbee_coordinator:{ border: '#ff8800', icon: '#ff8800' },
zigbee_router: { border: '#ffff00', icon: '#ffff00' },
zigbee_enddevice: { border: '#ff00ff', icon: '#ff00ff' },
zwave_coordinator: { border: '#ff8800', icon: '#ff8800' },
zwave_router: { border: '#ffff00', icon: '#ffff00' },
zwave_enddevice: { border: '#ff00ff', icon: '#ff00ff' },
generic: { border: '#8888ff', icon: '#8888ff' },
groupRect: { border: '#00ffff', icon: '#00ffff' },
group: { border: '#00ffff', icon: '#00ffff' },
@@ -380,6 +392,9 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
zigbee_coordinator:{ border: '#33ff66', icon: '#33ff66' },
zigbee_router: { border: '#66ff33', icon: '#66ff33' },
zigbee_enddevice: { border: '#008822', icon: '#008822' },
zwave_coordinator: { border: '#33ff66', icon: '#33ff66' },
zwave_router: { border: '#66ff33', icon: '#66ff33' },
zwave_enddevice: { border: '#008822', icon: '#008822' },
generic: { border: '#006600', icon: '#006600' },
groupRect: { border: '#00ff41', icon: '#00ff41' },
group: { border: '#00ff41', icon: '#00ff41' },
@@ -459,6 +474,9 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
zigbee_coordinator:{ border: '#ff6e00', icon: '#ff6e00' },
zigbee_router: { border: '#e3b341', icon: '#e3b341' },
zigbee_enddevice: { border: '#a855f7', icon: '#a855f7' },
zwave_coordinator:{ border: '#ff6e00', icon: '#ff6e00' },
zwave_router: { border: '#e3b341', icon: '#e3b341' },
zwave_enddevice: { border: '#a855f7', icon: '#a855f7' },
generic: { border: '#8b949e', icon: '#8b949e' },
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
group: { border: '#00d4ff', icon: '#00d4ff' },
+21
View File
@@ -0,0 +1,21 @@
import type { NodeProperty, NodeType } from '@/types'
const ZWAVE_TYPES: NodeType[] = ['zwave_coordinator', 'zwave_router', 'zwave_enddevice']
export function isZwaveType(type: NodeType | string | undefined | null): boolean {
return !!type && (ZWAVE_TYPES as string[]).includes(type)
}
/** Build the Z-Wave ID/Vendor/Model property rows shown in the right panel.
* Matches backend `build_zwave_properties` (no LQI — Z-Wave has none). */
export function buildZwaveProperties(input: {
ieee_address?: string | null
vendor?: string | null
model?: string | null
}): NodeProperty[] {
const props: NodeProperty[] = []
if (input.ieee_address) props.push({ key: 'Z-Wave ID', value: input.ieee_address, icon: null, visible: false })
if (input.vendor) props.push({ key: 'Vendor', value: input.vendor, icon: null, visible: false })
if (input.model) props.push({ key: 'Model', value: input.model, icon: null, visible: false })
return props
}