diff --git a/VERSION b/VERSION index 5ad2491..169f19b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.10.2 +1.11.0 \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b6681bd..00ebfd7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,18 +1,19 @@ { "name": "frontend", - "version": "1.8.2", + "version": "1.10.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "1.8.2", + "version": "1.10.2", "dependencies": { "@base-ui/react": "^1.2.0", "@dagrejs/dagre": "^2.0.4", "@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/inter": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", + "@radix-ui/react-tooltip": "^1.2.8", "@types/js-yaml": "^4.0.9", "@xyflow/react": "^12.10.1", "axios": "^1.13.6", @@ -2011,6 +2012,415 @@ "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", "license": "MIT" }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -3024,7 +3434,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" diff --git a/frontend/package.json b/frontend/package.json index c445b74..599aab9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.10.2", + "version": "1.11.0", "type": "module", "scripts": { "dev": "vite", @@ -19,6 +19,7 @@ "@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/inter": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", + "@radix-ui/react-tooltip": "^1.2.8", "@types/js-yaml": "^4.0.9", "@xyflow/react": "^12.10.1", "axios": "^1.13.6", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1c83913..6b10007 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -33,6 +33,7 @@ import type { NodeData, EdgeData } from '@/types' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE_STORAGE_KEY = 'homelable_canvas' +const CONTAINER_MODE_TYPES = new Set(['proxmox', 'vm', 'lxc', 'docker_host']) export default function App() { const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore() @@ -100,11 +101,11 @@ export default function App() { .then((res) => { const { nodes: apiNodes, edges: apiEdges } = res.data if (apiNodes.length > 0) { - // Build a map of proxmox container mode to know if children should be nested + // Build a map of container mode nodes to know if children should be nested const proxmoxContainerMap = new Map( (apiNodes as ApiNode[]) - .filter((n) => n.type === 'proxmox' || n.type === 'group') - .map((n) => [n.id, n.type === 'group' ? true : n.container_mode !== false]) + .filter((n) => n.type === 'group' || n.container_mode === true) + .map((n) => [n.id, true]) ) const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)) const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge) @@ -151,7 +152,7 @@ export default function App() { const handleAddNode = useCallback((data: Partial) => { snapshotHistory() const id = generateUUID() - const isProxmox = data.type === 'proxmox' + const isContainerNode = data.container_mode === true const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null // Children position is relative to parent; place near top-left with padding const position = parentNode @@ -164,7 +165,7 @@ export default function App() { position, data: { status: 'unknown', services: [], ...data } as NodeData, ...(data.parent_id ? { parentId: data.parent_id, extent: 'parent' as const } : {}), - ...(isProxmox ? { width: 300, height: 200 } : {}), + ...(isContainerNode ? { width: 300, height: 200 } : {}), } addNode(newNode) toast.success(`Added "${data.label}"`) @@ -242,13 +243,13 @@ export default function App() { snapshotHistory() const existingNode = nodes.find((n) => n.id === editNodeId) updateNode(editNodeId, data) - // If proxmox container_mode changed, apply structural changes (children parentId, node dimensions) - if (data.type === 'proxmox' && typeof data.container_mode === 'boolean') { + // If container_mode changed, apply structural changes (children parentId, node dimensions) + if (typeof data.container_mode === 'boolean') { setProxmoxContainerMode(editNodeId, data.container_mode) } // Sync virtual edge when parent_id changes on an LXC/VM node const nodeType = data.type ?? existingNode?.data.type - if ((nodeType === 'lxc' || nodeType === 'vm') && 'parent_id' in data) { + if ((nodeType === 'lxc' || nodeType === 'vm' || nodeType === 'docker_container') && 'parent_id' in data) { const oldParentId = existingNode?.data.parent_id ?? null const newParentId = data.parent_id ?? null if (oldParentId !== newParentId) { @@ -320,15 +321,19 @@ export default function App() { if (!pendingConnection) return snapshotHistory() onConnect({ ...pendingConnection, ...edgeData } as unknown as Connection) - // When a virtual edge is drawn between LXC/VM (top) and Proxmox (bottom), sync parent_id + // When a virtual edge is drawn between a child node and a container node, sync parent_id if (edgeData.type === 'virtual') { const src = nodes.find((n) => n.id === pendingConnection.source) const tgt = nodes.find((n) => n.id === pendingConnection.target) - const srcType = src?.data.type - const tgtType = tgt?.data.type - if ((srcType === 'lxc' || srcType === 'vm') && tgtType === 'proxmox') { + const srcType = src?.data.type as NodeData['type'] + const tgtType = tgt?.data.type as NodeData['type'] + if ((srcType === 'lxc' || srcType === 'vm') && CONTAINER_MODE_TYPES.has(tgtType)) { updateNode(pendingConnection.source, { parent_id: pendingConnection.target }) - } else if (srcType === 'proxmox' && (tgtType === 'lxc' || tgtType === 'vm')) { + } else if (CONTAINER_MODE_TYPES.has(srcType) && (tgtType === 'lxc' || tgtType === 'vm')) { + updateNode(pendingConnection.target, { parent_id: pendingConnection.source }) + } else if (srcType === 'docker_container' && tgtType === 'docker_host') { + updateNode(pendingConnection.source, { parent_id: pendingConnection.target }) + } else if (tgtType === 'docker_container' && srcType === 'docker_host') { updateNode(pendingConnection.target, { parent_id: pendingConnection.source }) } } @@ -423,7 +428,9 @@ export default function App() { onClose={() => setAddNodeOpen(false)} onSubmit={handleAddNode} title="Add Node" - proxmoxNodes={nodes.filter((n) => n.type === 'proxmox').map((n) => ({ id: n.id, label: n.data.label }))} + parentContainerNodes={nodes + .filter((n) => CONTAINER_MODE_TYPES.has(n.data.type) && n.data.container_mode) + .map((n) => ({ id: n.id, label: n.data.label, nodeType: n.data.type }))} /> {/* key forces re-mount when editing a different node, resetting form state */} @@ -434,7 +441,9 @@ export default function App() { onSubmit={handleUpdateNode} initial={editNode?.data} title="Edit Node" - proxmoxNodes={nodes.filter((n) => n.type === 'proxmox').map((n) => ({ id: n.id, label: n.data.label }))} + parentContainerNodes={nodes + .filter((n) => n.id !== editNodeId && CONTAINER_MODE_TYPES.has(n.data.type) && n.data.container_mode) + .map((n) => ({ id: n.id, label: n.data.label, nodeType: n.data.type }))} /> + {/* Status dot — absolute to avoid affecting node auto-width */} +
+ {/* Main row */}
{/* Icon */} @@ -121,7 +128,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: return (
{Icon && } - {prop.key} + {prop.key} · {prop.value}
) @@ -139,7 +146,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
{data.cpu_model && ( - {data.cpu_model} + {data.cpu_model} )} {data.cpu_count != null && ( {data.cpu_model ? `· ${data.cpu_count}c` : `${data.cpu_count} cores`} @@ -166,13 +173,6 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: )} - {/* Status dot */} -
- {(BOTTOM_HANDLE_POSITIONS[data.bottom_handles ?? 1] ?? BOTTOM_HANDLE_POSITIONS[1]).map((leftPct, idx) => { const sourceId = BOTTOM_HANDLE_IDS[idx] const targetId = idx === 0 ? 'bottom-t' : `bottom-${idx + 1}-t` diff --git a/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx b/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx index f8bba56..8f4cc72 100644 --- a/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx +++ b/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx @@ -5,6 +5,8 @@ import type { NodeData } from '@/types' import { resolveNodeColors } from '@/utils/nodeColors' import { resolveNodeIcon } from '@/utils/nodeIcons' import { resolvePropertyIcon } from '@/utils/propertyIcons' +import { useCanvasStore } from '@/stores/canvasStore' +import { maskIp, splitIps } from '@/utils/maskIp' import { useThemeStore } from '@/stores/themeStore' import { THEMES } from '@/utils/themes' import { BaseNode } from './BaseNode' @@ -13,6 +15,7 @@ export function ProxmoxGroupNode(props: NodeProps>) { const { data, selected } = props const activeTheme = useThemeStore((s) => s.activeTheme) + const hideIp = useCanvasStore((s) => s.hideIp) const theme = THEMES[activeTheme] const colors = resolveNodeColors(data, activeTheme) @@ -53,7 +56,7 @@ export function ProxmoxGroupNode(props: NodeProps>) { minHeight={160} isVisible={selected} lineStyle={{ borderColor: glow, opacity: 0.6 }} - handleStyle={{ borderColor: glow, backgroundColor: theme.colors.nodeCardBackground }} + handleStyle={{ borderColor: glow, backgroundColor: theme.colors.nodeCardBackground, width: 6, height: 6 }} /> {/* Group border */} @@ -71,7 +74,7 @@ export function ProxmoxGroupNode(props: NodeProps>) { > {/* Header bar */}
>) { > {data.label} - {data.ip && ( + {data.ip && splitIps(data.ip).map((ip) => ( - {data.ip} + {hideIp ? maskIp(ip) : ip} - )} + ))}
{/* Status dot */}
@@ -125,7 +129,7 @@ export function ProxmoxGroupNode(props: NodeProps>) { }} > {Icon && } - {prop.key} + {prop.key} · {prop.value}
) diff --git a/frontend/src/components/canvas/nodes/index.tsx b/frontend/src/components/canvas/nodes/index.tsx index aec536d..2dcf080 100644 --- a/frontend/src/components/canvas/nodes/index.tsx +++ b/frontend/src/components/canvas/nodes/index.tsx @@ -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, PlugZap, Anchor, + HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor, Package, } from 'lucide-react' import { BaseNode } from './BaseNode' import type { NodeData } from '@/types' @@ -22,5 +22,6 @@ export const CameraNode = (props: N) => export const PrinterNode = (props: N) => export const ComputerNode = (props: N) => export const CplNode = (props: N) => -export const DockerNode = (props: N) => +export const DockerHostNode = (props: N) => +export const DockerContainerNode = (props: N) => export const GenericNode = (props: N) => diff --git a/frontend/src/components/canvas/nodes/nodeTypes.ts b/frontend/src/components/canvas/nodes/nodeTypes.ts index 00ae141..6b33ac4 100644 --- a/frontend/src/components/canvas/nodes/nodeTypes.ts +++ b/frontend/src/components/canvas/nodes/nodeTypes.ts @@ -1,4 +1,4 @@ -import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerNode, GenericNode } from './index' +import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode } from './index' import { ProxmoxGroupNode } from './ProxmoxGroupNode' import { GroupRectNode } from './GroupRectNode' import { GroupNode } from './GroupNode' @@ -18,7 +18,8 @@ export const nodeTypes = { printer: PrinterNode, computer: ComputerNode, cpl: CplNode, - docker: DockerNode, + docker_host: DockerHostNode, + docker_container: DockerContainerNode, generic: GenericNode, groupRect: GroupRectNode, group: GroupNode, diff --git a/frontend/src/components/modals/NodeModal.tsx b/frontend/src/components/modals/NodeModal.tsx index bcb61d0..f8cf3f1 100644 --- a/frontend/src/components/modals/NodeModal.tsx +++ b/frontend/src/components/modals/NodeModal.tsx @@ -1,4 +1,4 @@ -import { createElement, Fragment, useState } from 'react' +import { Fragment, createElement, useState } from 'react' import { RotateCcw, ChevronDown } from 'lucide-react' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' @@ -11,12 +11,13 @@ import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS } from '@/utils const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [ { label: 'Hardware', types: ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer'] }, - { label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker'] }, + { label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] }, { label: 'IoT', types: ['iot', 'camera', 'cpl'] }, { label: 'Generic', types: ['computer', 'generic', 'groupRect'] }, ] const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health'] +const CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host'] const CHECK_METHOD_LABELS: Record = { none: 'None', @@ -37,7 +38,7 @@ const DEFAULT_DATA: Partial = { status: 'unknown', check_method: 'ping', services: [], - container_mode: true, + container_mode: false, custom_colors: undefined, custom_icon: undefined, } @@ -48,12 +49,12 @@ interface NodeModalProps { onSubmit: (data: Partial) => void initial?: Partial title?: string - proxmoxNodes?: { id: string; label: string }[] + parentContainerNodes?: { id: string; label: string; nodeType?: NodeType }[] } -const CHILD_TYPES: NodeType[] = ['vm', 'lxc'] - -export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', proxmoxNodes = [] }: NodeModalProps) { +// NodeModal is always mounted with a key that changes on open/edit, so useState +// initial value is enough - no need for a reset effect. +export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentContainerNodes = [] }: NodeModalProps) { const [form, setForm] = useState>({ ...DEFAULT_DATA, ...initial }) const [iconSearch, setIconSearch] = useState('') const [iconPickerOpen, setIconPickerOpen] = useState(false) @@ -69,10 +70,19 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' return } setLabelError(false) - onSubmit(form) + const selectedType = (form.type ?? 'generic') as NodeType + const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType) + onSubmit({ + ...form, + container_mode: canUseContainerMode ? !!form.container_mode : false, + }) onClose() } + const filteredParentNodes = form.type === 'docker_container' + ? parentContainerNodes.filter((n) => n.nodeType === 'docker_host') + : parentContainerNodes + return ( !o && onClose()}> @@ -92,7 +102,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' {NODE_TYPE_GROUPS.map((group, i) => ( - {i > 0 && } + {i > 0 && } {group.label} @@ -143,7 +153,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
- {/* Inline icon picker — full width, shown below the type+icon row */} + {/* Inline icon picker - full width, shown below the type+icon row */} {iconPickerOpen && (
- {/* Parent Proxmox (VM / LXC only) */} - {CHILD_TYPES.includes(form.type as NodeType) && proxmoxNodes.length > 0 && ( + {/* Parent container */} + {form.type !== 'groupRect' && form.type !== 'group' && filteredParentNodes.length > 0 && (
- +
@@ -390,4 +404,4 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' ) -} +} \ No newline at end of file diff --git a/frontend/src/components/modals/__tests__/NodeModal.test.tsx b/frontend/src/components/modals/__tests__/NodeModal.test.tsx index 7a8eee7..828a8e8 100644 --- a/frontend/src/components/modals/__tests__/NodeModal.test.tsx +++ b/frontend/src/components/modals/__tests__/NodeModal.test.tsx @@ -234,15 +234,18 @@ describe('NodeModal', () => { expect(screen.queryByTitle('Router')).toBeNull() }) - // ── Container mode (proxmox only) ───────────────────────────────────── + // ── Container mode ───────────────────────────────────────────────────── - it('shows Container Mode toggle for proxmox type', () => { - renderModal({ initial: { ...BASE, type: 'proxmox' } }) + const containerModeTypes = ['proxmox', 'vm', 'lxc', 'docker_host'] as const + const nonContainerModeTypes = ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer', 'iot', 'camera', 'cpl', 'computer', 'generic', 'docker_container', 'groupRect', 'group'] as const + + it.each(containerModeTypes)('shows Container Mode toggle for %s type', (type) => { + renderModal({ initial: { ...BASE, type } }) expect(screen.getByText('Container Mode')).toBeDefined() }) - it('hides Container Mode for non-proxmox types', () => { - renderModal({ initial: BASE }) + it.each(nonContainerModeTypes)('hides Container Mode for %s type', (type) => { + renderModal({ initial: { ...BASE, type } }) expect(screen.queryByText('Container Mode')).toBeNull() }) @@ -253,33 +256,48 @@ describe('NodeModal', () => { expect((onSubmit.mock.calls[0][0] as Partial).container_mode).toBe(false) }) - // ── Parent Proxmox (vm / lxc only) ─────────────────────────────────── + // ── Parent container ────────────────────────────────────────────────── - it('shows Parent Proxmox for vm with proxmoxNodes', () => { + const parentContainerVisibleTypes = ['proxmox', 'vm', 'lxc', 'docker_host', 'isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer', 'iot', 'camera', 'cpl', 'computer', 'generic'] as const + const parentContainerHiddenTypes = ['groupRect', 'group'] as const + + it.each(parentContainerVisibleTypes)('shows Parent Container for %s type when options are provided', (type) => { renderModal({ - initial: { ...BASE, type: 'vm' }, - proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }], + initial: { ...BASE, type }, + parentContainerNodes: [{ id: 'c1', label: 'Container 01' }], }) - expect(screen.getByText('Parent Proxmox')).toBeDefined() - expect(screen.getByText('PVE-01')).toBeDefined() + expect(screen.getByText('Parent Container')).toBeDefined() + expect(screen.getByText('Container 01')).toBeDefined() }) - it('shows Parent Proxmox for lxc with proxmoxNodes', () => { + it.each(parentContainerHiddenTypes)('hides Parent Container for %s type even when options are provided', (type) => { + renderModal({ initial: { ...BASE, type }, parentContainerNodes: [{ id: 'c1', label: 'Container 01' }] }) + expect(screen.queryByText('Parent Container')).toBeNull() + }) + + it.each(parentContainerVisibleTypes)('hides Parent Container for %s type when no container options are available', (type) => { + renderModal({ initial: { ...BASE, type } }) + expect(screen.queryByText('Parent Container')).toBeNull() + }) + + it('docker_container shows only docker_host parents', () => { renderModal({ - initial: { ...BASE, type: 'lxc' }, - proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }], + initial: { ...BASE, type: 'docker_container' }, + parentContainerNodes: [ + { id: 'h1', label: 'My Docker Host', nodeType: 'docker_host' }, + { id: 'p1', label: 'My Proxmox', nodeType: 'proxmox' }, + ], }) - expect(screen.getByText('Parent Proxmox')).toBeDefined() + expect(screen.getByText('My Docker Host')).toBeDefined() + expect(screen.queryByText('My Proxmox')).toBeNull() }) - it('hides Parent Proxmox for server type', () => { - renderModal({ initial: BASE, proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }] }) - expect(screen.queryByText('Parent Proxmox')).toBeNull() - }) - - it('hides Parent Proxmox for vm when no proxmoxNodes', () => { - renderModal({ initial: { ...BASE, type: 'vm' } }) - expect(screen.queryByText('Parent Proxmox')).toBeNull() + it('docker_container hides Parent Container when no docker_host is available', () => { + renderModal({ + initial: { ...BASE, type: 'docker_container' }, + parentContainerNodes: [{ id: 'p1', label: 'My Proxmox', nodeType: 'proxmox' }], + }) + expect(screen.queryByText('Parent Container')).toBeNull() }) // ── Appearance ──────────────────────────────────────────────────────── diff --git a/frontend/src/components/panels/DetailPanel.tsx b/frontend/src/components/panels/DetailPanel.tsx index 47b0f1d..6bc8c5a 100644 --- a/frontend/src/components/panels/DetailPanel.tsx +++ b/frontend/src/components/panels/DetailPanel.tsx @@ -2,6 +2,7 @@ import { createElement, useState } from 'react' import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip' import { useCanvasStore } from '@/stores/canvasStore' import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types' import { getServiceUrl } from '@/utils/serviceUrl' @@ -661,23 +662,87 @@ const CATEGORY_COLORS: Record = { function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) { const url = getServiceUrl(svc, host) const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e' - const portLabel = svc.port != null ? String(svc.port) : 'host' - const pathLabel = svc.path?.trim() ? svc.path.trim() : null - const inner = ( -
-
- - {svc.service_name} - {pathLabel && {pathLabel}} -
-
- {portLabel}/{svc.protocol} - {url && } - - + const hasPort = svc.port != null + const portLabel = hasPort ? String(svc.port) : '' + const pathLabel = svc.path?.trim() ? svc.path.trim() : '' + + return ( +
+ + {url ? ( + e.stopPropagation()} + > + {svc.service_name} + + ) : ( + + {svc.service_name} + + )} +
+ {pathLabel && ( + + + + + {pathLabel} + + + {pathLabel} + + + )} + {hasPort && ( + {portLabel}/{svc.protocol} + )} + {url ? ( + e.stopPropagation()} + > + + + ) : ( + + )} + +
) - if (url) return {inner} - return inner } diff --git a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx index e90c400..52c3c5f 100644 --- a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx +++ b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx @@ -445,4 +445,46 @@ describe('DetailPanel', () => { expect(screen.getByText(/192\.168\.1\.10, 192\.168\.1\.11/)).toBeDefined() }) }) + + describe('ServiceBadge rendering', () => { + it('renders service name and port/protocol label', () => { + setupStore({ services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] }) + render() + expect(screen.getByText('nginx')).toBeDefined() + expect(screen.getByText('8080/tcp')).toBeDefined() + }) + + it('renders path label when path is set', () => { + setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'web', path: '/admin' }] }) + render() + expect(screen.getByText('/admin')).toBeDefined() + }) + + it('renders no path text when path is empty', () => { + setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'web', path: '' }] }) + render() + expect(screen.queryByText('/')).toBeNull() + }) + + it('renders port/protocol omitted when port is absent', () => { + setupStore({ services: [{ protocol: 'tcp', service_name: 'health', path: '' }] }) + render() + expect(screen.getByText('health')).toBeDefined() + expect(screen.queryByText(/\/tcp/)).toBeNull() + }) + + it('renders service name as link when ip and port are set', () => { + setupStore({ ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] }) + render() + const link = screen.getByRole('link', { name: 'nginx' }) + expect(link.getAttribute('href')).toContain('192.168.1.10') + expect(link.getAttribute('target')).toBe('_blank') + }) + + it('renders service name as plain text when no url can be built', () => { + setupStore({ ip: undefined, services: [{ protocol: 'tcp', service_name: 'health', path: '' }] }) + render() + expect(screen.getByText('health').tagName).not.toBe('A') + }) + }) }) diff --git a/frontend/src/components/ui/tooltip.tsx b/frontend/src/components/ui/tooltip.tsx index 69e8a82..bcf9f23 100644 --- a/frontend/src/components/ui/tooltip.tsx +++ b/frontend/src/components/ui/tooltip.tsx @@ -1,64 +1,30 @@ -"use client" +import * as TooltipPrimitive from '@radix-ui/react-tooltip' -import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip" - -import { cn } from "@/lib/utils" - -function TooltipProvider({ - delay = 0, - ...props -}: TooltipPrimitive.Provider.Props) { - return ( - - ) -} - -function Tooltip({ ...props }: TooltipPrimitive.Root.Props) { - return -} - -function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) { - return -} +const TooltipProvider = TooltipPrimitive.Provider +const Tooltip = TooltipPrimitive.Root +const TooltipTrigger = TooltipPrimitive.Trigger function TooltipContent({ className, - side = "top", sideOffset = 4, - align = "center", - alignOffset = 0, children, ...props -}: TooltipPrimitive.Popup.Props & - Pick< - TooltipPrimitive.Positioner.Props, - "align" | "alignOffset" | "side" | "sideOffset" - >) { +}: React.ComponentPropsWithoutRef) { return ( - - - {children} - - - + {children} + ) } diff --git a/frontend/src/stores/__tests__/canvasStore.test.ts b/frontend/src/stores/__tests__/canvasStore.test.ts index f436f69..5e8c9ff 100644 --- a/frontend/src/stores/__tests__/canvasStore.test.ts +++ b/frontend/src/stores/__tests__/canvasStore.test.ts @@ -49,6 +49,32 @@ describe('canvasStore', () => { expect(hasUnsavedChanges).toBe(true) }) + it('addNode nests under parent only when parent is in container mode', () => { + const parent = { ...makeNode('p1', { container_mode: false }), position: { x: 100, y: 100 } } + const child = { ...makeNode('c1', { parent_id: 'p1' }), position: { x: 150, y: 180 } } + useCanvasStore.getState().addNode(parent) + useCanvasStore.getState().addNode(child) + const childNode = useCanvasStore.getState().nodes.find((n) => n.id === 'c1') + expect(childNode?.parentId).toBeUndefined() + + useCanvasStore.getState().updateNode('p1', { container_mode: true }) + useCanvasStore.getState().setProxmoxContainerMode('p1', true) + const nested = useCanvasStore.getState().nodes.find((n) => n.id === 'c1') + expect(nested?.parentId).toBe('p1') + expect(nested?.extent).toBe('parent') + }) + + it('docker_container nests under docker_host with container_mode on', () => { + const host = { ...makeNode('dh1', { type: 'docker_host', container_mode: true }), position: { x: 100, y: 100 } } + const container = { ...makeNode('dc1', { type: 'docker_container' }), position: { x: 160, y: 180 } } + useCanvasStore.getState().addNode(host) + useCanvasStore.getState().addNode(container) + useCanvasStore.getState().updateNode('dc1', { parent_id: 'dh1' }) + const node = useCanvasStore.getState().nodes.find((n) => n.id === 'dc1') + expect(node?.parentId).toBe('dh1') + expect(node?.extent).toBe('parent') + }) + it('updateNode updates data fields', () => { useCanvasStore.getState().addNode(makeNode('n1', { label: 'old' })) useCanvasStore.getState().updateNode('n1', { label: 'new', ip: '10.0.0.1' }) @@ -84,7 +110,7 @@ describe('canvasStore', () => { it('updateNode clearing parent_id converts position to absolute and clears parentId', () => { const proxmox = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 100, y: 100 } } - const lxc = { ...makeNode('lxc1', { type: 'lxc', parent_id: 'px1' }), position: { x: 30, y: 40 }, parentId: 'px1', extent: 'parent' as const } + const lxc = { ...makeNode('lxc1', { type: 'lxc', parent_id: 'px1' }), position: { x: 130, y: 140 }, parentId: 'px1', extent: 'parent' as const } useCanvasStore.getState().addNode(proxmox) useCanvasStore.getState().addNode(lxc) useCanvasStore.getState().updateNode('lxc1', { parent_id: undefined }) @@ -214,7 +240,7 @@ describe('canvasStore', () => { }) it('deleteNode also removes children with matching parentId', () => { - useCanvasStore.getState().addNode(makeNode('parent')) + useCanvasStore.getState().addNode(makeNode('parent', { container_mode: true })) useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' })) useCanvasStore.getState().deleteNode('parent') const { nodes } = useCanvasStore.getState() @@ -222,8 +248,8 @@ describe('canvasStore', () => { expect(nodes.find((n) => n.id === 'child')).toBeUndefined() }) - it('addNode with parent_id sets parentId and extent', () => { - useCanvasStore.getState().addNode(makeNode('parent')) + it('addNode with parent_id sets parentId and extent when parent is in container mode', () => { + useCanvasStore.getState().addNode(makeNode('parent', { container_mode: true })) useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' })) const child = useCanvasStore.getState().nodes.find((n) => n.id === 'child') expect(child?.parentId).toBe('parent') @@ -422,6 +448,26 @@ describe('canvasStore', () => { expect(hasUnsavedChanges).toBe(true) }) + it('setProxmoxContainerMode ON sets width/height for docker_host (not just proxmox)', () => { + const host: Node = { id: 'dh', type: 'docker_host', position: { x: 0, y: 0 }, data: { label: 'dh', type: 'docker_host', status: 'unknown', services: [], container_mode: false } } + useCanvasStore.setState({ nodes: [host] }) + useCanvasStore.getState().setProxmoxContainerMode('dh', true) + const updated = useCanvasStore.getState().nodes.find((n) => n.id === 'dh') + expect(updated?.data.container_mode).toBe(true) + expect(updated?.width).toBe(300) + expect(updated?.height).toBe(200) + }) + + it('setProxmoxContainerMode OFF clears width/height for docker_host', () => { + const host: Node = { id: 'dh', type: 'docker_host', position: { x: 0, y: 0 }, width: 300, height: 200, data: { label: 'dh', type: 'docker_host', status: 'unknown', services: [], container_mode: true } } + useCanvasStore.setState({ nodes: [host] }) + useCanvasStore.getState().setProxmoxContainerMode('dh', false) + const updated = useCanvasStore.getState().nodes.find((n) => n.id === 'dh') + expect(updated?.data.container_mode).toBe(false) + expect(updated?.width).toBeUndefined() + expect(updated?.height).toBeUndefined() + }) + it('setProxmoxContainerMode ON nests children inside proxmox', () => { const proxmox: Node = { id: 'px', type: 'proxmox', position: { x: 0, y: 0 }, data: { label: 'px', type: 'proxmox', status: 'unknown', services: [], container_mode: false } } const child = makeNode('vm1', { parent_id: 'px', type: 'vm' }) diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index 9df76ad..5e1c043 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -172,8 +172,18 @@ export const useCanvasStore = create((set) => ({ addNode: (node) => set((state) => { - const enriched = node.data.parent_id - ? { ...node, parentId: node.data.parent_id, extent: 'parent' as const } + const parent = node.data.parent_id ? state.nodes.find((n) => n.id === node.data.parent_id) : null + const shouldNestInParent = !!(parent?.data.container_mode) + const enriched = node.data.parent_id && shouldNestInParent + ? { + ...node, + parentId: node.data.parent_id, + extent: 'parent' as const, + position: { + x: Math.max(10, node.position.x - parent.position.x), + y: Math.max(10, node.position.y - parent.position.y), + }, + } : node // Parents must come before children in the array (React Flow requirement) const withoutNew = state.nodes.filter((n) => n.id !== node.id) @@ -283,14 +293,37 @@ export const useCanvasStore = create((set) => ({ setProxmoxContainerMode: (proxmoxId, enabled) => set((state) => { + const parentNode = state.nodes.find((n) => n.id === proxmoxId) let nodes = state.nodes.map((n) => { if (n.id === proxmoxId) { const withMode = { ...n, data: { ...n.data, container_mode: enabled } } return enabled - ? { ...withMode, width: 300, height: 200 } + ? { ...withMode, width: n.width ?? 300, height: n.height ?? 200 } : { ...withMode, width: undefined, height: undefined } } if (n.data.parent_id === proxmoxId) { + if (enabled && parentNode) { + return { + ...n, + parentId: proxmoxId, + extent: 'parent' as const, + position: { + x: Math.max(10, n.position.x - parentNode.position.x), + y: Math.max(10, n.position.y - parentNode.position.y), + }, + } + } + if (!enabled && parentNode) { + return { + ...n, + parentId: undefined, + extent: undefined, + position: { + x: parentNode.position.x + n.position.x, + y: parentNode.position.y + n.position.y, + }, + } + } return enabled ? { ...n, parentId: proxmoxId, extent: 'parent' as const } : { ...n, parentId: undefined, extent: undefined } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a9bdaee..b4e0f59 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -13,7 +13,8 @@ export type NodeType = | 'printer' | 'computer' | 'cpl' - | 'docker' + | 'docker_host' + | 'docker_container' | 'generic' | 'groupRect' | 'group' @@ -127,7 +128,8 @@ export const NODE_TYPE_LABELS: Record = { printer: 'Printer', computer: 'Computer', cpl: 'CPL / Powerline', - docker: 'Docker Host', + docker_host: 'Docker Host', + docker_container: 'Docker Container', generic: 'Generic Device', groupRect: 'Group Rectangle', group: 'Node Group', diff --git a/frontend/src/utils/__tests__/themes.test.ts b/frontend/src/utils/__tests__/themes.test.ts index aa06ea5..48e9854 100644 --- a/frontend/src/utils/__tests__/themes.test.ts +++ b/frontend/src/utils/__tests__/themes.test.ts @@ -4,7 +4,7 @@ import type { NodeType, EdgeType, NodeStatus } from '@/types' const NODE_TYPES: NodeType[] = [ 'isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc', - 'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker', 'generic', 'groupRect', + 'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker_host', 'docker_container', 'generic', 'groupRect', ] const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster'] const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown'] @@ -84,7 +84,7 @@ describe('THEMES', () => { expect(d.nodeAccents.server.border).toBe('#a855f7') expect(d.nodeAccents.isp.border).toBe('#00d4ff') expect(d.nodeAccents.proxmox.border).toBe('#ff6e00') - expect(d.nodeAccents.docker.border).toBe('#2496ED') + expect(d.nodeAccents.docker_host.border).toBe('#2496ED') expect(d.nodeCardBackground).toBe('#21262d') expect(d.nodeIconBackground).toBe('#161b22') expect(d.canvasBackground).toBe('#0d1117') diff --git a/frontend/src/utils/canvasSerializer.ts b/frontend/src/utils/canvasSerializer.ts index a0b0a43..562c400 100644 --- a/frontend/src/utils/canvasSerializer.ts +++ b/frontend/src/utils/canvasSerializer.ts @@ -134,6 +134,7 @@ export function deserializeApiNode( n: ApiNode, proxmoxContainerMap: Map, ): Node { + const normalizedType = n.type === 'docker' ? 'docker_host' : n.type if (n.type === 'groupRect') { const w = (n.custom_colors?.width as number | undefined) ?? 360 const h = (n.custom_colors?.height as number | undefined) ?? 240 @@ -152,12 +153,15 @@ export function deserializeApiNode( const parentIsContainer = n.parent_id ? (proxmoxContainerMap.get(n.parent_id) ?? false) : false return { id: n.id, - type: n.type, + type: normalizedType, position: { x: n.pos_x, y: n.pos_y }, - data: n as unknown as NodeData, + data: { ...n, type: normalizedType } as unknown as NodeData, ...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}), - ...(n.width ? { width: n.width } : n.type === 'proxmox' && n.container_mode !== false ? { width: 300 } : {}), - ...(n.height ? { height: n.height } : n.type === 'proxmox' && n.container_mode !== false ? { height: 200 } : {}), + ...(['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) && n.container_mode !== false + ? { width: n.width ?? 300, height: n.height ?? 200 } + : {}), + ...(n.width && !['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) ? { width: n.width } : {}), + ...(n.height && !['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) ? { height: n.height } : {}), } } diff --git a/frontend/src/utils/nodeIcons.ts b/frontend/src/utils/nodeIcons.ts index 6dbf5f5..3523718 100644 --- a/frontend/src/utils/nodeIcons.ts +++ b/frontend/src/utils/nodeIcons.ts @@ -32,21 +32,21 @@ export interface IconEntry { export const ICON_REGISTRY: IconEntry[] = [ // --- Infrastructure --- - { key: 'globe', label: 'Globe / ISP', category: 'Infrastructure', icon: Globe }, - { key: 'router', label: 'Router', category: 'Infrastructure', icon: Router }, - { key: 'network', label: 'Switch / Network', category: 'Infrastructure', icon: Network }, - { key: 'server', label: 'Server', category: 'Infrastructure', icon: Server }, + { key: 'globe', label: 'Globe / ISP', category: 'Infrastructure', icon: Globe }, + { key: 'router', label: 'Router', category: 'Infrastructure', icon: Router }, + { key: 'network', label: 'Switch / Network', category: 'Infrastructure', icon: Network }, + { key: 'server', label: 'Server', category: 'Infrastructure', icon: Server }, { key: 'layers', label: 'Proxmox / Hypervisor', category: 'Infrastructure', icon: Layers }, - { key: 'box', label: 'VM', category: 'Infrastructure', icon: Box }, - { key: 'container', label: 'Container / LXC', category: 'Infrastructure', icon: Container }, - { key: 'harddrive', label: 'NAS / Storage', category: 'Infrastructure', icon: HardDrive }, - { key: 'cpu', label: 'IoT / Embedded', category: 'Infrastructure', icon: Cpu }, - { key: 'wifi', label: 'Access Point', category: 'Infrastructure', icon: Wifi }, - { key: 'circle', label: 'Generic', category: 'Infrastructure', icon: Circle }, - { key: 'monitor', label: 'Workstation', category: 'Infrastructure', icon: Monitor }, - { key: 'smartphone', label: 'Phone / Mobile', category: 'Infrastructure', icon: Smartphone }, - { key: 'printer', label: 'Printer', category: 'Infrastructure', icon: Printer }, - { key: 'plugzap', label: 'CPL / Powerline', category: 'Infrastructure', icon: PlugZap }, + { key: 'box', label: 'VM', category: 'Infrastructure', icon: Box }, + { key: 'container', label: 'Container / LXC', category: 'Infrastructure', icon: Container }, + { key: 'harddrive', label: 'NAS / Storage', category: 'Infrastructure', icon: HardDrive }, + { key: 'cpu', label: 'IoT / Embedded', category: 'Infrastructure', icon: Cpu }, + { key: 'wifi', label: 'Access Point', category: 'Infrastructure', icon: Wifi }, + { key: 'circle', label: 'Generic', category: 'Infrastructure', icon: Circle }, + { key: 'monitor', label: 'Workstation', category: 'Infrastructure', icon: Monitor }, + { key: 'smartphone', label: 'Phone / Mobile', category: 'Infrastructure', icon: Smartphone }, + { key: 'printer', label: 'Printer', category: 'Infrastructure', icon: Printer }, + { key: 'plugzap', label: 'CPL / Powerline', category: 'Infrastructure', icon: PlugZap }, // --- Media --- { key: 'play', label: 'Jellyfin / Emby', category: 'Media', icon: Play }, @@ -62,44 +62,44 @@ export const ICON_REGISTRY: IconEntry[] = [ { key: 'cctv', label: 'CCTV / IP Camera', category: 'Media', icon: Cctv }, // --- Monitoring --- - { key: 'activity', label: 'Prometheus / Uptime', category: 'Monitoring', icon: Activity }, - { key: 'barchart', label: 'Grafana / Kibana', category: 'Monitoring', icon: BarChart2 }, - { key: 'linechart', label: 'InfluxDB / Metrics', category: 'Monitoring', icon: LineChart }, + { key: 'activity', label: 'Prometheus / Uptime', category: 'Monitoring', icon: Activity }, + { key: 'barchart', label: 'Grafana / Kibana', category: 'Monitoring', icon: BarChart2 }, + { key: 'linechart', label: 'InfluxDB / Metrics', category: 'Monitoring', icon: LineChart }, { key: 'eye', label: 'Overseerr / Watchlist', category: 'Monitoring', icon: Eye }, - { key: 'bell', label: 'Alerts / Notifiarr', category: 'Monitoring', icon: Bell }, - { key: 'gauge', label: 'Dashboard / Status', category: 'Monitoring', icon: Gauge }, + { key: 'bell', label: 'Alerts / Notifiarr', category: 'Monitoring', icon: Bell }, + { key: 'gauge', label: 'Dashboard / Status', category: 'Monitoring', icon: Gauge }, // --- Storage & Databases --- - { key: 'database', label: 'Database (SQL/NoSQL)', category: 'Storage', icon: Database }, - { key: 'archive', label: 'Backup / Archive', category: 'Storage', icon: Archive }, - { key: 'cloud', label: 'Nextcloud / S3', category: 'Storage', icon: Cloud }, - { key: 'folder', label: 'Files / Filebrowser', category: 'Storage', icon: FolderOpen }, + { key: 'database', label: 'Database (SQL/NoSQL)', category: 'Storage', icon: Database }, + { key: 'archive', label: 'Backup / Archive', category: 'Storage', icon: Archive }, + { key: 'cloud', label: 'Nextcloud / S3', category: 'Storage', icon: Cloud }, + { key: 'folder', label: 'Files / Filebrowser', category: 'Storage', icon: FolderOpen }, { key: 'download', label: 'Downloader (Torrent/NZB)', category: 'Storage', icon: Download }, - { key: 'upload', label: 'Upload / Sync', category: 'Storage', icon: Upload }, - { key: 'refresh', label: 'Sync / Resilio', category: 'Storage', icon: RefreshCw }, + { key: 'upload', label: 'Upload / Sync', category: 'Storage', icon: Upload }, + { key: 'refresh', label: 'Sync / Resilio', category: 'Storage', icon: RefreshCw }, // --- Security & Auth --- - { key: 'shield', label: 'Pi-hole / DNS Block', category: 'Security', icon: Shield }, - { key: 'shieldcheck', label: 'AdGuard Home', category: 'Security', icon: ShieldCheck }, + { key: 'shield', label: 'Pi-hole / DNS Block', category: 'Security', icon: Shield }, + { key: 'shieldcheck', label: 'AdGuard Home', category: 'Security', icon: ShieldCheck }, { key: 'lock', label: 'Authelia / Authentik', category: 'Security', icon: Lock }, - { key: 'key', label: 'Vaultwarden / Vault', category: 'Security', icon: Key }, - { key: 'users', label: 'LDAP / SSO', category: 'Security', icon: Users }, - { key: 'usercheck', label: 'Keycloak', category: 'Security', icon: UserCheck }, - { key: 'filter', label: 'Prowlarr / Jackett', category: 'Security', icon: Filter }, - { key: 'search', label: 'Search / Indexer', category: 'Security', icon: Search }, + { key: 'key', label: 'Vaultwarden / Vault', category: 'Security', icon: Key }, + { key: 'users', label: 'LDAP / SSO', category: 'Security', icon: Users }, + { key: 'usercheck', label: 'Keycloak', category: 'Security', icon: UserCheck }, + { key: 'filter', label: 'Prowlarr / Jackett', category: 'Security', icon: Filter }, + { key: 'search', label: 'Search / Indexer', category: 'Security', icon: Search }, // --- Automation & Smart Home --- - { key: 'home', label: 'Home Assistant', category: 'Automation', icon: Home }, - { key: 'zap', label: 'ESPHome / Node-RED', category: 'Automation', icon: Zap }, - { key: 'workflow', label: 'n8n / Node-RED', category: 'Automation', icon: Workflow }, - { key: 'bot', label: 'Bot / Automation', category: 'Automation', icon: Bot }, + { key: 'home', label: 'Home Assistant', category: 'Automation', icon: Home }, + { key: 'zap', label: 'ESPHome / Node-RED', category: 'Automation', icon: Zap }, + { key: 'workflow', label: 'n8n / Node-RED', category: 'Automation', icon: Workflow }, + { key: 'bot', label: 'Bot / Automation', category: 'Automation', icon: Bot }, { key: 'thermometer', label: 'Sensor / Temperature', category: 'Automation', icon: Thermometer }, - { key: 'lightbulb', label: 'Smart Light / Zigbee', category: 'Automation', icon: Lightbulb }, - { key: 'radio', label: 'MQTT / RTL-SDR', category: 'Automation', icon: Radio }, + { key: 'lightbulb', label: 'Smart Light / Zigbee', category: 'Automation', icon: Lightbulb }, + { key: 'radio', label: 'MQTT / RTL-SDR', category: 'Automation', icon: Radio }, // --- Containers & Dev --- - { key: 'anchor', label: 'Portainer / Docker', category: 'Dev & Containers', icon: Anchor }, - { key: 'package', label: 'Docker Host', category: 'Dev & Containers', icon: Package }, + { key: 'anchor', label: 'Portainer / Docker', category: 'Dev & Containers', icon: Anchor }, + { key: 'package', label: 'Docker Host / Container', category: 'Dev & Containers', icon: Package }, { key: 'gitbranch', label: 'Gitea / Gitlab', category: 'Dev & Containers', icon: GitBranch }, { key: 'terminal', label: 'SSH / Shell', category: 'Dev & Containers', icon: Terminal }, { key: 'code', label: 'VS Code Server', category: 'Dev & Containers', icon: Code2 }, @@ -118,24 +118,25 @@ export const ICON_MAP: Record = Object.fromEntries( ) export const NODE_TYPE_DEFAULT_ICONS: Record = { - isp: Globe, - router: Router, - switch: Network, - server: Server, - proxmox: Layers, - vm: Box, - lxc: Container, - nas: HardDrive, - iot: Cpu, - ap: Wifi, - camera: Cctv, - printer: Printer, - computer: Monitor, - cpl: PlugZap, - docker: Anchor, - generic: Circle, - group: Circle, - groupRect: Circle, + isp: Globe, + router: Router, + switch: Network, + server: Server, + proxmox: Layers, + vm: Box, + lxc: Container, + nas: HardDrive, + iot: Cpu, + ap: Wifi, + camera: Cctv, + printer: Printer, + computer: Monitor, + cpl: PlugZap, + docker_host: Anchor, + docker_container: Package, + generic: Circle, + group: Circle, + groupRect: Circle, } /** Resolve the display icon for a node — custom_icon takes priority over type default. */ diff --git a/frontend/src/utils/themes.ts b/frontend/src/utils/themes.ts index 23d0be0..7f1be3c 100644 --- a/frontend/src/utils/themes.ts +++ b/frontend/src/utils/themes.ts @@ -42,24 +42,25 @@ export const THEMES: Record = { description: 'Dark futuristic — the original Homelable look', colors: { nodeAccents: { - isp: { border: '#00d4ff', icon: '#00d4ff' }, - router: { border: '#00d4ff', icon: '#00d4ff' }, - switch: { border: '#39d353', icon: '#39d353' }, - server: { border: '#a855f7', icon: '#a855f7' }, - proxmox: { border: '#ff6e00', icon: '#ff6e00' }, - vm: { border: '#a855f7', icon: '#a855f7' }, - lxc: { border: '#00d4ff', icon: '#00d4ff' }, - nas: { border: '#39d353', icon: '#39d353' }, - iot: { border: '#e3b341', icon: '#e3b341' }, - ap: { border: '#00d4ff', icon: '#00d4ff' }, - camera: { border: '#8b949e', icon: '#8b949e' }, - printer: { border: '#8b949e', icon: '#8b949e' }, - computer: { border: '#a855f7', icon: '#a855f7' }, - cpl: { border: '#e3b341', icon: '#e3b341' }, - docker: { border: '#2496ED', icon: '#2496ED' }, - generic: { border: '#8b949e', icon: '#8b949e' }, - groupRect:{ border: '#00d4ff', icon: '#00d4ff' }, - group: { border: '#00d4ff', icon: '#00d4ff' }, + isp: { border: '#00d4ff', icon: '#00d4ff' }, + router: { border: '#00d4ff', icon: '#00d4ff' }, + switch: { border: '#39d353', icon: '#39d353' }, + server: { border: '#a855f7', icon: '#a855f7' }, + proxmox: { border: '#ff6e00', icon: '#ff6e00' }, + vm: { border: '#a855f7', icon: '#a855f7' }, + lxc: { border: '#00d4ff', icon: '#00d4ff' }, + nas: { border: '#39d353', icon: '#39d353' }, + iot: { border: '#e3b341', icon: '#e3b341' }, + ap: { border: '#00d4ff', icon: '#00d4ff' }, + camera: { border: '#8b949e', icon: '#8b949e' }, + printer: { border: '#8b949e', icon: '#8b949e' }, + computer: { border: '#a855f7', icon: '#a855f7' }, + cpl: { border: '#e3b341', icon: '#e3b341' }, + docker_host: { border: '#2496ED', icon: '#2496ED' }, + docker_container: { border: '#0ea5e9', icon: '#0ea5e9' }, + generic: { border: '#8b949e', icon: '#8b949e' }, + groupRect: { border: '#00d4ff', icon: '#00d4ff' }, + group: { border: '#00d4ff', icon: '#00d4ff' }, }, nodeCardBackground: '#21262d', nodeIconBackground: '#161b22', @@ -97,24 +98,25 @@ export const THEMES: Record = { description: 'Pure black with maximum contrast', colors: { nodeAccents: { - isp: { border: '#22d3ee', icon: '#22d3ee' }, - router: { border: '#22d3ee', icon: '#22d3ee' }, - switch: { border: '#4ade80', icon: '#4ade80' }, - server: { border: '#c084fc', icon: '#c084fc' }, - proxmox: { border: '#fb923c', icon: '#fb923c' }, - vm: { border: '#c084fc', icon: '#c084fc' }, - lxc: { border: '#22d3ee', icon: '#22d3ee' }, - nas: { border: '#4ade80', icon: '#4ade80' }, - iot: { border: '#fbbf24', icon: '#fbbf24' }, - ap: { border: '#22d3ee', icon: '#22d3ee' }, - camera: { border: '#94a3b8', icon: '#94a3b8' }, - printer: { border: '#94a3b8', icon: '#94a3b8' }, - computer: { border: '#c084fc', icon: '#c084fc' }, - cpl: { border: '#fbbf24', icon: '#fbbf24' }, - docker: { border: '#2496ED', icon: '#2496ED' }, - generic: { border: '#94a3b8', icon: '#94a3b8' }, - groupRect:{ border: '#22d3ee', icon: '#22d3ee' }, - group: { border: '#22d3ee', icon: '#22d3ee' }, + isp: { border: '#22d3ee', icon: '#22d3ee' }, + router: { border: '#22d3ee', icon: '#22d3ee' }, + switch: { border: '#4ade80', icon: '#4ade80' }, + server: { border: '#c084fc', icon: '#c084fc' }, + proxmox: { border: '#fb923c', icon: '#fb923c' }, + vm: { border: '#c084fc', icon: '#c084fc' }, + lxc: { border: '#22d3ee', icon: '#22d3ee' }, + nas: { border: '#4ade80', icon: '#4ade80' }, + iot: { border: '#fbbf24', icon: '#fbbf24' }, + ap: { border: '#22d3ee', icon: '#22d3ee' }, + camera: { border: '#94a3b8', icon: '#94a3b8' }, + printer: { border: '#94a3b8', icon: '#94a3b8' }, + computer: { border: '#c084fc', icon: '#c084fc' }, + cpl: { border: '#fbbf24', icon: '#fbbf24' }, + docker_host: { border: '#2496ED', icon: '#2496ED' }, + docker_container: { border: '#38bdf8', icon: '#38bdf8' }, + generic: { border: '#94a3b8', icon: '#94a3b8' }, + groupRect: { border: '#22d3ee', icon: '#22d3ee' }, + group: { border: '#22d3ee', icon: '#22d3ee' }, }, nodeCardBackground: '#0a0a0a', nodeIconBackground: '#111111', @@ -152,24 +154,25 @@ export const THEMES: Record = { description: 'Clean light theme with dark text', colors: { nodeAccents: { - isp: { border: '#0284c7', icon: '#0284c7' }, - router: { border: '#0284c7', icon: '#0284c7' }, - switch: { border: '#16a34a', icon: '#16a34a' }, - server: { border: '#7c3aed', icon: '#7c3aed' }, - proxmox: { border: '#ea580c', icon: '#ea580c' }, - vm: { border: '#7c3aed', icon: '#7c3aed' }, - lxc: { border: '#0284c7', icon: '#0284c7' }, - nas: { border: '#16a34a', icon: '#16a34a' }, - iot: { border: '#b45309', icon: '#b45309' }, - ap: { border: '#0284c7', icon: '#0284c7' }, - camera: { border: '#6b7280', icon: '#6b7280' }, - printer: { border: '#6b7280', icon: '#6b7280' }, - computer: { border: '#7c3aed', icon: '#7c3aed' }, - cpl: { border: '#b45309', icon: '#b45309' }, - docker: { border: '#2496ED', icon: '#2496ED' }, - generic: { border: '#6b7280', icon: '#6b7280' }, - groupRect:{ border: '#0284c7', icon: '#0284c7' }, - group: { border: '#0284c7', icon: '#0284c7' }, + isp: { border: '#0284c7', icon: '#0284c7' }, + router: { border: '#0284c7', icon: '#0284c7' }, + switch: { border: '#16a34a', icon: '#16a34a' }, + server: { border: '#7c3aed', icon: '#7c3aed' }, + proxmox: { border: '#ea580c', icon: '#ea580c' }, + vm: { border: '#7c3aed', icon: '#7c3aed' }, + lxc: { border: '#0284c7', icon: '#0284c7' }, + nas: { border: '#16a34a', icon: '#16a34a' }, + iot: { border: '#b45309', icon: '#b45309' }, + ap: { border: '#0284c7', icon: '#0284c7' }, + camera: { border: '#6b7280', icon: '#6b7280' }, + printer: { border: '#6b7280', icon: '#6b7280' }, + computer: { border: '#7c3aed', icon: '#7c3aed' }, + cpl: { border: '#b45309', icon: '#b45309' }, + docker_host: { border: '#2496ED', icon: '#2496ED' }, + docker_container: { border: '#0369a1', icon: '#0369a1' }, + generic: { border: '#6b7280', icon: '#6b7280' }, + groupRect: { border: '#0284c7', icon: '#0284c7' }, + group: { border: '#0284c7', icon: '#0284c7' }, }, nodeCardBackground: '#ffffff', nodeIconBackground: '#f0f6ff', @@ -207,24 +210,25 @@ export const THEMES: Record = { description: 'Cyberpunk vibes with vivid glowing accents', colors: { nodeAccents: { - isp: { border: '#00ffff', icon: '#00ffff' }, - router: { border: '#00ffff', icon: '#00ffff' }, - switch: { border: '#00ff80', icon: '#00ff80' }, - server: { border: '#ff00ff', icon: '#ff00ff' }, - proxmox: { border: '#ff8800', icon: '#ff8800' }, - vm: { border: '#ff00ff', icon: '#ff00ff' }, - lxc: { border: '#00ffff', icon: '#00ffff' }, - nas: { border: '#00ff80', icon: '#00ff80' }, - iot: { border: '#ffff00', icon: '#ffff00' }, - ap: { border: '#00ffff', icon: '#00ffff' }, - camera: { border: '#8888ff', icon: '#8888ff' }, - printer: { border: '#8888ff', icon: '#8888ff' }, - computer: { border: '#ff00ff', icon: '#ff00ff' }, - cpl: { border: '#ffff00', icon: '#ffff00' }, - docker: { border: '#00aaff', icon: '#00aaff' }, - generic: { border: '#8888ff', icon: '#8888ff' }, - groupRect:{ border: '#00ffff', icon: '#00ffff' }, - group: { border: '#00ffff', icon: '#00ffff' }, + isp: { border: '#00ffff', icon: '#00ffff' }, + router: { border: '#00ffff', icon: '#00ffff' }, + switch: { border: '#00ff80', icon: '#00ff80' }, + server: { border: '#ff00ff', icon: '#ff00ff' }, + proxmox: { border: '#ff8800', icon: '#ff8800' }, + vm: { border: '#ff00ff', icon: '#ff00ff' }, + lxc: { border: '#00ffff', icon: '#00ffff' }, + nas: { border: '#00ff80', icon: '#00ff80' }, + iot: { border: '#ffff00', icon: '#ffff00' }, + ap: { border: '#00ffff', icon: '#00ffff' }, + camera: { border: '#8888ff', icon: '#8888ff' }, + printer: { border: '#8888ff', icon: '#8888ff' }, + computer: { border: '#ff00ff', icon: '#ff00ff' }, + cpl: { border: '#ffff00', icon: '#ffff00' }, + docker_host: { border: '#00aaff', icon: '#00aaff' }, + docker_container: { border: '#00ddff', icon: '#00ddff' }, + generic: { border: '#8888ff', icon: '#8888ff' }, + groupRect: { border: '#00ffff', icon: '#00ffff' }, + group: { border: '#00ffff', icon: '#00ffff' }, }, nodeCardBackground: '#0f0f2a', nodeIconBackground: '#0a0a1a', @@ -262,24 +266,25 @@ export const THEMES: Record = { description: 'Everything in terminal green', colors: { nodeAccents: { - isp: { border: '#00ff41', icon: '#00ff41' }, - router: { border: '#00ff41', icon: '#00ff41' }, - switch: { border: '#00cc33', icon: '#00cc33' }, - server: { border: '#008822', icon: '#008822' }, - proxmox: { border: '#33ff66', icon: '#33ff66' }, - vm: { border: '#008822', icon: '#008822' }, - lxc: { border: '#00ff41', icon: '#00ff41' }, - nas: { border: '#00cc33', icon: '#00cc33' }, - iot: { border: '#66ff33', icon: '#66ff33' }, - ap: { border: '#00ff41', icon: '#00ff41' }, - camera: { border: '#005500', icon: '#005500' }, - printer: { border: '#005500', icon: '#005500' }, - computer: { border: '#008822', icon: '#008822' }, - cpl: { border: '#66ff33', icon: '#66ff33' }, - docker: { border: '#00cc88', icon: '#00cc88' }, - generic: { border: '#006600', icon: '#006600' }, - groupRect:{ border: '#00ff41', icon: '#00ff41' }, - group: { border: '#00ff41', icon: '#00ff41' }, + isp: { border: '#00ff41', icon: '#00ff41' }, + router: { border: '#00ff41', icon: '#00ff41' }, + switch: { border: '#00cc33', icon: '#00cc33' }, + server: { border: '#008822', icon: '#008822' }, + proxmox: { border: '#33ff66', icon: '#33ff66' }, + vm: { border: '#008822', icon: '#008822' }, + lxc: { border: '#00ff41', icon: '#00ff41' }, + nas: { border: '#00cc33', icon: '#00cc33' }, + iot: { border: '#66ff33', icon: '#66ff33' }, + ap: { border: '#00ff41', icon: '#00ff41' }, + camera: { border: '#005500', icon: '#005500' }, + printer: { border: '#005500', icon: '#005500' }, + computer: { border: '#008822', icon: '#008822' }, + cpl: { border: '#66ff33', icon: '#66ff33' }, + docker_host: { border: '#00cc88', icon: '#00cc88' }, + docker_container: { border: '#00aacc', icon: '#00aacc' }, + generic: { border: '#006600', icon: '#006600' }, + groupRect: { border: '#00ff41', icon: '#00ff41' }, + group: { border: '#00ff41', icon: '#00ff41' }, }, nodeCardBackground: '#001100', nodeIconBackground: '#002200',