fix: network diagram editor UX — straight edges, snap-to-grid, ISP in Cloud, group resize

- Straight edges: replace SmoothStepEdge with BaseEdge + getStraightPath so
  connections draw direct diagonal lines instead of orthogonal bent paths
- Snap-to-grid: add snapToGrid/snapGrid=[20,20] to NetworkCanvas so nodes
  align consistently when dragged
- ISP in Cloud: remove standalone "Internet" sidebar section, inject ISP into
  the Cloud category loop with search support and correct item count
- Group node resize: add NodeResizer to GroupNode (subnet/VLAN/site/DMZ),
  handles visible when selected; dimensions saved/restored correctly on
  reload (also fixes group node load bug where type was always 'device')
- DiagramNode type: add nodeType and style optional fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-04-04 23:09:49 +00:00
parent 663a96c8a5
commit b490719667
7 changed files with 103 additions and 56 deletions

View File

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

View File

@@ -85,6 +85,8 @@ export function NetworkCanvas({
defaultEdgeOptions={{ type: 'connection' }}
deleteKeyCode={['Backspace', 'Delete']}
multiSelectionKeyCode="Shift"
snapToGrid={true}
snapGrid={[20, 20]}
fitView
className="bg-page"
>

View File

@@ -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 (
<>
<SmoothStepEdge
{...props}
<BaseEdge
path={edgePath}
style={{
...style,
...(props.selected ? { stroke: '#60a5fa', strokeWidth: style.strokeWidth + 1 } : {}),
@@ -39,7 +46,7 @@ function ConnectionEdgeComponent(props: EdgeProps) {
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)`,
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
pointerEvents: 'all',
}}
>

View File

@@ -84,9 +84,13 @@ export function DeviceToolbar({ deviceTypes, onDeviceTypesChange }: DeviceToolba
<div className="flex-1 overflow-y-auto px-2 pb-2">
{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 (
<div key={cat} className="mb-1">
@@ -96,7 +100,7 @@ export function DeviceToolbar({ deviceTypes, onDeviceTypesChange }: DeviceToolba
>
{collapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
{CATEGORY_LABELS[cat] || cat}
<span className="ml-auto text-[10px] font-normal">{items.length}</span>
<span className="ml-auto text-[10px] font-normal">{totalCount}</span>
</button>
{!collapsed && (
<div className="flex flex-col gap-0.5">
@@ -115,6 +119,24 @@ export function DeviceToolbar({ deviceTypes, onDeviceTypesChange }: DeviceToolba
</div>
)
})}
{showIsp && (
<div
draggable
onDragStart={e => {
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"
>
<GripVertical size={12} className="shrink-0 text-muted-foreground/50" />
<Globe size={14} style={{ color: 'var(--color-accent)' }} />
<span>ISP</span>
</div>
)}
</div>
)}
</div>
@@ -122,31 +144,6 @@ export function DeviceToolbar({ deviceTypes, onDeviceTypesChange }: DeviceToolba
})}
</div>
{/* Internet 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">
Internet
</div>
<div className="flex flex-col gap-0.5">
<div
draggable
onDragStart={e => {
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"
>
<GripVertical size={12} className="shrink-0 text-muted-foreground/50" />
<Globe size={14} style={{ color: 'var(--color-accent)' }} />
<span>ISP</span>
</div>
</div>
</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">

View File

@@ -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 (
<BaseNode
className={cn(
'h-full w-full min-h-[200px] min-w-[300px] overflow-hidden rounded-lg bg-elevated/30 border-default/50',
selected && 'border-accent',
)}
>
<Panel className="m-0 p-0" position="top-left">
<GroupNodeLabel className={getLabelClassName('top-left')}>
{label}
</GroupNodeLabel>
</Panel>
</BaseNode>
<>
<NodeResizer
isVisible={selected}
minWidth={150}
minHeight={100}
lineStyle={{ borderColor: 'var(--color-accent)', borderWidth: 1 }}
handleStyle={{ width: 8, height: 8, borderColor: 'var(--color-accent)', background: 'var(--color-card)' }}
/>
<BaseNode
className={cn(
'h-full w-full min-h-[100px] min-w-[150px] overflow-hidden rounded-lg bg-elevated/30 border-default/50',
selected && 'border-accent',
)}
>
<Panel className="m-0 p-0" position="top-left">
<GroupNodeLabel className={getLabelClassName('top-left')}>
{label}
</GroupNodeLabel>
</Panel>
</BaseNode>
</>
)
}

View File

@@ -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<string, unknown>
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

View File

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