feat: drag-to-resize device nodes + BrickWallFire for firewall

- NodeResizer on DeviceNode (same pattern as group nodes); icon scales
  proportionally with node width, clamped 16–60px
- Removes S/M/L static picker — resize is now direct manipulation
- firewall: ShieldAlert → BrickWallFire

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-04-13 04:07:52 +00:00
parent 31324aa154
commit 47353a68cd
3 changed files with 54 additions and 71 deletions

View File

@@ -1,5 +1,5 @@
import { memo } from 'react'
import { Position, type NodeProps } from '@xyflow/react'
import { Position, NodeResizer, type NodeProps } from '@xyflow/react'
import { BaseNode, BaseNodeHeader, BaseNodeHeaderTitle, BaseNodeContent } from '../ui/base-node'
import { BaseHandle } from '../ui/base-handle'
import { NodeStatusIndicator, type NodeStatus } from '../ui/node-status-indicator'
@@ -7,14 +7,10 @@ import { NodeTooltip, NodeTooltipTrigger, NodeTooltipContent } from '../ui/node-
import { getDeviceRenderConfig } from './deviceRegistry'
import type { DeviceProperties } from '@/types'
export type IconSize = 'sm' | 'md' | 'lg'
export const ICON_SIZE_PX: Record<IconSize, number> = { sm: 18, md: 28, lg: 42 }
export interface DeviceNodeData {
label: string
deviceType: string
category?: string
iconSize?: IconSize
properties: DeviceProperties
[key: string]: unknown
}
@@ -29,55 +25,66 @@ function TooltipRow({ label, value }: { label: string; value: string | null | un
)
}
function DeviceNodeComponent({ data }: NodeProps) {
function DeviceNodeComponent({ data, selected, width }: NodeProps) {
const nodeData = data as unknown as DeviceNodeData
const { icon: Icon, color } = getDeviceRenderConfig(nodeData.deviceType, nodeData.category)
const status = (nodeData.properties?.status || 'unknown') as NodeStatus
const ip = nodeData.properties?.ip
const props = nodeData.properties || {}
const iconPx = ICON_SIZE_PX[nodeData.iconSize ?? 'md']
// Scale icon proportionally: 28px at default 120px wide, clamped 1660px
const iconPx = Math.round(Math.max(16, Math.min(60, ((width ?? 120) / 120) * 28)))
const hasTooltipContent = props.hostname || props.ip || props.vendor || props.model || props.role || props.notes
return (
<NodeStatusIndicator status={status}>
<NodeTooltip>
<NodeTooltipTrigger>
<BaseNode className="min-w-[120px] group">
<BaseNodeHeader className="flex-col gap-1 items-center py-3 px-4">
<Icon size={iconPx} style={{ color }} />
<BaseNodeHeaderTitle className="text-center text-xs">
{nodeData.label}
</BaseNodeHeaderTitle>
</BaseNodeHeader>
{ip && (
<BaseNodeContent className="items-center pt-0">
<span className="font-mono text-[10px] text-muted-foreground">{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(' ')} />
<>
<NodeResizer
isVisible={selected}
minWidth={80}
minHeight={70}
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="w-full min-w-[80px] group">
<BaseNodeHeader className="flex-col gap-1 items-center py-3 px-4">
<Icon size={iconPx} style={{ color }} />
<BaseNodeHeaderTitle className="text-center text-xs">
{nodeData.label}
</BaseNodeHeaderTitle>
</BaseNodeHeader>
{ip && (
<BaseNodeContent className="items-center pt-0">
<span className="font-mono text-[10px] text-muted-foreground">{ip}</span>
</BaseNodeContent>
)}
<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>
<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>
</>
)
}