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)
This commit is contained in:
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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<Node<NodeData>> {
|
||||
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 (
|
||||
<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={{
|
||||
background: colors.background,
|
||||
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 }} />
|
||||
|
||||
{/* Icon */}
|
||||
<div
|
||||
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
|
||||
style={{
|
||||
color: isOnline ? colors.icon : theme.colors.nodeSubtextColor,
|
||||
background: theme.colors.nodeIconBackground,
|
||||
}}
|
||||
>
|
||||
{createElement(resolvedIcon, { size: 15 })}
|
||||
{/* Main row */}
|
||||
<div className="flex flex-row items-center gap-2.5 px-2.5 py-2">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
|
||||
style={{
|
||||
color: isOnline ? colors.icon : theme.colors.nodeSubtextColor,
|
||||
background: theme.colors.nodeIconBackground,
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
|
||||
{/* Details */}
|
||||
<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}
|
||||
{/* Hardware section */}
|
||||
{showHardware && (
|
||||
<>
|
||||
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
|
||||
<div className="flex flex-col gap-1 px-2.5 py-1.5">
|
||||
{/* Line 1: CPU */}
|
||||
{(data.cpu_model || data.cpu_count != null) && (
|
||||
<div className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||
<Cpu size={9} className="shrink-0" />
|
||||
{data.cpu_model && (
|
||||
<span className="truncate max-w-[80px]" title={data.cpu_model}>{data.cpu_model}</span>
|
||||
)}
|
||||
{data.cpu_count != null && (
|
||||
<span className="shrink-0">{data.cpu_model ? `· ${data.cpu_count}c` : `${data.cpu_count} cores`}</span>
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Status dot */}
|
||||
<div
|
||||
|
||||
@@ -49,6 +49,8 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
const [iconSearch, setIconSearch] = useState('')
|
||||
const [iconPickerOpen, setIconPickerOpen] = 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) =>
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
@@ -330,6 +332,88 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
)}
|
||||
</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 */}
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<Label className="text-xs text-muted-foreground">Notes</Label>
|
||||
|
||||
@@ -62,4 +62,109 @@ describe('NodeModal', () => {
|
||||
fireEvent.click(screen.getByText('Cancel'))
|
||||
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>
|
||||
|
||||
{/* 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 */}
|
||||
<div className="px-4 py-3 border-t border-border">
|
||||
<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 }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-2 items-baseline">
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -56,6 +56,11 @@ export interface NodeData extends Record<string, unknown> {
|
||||
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?: {
|
||||
|
||||
Reference in New Issue
Block a user