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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user