Compare commits

..

4 Commits

Author SHA1 Message Date
Pouzor 2e49c14028 feat: add YAML export — serialize canvas nodes/edges to downloadable .yaml file 2026-03-23 21:59:04 +01:00
Pouzor d9787fdcbb security issue 2026-03-21 01:09:06 +01:00
Pouzor 06ec18a137 feat: add hardware specs to nodes (CPU, RAM, Disk) with canvas display
- Add cpu_count, cpu_model, ram_gb, disk_gb, show_hardware fields to NodeData
- NodeModal: collapsible Hardware section with toggle to show specs on canvas
- BaseNode: 2-line hardware section with Cpu/MemoryStick/HardDrive icons, only renders set fields
- DetailPanel: Hardware section with GB/TB formatting
- Backend: schema, model, DB migration, canvas save/load support
- Tests: frontend (NodeModal, DetailPanel) + backend (canvas persist/load)
2026-03-21 00:01:15 +01:00
Pouzor adb4474687 feat: add Docker node type, group rect border style, and categorized node type selector
- Fix DockerNode default icon: use Anchor instead of Package
- Add border_style option to Group Rectangle (solid/dashed/dotted/double/none)
- Group node type selector by category (Hardware, Virtualization, IoT, Generic)
- Add tests for border style feature
2026-03-20 21:09:58 +01:00
27 changed files with 945 additions and 45 deletions
+10
View File
@@ -42,6 +42,16 @@ async def init_db() -> None:
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT") await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT")
with suppress(Exception): with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0") await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0")
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_count INTEGER")
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_model TEXT")
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN ram_gb REAL")
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN disk_gb REAL")
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_hardware BOOLEAN NOT NULL DEFAULT 0")
async def get_db() -> AsyncGenerator[AsyncSession, None]: async def get_db() -> AsyncGenerator[AsyncSession, None]:
+5
View File
@@ -37,6 +37,11 @@ class Node(Base):
container_mode: Mapped[bool] = mapped_column(Boolean, default=False) container_mode: Mapped[bool] = mapped_column(Boolean, default=False)
custom_colors: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) custom_colors: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
custom_icon: Mapped[str | None] = mapped_column(String, nullable=True) custom_icon: Mapped[str | None] = mapped_column(String, nullable=True)
cpu_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
cpu_model: Mapped[str | None] = mapped_column(String, nullable=True)
ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
show_hardware: Mapped[bool] = mapped_column(Boolean, default=False)
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
response_time_ms: Mapped[int | None] = mapped_column(Integer) response_time_ms: Mapped[int | None] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+5
View File
@@ -23,6 +23,11 @@ class NodeSave(BaseModel):
container_mode: bool = False container_mode: bool = False
custom_colors: dict[str, Any] | None = None custom_colors: dict[str, Any] | None = None
custom_icon: str | None = None custom_icon: str | None = None
cpu_count: int | None = None
cpu_model: str | None = None
ram_gb: float | None = None
disk_gb: float | None = None
show_hardware: bool = False
pos_x: float = 0 pos_x: float = 0
pos_y: float = 0 pos_y: float = 0
+10
View File
@@ -22,6 +22,11 @@ class NodeBase(BaseModel):
container_mode: bool = False container_mode: bool = False
custom_colors: dict[str, Any] | None = None custom_colors: dict[str, Any] | None = None
custom_icon: str | None = None custom_icon: str | None = None
cpu_count: int | None = None
cpu_model: str | None = None
ram_gb: float | None = None
disk_gb: float | None = None
show_hardware: bool = False
class NodeCreate(NodeBase): class NodeCreate(NodeBase):
@@ -46,6 +51,11 @@ class NodeUpdate(BaseModel):
container_mode: bool | None = None container_mode: bool | None = None
custom_colors: dict[str, Any] | None = None custom_colors: dict[str, Any] | None = None
custom_icon: str | None = None custom_icon: str | None = None
cpu_count: int | None = None
cpu_model: str | None = None
ram_gb: float | None = None
disk_gb: float | None = None
show_hardware: bool | None = None
class NodeResponse(NodeBase): class NodeResponse(NodeBase):
+53
View File
@@ -138,3 +138,56 @@ async def test_save_canvas_custom_icon_cleared_when_null(client: AsyncClient, he
async def test_save_canvas_requires_auth(client: AsyncClient): async def test_save_canvas_requires_auth(client: AsyncClient):
res = await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}}) res = await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}})
assert res.status_code == 401 assert res.status_code == 401
async def test_save_canvas_persists_hardware_fields(client: AsyncClient, headers: dict):
n1 = node_payload(cpu_count=8, cpu_model="Intel i7-12700K", ram_gb=32.0, disk_gb=500.0)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
node = canvas["nodes"][0]
assert node["cpu_count"] == 8
assert node["cpu_model"] == "Intel i7-12700K"
assert node["ram_gb"] == 32.0
assert node["disk_gb"] == 500.0
async def test_save_canvas_hardware_fields_nullable(client: AsyncClient, headers: dict):
n1 = node_payload(cpu_count=4, ram_gb=16.0)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
node = canvas["nodes"][0]
assert node["cpu_count"] == 4
assert node["ram_gb"] == 16.0
assert node["cpu_model"] is None
assert node["disk_gb"] is None
async def test_save_canvas_persists_show_hardware(client: AsyncClient, headers: dict):
n1 = node_payload(show_hardware=True, cpu_count=4, ram_gb=16.0)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["show_hardware"] is True
async def test_save_canvas_show_hardware_defaults_false(client: AsyncClient, headers: dict):
n1 = node_payload()
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["show_hardware"] is False
async def test_save_canvas_hardware_fields_cleared_on_update(client: AsyncClient, headers: dict):
n1 = node_payload(cpu_count=8, ram_gb=32.0)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
n1_cleared = {**n1, "cpu_count": None, "ram_gb": None}
await client.post("/api/v1/canvas/save", json={"nodes": [n1_cleared], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
node = canvas["nodes"][0]
assert node["cpu_count"] is None
assert node["ram_gb"] is None
+11 -3
View File
@@ -13,12 +13,14 @@
"@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8",
"@types/js-yaml": "^4.0.9",
"@xyflow/react": "^12.10.1", "@xyflow/react": "^12.10.1",
"axios": "^1.13.6", "axios": "^1.13.6",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dagre": "^0.8.5", "dagre": "^0.8.5",
"html-to-image": "^1.11.13", "html-to-image": "^1.11.13",
"js-yaml": "^4.1.1",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
@@ -3008,6 +3010,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/js-yaml": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
"license": "MIT"
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -5474,9 +5482,9 @@
} }
}, },
"node_modules/flatted": { "node_modules/flatted": {
"version": "3.4.1", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
+2
View File
@@ -19,12 +19,14 @@
"@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8",
"@types/js-yaml": "^4.0.9",
"@xyflow/react": "^12.10.1", "@xyflow/react": "^12.10.1",
"axios": "^1.13.6", "axios": "^1.13.6",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dagre": "^0.8.5", "dagre": "^0.8.5",
"html-to-image": "^1.11.13", "html-to-image": "^1.11.13",
"js-yaml": "^4.1.1",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
+17
View File
@@ -5,6 +5,7 @@ import { applyDagreLayout } from '@/utils/layout'
import { generateUUID } from '@/utils/uuid' import { generateUUID } from '@/utils/uuid'
import { generateMarkdownTable } from '@/utils/exportMarkdown' import { generateMarkdownTable } from '@/utils/exportMarkdown'
import { exportToPng } from '@/utils/export' import { exportToPng } from '@/utils/export'
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
import { TooltipProvider } from '@/components/ui/tooltip' import { TooltipProvider } from '@/components/ui/tooltip'
import { Toaster } from '@/components/ui/sonner' import { Toaster } from '@/components/ui/sonner'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -103,6 +104,11 @@ export default function App() {
container_mode: n.data.container_mode ?? false, container_mode: n.data.container_mode ?? false,
custom_colors: n.data.custom_colors ?? null, custom_colors: n.data.custom_colors ?? null,
custom_icon: n.data.custom_icon ?? null, custom_icon: n.data.custom_icon ?? null,
cpu_count: n.data.cpu_count ?? null,
cpu_model: n.data.cpu_model ?? null,
ram_gb: n.data.ram_gb ?? null,
disk_gb: n.data.disk_gb ?? null,
show_hardware: n.data.show_hardware ?? false,
pos_x: n.position.x, pos_x: n.position.x,
pos_y: n.position.y, pos_y: n.position.y,
} }
@@ -273,6 +279,7 @@ export default function App() {
services: [], services: [],
custom_colors: { custom_colors: {
border: data.border_color, border: data.border_color,
border_style: data.border_style,
background: data.background_color, background: data.background_color,
text_color: data.text_color, text_color: data.text_color,
text_position: data.text_position, text_position: data.text_position,
@@ -295,6 +302,7 @@ export default function App() {
custom_colors: { custom_colors: {
...existing?.data.custom_colors, ...existing?.data.custom_colors,
border: data.border_color, border: data.border_color,
border_style: data.border_style,
background: data.background_color, background: data.background_color,
text_color: data.text_color, text_color: data.text_color,
text_position: data.text_position, text_position: data.text_position,
@@ -364,6 +372,13 @@ export default function App() {
toast.success('Markdown table copied to clipboard') toast.success('Markdown table copied to clipboard')
}, [nodes]) }, [nodes])
const handleExportYaml = useCallback(() => {
if (nodes.length === 0) { toast.error('No nodes to export'); return }
const content = exportCanvasToYaml(nodes, edges)
downloadYaml(content)
toast.success('Canvas exported as YAML')
}, [nodes, edges])
const handleExport = useCallback(async () => { const handleExport = useCallback(async () => {
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow') const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
if (!el) { toast.error('Canvas not ready'); return } if (!el) { toast.error('Canvas not ready'); return }
@@ -442,6 +457,7 @@ export default function App() {
onRedo={redo} onRedo={redo}
onShortcuts={() => setShortcutsOpen(true)} onShortcuts={() => setShortcutsOpen(true)}
onExportMd={handleExportMd} onExportMd={handleExportMd}
onExportYaml={handleExportYaml}
/> />
<div className="flex flex-1 min-h-0"> <div className="flex flex-1 min-h-0">
<div ref={canvasRef} className="flex-1 min-w-0 h-full"> <div ref={canvasRef} className="flex-1 min-w-0 h-full">
@@ -525,6 +541,7 @@ export default function App() {
text_color: rc.text_color ?? '#e6edf3', text_color: rc.text_color ?? '#e6edf3',
text_position: rc.text_position ?? 'top-left', text_position: rc.text_position ?? 'top-left',
border_color: rc.border ?? '#00d4ff', border_color: rc.border ?? '#00d4ff',
border_style: rc.border_style ?? 'solid',
background_color: rc.background ?? '#00d4ff0d', background_color: rc.background ?? '#00d4ff0d',
z_order: rc.z_order ?? 1, z_order: rc.z_order ?? 1,
} }
@@ -1,6 +1,6 @@
import { createElement } from 'react' import { createElement } from 'react'
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react' import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'
import { type LucideIcon } from 'lucide-react' import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react'
import type { NodeData } from '@/types' import type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors' import { resolveNodeColors } from '@/utils/nodeColors'
import { resolveNodeIcon } from '@/utils/nodeIcons' import { resolveNodeIcon } from '@/utils/nodeIcons'
@@ -13,6 +13,11 @@ interface BaseNodeProps extends NodeProps<Node<NodeData>> {
icon: LucideIcon icon: LucideIcon
} }
function formatStorage(gb: number): string {
if (gb >= 1024) return `${(gb / 1024).toFixed(1).replace(/\.0$/, '')} TB`
return `${gb} GB`
}
export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) { export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
const activeTheme = useThemeStore((s) => s.activeTheme) const activeTheme = useThemeStore((s) => s.activeTheme)
const hideIp = useCanvasStore((s) => s.hideIp) const hideIp = useCanvasStore((s) => s.hideIp)
@@ -22,10 +27,11 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
const colors = resolveNodeColors(data, activeTheme) const colors = resolveNodeColors(data, activeTheme)
const statusColor = theme.colors.statusColors[data.status] const statusColor = theme.colors.statusColors[data.status]
const isOnline = data.status === 'online' const isOnline = data.status === 'online'
const showHardware = data.show_hardware && (data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null)
return ( return (
<div <div
className="relative flex flex-row items-center gap-2.5 px-2.5 py-2 rounded-lg border transition-all duration-200" className="relative flex flex-col rounded-lg border transition-all duration-200"
style={{ style={{
background: colors.background, background: colors.background,
borderColor: colors.border, borderColor: colors.border,
@@ -47,36 +53,77 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
/> />
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} /> <Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
{/* Icon */} {/* Main row */}
<div <div className="flex flex-row items-center gap-2.5 px-2.5 py-2">
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0" {/* Icon */}
style={{ <div
color: isOnline ? colors.icon : theme.colors.nodeSubtextColor, className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
background: theme.colors.nodeIconBackground, style={{
}} color: isOnline ? colors.icon : theme.colors.nodeSubtextColor,
> background: theme.colors.nodeIconBackground,
{createElement(resolvedIcon, { size: 15 })} }}
>
{createElement(resolvedIcon, { size: 15 })}
</div>
{/* Label + IP */}
<div className="flex flex-col min-w-0">
<div
className="text-xs font-medium leading-tight truncate max-w-[110px]"
style={{ color: theme.colors.nodeLabelColor }}
title={data.label}
>
{data.label}
</div>
{data.ip && (
<div
className="font-mono text-[10px] truncate"
style={{ color: theme.colors.nodeSubtextColor }}
title={data.ip}
>
{hideIp ? maskIp(data.ip) : data.ip}
</div>
)}
</div>
</div> </div>
{/* Details */} {/* Hardware section */}
<div className="flex flex-col min-w-0"> {showHardware && (
<div <>
className="text-xs font-medium leading-tight truncate max-w-[110px]" <div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
style={{ color: theme.colors.nodeLabelColor }} <div className="flex flex-col gap-1 px-2.5 py-1.5">
title={data.label} {/* Line 1: CPU */}
> {(data.cpu_model || data.cpu_count != null) && (
{data.label} <div className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
</div> <Cpu size={9} className="shrink-0" />
{data.ip && ( {data.cpu_model && (
<div <span className="truncate max-w-[80px]" title={data.cpu_model}>{data.cpu_model}</span>
className="font-mono text-[10px] truncate" )}
style={{ color: theme.colors.nodeSubtextColor }} {data.cpu_count != null && (
title={data.ip} <span className="shrink-0">{data.cpu_model ? `· ${data.cpu_count}c` : `${data.cpu_count} cores`}</span>
> )}
{hideIp ? maskIp(data.ip) : data.ip} </div>
)}
{/* Line 2: RAM + Disk */}
{(data.ram_gb != null || data.disk_gb != null) && (
<div className="flex items-center gap-2 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
{data.ram_gb != null && (
<span className="flex items-center gap-0.5">
<MemoryStick size={9} className="shrink-0" />
{formatStorage(data.ram_gb)}
</span>
)}
{data.disk_gb != null && (
<span className="flex items-center gap-0.5">
<HardDrive size={9} className="shrink-0" />
{formatStorage(data.disk_gb)}
</span>
)}
</div>
)}
</div> </div>
)} </>
</div> )}
{/* Status dot */} {/* Status dot */}
<div <div
@@ -31,6 +31,7 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
const rc = data.custom_colors ?? {} const rc = data.custom_colors ?? {}
const borderColor = rc.border ?? '#00d4ff' const borderColor = rc.border ?? '#00d4ff'
const borderStyle = rc.border_style ?? 'solid'
const backgroundColor = rc.background ?? 'rgba(0,212,255,0.05)' const backgroundColor = rc.background ?? 'rgba(0,212,255,0.05)'
const textColor = rc.text_color ?? '#e6edf3' const textColor = rc.text_color ?? '#e6edf3'
const fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter const fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter
@@ -61,7 +62,7 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
justifyContent: posStyle.justifyContent, justifyContent: posStyle.justifyContent,
padding: 12, padding: 12,
background: backgroundColor, background: backgroundColor,
border: `${selected ? 2 : 1}px solid ${selected ? '#00d4ff' : borderColor}`, border: `${selected ? 2 : 1}px ${selected ? 'solid' : borderStyle} ${selected ? '#00d4ff' : borderColor}`,
borderRadius: 10, borderRadius: 10,
fontFamily, fontFamily,
color: textColor, color: textColor,
@@ -1,7 +1,7 @@
import { type NodeProps, type Node } from '@xyflow/react' import { type NodeProps, type Node } from '@xyflow/react'
import { import {
Globe, Router, Network, Server, Layers, Box, Container, Globe, Router, Network, Server, Layers, Box, Container,
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor,
} from 'lucide-react' } from 'lucide-react'
import { BaseNode } from './BaseNode' import { BaseNode } from './BaseNode'
import type { NodeData } from '@/types' import type { NodeData } from '@/types'
@@ -22,4 +22,5 @@ export const CameraNode = (props: N) => <BaseNode {...props} icon={Cctv} />
export const PrinterNode = (props: N) => <BaseNode {...props} icon={Printer} /> export const PrinterNode = (props: N) => <BaseNode {...props} icon={Printer} />
export const ComputerNode = (props: N) => <BaseNode {...props} icon={Monitor} /> export const ComputerNode = (props: N) => <BaseNode {...props} icon={Monitor} />
export const CplNode = (props: N) => <BaseNode {...props} icon={PlugZap} /> export const CplNode = (props: N) => <BaseNode {...props} icon={PlugZap} />
export const DockerNode = (props: N) => <BaseNode {...props} icon={Anchor} />
export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} /> export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
@@ -1,4 +1,4 @@
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, GenericNode } from './index' import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerNode, GenericNode } from './index'
import { ProxmoxGroupNode } from './ProxmoxGroupNode' import { ProxmoxGroupNode } from './ProxmoxGroupNode'
import { GroupRectNode } from './GroupRectNode' import { GroupRectNode } from './GroupRectNode'
@@ -17,6 +17,7 @@ export const nodeTypes = {
printer: PrinterNode, printer: PrinterNode,
computer: ComputerNode, computer: ComputerNode,
cpl: CplNode, cpl: CplNode,
docker: DockerNode,
generic: GenericNode, generic: GenericNode,
groupRect: GroupRectNode, groupRect: GroupRectNode,
} }
@@ -6,22 +6,34 @@ import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import type { TextPosition } from '@/types' import type { TextPosition } from '@/types'
export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
export interface GroupRectFormData { export interface GroupRectFormData {
label: string label: string
font: string font: string
text_color: string text_color: string
text_position: TextPosition text_position: TextPosition
border_color: string border_color: string
border_style: BorderStyle
background_color: string background_color: string
z_order: number z_order: number
} }
const BORDER_STYLES: { value: BorderStyle; label: string; preview: string }[] = [
{ value: 'solid', label: 'Solid', preview: '───' },
{ value: 'dashed', label: 'Dashed', preview: '╌╌╌' },
{ value: 'dotted', label: 'Dotted', preview: '···' },
{ value: 'double', label: 'Double', preview: '═══' },
{ value: 'none', label: 'None', preview: ' ' },
]
const DEFAULT_FORM: GroupRectFormData = { const DEFAULT_FORM: GroupRectFormData = {
label: '', label: '',
font: 'inter', font: 'inter',
text_color: '#e6edf3', text_color: '#e6edf3',
text_position: 'top-left', text_position: 'top-left',
border_color: '#00d4ff', border_color: '#00d4ff',
border_style: 'solid',
background_color: '#00d4ff0d', background_color: '#00d4ff0d',
z_order: 1, z_order: 1,
} }
@@ -157,6 +169,33 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
</div> </div>
</div> </div>
{/* Border style */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Border Style</Label>
<div className="grid grid-cols-5 gap-1">
{BORDER_STYLES.map(({ value, label, preview }) => {
const isSelected = form.border_style === value
return (
<button
key={value}
type="button"
title={label}
onClick={() => set('border_style', value)}
className="flex flex-col items-center justify-center h-10 rounded text-xs gap-0.5 transition-colors"
style={{
background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
color: isSelected ? '#00d4ff' : '#8b949e',
}}
>
<span className="font-mono text-[11px] leading-none">{preview}</span>
<span className="text-[9px]">{label}</span>
</button>
)
})}
</div>
</div>
{/* Z-order */} {/* Z-order */}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Z-Order (1 = furthest back)</Label> <Label className="text-xs text-muted-foreground">Z-Order (1 = furthest back)</Label>
+105 -6
View File
@@ -4,12 +4,17 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types' import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors' import { resolveNodeColors } from '@/utils/nodeColors'
import { ICON_REGISTRY, ICON_CATEGORIES } from '@/utils/nodeIcons' import { ICON_REGISTRY, ICON_CATEGORIES } from '@/utils/nodeIcons'
const NODE_TYPES = Object.entries(NODE_TYPE_LABELS) as [NodeType, string][] 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: 'IoT', types: ['iot', 'camera', 'cpl'] },
{ label: 'Generic', types: ['computer', 'generic', 'groupRect'] },
]
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health'] const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
@@ -44,6 +49,8 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
const [iconSearch, setIconSearch] = useState('') const [iconSearch, setIconSearch] = useState('')
const [iconPickerOpen, setIconPickerOpen] = useState(false) const [iconPickerOpen, setIconPickerOpen] = useState(false)
const [labelError, setLabelError] = useState(false) const [labelError, setLabelError] = useState(false)
const hasHardwareData = !!(initial?.cpu_count || initial?.cpu_model || initial?.ram_gb || initial?.disk_gb)
const [hardwareOpen, setHardwareOpen] = useState(hasHardwareData)
const set = (key: keyof NodeData, value: unknown) => const set = (key: keyof NodeData, value: unknown) =>
setForm((f) => ({ ...f, [key]: value })) setForm((f) => ({ ...f, [key]: value }))
@@ -76,10 +83,20 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]"> <SelectContent className="bg-[#21262d] border-[#30363d]">
{NODE_TYPES.map(([value, label]) => ( {NODE_TYPE_GROUPS.map((group, i) => (
<SelectItem key={value} value={value} className="text-sm"> <>
{label} {i > 0 && <SelectSeparator key={`sep-${group.label}`} className="bg-[#30363d]" />}
</SelectItem> <SelectGroup key={group.label}>
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50 px-2 py-1">
{group.label}
</SelectLabel>
{group.types.map((type) => (
<SelectItem key={type} value={type} className="text-sm pl-4">
{NODE_TYPE_LABELS[type]}
</SelectItem>
))}
</SelectGroup>
</>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -315,6 +332,88 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
)} )}
</div> </div>
{/* Hardware specs (hidden for groupRect) */}
{form.type !== 'groupRect' && (
<div className="flex flex-col gap-2 col-span-2">
<div className="flex items-center justify-between w-full">
<button
type="button"
onClick={() => setHardwareOpen((o) => !o)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<span className="font-medium">Hardware</span>
<ChevronDown size={12} style={{ transform: hardwareOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} />
</button>
{hardwareOpen && (
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground/60">Show on node</span>
<button
type="button"
role="switch"
aria-checked={!!form.show_hardware}
onClick={() => set('show_hardware', !form.show_hardware)}
className="relative inline-flex h-4 w-7 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus:outline-none"
style={{ background: form.show_hardware ? '#00d4ff' : '#30363d' }}
>
<span
className="pointer-events-none inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform"
style={{ transform: form.show_hardware ? 'translateX(12px)' : 'translateX(0)' }}
/>
</button>
</div>
)}
</div>
{hardwareOpen && (
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">CPU Model</Label>
<Input
value={form.cpu_model ?? ''}
onChange={(e) => set('cpu_model', e.target.value || undefined)}
placeholder="e.g. Intel Xeon E5-2680"
className="bg-[#21262d] border-[#30363d] text-sm h-8"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">CPU Cores</Label>
<Input
type="number"
min={1}
value={form.cpu_count ?? ''}
onChange={(e) => set('cpu_count', e.target.value ? parseInt(e.target.value, 10) : undefined)}
placeholder="e.g. 8"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">RAM (GB)</Label>
<Input
type="number"
min={0}
step={0.5}
value={form.ram_gb ?? ''}
onChange={(e) => set('ram_gb', e.target.value ? parseFloat(e.target.value) : undefined)}
placeholder="e.g. 32"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
</div>
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Disk (GB)</Label>
<Input
type="number"
min={0}
step={1}
value={form.disk_gb ?? ''}
onChange={(e) => set('disk_gb', e.target.value ? parseFloat(e.target.value) : undefined)}
placeholder="e.g. 500"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
</div>
</div>
)}
</div>
)}
{/* Notes */} {/* Notes */}
<div className="flex flex-col gap-1.5 col-span-2"> <div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Notes</Label> <Label className="text-xs text-muted-foreground">Notes</Label>
@@ -80,4 +80,56 @@ describe('GroupRectModal', () => {
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.text_position).toBe('bottom-right') expect(submitted.text_position).toBe('bottom-right')
}) })
it('renders Border Style section', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByText('Border Style')).toBeDefined()
expect(screen.getByTitle('Solid')).toBeDefined()
expect(screen.getByTitle('Dashed')).toBeDefined()
expect(screen.getByTitle('Dotted')).toBeDefined()
expect(screen.getByTitle('Double')).toBeDefined()
expect(screen.getByTitle('None')).toBeDefined()
})
it('defaults border_style to solid', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.border_style).toBe('solid')
})
it('selects border style on click', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByTitle('Dashed'))
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.border_style).toBe('dashed')
})
it('pre-fills border_style from initial prop', () => {
const onSubmit = vi.fn()
render(
<GroupRectModal
open
onClose={vi.fn()}
onSubmit={onSubmit}
initial={{ border_style: 'dotted' }}
/>
)
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.border_style).toBe('dotted')
})
it('toggles border style — clicking selected style deselects back to solid', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByTitle('Dotted'))
fireEvent.click(screen.getByTitle('Solid'))
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.border_style).toBe('solid')
})
}) })
@@ -62,4 +62,109 @@ describe('NodeModal', () => {
fireEvent.click(screen.getByText('Cancel')) fireEvent.click(screen.getByText('Cancel'))
expect(onClose).toHaveBeenCalledOnce() expect(onClose).toHaveBeenCalledOnce()
}) })
describe('Hardware section', () => {
it('renders Hardware toggle button', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByText('Hardware')).toBeDefined()
})
it('hardware fields are hidden by default', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.queryByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeNull()
})
it('expands hardware fields on toggle click', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
fireEvent.click(screen.getByText('Hardware'))
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
expect(screen.getByPlaceholderText('e.g. 8')).toBeDefined()
expect(screen.getByPlaceholderText('e.g. 32')).toBeDefined()
expect(screen.getByPlaceholderText('e.g. 500')).toBeDefined()
})
it('submits hardware fields when filled', () => {
const onSubmit = vi.fn()
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Homelab' } })
fireEvent.click(screen.getByText('Hardware'))
fireEvent.change(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680'), { target: { value: 'Intel i7-12700K' } })
fireEvent.change(screen.getByPlaceholderText('e.g. 8'), { target: { value: '12' } })
fireEvent.change(screen.getByPlaceholderText('e.g. 32'), { target: { value: '64' } })
fireEvent.change(screen.getByPlaceholderText('e.g. 500'), { target: { value: '2000' } })
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0]
expect(submitted.cpu_model).toBe('Intel i7-12700K')
expect(submitted.cpu_count).toBe(12)
expect(submitted.ram_gb).toBe(64)
expect(submitted.disk_gb).toBe(2000)
})
it('auto-expands when initial has hardware data', () => {
render(
<NodeModal
open
onClose={vi.fn()}
onSubmit={vi.fn()}
initial={{ label: 'Server', cpu_count: 8, ram_gb: 32 }}
/>
)
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
})
it('hides hardware section for groupRect type', () => {
render(
<NodeModal
open
onClose={vi.fn()}
onSubmit={vi.fn()}
initial={{ type: 'groupRect' }}
/>
)
expect(screen.queryByText('Hardware')).toBeNull()
})
it('show on node toggle is hidden when section is collapsed', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.queryByText('Show on node')).toBeNull()
})
it('show on node toggle appears when section is expanded', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
fireEvent.click(screen.getByText('Hardware'))
expect(screen.getByText('Show on node')).toBeDefined()
})
it('show_hardware defaults to false', () => {
const onSubmit = vi.fn()
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
fireEvent.click(screen.getByText('Add'))
expect(onSubmit.mock.calls[0][0].show_hardware).toBeFalsy()
})
it('toggling show on node sets show_hardware to true', () => {
const onSubmit = vi.fn()
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
fireEvent.click(screen.getByText('Hardware'))
fireEvent.click(screen.getByRole('switch'))
fireEvent.click(screen.getByText('Add'))
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
})
it('pre-fills show_hardware from initial prop', () => {
const onSubmit = vi.fn()
render(
<NodeModal
open
onClose={vi.fn()}
onSubmit={onSubmit}
initial={{ label: 'Node', show_hardware: true, cpu_count: 8 }}
/>
)
fireEvent.click(screen.getByText('Add'))
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
})
})
}) })
@@ -100,6 +100,17 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
)} )}
</div> </div>
{/* Hardware */}
{(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null) && (
<div className="flex flex-col gap-3 px-4 py-3 text-sm border-t border-border">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Hardware</span>
{data.cpu_model && <DetailRow label="CPU" value={data.cpu_model} />}
{data.cpu_count != null && <DetailRow label="Cores" value={String(data.cpu_count)} mono />}
{data.ram_gb != null && <DetailRow label="RAM" value={formatStorage(data.ram_gb)} mono />}
{data.disk_gb != null && <DetailRow label="Disk" value={formatStorage(data.disk_gb)} mono />}
</div>
)}
{/* Services */} {/* Services */}
<div className="px-4 py-3 border-t border-border"> <div className="px-4 py-3 border-t border-border">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
@@ -202,6 +213,11 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
) )
} }
function formatStorage(gb: number): string {
if (gb >= 1024) return `${(gb / 1024).toFixed(1).replace(/\.0$/, '')} TB`
return `${gb} GB`
}
function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) { function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return ( return (
<div className="flex justify-between gap-2 items-baseline"> <div className="flex justify-between gap-2 items-baseline">
+6 -2
View File
@@ -1,4 +1,4 @@
import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Table2 } from 'lucide-react' import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Table2, FileDown } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Logo } from '@/components/ui/Logo' import { Logo } from '@/components/ui/Logo'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
@@ -12,9 +12,10 @@ interface ToolbarProps {
onRedo: () => void onRedo: () => void
onShortcuts: () => void onShortcuts: () => void
onExportMd: () => void onExportMd: () => void
onExportYaml: () => void
} }
export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd }: ToolbarProps) { export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd, onExportYaml }: ToolbarProps) {
const { hasUnsavedChanges, past, future } = useCanvasStore() const { hasUnsavedChanges, past, future } = useCanvasStore()
return ( return (
@@ -52,6 +53,9 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExportMd} title="Copy inventory as Markdown table"> <Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExportMd} title="Copy inventory as Markdown table">
<Table2 size={14} /> MD <Table2 size={14} /> MD
</Button> </Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExportYaml} title="Export canvas as YAML">
<FileDown size={14} /> YAML
</Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onShortcuts} title="Keyboard shortcuts (?)"> <Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onShortcuts} title="Keyboard shortcuts (?)">
<HelpCircle size={14} /> <HelpCircle size={14} />
</Button> </Button>
@@ -0,0 +1,118 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { DetailPanel } from '../DetailPanel'
import * as canvasStore from '@/stores/canvasStore'
import type { NodeData } from '@/types'
import type { Node } from '@xyflow/react'
vi.mock('@/stores/canvasStore')
function makeNode(data: Partial<NodeData>): Node<NodeData> {
return {
id: 'n1',
type: data.type ?? 'server',
position: { x: 0, y: 0 },
data: {
label: 'Test Node',
type: 'server',
status: 'online',
services: [],
...data,
},
}
}
function setupStore(nodeData: Partial<NodeData> = {}) {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode(nodeData)],
selectedNodeId: 'n1',
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
}
describe('DetailPanel', () => {
beforeEach(() => {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [],
selectedNodeId: null,
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
})
it('renders nothing when no node is selected', () => {
const { container } = render(<DetailPanel onEdit={vi.fn()} />)
expect(container.firstChild).toBeNull()
})
it('renders node label and status', () => {
setupStore({ label: 'My Server', status: 'online' })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('My Server')).toBeDefined()
expect(screen.getByText('online')).toBeDefined()
})
it('renders nothing for groupRect nodes', () => {
setupStore({ type: 'groupRect', label: 'Zone' })
const { container } = render(<DetailPanel onEdit={vi.fn()} />)
expect(container.firstChild).toBeNull()
})
describe('Hardware section', () => {
it('does not render hardware section when no hardware data', () => {
setupStore({ label: 'Server' })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.queryByText('Hardware')).toBeNull()
})
it('renders hardware section when cpu_count is set', () => {
setupStore({ cpu_count: 8 })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('Hardware')).toBeDefined()
expect(screen.getByText('8')).toBeDefined()
})
it('renders cpu_model', () => {
setupStore({ cpu_model: 'Intel Xeon E5-2680' })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('Intel Xeon E5-2680')).toBeDefined()
})
it('formats ram_gb in GB', () => {
setupStore({ ram_gb: 32 })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('32 GB')).toBeDefined()
})
it('formats ram_gb >= 1024 as TB', () => {
setupStore({ ram_gb: 2048 })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('2 TB')).toBeDefined()
})
it('formats disk_gb in GB', () => {
setupStore({ disk_gb: 500 })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('500 GB')).toBeDefined()
})
it('formats disk_gb >= 1024 as TB', () => {
setupStore({ disk_gb: 1536 })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('1.5 TB')).toBeDefined()
})
it('renders all hardware fields together', () => {
setupStore({ cpu_count: 16, cpu_model: 'AMD EPYC', ram_gb: 128, disk_gb: 4096 })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('Hardware')).toBeDefined()
expect(screen.getByText('AMD EPYC')).toBeDefined()
expect(screen.getByText('16')).toBeDefined()
expect(screen.getByText('128 GB')).toBeDefined()
expect(screen.getByText('4 TB')).toBeDefined()
})
})
})
+8
View File
@@ -13,6 +13,7 @@ export type NodeType =
| 'printer' | 'printer'
| 'computer' | 'computer'
| 'cpl' | 'cpl'
| 'docker'
| 'generic' | 'generic'
| 'groupRect' | 'groupRect'
@@ -55,6 +56,11 @@ export interface NodeData extends Record<string, unknown> {
last_seen?: string last_seen?: string
response_time_ms?: number response_time_ms?: number
notes?: string notes?: string
cpu_count?: number
cpu_model?: string
ram_gb?: number
disk_gb?: number
show_hardware?: boolean
parent_id?: string parent_id?: string
container_mode?: boolean container_mode?: boolean
custom_colors?: { custom_colors?: {
@@ -65,6 +71,7 @@ export interface NodeData extends Record<string, unknown> {
text_color?: string text_color?: string
text_position?: TextPosition text_position?: TextPosition
font?: string font?: string
border_style?: 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
z_order?: number z_order?: number
width?: number width?: number
height?: number height?: number
@@ -99,6 +106,7 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
printer: 'Printer', printer: 'Printer',
computer: 'Computer', computer: 'Computer',
cpl: 'CPL / Powerline', cpl: 'CPL / Powerline',
docker: 'Docker Host',
generic: 'Generic Device', generic: 'Generic Device',
groupRect: 'Group Rectangle', groupRect: 'Group Rectangle',
} }
+25
View File
@@ -0,0 +1,25 @@
import type { EdgeType, NodeType } from '@/types'
export interface YamlNodeConnection {
label: string
linkType: EdgeType
linkLabel: string
}
export interface YamlNode {
nodeType: NodeType
nodeIcon?: string
label: string
hostname?: string
ipAddress?: string
checkMethod?: string
checkTarget?: string
notes?: string
parent?: YamlNodeConnection
clusterR?: YamlNodeConnection
clusterL?: YamlNodeConnection
cpuModel?: string
cpuCore?: number
ram?: number
disk?: number
}
@@ -0,0 +1,130 @@
import { describe, it, expect } from 'vitest'
import { exportCanvasToYaml } from '../exportYaml'
import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types'
import yaml from 'js-yaml'
const makeNode = (overrides: Partial<NodeData> = {}, id = '1', parentId?: string): Node<NodeData> => ({
id,
type: overrides.type ?? 'server',
position: { x: 0, y: 0 },
parentId,
data: { label: 'Test', type: 'server', status: 'online', services: [], ...overrides },
})
const makeEdge = (id: string, source: string, target: string, data: Partial<EdgeData> = {}): Edge<EdgeData> => ({
id,
source,
target,
data: { type: 'ethernet', ...data } as EdgeData,
})
describe('exportCanvasToYaml', () => {
it('serializes a simple node with basic fields', () => {
const nodes = [makeNode({ label: 'My Server', type: 'server', ip: '192.168.1.10', hostname: 'srv.local' })]
const result = yaml.load(exportCanvasToYaml(nodes, [])) as object[]
expect(result).toHaveLength(1)
const entry = result[0] as Record<string, unknown>
expect(entry.nodeType).toBe('server')
expect(entry.label).toBe('My Server')
expect(entry.ipAddress).toBe('192.168.1.10')
expect(entry.hostname).toBe('srv.local')
})
it('omits empty/null/undefined optional fields', () => {
const nodes = [makeNode({ label: 'Router', type: 'router', hostname: undefined, ip: undefined, notes: undefined })]
const result = yaml.load(exportCanvasToYaml(nodes, [])) as object[]
const entry = result[0] as Record<string, unknown>
expect(entry).not.toHaveProperty('hostname')
expect(entry).not.toHaveProperty('ipAddress')
expect(entry).not.toHaveProperty('notes')
})
it('omits hardware specs when zero or falsy', () => {
const nodes = [makeNode({ label: 'Server', type: 'server', cpu_count: 0, ram_gb: 0, disk_gb: 0 })]
const result = yaml.load(exportCanvasToYaml(nodes, [])) as object[]
const entry = result[0] as Record<string, unknown>
expect(entry).not.toHaveProperty('cpuCore')
expect(entry).not.toHaveProperty('ram')
expect(entry).not.toHaveProperty('disk')
})
it('includes hardware specs when non-zero', () => {
const nodes = [makeNode({ label: 'Server', type: 'server', cpu_count: 16, ram_gb: 64, disk_gb: 2000, cpu_model: 'Intel Xeon' })]
const result = yaml.load(exportCanvasToYaml(nodes, [])) as object[]
const entry = result[0] as Record<string, unknown>
expect(entry.cpuCore).toBe(16)
expect(entry.ram).toBe(64)
expect(entry.disk).toBe(2000)
expect(entry.cpuModel).toBe('Intel Xeon')
})
it('serializes parent relationship from parentId', () => {
const parent = makeNode({ label: 'Proxmox1', type: 'proxmox' }, 'pve1')
const child = makeNode({ label: 'VM1', type: 'vm' }, 'vm1', 'pve1')
const edge = makeEdge('e1', 'pve1', 'vm1', { type: 'virtual' })
const result = yaml.load(exportCanvasToYaml([parent, child], [edge])) as object[]
const childEntry = (result as Record<string, unknown>[]).find((e) => e.label === 'VM1')!
expect(childEntry.parent).toEqual({ label: 'Proxmox1', linkType: 'virtual', linkLabel: '' })
})
it('serializes clusterR edge on source node', () => {
const nodeA = makeNode({ label: 'NodeA', type: 'proxmox' }, 'a')
const nodeB = makeNode({ label: 'NodeB', type: 'proxmox' }, 'b')
const edge = makeEdge('e1', 'a', 'b', { type: 'ethernet', label: '10GbE' })
const result = yaml.load(exportCanvasToYaml([nodeA, nodeB], [edge])) as Record<string, unknown>[]
const entryA = result.find((e) => e.label === 'NodeA')!
expect(entryA.clusterR).toEqual({ label: 'NodeB', linkType: 'ethernet', linkLabel: '10GbE' })
})
it('does not duplicate an edge as both clusterR and clusterL', () => {
const nodeA = makeNode({ label: 'NodeA', type: 'proxmox' }, 'a')
const nodeB = makeNode({ label: 'NodeB', type: 'proxmox' }, 'b')
const edge = makeEdge('e1', 'a', 'b', { type: 'ethernet' })
const result = yaml.load(exportCanvasToYaml([nodeA, nodeB], [edge])) as Record<string, unknown>[]
const entryA = result.find((e) => e.label === 'NodeA')!
const entryB = result.find((e) => e.label === 'NodeB')!
// clusterR on A and clusterL on B would duplicate — only one side should have it
const hasClusterR = 'clusterR' in entryA
const hasClusterL = 'clusterL' in entryB
expect(hasClusterR && hasClusterL).toBe(false)
})
it('excludes groupRect nodes from output', () => {
const nodes = [
makeNode({ label: 'Zone', type: 'groupRect' }, '1'),
makeNode({ label: 'Server', type: 'server' }, '2'),
]
const result = yaml.load(exportCanvasToYaml(nodes, [])) as object[]
expect(result).toHaveLength(1)
expect((result[0] as Record<string, unknown>).label).toBe('Server')
})
it('roundtrip: all non-empty fields appear in YAML output', () => {
const nodes = [makeNode({
label: 'Full Node',
type: 'server',
ip: '10.0.0.1',
hostname: 'full.local',
check_method: 'ping',
check_target: '10.0.0.1',
notes: 'test notes',
cpu_model: 'AMD EPYC',
cpu_count: 32,
ram_gb: 128,
disk_gb: 4000,
custom_icon: 'star',
})]
const yamlStr = exportCanvasToYaml(nodes, [])
expect(yamlStr).toContain('Full Node')
expect(yamlStr).toContain('10.0.0.1')
expect(yamlStr).toContain('full.local')
expect(yamlStr).toContain('ping')
expect(yamlStr).toContain('test notes')
expect(yamlStr).toContain('AMD EPYC')
expect(yamlStr).toContain('32')
expect(yamlStr).toContain('128')
expect(yamlStr).toContain('4000')
expect(yamlStr).toContain('star')
})
})
@@ -28,6 +28,7 @@ describe('ICON_REGISTRY', () => {
expect(keys).toContain('play') // Jellyfin expect(keys).toContain('play') // Jellyfin
expect(keys).toContain('shield') // Pi-hole expect(keys).toContain('shield') // Pi-hole
expect(keys).toContain('anchor') // Portainer expect(keys).toContain('anchor') // Portainer
expect(keys).toContain('package') // Docker Host
expect(keys).toContain('key') // Vaultwarden expect(keys).toContain('key') // Vaultwarden
expect(keys).toContain('database') // DB services expect(keys).toContain('database') // DB services
expect(keys).toContain('cctv') // IP Camera / CCTV expect(keys).toContain('cctv') // IP Camera / CCTV
+2 -1
View File
@@ -4,7 +4,7 @@ import type { NodeType, EdgeType, NodeStatus } from '@/types'
const NODE_TYPES: NodeType[] = [ const NODE_TYPES: NodeType[] = [
'isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc',
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'generic', 'groupRect', 'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker', 'generic', 'groupRect',
] ]
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster'] const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown'] const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown']
@@ -84,6 +84,7 @@ describe('THEMES', () => {
expect(d.nodeAccents.server.border).toBe('#a855f7') expect(d.nodeAccents.server.border).toBe('#a855f7')
expect(d.nodeAccents.isp.border).toBe('#00d4ff') expect(d.nodeAccents.isp.border).toBe('#00d4ff')
expect(d.nodeAccents.proxmox.border).toBe('#ff6e00') expect(d.nodeAccents.proxmox.border).toBe('#ff6e00')
expect(d.nodeAccents.docker.border).toBe('#2496ED')
expect(d.nodeCardBackground).toBe('#21262d') expect(d.nodeCardBackground).toBe('#21262d')
expect(d.nodeIconBackground).toBe('#161b22') expect(d.nodeIconBackground).toBe('#161b22')
expect(d.canvasBackground).toBe('#0d1117') expect(d.canvasBackground).toBe('#0d1117')
+136
View File
@@ -0,0 +1,136 @@
import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData, EdgeType } from '@/types'
import type { YamlNode, YamlNodeConnection } from '@/types/yaml'
import yaml from 'js-yaml'
/** Build a map of node id → label for edge resolution */
function buildIdToLabel(nodes: Node<NodeData>[]): Map<string, string> {
const m = new Map<string, string>()
for (const n of nodes) m.set(n.id, n.data.label)
return m
}
function makeConnection(targetLabel: string, edgeType: EdgeType, edgeLabel: string | undefined): YamlNodeConnection {
return {
label: targetLabel,
linkType: edgeType,
linkLabel: edgeLabel ?? '',
}
}
/**
* Serialize React Flow canvas state to a YAML string.
* Each node becomes one entry; edges are embedded as parent/clusterR/clusterL sub-objects.
* Edge deduplication: each edge is written on exactly one side (source as clusterR, target as clusterL)
* unless the edge type is 'virtual' or there is a parentId relationship, in which case
* it becomes the 'parent' field of the child node.
*/
export function exportCanvasToYaml(nodes: Node<NodeData>[], edges: Edge<EdgeData>[]): string {
const idToLabel = buildIdToLabel(nodes)
// Build per-node edge maps (id → connections)
// We use a Set to track already-serialized edge ids (deduplication).
const serializedEdges = new Set<string>()
// Index edges by source and target for quick lookup
const edgesBySource = new Map<string, Edge<EdgeData>[]>()
const edgesByTarget = new Map<string, Edge<EdgeData>[]>()
for (const e of edges) {
if (!edgesBySource.has(e.source)) edgesBySource.set(e.source, [])
edgesBySource.get(e.source)!.push(e)
if (!edgesByTarget.has(e.target)) edgesByTarget.set(e.target, [])
edgesByTarget.get(e.target)!.push(e)
}
const yamlNodes: YamlNode[] = []
for (const node of nodes) {
const d = node.data
// Skip groupRect nodes — they are canvas decoration only
if (d.type === 'groupRect') continue
const entry: YamlNode = {
nodeType: d.type,
label: d.label,
}
if (d.custom_icon) entry.nodeIcon = d.custom_icon
if (d.hostname) entry.hostname = d.hostname
if (d.ip) entry.ipAddress = d.ip
if (d.check_method && d.check_method !== 'none') entry.checkMethod = d.check_method
if (d.check_target) entry.checkTarget = d.check_target
if (d.notes) entry.notes = d.notes
// Hardware specs — omit zero values
if (d.cpu_model) entry.cpuModel = d.cpu_model
if (d.cpu_count && d.cpu_count > 0) entry.cpuCore = d.cpu_count
if (d.ram_gb && d.ram_gb > 0) entry.ram = d.ram_gb
if (d.disk_gb && d.disk_gb > 0) entry.disk = d.disk_gb
// Parent relationship: if this node has a parentId in React Flow,
// encode it as a 'parent' connection using any virtual edge between them.
if (node.parentId) {
const parentLabel = idToLabel.get(node.parentId) ?? node.parentId
// Find an edge between parent and this node (either direction)
const parentEdges = [
...(edgesBySource.get(node.parentId) ?? []).filter((e) => e.target === node.id),
...(edgesByTarget.get(node.parentId) ?? []).filter((e) => e.source === node.id),
]
const pEdge = parentEdges[0]
const linkType: EdgeType = (pEdge?.data?.type as EdgeType) ?? 'virtual'
const linkLabel = pEdge?.data?.label ?? ''
entry.parent = { label: parentLabel, linkType, linkLabel: linkLabel as string }
if (pEdge) serializedEdges.add(pEdge.id)
}
// Non-parent edges: serialize as clusterR (source side) or clusterL (target side).
// We process source edges as clusterR on this node; target edges as clusterL on this node,
// but only if the edge hasn't been serialized yet (deduplication: source wins).
const sourceEdgesForNode = (edgesBySource.get(node.id) ?? []).filter(
(e) => !serializedEdges.has(e.id) && e.target !== node.parentId && e.source !== node.parentId,
)
for (const e of sourceEdgesForNode) {
const targetLabel = idToLabel.get(e.target)
if (!targetLabel) continue
const edgeType: EdgeType = (e.data?.type as EdgeType) ?? 'ethernet'
const edgeLabel = e.data?.label as string | undefined
if (!entry.clusterR) {
entry.clusterR = makeConnection(targetLabel, edgeType, edgeLabel)
}
// Only first clusterR wins per node; mark all source edges as serialized
serializedEdges.add(e.id)
}
const targetEdgesForNode = (edgesByTarget.get(node.id) ?? []).filter(
(e) => !serializedEdges.has(e.id) && e.source !== node.parentId && e.target !== node.parentId,
)
for (const e of targetEdgesForNode) {
const sourceLabel = idToLabel.get(e.source)
if (!sourceLabel) continue
const edgeType: EdgeType = (e.data?.type as EdgeType) ?? 'ethernet'
const edgeLabel = e.data?.label as string | undefined
if (!entry.clusterL) {
entry.clusterL = makeConnection(sourceLabel, edgeType, edgeLabel)
}
serializedEdges.add(e.id)
}
yamlNodes.push(entry)
}
return yaml.dump(yamlNodes, { lineWidth: -1, noRefs: true })
}
/** Trigger a browser file download with the given YAML content */
export function downloadYaml(content: string, filename = 'homelable-export.yaml'): void {
const blob = new Blob([content], { type: 'text/yaml;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
+2 -1
View File
@@ -14,7 +14,7 @@ import {
// Transfers & sync // Transfers & sync
Download, Upload, RefreshCw, Download, Upload, RefreshCw,
// Containers & Dev // Containers & Dev
Anchor, GitBranch, Terminal, Code2, Settings, Anchor, Package, GitBranch, Terminal, Code2, Settings,
// Communications // Communications
Mail, MessageSquare, Phone, Mail, MessageSquare, Phone,
// Misc devices // Misc devices
@@ -98,6 +98,7 @@ export const ICON_REGISTRY: IconEntry[] = [
// --- Containers & Dev --- // --- Containers & Dev ---
{ key: 'anchor', label: 'Portainer / Docker', category: 'Dev & Containers', icon: Anchor }, { key: 'anchor', label: 'Portainer / Docker', category: 'Dev & Containers', icon: Anchor },
{ key: 'package', label: 'Docker Host', category: 'Dev & Containers', icon: Package },
{ key: 'gitbranch', label: 'Gitea / Gitlab', category: 'Dev & Containers', icon: GitBranch }, { key: 'gitbranch', label: 'Gitea / Gitlab', category: 'Dev & Containers', icon: GitBranch },
{ key: 'terminal', label: 'SSH / Shell', category: 'Dev & Containers', icon: Terminal }, { key: 'terminal', label: 'SSH / Shell', category: 'Dev & Containers', icon: Terminal },
{ key: 'code', label: 'VS Code Server', category: 'Dev & Containers', icon: Code2 }, { key: 'code', label: 'VS Code Server', category: 'Dev & Containers', icon: Code2 },
+5
View File
@@ -56,6 +56,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
printer: { border: '#8b949e', icon: '#8b949e' }, printer: { border: '#8b949e', icon: '#8b949e' },
computer: { border: '#a855f7', icon: '#a855f7' }, computer: { border: '#a855f7', icon: '#a855f7' },
cpl: { border: '#e3b341', icon: '#e3b341' }, cpl: { border: '#e3b341', icon: '#e3b341' },
docker: { border: '#2496ED', icon: '#2496ED' },
generic: { border: '#8b949e', icon: '#8b949e' }, generic: { border: '#8b949e', icon: '#8b949e' },
groupRect:{ border: '#00d4ff', icon: '#00d4ff' }, groupRect:{ border: '#00d4ff', icon: '#00d4ff' },
}, },
@@ -109,6 +110,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
printer: { border: '#94a3b8', icon: '#94a3b8' }, printer: { border: '#94a3b8', icon: '#94a3b8' },
computer: { border: '#c084fc', icon: '#c084fc' }, computer: { border: '#c084fc', icon: '#c084fc' },
cpl: { border: '#fbbf24', icon: '#fbbf24' }, cpl: { border: '#fbbf24', icon: '#fbbf24' },
docker: { border: '#2496ED', icon: '#2496ED' },
generic: { border: '#94a3b8', icon: '#94a3b8' }, generic: { border: '#94a3b8', icon: '#94a3b8' },
groupRect:{ border: '#22d3ee', icon: '#22d3ee' }, groupRect:{ border: '#22d3ee', icon: '#22d3ee' },
}, },
@@ -162,6 +164,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
printer: { border: '#6b7280', icon: '#6b7280' }, printer: { border: '#6b7280', icon: '#6b7280' },
computer: { border: '#7c3aed', icon: '#7c3aed' }, computer: { border: '#7c3aed', icon: '#7c3aed' },
cpl: { border: '#b45309', icon: '#b45309' }, cpl: { border: '#b45309', icon: '#b45309' },
docker: { border: '#2496ED', icon: '#2496ED' },
generic: { border: '#6b7280', icon: '#6b7280' }, generic: { border: '#6b7280', icon: '#6b7280' },
groupRect:{ border: '#0284c7', icon: '#0284c7' }, groupRect:{ border: '#0284c7', icon: '#0284c7' },
}, },
@@ -215,6 +218,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
printer: { border: '#8888ff', icon: '#8888ff' }, printer: { border: '#8888ff', icon: '#8888ff' },
computer: { border: '#ff00ff', icon: '#ff00ff' }, computer: { border: '#ff00ff', icon: '#ff00ff' },
cpl: { border: '#ffff00', icon: '#ffff00' }, cpl: { border: '#ffff00', icon: '#ffff00' },
docker: { border: '#00aaff', icon: '#00aaff' },
generic: { border: '#8888ff', icon: '#8888ff' }, generic: { border: '#8888ff', icon: '#8888ff' },
groupRect:{ border: '#00ffff', icon: '#00ffff' }, groupRect:{ border: '#00ffff', icon: '#00ffff' },
}, },
@@ -268,6 +272,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
printer: { border: '#005500', icon: '#005500' }, printer: { border: '#005500', icon: '#005500' },
computer: { border: '#008822', icon: '#008822' }, computer: { border: '#008822', icon: '#008822' },
cpl: { border: '#66ff33', icon: '#66ff33' }, cpl: { border: '#66ff33', icon: '#66ff33' },
docker: { border: '#00cc88', icon: '#00cc88' },
generic: { border: '#006600', icon: '#006600' }, generic: { border: '#006600', icon: '#006600' },
groupRect:{ border: '#00ff41', icon: '#00ff41' }, groupRect:{ border: '#00ff41', icon: '#00ff41' },
}, },