diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cc15d92..5b863e9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to ResolutionFlow are documented here. ## [Unreleased] +## [2026-04-04] Network Diagram Editor UX Improvements + +### Added +- Snap-to-grid (20px) on Network Diagram canvas — nodes align consistently when dragged +- NodeResizer on group nodes (subnet/VLAN/site/DMZ) — select a group and drag its handles to resize +- Group node dimensions now saved to and restored from the backend on reload + +### Fixed +- Connection edges now render as straight lines instead of orthogonal bent paths +- ISP device now appears inside the Cloud category in the sidebar instead of a standalone "Internet" section; respects search and item count +- Group nodes now restore correctly as `type: 'group'` on diagram load (previously loaded as `type: 'device'`, breaking group display after save) + +--- + ### Added - Tree Templates + Import/Export marketplace (#66) - Recurring Issue Detection — client-specific pattern alerts (#60) diff --git a/frontend/src/components/network/NetworkCanvas.tsx b/frontend/src/components/network/NetworkCanvas.tsx index 1c6569b8..307e047a 100644 --- a/frontend/src/components/network/NetworkCanvas.tsx +++ b/frontend/src/components/network/NetworkCanvas.tsx @@ -85,6 +85,8 @@ export function NetworkCanvas({ defaultEdgeOptions={{ type: 'connection' }} deleteKeyCode={['Backspace', 'Delete']} multiSelectionKeyCode="Shift" + snapToGrid={true} + snapGrid={[20, 20]} fitView className="bg-page" > diff --git a/frontend/src/components/network/edges/ConnectionEdge.tsx b/frontend/src/components/network/edges/ConnectionEdge.tsx index 0e38ba72..2d79b120 100644 --- a/frontend/src/components/network/edges/ConnectionEdge.tsx +++ b/frontend/src/components/network/edges/ConnectionEdge.tsx @@ -1,5 +1,5 @@ import { memo } from 'react' -import { SmoothStepEdge, EdgeLabelRenderer, type EdgeProps } from '@xyflow/react' +import { BaseEdge, EdgeLabelRenderer, getStraightPath, type EdgeProps } from '@xyflow/react' interface ConnectionEdgeData { connectionType?: string @@ -24,10 +24,17 @@ function ConnectionEdgeComponent(props: EdgeProps) { const connectionType = edgeData?.connectionType || 'ethernet' const style = CONNECTION_STYLES[connectionType] || DEFAULT_STYLE + const [edgePath, labelX, labelY] = getStraightPath({ + sourceX: props.sourceX, + sourceY: props.sourceY, + targetX: props.targetX, + targetY: props.targetY, + }) + return ( <> - diff --git a/frontend/src/components/network/panels/DeviceToolbar.tsx b/frontend/src/components/network/panels/DeviceToolbar.tsx index 53411006..22f59669 100644 --- a/frontend/src/components/network/panels/DeviceToolbar.tsx +++ b/frontend/src/components/network/panels/DeviceToolbar.tsx @@ -84,9 +84,13 @@ export function DeviceToolbar({ deviceTypes, onDeviceTypesChange }: DeviceToolba
{CATEGORY_ORDER.map(cat => { - const items = filteredByCategory[cat] - if (!items?.length) return null + const items = filteredByCategory[cat] || [] + const isCloud = cat === 'cloud' + const ispMatchesSearch = !search || 'isp'.includes(search.toLowerCase()) || 'internet service provider'.includes(search.toLowerCase()) + const showIsp = isCloud && ispMatchesSearch + if (!items.length && !showIsp) return null const collapsed = collapsedCategories.has(cat) + const totalCount = items.length + (showIsp ? 1 : 0) return (
@@ -96,7 +100,7 @@ export function DeviceToolbar({ deviceTypes, onDeviceTypesChange }: DeviceToolba > {collapsed ? : } {CATEGORY_LABELS[cat] || cat} - {items.length} + {totalCount} {!collapsed && (
@@ -115,6 +119,24 @@ export function DeviceToolbar({ deviceTypes, onDeviceTypesChange }: DeviceToolba
) })} + {showIsp && ( +
{ + e.dataTransfer.setData('application/reactflow-device', JSON.stringify({ + slug: 'isp', + label: 'ISP', + category: 'cloud', + })) + 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 active:scale-[0.98] transition-transform" + > + + + ISP +
+ )}
)}
@@ -122,31 +144,6 @@ export function DeviceToolbar({ deviceTypes, onDeviceTypesChange }: DeviceToolba })} - {/* Internet section */} -
-
- Internet -
-
-
{ - e.dataTransfer.setData('application/reactflow-device', JSON.stringify({ - slug: 'isp', - label: 'ISP', - category: 'cloud', - })) - 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 active:scale-[0.98] transition-transform" - > - - - ISP -
-
-
- {/* Grouping section */}
diff --git a/frontend/src/components/network/ui/labeled-group-node.tsx b/frontend/src/components/network/ui/labeled-group-node.tsx index 18127dc0..ecab2c80 100644 --- a/frontend/src/components/network/ui/labeled-group-node.tsx +++ b/frontend/src/components/network/ui/labeled-group-node.tsx @@ -1,5 +1,5 @@ import type { ReactNode, ComponentProps } from 'react' -import { Panel, type NodeProps, type PanelPosition } from '@xyflow/react' +import { Panel, NodeResizer, type NodeProps, type PanelPosition } from '@xyflow/react' import { BaseNode } from './base-node' import { cn } from '@/lib/utils' @@ -43,17 +43,26 @@ export function GroupNode({ data, selected }: NodeProps) { const label = nodeData.label || 'Group' return ( - - - - {label} - - - + <> + + + + + {label} + + + + ) } diff --git a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx index 0ddcb4eb..cd178324 100644 --- a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +++ b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx @@ -115,16 +115,30 @@ function DiagramEditorInner() { setAssetName(diagram.asset_name) setDescription(diagram.description) setNodes( - diagram.nodes.map(n => ({ - id: n.id, - type: 'device', - position: n.position, - data: { - label: n.label, - deviceType: n.type, - properties: n.properties, - } satisfies DeviceNodeData, - })) + diagram.nodes.map(n => { + if (n.nodeType === 'group') { + return { + id: n.id, + type: 'group', + position: n.position, + style: n.style || { width: 300, height: 200 }, + data: { + label: n.label, + groupType: n.type, + }, + } + } + return { + id: n.id, + type: 'device', + position: n.position, + data: { + label: n.label, + deviceType: n.type, + properties: n.properties, + } satisfies DeviceNodeData, + } + }) ) setEdges( diagram.edges.map(e => ({ @@ -155,6 +169,8 @@ function DiagramEditorInner() { return getNodes().map(n => { if (n.type === 'group') { const data = n.data as Record + const width = typeof n.style?.width === 'number' ? n.style.width : (n.measured?.width ?? 300) + const height = typeof n.style?.height === 'number' ? n.style.height : (n.measured?.height ?? 200) return { id: n.id, type: (data.groupType as string) || 'subnet', @@ -162,7 +178,7 @@ function DiagramEditorInner() { position: n.position, properties: {} as DeviceProperties, nodeType: 'group', - style: n.style || null, + style: { width, height }, } } const data = n.data as unknown as DeviceNodeData diff --git a/frontend/src/types/network-diagram.ts b/frontend/src/types/network-diagram.ts index cfca8cc0..dd02155d 100644 --- a/frontend/src/types/network-diagram.ts +++ b/frontend/src/types/network-diagram.ts @@ -16,6 +16,8 @@ export interface DiagramNode { label: string position: { x: number; y: number } properties: DeviceProperties + nodeType?: string + style?: { width?: number; height?: number } | null } export interface DiagramEdge {