From 06ec18a137df0b8b69fb21e213edce93cff576e3 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sat, 21 Mar 2026 00:01:15 +0100 Subject: [PATCH] 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) --- backend/app/db/database.py | 10 ++ backend/app/db/models.py | 5 + backend/app/schemas/canvas.py | 5 + backend/app/schemas/nodes.py | 10 ++ backend/tests/test_canvas.py | 53 ++++++++ frontend/src/App.tsx | 5 + .../src/components/canvas/nodes/BaseNode.tsx | 105 +++++++++++----- frontend/src/components/modals/NodeModal.tsx | 84 +++++++++++++ .../modals/__tests__/NodeModal.test.tsx | 105 ++++++++++++++++ .../src/components/panels/DetailPanel.tsx | 16 +++ .../panels/__tests__/DetailPanel.test.tsx | 118 ++++++++++++++++++ frontend/src/types/index.ts | 5 + 12 files changed, 492 insertions(+), 29 deletions(-) create mode 100644 frontend/src/components/panels/__tests__/DetailPanel.test.tsx diff --git a/backend/app/db/database.py b/backend/app/db/database.py index 644c439..a2a57a5 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -42,6 +42,16 @@ async def init_db() -> None: await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT") with suppress(Exception): 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]: diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 4f84454..8438e16 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -37,6 +37,11 @@ class Node(Base): container_mode: Mapped[bool] = mapped_column(Boolean, default=False) custom_colors: Mapped[dict[str, Any] | None] = mapped_column(JSON, 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)) response_time_ms: Mapped[int | None] = mapped_column(Integer) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) diff --git a/backend/app/schemas/canvas.py b/backend/app/schemas/canvas.py index e3b4948..348c19e 100644 --- a/backend/app/schemas/canvas.py +++ b/backend/app/schemas/canvas.py @@ -23,6 +23,11 @@ class NodeSave(BaseModel): container_mode: bool = False custom_colors: dict[str, Any] | 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_y: float = 0 diff --git a/backend/app/schemas/nodes.py b/backend/app/schemas/nodes.py index 9f71db3..afe7f2e 100644 --- a/backend/app/schemas/nodes.py +++ b/backend/app/schemas/nodes.py @@ -22,6 +22,11 @@ class NodeBase(BaseModel): container_mode: bool = False custom_colors: dict[str, Any] | 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): @@ -46,6 +51,11 @@ class NodeUpdate(BaseModel): container_mode: bool | None = None custom_colors: dict[str, Any] | 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): diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py index a14bc78..8519923 100644 --- a/backend/tests/test_canvas.py +++ b/backend/tests/test_canvas.py @@ -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): res = await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}}) 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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f09a725..12a102d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -103,6 +103,11 @@ export default function App() { container_mode: n.data.container_mode ?? false, custom_colors: n.data.custom_colors ?? 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_y: n.position.y, } diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index f465651..28fc616 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -1,6 +1,6 @@ import { createElement } from '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 { resolveNodeColors } from '@/utils/nodeColors' import { resolveNodeIcon } from '@/utils/nodeIcons' @@ -13,6 +13,11 @@ interface BaseNodeProps extends NodeProps> { 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) { const activeTheme = useThemeStore((s) => s.activeTheme) const hideIp = useCanvasStore((s) => s.hideIp) @@ -22,10 +27,11 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) { const colors = resolveNodeColors(data, activeTheme) const statusColor = theme.colors.statusColors[data.status] 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 (
- {/* Icon */} -
- {createElement(resolvedIcon, { size: 15 })} + {/* Main row */} +
+ {/* Icon */} +
+ {createElement(resolvedIcon, { size: 15 })} +
+ + {/* Label + IP */} +
+
+ {data.label} +
+ {data.ip && ( +
+ {hideIp ? maskIp(data.ip) : data.ip} +
+ )} +
- {/* Details */} -
-
- {data.label} -
- {data.ip && ( -
- {hideIp ? maskIp(data.ip) : data.ip} + {/* Hardware section */} + {showHardware && ( + <> +
+
+ {/* Line 1: CPU */} + {(data.cpu_model || data.cpu_count != null) && ( +
+ + {data.cpu_model && ( + {data.cpu_model} + )} + {data.cpu_count != null && ( + {data.cpu_model ? `ยท ${data.cpu_count}c` : `${data.cpu_count} cores`} + )} +
+ )} + {/* Line 2: RAM + Disk */} + {(data.ram_gb != null || data.disk_gb != null) && ( +
+ {data.ram_gb != null && ( + + + {formatStorage(data.ram_gb)} + + )} + {data.disk_gb != null && ( + + + {formatStorage(data.disk_gb)} + + )} +
+ )}
- )} -
+ + )} {/* Status dot */}
setForm((f) => ({ ...f, [key]: value })) @@ -330,6 +332,88 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' )}
+ {/* Hardware specs (hidden for groupRect) */} + {form.type !== 'groupRect' && ( +
+
+ + {hardwareOpen && ( +
+ Show on node + +
+ )} +
+ {hardwareOpen && ( +
+
+ + set('cpu_model', e.target.value || undefined)} + placeholder="e.g. Intel Xeon E5-2680" + className="bg-[#21262d] border-[#30363d] text-sm h-8" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ )} +
+ )} + {/* Notes */}
diff --git a/frontend/src/components/modals/__tests__/NodeModal.test.tsx b/frontend/src/components/modals/__tests__/NodeModal.test.tsx index c45a498..f9bb42b 100644 --- a/frontend/src/components/modals/__tests__/NodeModal.test.tsx +++ b/frontend/src/components/modals/__tests__/NodeModal.test.tsx @@ -62,4 +62,109 @@ describe('NodeModal', () => { fireEvent.click(screen.getByText('Cancel')) expect(onClose).toHaveBeenCalledOnce() }) + + describe('Hardware section', () => { + it('renders Hardware toggle button', () => { + render() + expect(screen.getByText('Hardware')).toBeDefined() + }) + + it('hardware fields are hidden by default', () => { + render() + expect(screen.queryByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeNull() + }) + + it('expands hardware fields on toggle click', () => { + render() + 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() + 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( + + ) + expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined() + }) + + it('hides hardware section for groupRect type', () => { + render( + + ) + expect(screen.queryByText('Hardware')).toBeNull() + }) + + it('show on node toggle is hidden when section is collapsed', () => { + render() + expect(screen.queryByText('Show on node')).toBeNull() + }) + + it('show on node toggle appears when section is expanded', () => { + render() + fireEvent.click(screen.getByText('Hardware')) + expect(screen.getByText('Show on node')).toBeDefined() + }) + + it('show_hardware defaults to false', () => { + const onSubmit = vi.fn() + render() + 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() + 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( + + ) + fireEvent.click(screen.getByText('Add')) + expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true) + }) + }) }) diff --git a/frontend/src/components/panels/DetailPanel.tsx b/frontend/src/components/panels/DetailPanel.tsx index a38bfbe..24cfa32 100644 --- a/frontend/src/components/panels/DetailPanel.tsx +++ b/frontend/src/components/panels/DetailPanel.tsx @@ -100,6 +100,17 @@ export function DetailPanel({ onEdit }: DetailPanelProps) { )}
+ {/* Hardware */} + {(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null) && ( +
+ Hardware + {data.cpu_model && } + {data.cpu_count != null && } + {data.ram_gb != null && } + {data.disk_gb != null && } +
+ )} + {/* Services */}
@@ -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 }) { return (
diff --git a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx new file mode 100644 index 0000000..da9fb35 --- /dev/null +++ b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx @@ -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): Node { + 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 = {}) { + vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ + nodes: [makeNode(nodeData)], + selectedNodeId: 'n1', + setSelectedNode: vi.fn(), + deleteNode: vi.fn(), + updateNode: vi.fn(), + } as unknown as ReturnType) +} + +describe('DetailPanel', () => { + beforeEach(() => { + vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ + nodes: [], + selectedNodeId: null, + setSelectedNode: vi.fn(), + deleteNode: vi.fn(), + updateNode: vi.fn(), + } as unknown as ReturnType) + }) + + it('renders nothing when no node is selected', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('renders node label and status', () => { + setupStore({ label: 'My Server', status: 'online' }) + render() + 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() + expect(container.firstChild).toBeNull() + }) + + describe('Hardware section', () => { + it('does not render hardware section when no hardware data', () => { + setupStore({ label: 'Server' }) + render() + expect(screen.queryByText('Hardware')).toBeNull() + }) + + it('renders hardware section when cpu_count is set', () => { + setupStore({ cpu_count: 8 }) + render() + expect(screen.getByText('Hardware')).toBeDefined() + expect(screen.getByText('8')).toBeDefined() + }) + + it('renders cpu_model', () => { + setupStore({ cpu_model: 'Intel Xeon E5-2680' }) + render() + expect(screen.getByText('Intel Xeon E5-2680')).toBeDefined() + }) + + it('formats ram_gb in GB', () => { + setupStore({ ram_gb: 32 }) + render() + expect(screen.getByText('32 GB')).toBeDefined() + }) + + it('formats ram_gb >= 1024 as TB', () => { + setupStore({ ram_gb: 2048 }) + render() + expect(screen.getByText('2 TB')).toBeDefined() + }) + + it('formats disk_gb in GB', () => { + setupStore({ disk_gb: 500 }) + render() + expect(screen.getByText('500 GB')).toBeDefined() + }) + + it('formats disk_gb >= 1024 as TB', () => { + setupStore({ disk_gb: 1536 }) + render() + 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() + 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() + }) + }) +}) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index d2f2c9f..1bd9915 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -56,6 +56,11 @@ export interface NodeData extends Record { last_seen?: string response_time_ms?: number notes?: string + cpu_count?: number + cpu_model?: string + ram_gb?: number + disk_gb?: number + show_hardware?: boolean parent_id?: string container_mode?: boolean custom_colors?: {