feat: add grouping toolbar items and traffic flow toggle
DeviceToolbar gets Subnet/VLAN/Site/DMZ grouping section with drag-drop. PropertiesPanel gets Show Traffic toggle that switches edges between connection and animated types. DiagramEditor handles both device and group node drops. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import { Search, Plus, ChevronDown, ChevronRight, X } from 'lucide-react'
|
||||
import { Search, Plus, ChevronDown, ChevronRight, X, LayoutGrid } from 'lucide-react'
|
||||
import { getDeviceRenderConfig, CATEGORY_LABELS, CATEGORY_ORDER } from '../nodes/deviceRegistry'
|
||||
import type { DeviceTypeResponse, DeviceTypeCreate } from '@/types'
|
||||
import { deviceTypesApi } from '@/api'
|
||||
@@ -121,6 +121,34 @@ export function DeviceToolbar({ deviceTypes, onDeviceTypesChange }: DeviceToolba
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Grouping section */}
|
||||
<div className="mb-1 mt-2 border-t border-default pt-2">
|
||||
<div className="flex items-center gap-1 px-1 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Grouping
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{[
|
||||
{ slug: 'subnet', label: 'Subnet' },
|
||||
{ slug: 'vlan', label: 'VLAN' },
|
||||
{ slug: 'site', label: 'Site' },
|
||||
{ slug: 'dmz', label: 'DMZ' },
|
||||
].map(item => (
|
||||
<div
|
||||
key={item.slug}
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.setData('application/reactflow-group', JSON.stringify(item))
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
}}
|
||||
className="flex cursor-grab items-center gap-2 rounded px-2 py-1.5 text-xs text-primary hover:bg-elevated active:cursor-grabbing"
|
||||
>
|
||||
<LayoutGrid size={14} className="text-muted-foreground" />
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-default p-2">
|
||||
{!showAddForm ? (
|
||||
<button
|
||||
|
||||
@@ -10,6 +10,7 @@ interface PropertiesPanelProps {
|
||||
selectedEdge: Edge | null
|
||||
onNodeUpdate: (nodeId: string, data: Partial<DeviceNodeData>) => void
|
||||
onEdgeUpdate: (edgeId: string, data: Partial<DiagramEdge>) => void
|
||||
onEdgeTypeChange: (edgeId: string, edgeType: string) => void
|
||||
onDeleteNode: (nodeId: string) => void
|
||||
onDeleteEdge: (edgeId: string) => void
|
||||
}
|
||||
@@ -50,6 +51,7 @@ export function PropertiesPanel({
|
||||
selectedEdge,
|
||||
onNodeUpdate,
|
||||
onEdgeUpdate,
|
||||
onEdgeTypeChange,
|
||||
onDeleteNode,
|
||||
onDeleteEdge,
|
||||
}: PropertiesPanelProps) {
|
||||
@@ -137,6 +139,26 @@ export function PropertiesPanel({
|
||||
mono
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<FieldLabel>Show Traffic</FieldLabel>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newType = selectedEdge.type === 'animated' ? 'connection' : 'animated'
|
||||
onEdgeTypeChange(selectedEdge.id, newType)
|
||||
}}
|
||||
className={cn(
|
||||
'relative h-5 w-9 rounded-full transition-colors',
|
||||
selectedEdge.type === 'animated' ? 'bg-accent' : 'bg-elevated',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white transition-transform',
|
||||
selectedEdge.type === 'animated' && 'translate-x-4',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-default p-3">
|
||||
<button
|
||||
|
||||
@@ -200,10 +200,7 @@ function DiagramEditorInner() {
|
||||
|
||||
const onDrop = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault()
|
||||
const raw = event.dataTransfer.getData('application/reactflow-device')
|
||||
if (!raw) return
|
||||
|
||||
const { slug, label, category } = JSON.parse(raw) as { slug: string; label: string; category: string }
|
||||
const reactFlowBounds = (event.target as HTMLElement).closest('.react-flow')?.getBoundingClientRect()
|
||||
if (!reactFlowBounds) return
|
||||
|
||||
@@ -212,29 +209,53 @@ function DiagramEditorInner() {
|
||||
y: event.clientY - reactFlowBounds.top,
|
||||
}
|
||||
|
||||
const newNode: Node = {
|
||||
id: `device-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
type: 'device',
|
||||
position,
|
||||
data: {
|
||||
label,
|
||||
deviceType: slug,
|
||||
category,
|
||||
properties: {
|
||||
hostname: null,
|
||||
ip: null,
|
||||
subnet: null,
|
||||
vendor: null,
|
||||
model: null,
|
||||
role: null,
|
||||
vlan: null,
|
||||
notes: null,
|
||||
status: 'unknown',
|
||||
} satisfies DeviceProperties,
|
||||
} satisfies DeviceNodeData,
|
||||
// Handle device drops
|
||||
const deviceRaw = event.dataTransfer.getData('application/reactflow-device')
|
||||
if (deviceRaw) {
|
||||
const { slug, label, category } = JSON.parse(deviceRaw) as { slug: string; label: string; category: string }
|
||||
const newNode: Node = {
|
||||
id: `device-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
type: 'device',
|
||||
position,
|
||||
data: {
|
||||
label,
|
||||
deviceType: slug,
|
||||
category,
|
||||
properties: {
|
||||
hostname: null,
|
||||
ip: null,
|
||||
subnet: null,
|
||||
vendor: null,
|
||||
model: null,
|
||||
role: null,
|
||||
vlan: null,
|
||||
notes: null,
|
||||
status: 'unknown',
|
||||
} satisfies DeviceProperties,
|
||||
} satisfies DeviceNodeData,
|
||||
}
|
||||
setNodes(nds => [...nds, newNode])
|
||||
setIsDirty(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle group drops
|
||||
const groupRaw = event.dataTransfer.getData('application/reactflow-group')
|
||||
if (groupRaw) {
|
||||
const { slug, label } = JSON.parse(groupRaw) as { slug: string; label: string }
|
||||
const newNode: Node = {
|
||||
id: `group-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
type: 'group',
|
||||
position,
|
||||
style: { width: 300, height: 200 },
|
||||
data: {
|
||||
label,
|
||||
groupType: slug,
|
||||
},
|
||||
}
|
||||
setNodes(nds => [...nds, newNode])
|
||||
setIsDirty(true)
|
||||
}
|
||||
setNodes(nds => [...nds, newNode])
|
||||
setIsDirty(true)
|
||||
}, [setNodes])
|
||||
|
||||
const handleNodeUpdate = useCallback((nodeId: string, updates: Partial<DeviceNodeData>) => {
|
||||
@@ -262,6 +283,14 @@ function DiagramEditorInner() {
|
||||
setIsDirty(true)
|
||||
}, [setEdges])
|
||||
|
||||
const handleEdgeTypeChange = useCallback((edgeId: string, edgeType: string) => {
|
||||
setEdges(eds => eds.map(e => {
|
||||
if (e.id !== edgeId) return e
|
||||
return { ...e, type: edgeType }
|
||||
}))
|
||||
setIsDirty(true)
|
||||
}, [setEdges])
|
||||
|
||||
const handleDeleteNode = useCallback((nodeId: string) => {
|
||||
setNodes(nds => nds.filter(n => n.id !== nodeId))
|
||||
setEdges(eds => eds.filter(e => e.source !== nodeId && e.target !== nodeId))
|
||||
@@ -405,6 +434,7 @@ function DiagramEditorInner() {
|
||||
selectedEdge={selectedEdge}
|
||||
onNodeUpdate={handleNodeUpdate}
|
||||
onEdgeUpdate={handleEdgeUpdate}
|
||||
onEdgeTypeChange={handleEdgeTypeChange}
|
||||
onDeleteNode={handleDeleteNode}
|
||||
onDeleteEdge={handleDeleteEdge}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user