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:
14
CHANGELOG.md
14
CHANGELOG.md
@@ -4,6 +4,20 @@ All notable changes to ResolutionFlow are documented here.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### Added
|
||||||
- Tree Templates + Import/Export marketplace (#66)
|
- Tree Templates + Import/Export marketplace (#66)
|
||||||
- Recurring Issue Detection — client-specific pattern alerts (#60)
|
- Recurring Issue Detection — client-specific pattern alerts (#60)
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ export function NetworkCanvas({
|
|||||||
defaultEdgeOptions={{ type: 'connection' }}
|
defaultEdgeOptions={{ type: 'connection' }}
|
||||||
deleteKeyCode={['Backspace', 'Delete']}
|
deleteKeyCode={['Backspace', 'Delete']}
|
||||||
multiSelectionKeyCode="Shift"
|
multiSelectionKeyCode="Shift"
|
||||||
|
snapToGrid={true}
|
||||||
|
snapGrid={[20, 20]}
|
||||||
fitView
|
fitView
|
||||||
className="bg-page"
|
className="bg-page"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import { SmoothStepEdge, EdgeLabelRenderer, type EdgeProps } from '@xyflow/react'
|
import { BaseEdge, EdgeLabelRenderer, getStraightPath, type EdgeProps } from '@xyflow/react'
|
||||||
|
|
||||||
interface ConnectionEdgeData {
|
interface ConnectionEdgeData {
|
||||||
connectionType?: string
|
connectionType?: string
|
||||||
@@ -24,10 +24,17 @@ function ConnectionEdgeComponent(props: EdgeProps) {
|
|||||||
const connectionType = edgeData?.connectionType || 'ethernet'
|
const connectionType = edgeData?.connectionType || 'ethernet'
|
||||||
const style = CONNECTION_STYLES[connectionType] || DEFAULT_STYLE
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<SmoothStepEdge
|
<BaseEdge
|
||||||
{...props}
|
path={edgePath}
|
||||||
style={{
|
style={{
|
||||||
...style,
|
...style,
|
||||||
...(props.selected ? { stroke: '#60a5fa', strokeWidth: style.strokeWidth + 1 } : {}),
|
...(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"
|
className="nodrag nopan rounded bg-page px-1.5 py-0.5 text-[10px] text-muted-foreground"
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
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',
|
pointerEvents: 'all',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -84,9 +84,13 @@ export function DeviceToolbar({ deviceTypes, onDeviceTypesChange }: DeviceToolba
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-2 pb-2">
|
<div className="flex-1 overflow-y-auto px-2 pb-2">
|
||||||
{CATEGORY_ORDER.map(cat => {
|
{CATEGORY_ORDER.map(cat => {
|
||||||
const items = filteredByCategory[cat]
|
const items = filteredByCategory[cat] || []
|
||||||
if (!items?.length) return null
|
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 collapsed = collapsedCategories.has(cat)
|
||||||
|
const totalCount = items.length + (showIsp ? 1 : 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={cat} className="mb-1">
|
<div key={cat} className="mb-1">
|
||||||
@@ -96,7 +100,7 @@ export function DeviceToolbar({ deviceTypes, onDeviceTypesChange }: DeviceToolba
|
|||||||
>
|
>
|
||||||
{collapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
{collapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||||
{CATEGORY_LABELS[cat] || cat}
|
{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>
|
</button>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
@@ -115,6 +119,24 @@ export function DeviceToolbar({ deviceTypes, onDeviceTypesChange }: DeviceToolba
|
|||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -122,31 +144,6 @@ export function DeviceToolbar({ deviceTypes, onDeviceTypesChange }: DeviceToolba
|
|||||||
})}
|
})}
|
||||||
</div>
|
</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 */}
|
{/* Grouping section */}
|
||||||
<div className="mb-1 mt-2 border-t border-default pt-2">
|
<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">
|
<div className="flex items-center gap-1 px-1 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode, ComponentProps } from 'react'
|
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 { BaseNode } from './base-node'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -43,17 +43,26 @@ export function GroupNode({ data, selected }: NodeProps) {
|
|||||||
const label = nodeData.label || 'Group'
|
const label = nodeData.label || 'Group'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseNode
|
<>
|
||||||
className={cn(
|
<NodeResizer
|
||||||
'h-full w-full min-h-[200px] min-w-[300px] overflow-hidden rounded-lg bg-elevated/30 border-default/50',
|
isVisible={selected}
|
||||||
selected && 'border-accent',
|
minWidth={150}
|
||||||
)}
|
minHeight={100}
|
||||||
>
|
lineStyle={{ borderColor: 'var(--color-accent)', borderWidth: 1 }}
|
||||||
<Panel className="m-0 p-0" position="top-left">
|
handleStyle={{ width: 8, height: 8, borderColor: 'var(--color-accent)', background: 'var(--color-card)' }}
|
||||||
<GroupNodeLabel className={getLabelClassName('top-left')}>
|
/>
|
||||||
{label}
|
<BaseNode
|
||||||
</GroupNodeLabel>
|
className={cn(
|
||||||
</Panel>
|
'h-full w-full min-h-[100px] min-w-[150px] overflow-hidden rounded-lg bg-elevated/30 border-default/50',
|
||||||
</BaseNode>
|
selected && 'border-accent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Panel className="m-0 p-0" position="top-left">
|
||||||
|
<GroupNodeLabel className={getLabelClassName('top-left')}>
|
||||||
|
{label}
|
||||||
|
</GroupNodeLabel>
|
||||||
|
</Panel>
|
||||||
|
</BaseNode>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,16 +115,30 @@ function DiagramEditorInner() {
|
|||||||
setAssetName(diagram.asset_name)
|
setAssetName(diagram.asset_name)
|
||||||
setDescription(diagram.description)
|
setDescription(diagram.description)
|
||||||
setNodes(
|
setNodes(
|
||||||
diagram.nodes.map(n => ({
|
diagram.nodes.map(n => {
|
||||||
id: n.id,
|
if (n.nodeType === 'group') {
|
||||||
type: 'device',
|
return {
|
||||||
position: n.position,
|
id: n.id,
|
||||||
data: {
|
type: 'group',
|
||||||
label: n.label,
|
position: n.position,
|
||||||
deviceType: n.type,
|
style: n.style || { width: 300, height: 200 },
|
||||||
properties: n.properties,
|
data: {
|
||||||
} satisfies DeviceNodeData,
|
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(
|
setEdges(
|
||||||
diagram.edges.map(e => ({
|
diagram.edges.map(e => ({
|
||||||
@@ -155,6 +169,8 @@ function DiagramEditorInner() {
|
|||||||
return getNodes().map(n => {
|
return getNodes().map(n => {
|
||||||
if (n.type === 'group') {
|
if (n.type === 'group') {
|
||||||
const data = n.data as Record<string, unknown>
|
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 {
|
return {
|
||||||
id: n.id,
|
id: n.id,
|
||||||
type: (data.groupType as string) || 'subnet',
|
type: (data.groupType as string) || 'subnet',
|
||||||
@@ -162,7 +178,7 @@ function DiagramEditorInner() {
|
|||||||
position: n.position,
|
position: n.position,
|
||||||
properties: {} as DeviceProperties,
|
properties: {} as DeviceProperties,
|
||||||
nodeType: 'group',
|
nodeType: 'group',
|
||||||
style: n.style || null,
|
style: { width, height },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const data = n.data as unknown as DeviceNodeData
|
const data = n.data as unknown as DeviceNodeData
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export interface DiagramNode {
|
|||||||
label: string
|
label: string
|
||||||
position: { x: number; y: number }
|
position: { x: number; y: number }
|
||||||
properties: DeviceProperties
|
properties: DeviceProperties
|
||||||
|
nodeType?: string
|
||||||
|
style?: { width?: number; height?: number } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiagramEdge {
|
export interface DiagramEdge {
|
||||||
|
|||||||
Reference in New Issue
Block a user