- Colorize: semantic category colors for all device types (network=blue, security=orange, compute=emerald, endpoint=amber, storage=violet, cloud=cyan, infra=steel); better icons (Router, ShieldAlert, Boxes, Package, Gauge, PlugZap, Video, Radio); MiniMap uses category colors - Onboard: centered AI generate prompt on empty canvas with 5 MSP-specific example chips, ⌘↵ shortcut, spinner; AIAssistPanel only shown with nodes - Arrange: properties panel — status badge grid at top, fields grouped into Network (IP/Subnet/VLAN) and Hardware (Hostname/Vendor/Model/Role) sections - Delight: segmented topology color bar on listing cards; backend returns category_counts via single extra query on list endpoint - Harden: real PNG export via html-to-image + getNodesBounds/getViewportForBounds - Polish: ChevronDown replaces unicode ▾, click-outside for client filter, consistent spinner in empty prompt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
120 lines
3.7 KiB
TypeScript
120 lines
3.7 KiB
TypeScript
import { useCallback } from 'react'
|
|
import {
|
|
ReactFlow,
|
|
Background,
|
|
Controls,
|
|
MiniMap,
|
|
BackgroundVariant,
|
|
type OnConnect,
|
|
type OnNodesChange,
|
|
type OnEdgesChange,
|
|
type Node,
|
|
type Edge,
|
|
} from '@xyflow/react'
|
|
import { nodeTypes } from './nodes/nodeTypes'
|
|
import { edgeTypes } from './edges/edgeTypes'
|
|
import { getDeviceRenderConfig } from './nodes/deviceRegistry'
|
|
import type { DeviceNodeData } from './nodes/DeviceNode'
|
|
|
|
interface NetworkCanvasProps {
|
|
nodes: Node[]
|
|
edges: Edge[]
|
|
onNodesChange: OnNodesChange
|
|
onEdgesChange: OnEdgesChange
|
|
onConnect: OnConnect
|
|
onNodeSelect: (nodeId: string | null) => void
|
|
onEdgeSelect: (edgeId: string | null) => void
|
|
onDrop: (event: React.DragEvent) => void
|
|
onDragOver: (event: React.DragEvent) => void
|
|
onDragLeave?: (event: React.DragEvent) => void
|
|
isDragOver?: boolean
|
|
onNodeContextMenu?: (event: React.MouseEvent, node: Node) => void
|
|
onPaneContextMenu?: (event: MouseEvent | React.MouseEvent) => void
|
|
onPaneClick?: () => void
|
|
}
|
|
|
|
export function NetworkCanvas({
|
|
nodes,
|
|
edges,
|
|
onNodesChange,
|
|
onEdgesChange,
|
|
onConnect,
|
|
onNodeSelect,
|
|
onEdgeSelect,
|
|
onDrop,
|
|
onDragOver,
|
|
onDragLeave,
|
|
isDragOver,
|
|
onNodeContextMenu,
|
|
onPaneContextMenu,
|
|
onPaneClick: onPaneClickProp,
|
|
}: NetworkCanvasProps) {
|
|
const handleSelectionChange = useCallback(({ nodes: selectedNodes, edges: selectedEdges }: { nodes: Node[]; edges: Edge[] }) => {
|
|
if (selectedNodes.length === 1) {
|
|
onNodeSelect(selectedNodes[0].id)
|
|
onEdgeSelect(null)
|
|
} else if (selectedEdges.length === 1) {
|
|
onEdgeSelect(selectedEdges[0].id)
|
|
onNodeSelect(null)
|
|
} else {
|
|
onNodeSelect(null)
|
|
onEdgeSelect(null)
|
|
}
|
|
}, [onNodeSelect, onEdgeSelect])
|
|
|
|
const handlePaneClick = useCallback(() => {
|
|
onNodeSelect(null)
|
|
onEdgeSelect(null)
|
|
onPaneClickProp?.()
|
|
}, [onNodeSelect, onEdgeSelect, onPaneClickProp])
|
|
|
|
const getNodeColor = useCallback((node: Node) => {
|
|
if (node.type === 'group') return 'var(--color-bg-elevated)'
|
|
const data = node.data as unknown as DeviceNodeData
|
|
return getDeviceRenderConfig(data?.deviceType || '', data?.category).color
|
|
}, [])
|
|
|
|
return (
|
|
<div className="relative h-full w-full" onDragLeave={onDragLeave}>
|
|
<ReactFlow
|
|
nodes={nodes}
|
|
edges={edges}
|
|
onNodesChange={onNodesChange}
|
|
onEdgesChange={onEdgesChange}
|
|
onConnect={onConnect}
|
|
onSelectionChange={handleSelectionChange}
|
|
onPaneClick={handlePaneClick}
|
|
onDrop={onDrop}
|
|
onDragOver={onDragOver}
|
|
onNodeContextMenu={onNodeContextMenu}
|
|
onPaneContextMenu={onPaneContextMenu}
|
|
nodeTypes={nodeTypes}
|
|
edgeTypes={edgeTypes}
|
|
defaultEdgeOptions={{ type: 'connection' }}
|
|
deleteKeyCode={['Backspace', 'Delete']}
|
|
multiSelectionKeyCode="Shift"
|
|
snapToGrid={true}
|
|
snapGrid={[20, 20]}
|
|
fitView
|
|
className="bg-page"
|
|
>
|
|
<Background variant={BackgroundVariant.Dots} color="var(--color-border-default)" gap={20} size={1} />
|
|
<Controls className="!border-default !bg-card [&>button]:!border-default [&>button]:!bg-card [&>button]:!fill-text-primary" />
|
|
<MiniMap
|
|
nodeColor={getNodeColor}
|
|
maskColor="rgba(0,0,0,0.5)"
|
|
className="!border-default !bg-card"
|
|
position="bottom-right"
|
|
/>
|
|
</ReactFlow>
|
|
{isDragOver && (
|
|
<div className="pointer-events-none absolute inset-2 z-10 flex items-center justify-center rounded-lg border-2 border-dashed border-accent/30">
|
|
<span className="rounded-md bg-card/80 px-3 py-1.5 text-sm text-muted-foreground">
|
|
Drop to add
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|