feat: add device registry, DeviceNode, ConnectionEdge for React Flow
Creates the React Flow building blocks for the network diagram editor: device type registry with icon/color mappings, DeviceNode component with status indicators and connection handles, ConnectionEdge with per-type styling, and nodeTypes/edgeTypes registration maps. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
54
frontend/src/components/network/edges/ConnectionEdge.tsx
Normal file
54
frontend/src/components/network/edges/ConnectionEdge.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import { SmoothStepEdge, EdgeLabelRenderer, type EdgeProps } from '@xyflow/react'
|
||||||
|
|
||||||
|
interface ConnectionEdgeData {
|
||||||
|
connectionType?: string
|
||||||
|
speed?: string | null
|
||||||
|
notes?: string | null
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONNECTION_STYLES: Record<string, { stroke: string; strokeDasharray?: string; strokeWidth: number }> = {
|
||||||
|
ethernet: { stroke: '#60a5fa', strokeWidth: 2 },
|
||||||
|
fiber: { stroke: '#34d399', strokeWidth: 3 },
|
||||||
|
wifi: { stroke: '#a78bfa', strokeDasharray: '3,3', strokeWidth: 2 },
|
||||||
|
vpn: { stroke: '#eab308', strokeDasharray: '8,4', strokeWidth: 2 },
|
||||||
|
vlan: { stroke: '#848b9b', strokeWidth: 2 },
|
||||||
|
wan: { stroke: '#f87171', strokeDasharray: '12,4', strokeWidth: 2 },
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_STYLE = { stroke: '#848b9b', strokeWidth: 2 }
|
||||||
|
|
||||||
|
function ConnectionEdgeComponent(props: EdgeProps) {
|
||||||
|
const edgeData = props.data as ConnectionEdgeData | undefined
|
||||||
|
const connectionType = edgeData?.connectionType || 'ethernet'
|
||||||
|
const style = CONNECTION_STYLES[connectionType] || DEFAULT_STYLE
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SmoothStepEdge
|
||||||
|
{...props}
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
...(props.selected ? { stroke: '#60a5fa', strokeWidth: style.strokeWidth + 1 } : {}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{props.label && (
|
||||||
|
<EdgeLabelRenderer>
|
||||||
|
<div
|
||||||
|
className="nodrag nopan rounded bg-page px-1.5 py-0.5 text-[10px] text-muted-foreground"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
transform: `translate(-50%, -50%) translate(${(props.sourceX + props.targetX) / 2}px, ${(props.sourceY + props.targetY) / 2}px)`,
|
||||||
|
pointerEvents: 'all',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.label}
|
||||||
|
</div>
|
||||||
|
</EdgeLabelRenderer>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConnectionEdge = memo(ConnectionEdgeComponent)
|
||||||
5
frontend/src/components/network/edges/edgeTypes.ts
Normal file
5
frontend/src/components/network/edges/edgeTypes.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ConnectionEdge } from './ConnectionEdge'
|
||||||
|
|
||||||
|
export const edgeTypes = {
|
||||||
|
connection: ConnectionEdge,
|
||||||
|
}
|
||||||
51
frontend/src/components/network/nodes/DeviceNode.tsx
Normal file
51
frontend/src/components/network/nodes/DeviceNode.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import { Handle, Position, type NodeProps } from '@xyflow/react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { getDeviceRenderConfig } from './deviceRegistry'
|
||||||
|
import type { DeviceProperties } from '@/types'
|
||||||
|
|
||||||
|
export interface DeviceNodeData {
|
||||||
|
label: string
|
||||||
|
deviceType: string
|
||||||
|
category?: string
|
||||||
|
properties: DeviceProperties
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
online: 'bg-emerald-400',
|
||||||
|
offline: 'bg-red-400',
|
||||||
|
degraded: 'bg-yellow-400',
|
||||||
|
unknown: 'bg-gray-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceNodeComponent({ data, selected }: NodeProps) {
|
||||||
|
const nodeData = data as unknown as DeviceNodeData
|
||||||
|
const { icon: Icon, color } = getDeviceRenderConfig(nodeData.deviceType, nodeData.category)
|
||||||
|
const status = nodeData.properties?.status || 'unknown'
|
||||||
|
const ip = nodeData.properties?.ip
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative flex min-w-[120px] flex-col items-center gap-1 rounded-lg border bg-card px-4 py-3',
|
||||||
|
'border-default transition-colors',
|
||||||
|
'group',
|
||||||
|
selected && 'border-accent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn('absolute right-2 top-2 h-2 w-2 rounded-full', STATUS_COLORS[status])} />
|
||||||
|
<Icon size={28} style={{ color }} />
|
||||||
|
<span className="text-center text-xs font-medium text-heading">{nodeData.label}</span>
|
||||||
|
{ip && (
|
||||||
|
<span className="font-mono text-[10px] text-muted-foreground">{ip}</span>
|
||||||
|
)}
|
||||||
|
<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" />
|
||||||
|
<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" />
|
||||||
|
<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" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeviceNode = memo(DeviceNodeComponent)
|
||||||
73
frontend/src/components/network/nodes/deviceRegistry.ts
Normal file
73
frontend/src/components/network/nodes/deviceRegistry.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
Network, Layers, Shield, Wifi, Server, Monitor, Box, Cloud,
|
||||||
|
Printer, Smartphone, HardDrive, Scale, Database, CloudCog,
|
||||||
|
Cpu, Tablet, Laptop, BatteryCharging, LayoutGrid, RectangleVertical,
|
||||||
|
Cable, Camera, KeyRound,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
export interface DeviceRenderConfig {
|
||||||
|
icon: LucideIcon
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SYSTEM_DEVICE_ICONS: Record<string, DeviceRenderConfig> = {
|
||||||
|
'router': { icon: Network, color: 'var(--color-accent)' },
|
||||||
|
'switch': { icon: Layers, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'firewall': { icon: Shield, color: 'var(--color-accent)' },
|
||||||
|
'access-point': { icon: Wifi, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'load-balancer': { icon: Scale, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'server': { icon: Server, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'workstation': { icon: Monitor, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'vm': { icon: Box, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'container': { icon: Cpu, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'nas': { icon: Database, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'san': { icon: HardDrive, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'cloud-storage': { icon: CloudCog, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'cloud': { icon: Cloud, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'aws': { icon: Cloud, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'azure': { icon: Cloud, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'gcp': { icon: Cloud, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'printer': { icon: Printer, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'phone': { icon: Smartphone, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'iot': { icon: HardDrive, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'camera': { icon: Camera, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'tablet': { icon: Tablet, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'laptop': { icon: Laptop, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'ups': { icon: BatteryCharging, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'pdu': { icon: LayoutGrid, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'rack': { icon: RectangleVertical, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'patch-panel': { icon: Cable, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'nvr': { icon: Camera, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'badge-reader': { icon: KeyRound, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_DEFAULTS: Record<string, DeviceRenderConfig> = {
|
||||||
|
'network': { icon: Network, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'compute': { icon: Server, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'storage': { icon: Database, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'cloud': { icon: Cloud, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'endpoint': { icon: Monitor, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'infrastructure': { icon: LayoutGrid, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
'security': { icon: Shield, color: 'var(--color-text-muted-foreground)' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALLBACK: DeviceRenderConfig = { icon: Box, color: 'var(--color-text-muted-foreground)' }
|
||||||
|
|
||||||
|
export function getDeviceRenderConfig(slug: string, category?: string): DeviceRenderConfig {
|
||||||
|
if (SYSTEM_DEVICE_ICONS[slug]) return SYSTEM_DEVICE_ICONS[slug]
|
||||||
|
if (category && CATEGORY_DEFAULTS[category]) return CATEGORY_DEFAULTS[category]
|
||||||
|
return FALLBACK
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
'network': 'Network',
|
||||||
|
'compute': 'Compute',
|
||||||
|
'storage': 'Storage',
|
||||||
|
'cloud': 'Cloud',
|
||||||
|
'endpoint': 'Endpoints',
|
||||||
|
'infrastructure': 'Infrastructure',
|
||||||
|
'security': 'Security',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CATEGORY_ORDER = ['network', 'compute', 'storage', 'cloud', 'endpoint', 'infrastructure', 'security']
|
||||||
5
frontend/src/components/network/nodes/nodeTypes.ts
Normal file
5
frontend/src/components/network/nodes/nodeTypes.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { DeviceNode } from './DeviceNode'
|
||||||
|
|
||||||
|
export const nodeTypes = {
|
||||||
|
device: DeviceNode,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user