refactor: DeviceNode uses BaseNode, BaseHandle, StatusIndicator, Tooltip

Replaces hand-rolled node layout with composable React Flow UI
components. Status is now a border effect instead of a dot.
Hover tooltip shows hostname, IP, vendor, role, notes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-04-04 14:24:25 +00:00
parent 3aaf0e58aa
commit fe33ad1d5a

View File

@@ -1,6 +1,9 @@
import { memo } from 'react' import { memo } from 'react'
import { Handle, Position, type NodeProps } from '@xyflow/react' import { Position, type NodeProps } from '@xyflow/react'
import { cn } from '@/lib/utils' import { BaseNode, BaseNodeHeader, BaseNodeHeaderTitle, BaseNodeContent } from '../ui/base-node'
import { BaseHandle } from '../ui/base-handle'
import { NodeStatusIndicator, type NodeStatus } from '../ui/node-status-indicator'
import { NodeTooltip, NodeTooltipTrigger, NodeTooltipContent } from '../ui/node-tooltip'
import { getDeviceRenderConfig } from './deviceRegistry' import { getDeviceRenderConfig } from './deviceRegistry'
import type { DeviceProperties } from '@/types' import type { DeviceProperties } from '@/types'
@@ -12,39 +15,64 @@ export interface DeviceNodeData {
[key: string]: unknown [key: string]: unknown
} }
const STATUS_COLORS: Record<string, string> = { function TooltipRow({ label, value }: { label: string; value: string | null | undefined }) {
online: 'bg-emerald-400', if (!value) return null
offline: 'bg-red-400', return (
degraded: 'bg-yellow-400', <div className="flex gap-2">
unknown: 'bg-gray-500', <span className="text-[10px] uppercase tracking-wider text-muted-foreground">{label}</span>
<span className="text-xs font-mono text-primary">{value}</span>
</div>
)
} }
function DeviceNodeComponent({ data, selected }: NodeProps) { function DeviceNodeComponent({ data, selected }: NodeProps) {
const nodeData = data as unknown as DeviceNodeData const nodeData = data as unknown as DeviceNodeData
const { icon: Icon, color } = getDeviceRenderConfig(nodeData.deviceType, nodeData.category) const { icon: Icon, color } = getDeviceRenderConfig(nodeData.deviceType, nodeData.category)
const status = nodeData.properties?.status || 'unknown' const status = (nodeData.properties?.status || 'unknown') as NodeStatus
const ip = nodeData.properties?.ip const ip = nodeData.properties?.ip
const props = nodeData.properties || {}
const hasTooltipContent = props.hostname || props.ip || props.vendor || props.model || props.role || props.notes
return ( return (
<div <NodeStatusIndicator status={status}>
className={cn( <NodeTooltip>
'relative flex min-w-[120px] flex-col items-center gap-1 rounded-lg border bg-card px-4 py-3', <NodeTooltipTrigger>
'border-default transition-colors', <BaseNode className={`min-w-[120px] group ${selected ? 'border-accent' : ''}`}>
'group', <BaseNodeHeader className="flex-col gap-1 items-center py-3 px-4">
selected && 'border-accent', <Icon size={28} style={{ color }} />
)} <BaseNodeHeaderTitle className="text-center text-xs">
> {nodeData.label}
<div className={cn('absolute right-2 top-2 h-2 w-2 rounded-full', STATUS_COLORS[status])} /> </BaseNodeHeaderTitle>
<Icon size={28} style={{ color }} /> </BaseNodeHeader>
<span className="text-center text-xs font-medium text-heading">{nodeData.label}</span> {ip && (
{ip && ( <BaseNodeContent className="items-center pt-0">
<span className="font-mono text-[10px] text-muted-foreground">{ip}</span> <span className="font-mono text-[10px] text-muted-foreground">{ip}</span>
)} </BaseNodeContent>
<Handle type="target" position={Position.Top} className="!h-2 !w-2 !border-default !bg-elevated opacity-0 group-hover:opacity-100 transition-opacity" /> )}
<Handle type="source" position={Position.Bottom} className="!h-2 !w-2 !border-default !bg-elevated opacity-0 group-hover:opacity-100 transition-opacity" /> <BaseHandle type="target" position={Position.Top} />
<Handle type="target" position={Position.Left} id="left" className="!h-2 !w-2 !border-default !bg-elevated opacity-0 group-hover:opacity-100 transition-opacity" /> <BaseHandle type="source" position={Position.Bottom} />
<Handle type="source" position={Position.Right} id="right" className="!h-2 !w-2 !border-default !bg-elevated opacity-0 group-hover:opacity-100 transition-opacity" /> <BaseHandle type="target" position={Position.Left} id="left" />
</div> <BaseHandle type="source" position={Position.Right} id="right" />
</BaseNode>
</NodeTooltipTrigger>
{hasTooltipContent && (
<NodeTooltipContent position={Position.Bottom}>
<div className="flex flex-col gap-1 min-w-[140px]">
<TooltipRow label="Host" value={props.hostname} />
<TooltipRow label="IP" value={props.ip} />
{(props.vendor || props.model) && (
<TooltipRow label="HW" value={[props.vendor, props.model].filter(Boolean).join(' ')} />
)}
<TooltipRow label="Role" value={props.role} />
{props.notes && (
<TooltipRow label="Notes" value={props.notes.length > 100 ? props.notes.slice(0, 100) + '...' : props.notes} />
)}
</div>
</NodeTooltipContent>
)}
</NodeTooltip>
</NodeStatusIndicator>
) )
} }