- DeviceNode: flat bg-card (no surface gradient), darker icon plate inset, correct text-muted token for category label - GroupNode: label pill gets bg-card/90 background so it reads against canvas - ConnectionEdge: label now has border + bg-card so it doesn't float invisible - BaseHandle: tightened to 12px with accent-toned border - NodeStatusIndicator: glow reduced to 0.15 opacity (design system compliant) - ContextMenu: Ungroup now uses Ungroup icon instead of BoxSelect - DeviceToolbar: group type icons coloured with semantic palette - PropertiesPanel: empty state gets icon tile + cleaner copy hierarchy - DiagramEditor: shortcut ? button repositioned above MiniMap, accent hover - NetworkDiagrams list: card thumbnail placeholder uses dot-grid pattern, card menu gets icons and divider before destructive action Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
181 lines
7.6 KiB
TypeScript
181 lines
7.6 KiB
TypeScript
import { memo, useState, useRef, useEffect } from 'react'
|
|
import { Position, NodeResizer, useReactFlow, type NodeProps } from '@xyflow/react'
|
|
import { BaseNode, BaseNodeHeader, 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, CATEGORY_LABELS } from './deviceRegistry'
|
|
import { cn } from '@/lib/utils'
|
|
import type { DeviceProperties } from '@/types'
|
|
|
|
export interface DeviceNodeData {
|
|
label: string
|
|
deviceType: string
|
|
category?: string
|
|
properties: DeviceProperties
|
|
[key: string]: unknown
|
|
}
|
|
|
|
function TooltipRow({ label, value }: { label: string; value: string | null | undefined }) {
|
|
if (!value) return null
|
|
return (
|
|
<div className="flex gap-2">
|
|
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">{label}</span>
|
|
<span className="text-xs font-mono text-primary">{value}</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const NODE_DEFAULT = 120 // default square side in px
|
|
const NODE_MIN = 80 // minimum square side in px
|
|
const NODE_MAX = 280 // maximum square side in px
|
|
|
|
function DeviceNodeComponent({ id, data, selected, width, height }: NodeProps) {
|
|
const nodeData = data as unknown as DeviceNodeData
|
|
const { icon: Icon, color, accentClass, surfaceClass, category } = getDeviceRenderConfig(nodeData.deviceType, nodeData.category)
|
|
const status = (nodeData.properties?.status || 'unknown') as NodeStatus
|
|
const ip = nodeData.properties?.ip
|
|
const props = nodeData.properties || {}
|
|
|
|
// Use the shorter dimension so content never overflows a non-square node
|
|
const size = Math.min(width ?? NODE_DEFAULT, height ?? NODE_DEFAULT)
|
|
const scale = size / NODE_DEFAULT
|
|
|
|
// Icon: 28px at default, clamped to [14, 72]
|
|
const iconPx = Math.round(Math.max(14, Math.min(72, scale * 28)))
|
|
// Label font: 11px at default, clamped to [9, 20]
|
|
const labelPx = Math.max(9, Math.min(20, Math.round(scale * 11)))
|
|
// IP font: 9px at default, clamped to [8, 16]
|
|
const ipPx = Math.max(8, Math.min(16, Math.round(scale * 9)))
|
|
const metaPx = Math.max(8, Math.min(11, Math.round(scale * 8)))
|
|
const iconPlateSize = Math.round(Math.max(34, Math.min(82, scale * 50)))
|
|
|
|
const [editing, setEditing] = useState(false)
|
|
const [labelValue, setLabelValue] = useState(nodeData.label ?? '')
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
const { updateNodeData } = useReactFlow()
|
|
|
|
useEffect(() => {
|
|
if (editing) {
|
|
inputRef.current?.focus()
|
|
inputRef.current?.select()
|
|
}
|
|
}, [editing])
|
|
|
|
// Sync if data.label changes externally (e.g. undo/redo)
|
|
useEffect(() => {
|
|
if (!editing) setLabelValue(nodeData.label ?? '')
|
|
}, [nodeData.label, editing])
|
|
|
|
const hasTooltipContent = props.hostname || props.ip || props.vendor || props.model || props.role || props.notes
|
|
|
|
return (
|
|
<>
|
|
<NodeResizer
|
|
isVisible={selected}
|
|
minWidth={NODE_MIN}
|
|
minHeight={NODE_MIN}
|
|
maxWidth={NODE_MAX}
|
|
maxHeight={NODE_MAX}
|
|
keepAspectRatio
|
|
lineStyle={{ borderColor: 'var(--color-accent)', borderWidth: 1 }}
|
|
handleStyle={{ width: 8, height: 8, borderColor: 'var(--color-accent)', background: 'var(--color-card)' }}
|
|
/>
|
|
<NodeStatusIndicator status={status}>
|
|
<NodeTooltip>
|
|
<NodeTooltipTrigger>
|
|
<BaseNode className="group h-full w-full bg-card">
|
|
<div className={cn('pointer-events-none absolute inset-x-0 top-0 h-10 bg-gradient-to-b opacity-80', surfaceClass)} />
|
|
<BaseNodeHeader className="flex h-full flex-col items-center justify-center gap-2 px-2 py-3">
|
|
<div
|
|
className={cn(
|
|
'relative flex items-center justify-center rounded-xl border transition-colors',
|
|
accentClass,
|
|
)}
|
|
style={{ width: iconPlateSize, height: iconPlateSize }}
|
|
>
|
|
<div className="absolute inset-[4px] rounded-[10px] border border-white/[0.06] bg-sidebar/50" />
|
|
<div className="relative z-10">
|
|
<Icon size={iconPx} style={{ color }} />
|
|
</div>
|
|
</div>
|
|
{editing ? (
|
|
<input
|
|
ref={inputRef}
|
|
value={labelValue}
|
|
onChange={e => setLabelValue(e.target.value)}
|
|
onBlur={() => {
|
|
setEditing(false)
|
|
if (labelValue !== nodeData.label) {
|
|
updateNodeData(id, { ...nodeData, label: labelValue })
|
|
}
|
|
}}
|
|
onKeyDown={e => {
|
|
if (e.key === 'Enter') inputRef.current?.blur()
|
|
if (e.key === 'Escape') {
|
|
setLabelValue(nodeData.label ?? '')
|
|
setEditing(false)
|
|
}
|
|
e.stopPropagation()
|
|
}}
|
|
style={{ fontSize: labelPx }}
|
|
className="bg-transparent border-none outline-none text-center text-primary font-medium w-4/5"
|
|
/>
|
|
) : (
|
|
<span
|
|
style={{ fontSize: labelPx }}
|
|
className="max-w-[88%] cursor-default text-center font-medium leading-tight text-primary line-clamp-2"
|
|
onDoubleClick={e => {
|
|
e.stopPropagation()
|
|
setEditing(true)
|
|
}}
|
|
>
|
|
{labelValue}
|
|
</span>
|
|
)}
|
|
<span
|
|
style={{ fontSize: metaPx }}
|
|
className="text-[9px] uppercase tracking-[0.16em] text-muted"
|
|
>
|
|
{CATEGORY_LABELS[category] ?? nodeData.deviceType.replace(/-/g, ' ')}
|
|
</span>
|
|
</BaseNodeHeader>
|
|
{ip && (
|
|
<BaseNodeContent className="items-center pt-0 pb-2">
|
|
<span
|
|
className="rounded-full border border-default bg-page/70 px-2 py-0.5 font-mono text-muted-foreground"
|
|
style={{ fontSize: ipPx }}
|
|
>
|
|
{ip}
|
|
</span>
|
|
</BaseNodeContent>
|
|
)}
|
|
<BaseHandle type="target" position={Position.Top} />
|
|
<BaseHandle type="source" position={Position.Bottom} />
|
|
<BaseHandle type="target" position={Position.Left} id="left" />
|
|
<BaseHandle type="source" position={Position.Right} id="right" />
|
|
</BaseNode>
|
|
</NodeTooltipTrigger>
|
|
{hasTooltipContent && (
|
|
<NodeTooltipContent position={Position.Top}>
|
|
<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>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export const DeviceNode = memo(DeviceNodeComponent)
|