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 { 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 { BaseNode, BaseNodeHeader, BaseNodeHeaderTitle, BaseNodeContent } from '../ui/base-node'
import { BaseHandle } from '../ui/base-handle' import { BaseHandle } from '../ui/base-handle'
import { NodeStatusIndicator, type NodeStatus } from '../ui/node-status-indicator' 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 { getDeviceRenderConfig } from './deviceRegistry'
import type { DeviceProperties } from '@/types' 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 { export interface DeviceNodeData {
label: string label: string
deviceType: string deviceType: string
category?: string category?: string
iconSize?: IconSize
properties: DeviceProperties properties: DeviceProperties
[key: string]: unknown [key: string]: unknown
} }
@@ -29,21 +25,31 @@ 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 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') as NodeStatus const status = (nodeData.properties?.status || 'unknown') as NodeStatus
const ip = nodeData.properties?.ip const ip = nodeData.properties?.ip
const props = nodeData.properties || {} 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 const hasTooltipContent = props.hostname || props.ip || props.vendor || props.model || props.role || props.notes
return ( return (
<>
<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}> <NodeStatusIndicator status={status}>
<NodeTooltip> <NodeTooltip>
<NodeTooltipTrigger> <NodeTooltipTrigger>
<BaseNode className="min-w-[120px] group"> <BaseNode className="w-full min-w-[80px] group">
<BaseNodeHeader className="flex-col gap-1 items-center py-3 px-4"> <BaseNodeHeader className="flex-col gap-1 items-center py-3 px-4">
<Icon size={iconPx} style={{ color }} /> <Icon size={iconPx} style={{ color }} />
<BaseNodeHeaderTitle className="text-center text-xs"> <BaseNodeHeaderTitle className="text-center text-xs">
@@ -78,6 +84,7 @@ function DeviceNodeComponent({ data }: NodeProps) {
)} )}
</NodeTooltip> </NodeTooltip>
</NodeStatusIndicator> </NodeStatusIndicator>
</>
) )
} }

View File

@@ -1,6 +1,6 @@
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import { import {
Router, Network, ShieldAlert, Wifi, Server, Monitor, Boxes, Package, Cloud, Router, Network, BrickWallFire, Wifi, Server, Monitor, Boxes, Package, Cloud,
Printer, Smartphone, HardDrive, Gauge, Database, CloudCog, Printer, Smartphone, HardDrive, Gauge, Database, CloudCog,
Cpu, Tablet, Laptop, BatteryCharging, RectangleVertical, Cpu, Tablet, Laptop, BatteryCharging, RectangleVertical,
Cable, Camera, KeyRound, Globe, Video, PlugZap, Radio, Cable, Camera, KeyRound, Globe, Video, PlugZap, Radio,
@@ -35,7 +35,7 @@ const SYSTEM_DEVICE_ICONS: Record<string, DeviceRenderConfig> = {
'load-balancer': { icon: Gauge, color: NETWORK_COLOR }, 'load-balancer': { icon: Gauge, color: NETWORK_COLOR },
// Security // Security
'firewall': { icon: ShieldAlert, color: SECURITY_COLOR }, 'firewall': { icon: BrickWallFire, color: SECURITY_COLOR },
'badge-reader': { icon: KeyRound, color: SECURITY_COLOR }, 'badge-reader': { icon: KeyRound, color: SECURITY_COLOR },
// Compute // Compute

View File

@@ -3,7 +3,7 @@ import { Trash2, Minus, Spline, GitBranch, BringToFront, SendToBack } from 'luci
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { DeviceProperties, DiagramEdge } from '@/types' import type { DeviceProperties, DiagramEdge } from '@/types'
import type { Node, Edge } from '@xyflow/react' import type { Node, Edge } from '@xyflow/react'
import type { DeviceNodeData, IconSize } from '../nodes/DeviceNode' import type { DeviceNodeData } from '../nodes/DeviceNode'
interface PropertiesPanelProps { interface PropertiesPanelProps {
selectedNode: Node | null selectedNode: Node | null
@@ -272,30 +272,6 @@ export function PropertiesPanel({
<FieldInput value={nodeData.label} onChange={handleLabelChange} placeholder="Device name" /> <FieldInput value={nodeData.label} onChange={handleLabelChange} placeholder="Device name" />
</div> </div>
{/* Icon size */}
<div className="flex flex-col gap-1">
<FieldLabel>Icon Size</FieldLabel>
<div className="flex gap-1">
{(['sm', 'md', 'lg'] as IconSize[]).map(size => {
const active = (nodeData.iconSize ?? 'md') === size
return (
<button
key={size}
onClick={() => onNodeUpdate(selectedNode!.id, { iconSize: size } as Partial<DeviceNodeData>)}
className={cn(
'flex flex-1 items-center justify-center rounded border py-1.5 text-[10px] font-medium uppercase transition-colors',
active
? 'border-accent bg-accent/10 text-accent'
: 'border-default text-muted-foreground hover:border-hover hover:text-primary',
)}
>
{size}
</button>
)
})}
</div>
</div>
{/* Layering */} {/* Layering */}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<FieldLabel>Layer</FieldLabel> <FieldLabel>Layer</FieldLabel>