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:
chihlasm
2026-04-04 14:27:23 +00:00
parent fe33ad1d5a
commit a9c4bcc08b
3 changed files with 106 additions and 26 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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}
/>