feat(topology): Badge-подписи связей и parent-child группы
Docker / build (push) Failing after 19s

Связи показывают тип, подпись, протокол и IP туннеля вместе; группы на заднем слое, узлы внутри прилипают к группе.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-07-18 17:46:49 +07:00
co-authored by Cursor
parent e18d3b4ce6
commit 7f3371591e
7 changed files with 496 additions and 60 deletions
@@ -35,6 +35,8 @@ export function EdgeEditSheet({
const [lineStyle, setLineStyle] = useState<'solid' | 'dashed'>('solid')
const [direction, setDirection] = useState<TopologyEdgeDirection>('forward')
const [protocol, setProtocol] = useState('')
const [localIp, setLocalIp] = useState('')
const [remoteIp, setRemoteIp] = useState('')
const [notes, setNotes] = useState('')
useEffect(() => {
@@ -44,9 +46,13 @@ export function EdgeEditSheet({
setLineStyle(d.lineStyle ?? 'solid')
setDirection(d.direction ?? 'forward')
setProtocol(d.protocol ?? '')
setLocalIp(d.localIp ?? '')
setRemoteIp(d.remoteIp ?? '')
setNotes(d.notes ?? '')
}, [data, edgeId, open])
const showTunnelIps = relation === 'vpn' || relation === 'tunnel'
return (
<FormSheet
open={open}
@@ -55,7 +61,7 @@ export function EdgeEditSheet({
description={
locked
? 'Схема заблокирована — только просмотр'
: 'Тип связи и подпись отображаются на схеме'
: 'Тип, подпись, протокол и IP отображаются на схеме вместе'
}
submitLabel="Сохранить"
submitDisabled={locked || !edgeId}
@@ -67,6 +73,8 @@ export function EdgeEditSheet({
lineStyle,
direction,
protocol: protocol.trim(),
localIp: localIp.trim(),
remoteIp: remoteIp.trim(),
notes: notes.trim(),
})
onOpenChange(false)
@@ -92,7 +100,7 @@ export function EdgeEditSheet({
onChange={(e) => setLabel(e.target.value)}
disabled={locked}
maxLength={60}
placeholder="Например: eth0 / 10 Gbit"
placeholder="Например: eth0 / uplink — рядом с протоколом"
/>
</FormField>
<FormField label="Направление" htmlFor="edge-direction">
@@ -130,6 +138,32 @@ export function EdgeEditSheet({
placeholder="TCP/443, WireGuard, BGP…"
/>
</FormField>
{showTunnelIps ? (
<>
<FormField label="Локальный IP туннеля" htmlFor="edge-local-ip">
<Input
id="edge-local-ip"
value={localIp}
onChange={(e) => setLocalIp(e.target.value)}
disabled={locked}
maxLength={45}
placeholder="10.8.0.1"
className="font-mono"
/>
</FormField>
<FormField label="Удалённый IP туннеля" htmlFor="edge-remote-ip">
<Input
id="edge-remote-ip"
value={remoteIp}
onChange={(e) => setRemoteIp(e.target.value)}
disabled={locked}
maxLength={45}
placeholder="10.8.0.2"
className="font-mono"
/>
</FormField>
</>
) : null}
<FormField label="Заметки" htmlFor="edge-notes">
<Textarea
id="edge-notes"
@@ -0,0 +1,6 @@
import type { EdgeTypes } from '@xyflow/react'
import { TopologyEdge } from './edges/topology-edge'
export const topologyEdgeTypes = {
topology: TopologyEdge,
} satisfies EdgeTypes
+9 -15
View File
@@ -1,9 +1,5 @@
import { MarkerType, type Edge } from '@xyflow/react'
import {
defaultEdgeData,
edgeRelationLabel,
type TopologyEdgeData,
} from './types'
import { defaultEdgeData, type TopologyEdgeData } from './types'
export function applyEdgeVisuals(
edge: Edge<TopologyEdgeData>,
@@ -11,16 +7,11 @@ export function applyEdgeVisuals(
): Edge<TopologyEdgeData> {
const direction = data.direction ?? 'forward'
const lineStyle = data.lineStyle ?? 'solid'
const label =
data.label?.trim() ||
(data.protocol?.trim()
? `${edgeRelationLabel(data.relation)} · ${data.protocol.trim()}`
: edgeRelationLabel(data.relation))
return {
...edge,
type: 'smoothstep',
label,
type: 'topology',
label: undefined,
data,
style: {
...edge.style,
@@ -37,9 +28,12 @@ export function applyEdgeVisuals(
}
}
export function createConnectedEdge(
connection: { source: string; target: string; sourceHandle?: string | null; targetHandle?: string | null },
): Edge<TopologyEdgeData> {
export function createConnectedEdge(connection: {
source: string
target: string
sourceHandle?: string | null
targetHandle?: string | null
}): Edge<TopologyEdgeData> {
const data = defaultEdgeData()
return applyEdgeVisuals(
{
@@ -0,0 +1,94 @@
import { memo } from 'react'
import {
BaseEdge,
EdgeLabelRenderer,
getSmoothStepPath,
type Edge,
type EdgeProps,
} from '@xyflow/react'
import { Badge } from '@/components/reui/badge'
import { cn } from '@cfdm/ui/lib/utils'
import {
edgeRelationShortLabel,
formatEdgeTunnelIps,
type TopologyEdgeData,
} from '../types'
function TopologyEdgeComponent(props: EdgeProps<Edge<TopologyEdgeData>>) {
const {
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style,
markerEnd,
markerStart,
selected,
data,
} = props
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
})
const relation = data?.relation ?? 'network'
const title = data?.label?.trim() ?? ''
const protocol = data?.protocol?.trim() ?? ''
const tunnelIps = formatEdgeTunnelIps(data ?? {})
return (
<>
<BaseEdge
id={id}
path={edgePath}
markerEnd={markerEnd}
markerStart={markerStart}
style={{
...style,
strokeWidth: selected ? 2.5 : 1.5,
}}
/>
<EdgeLabelRenderer>
<div
className={cn(
'nodrag nopan absolute flex max-w-[220px] flex-wrap items-center justify-center gap-1 rounded-md border border-border bg-background/95 px-1.5 py-1 shadow-sm backdrop-blur-sm',
selected && 'ring-1 ring-primary/40',
)}
style={{
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'all',
}}
>
<Badge variant="info-light" size="xs" radius="full">
{edgeRelationShortLabel(relation)}
</Badge>
{title ? (
<Badge variant="outline" size="xs" radius="full">
{title}
</Badge>
) : null}
{protocol ? (
<Badge variant="primary-light" size="xs" radius="full" className="font-mono">
{protocol}
</Badge>
) : null}
{tunnelIps ? (
<Badge variant="secondary" size="xs" radius="full" className="font-mono">
{tunnelIps}
</Badge>
) : null}
</div>
</EdgeLabelRenderer>
</>
)
}
export const TopologyEdge = memo(TopologyEdgeComponent)
@@ -0,0 +1,228 @@
import type { TopologyFlowNode, TopologyNodeType } from './types'
const GROUP_Z = -1
const CONTENT_Z = 1
const ATTACHABLE = new Set<TopologyNodeType>(['vps', 'shape', 'note'])
export function isAttachableType(type: TopologyNodeType | undefined): boolean {
return type != null && ATTACHABLE.has(type)
}
export function normalizeGroupLayers(nodes: TopologyFlowNode[]): TopologyFlowNode[] {
return nodes.map((n) => {
if (n.type === 'group') {
return { ...n, zIndex: n.selected ? 0 : GROUP_Z }
}
if (n.zIndex != null && n.zIndex < CONTENT_Z) {
return { ...n, zIndex: CONTENT_Z }
}
return n.zIndex == null ? { ...n, zIndex: CONTENT_Z } : n
})
}
function nodeWidth(node: TopologyFlowNode): number {
return (
node.measured?.width ??
(typeof node.width === 'number' ? node.width : undefined) ??
(typeof node.style?.width === 'number' ? node.style.width : undefined) ??
(typeof node.style?.width === 'string' ? Number.parseFloat(node.style.width) : undefined) ??
220
)
}
function nodeHeight(node: TopologyFlowNode): number {
return (
node.measured?.height ??
(typeof node.height === 'number' ? node.height : undefined) ??
(typeof node.style?.height === 'number' ? node.style.height : undefined) ??
(typeof node.style?.height === 'string' ? Number.parseFloat(node.style.height) : undefined) ??
80
)
}
function absolutePosition(
node: TopologyFlowNode,
nodesById: Map<string, TopologyFlowNode>,
): { x: number; y: number } {
let x = node.position.x
let y = node.position.y
let parentId = node.parentId
const guard = new Set<string>()
while (parentId && !guard.has(parentId)) {
guard.add(parentId)
const parent = nodesById.get(parentId)
if (!parent) break
x += parent.position.x
y += parent.position.y
parentId = parent.parentId
}
return { x, y }
}
export function getNodeCenterAbsolute(
node: TopologyFlowNode,
nodes: TopologyFlowNode[],
): { x: number; y: number } {
const byId = new Map(nodes.map((n) => [n.id, n]))
const abs = absolutePosition(node, byId)
return {
x: abs.x + nodeWidth(node) / 2,
y: abs.y + nodeHeight(node) / 2,
}
}
function groupBounds(group: TopologyFlowNode, nodesById: Map<string, TopologyFlowNode>) {
const abs = absolutePosition(group, nodesById)
return { x: abs.x, y: abs.y, width: nodeWidth(group), height: nodeHeight(group) }
}
function pointInBounds(
point: { x: number; y: number },
bounds: { x: number; y: number; width: number; height: number },
): boolean {
return (
point.x >= bounds.x &&
point.x <= bounds.x + bounds.width &&
point.y >= bounds.y &&
point.y <= bounds.y + bounds.height
)
}
/** Найти группу, в чьи bounds попадает точка (предпочитаем наименьшую площадь). */
export function findGroupAtPoint(
nodes: TopologyFlowNode[],
point: { x: number; y: number },
excludeId?: string,
): TopologyFlowNode | null {
const byId = new Map(nodes.map((n) => [n.id, n]))
let best: TopologyFlowNode | null = null
let bestArea = Number.POSITIVE_INFINITY
for (const n of nodes) {
if (n.type !== 'group' || n.id === excludeId) continue
const b = groupBounds(n, byId)
if (!pointInBounds(point, b)) continue
const area = b.width * b.height
if (area < bestArea) {
best = n
bestArea = area
}
}
return best
}
export function attachNodeToGroup(
node: TopologyFlowNode,
group: TopologyFlowNode,
nodes: TopologyFlowNode[],
): TopologyFlowNode {
if (node.id === group.id) return node
if (node.parentId === group.id) {
return {
...node,
parentId: group.id,
extent: 'parent',
zIndex: CONTENT_Z,
}
}
const byId = new Map(nodes.map((n) => [n.id, n]))
const abs = absolutePosition(node, byId)
const groupAbs = absolutePosition(group, byId)
return {
...node,
parentId: group.id,
extent: 'parent',
position: {
x: abs.x - groupAbs.x,
y: abs.y - groupAbs.y,
},
zIndex: CONTENT_Z,
}
}
export function detachNodeFromGroup(
node: TopologyFlowNode,
nodes: TopologyFlowNode[],
): TopologyFlowNode {
if (!node.parentId) {
return { ...node, extent: undefined, zIndex: CONTENT_Z }
}
const byId = new Map(nodes.map((n) => [n.id, n]))
const abs = absolutePosition(node, byId)
return {
...node,
parentId: undefined,
extent: undefined,
position: abs,
zIndex: CONTENT_Z,
}
}
/** Применить attach/detach по центру узла после drag. */
export function reconcileNodeParenting(
node: TopologyFlowNode,
nodes: TopologyFlowNode[],
): TopologyFlowNode {
if (!isAttachableType(node.type)) return node
const center = getNodeCenterAbsolute(node, nodes)
const group = findGroupAtPoint(nodes, center, node.id)
if (group) {
return attachNodeToGroup(node, group, nodes)
}
if (node.parentId) {
return detachNodeFromGroup(node, nodes)
}
return node
}
/** Привязать новый узел к группе по точке (absolute flow coords). */
export function placeWithOptionalParent(
node: TopologyFlowNode,
flowPosition: { x: number; y: number },
nodes: TopologyFlowNode[],
): TopologyFlowNode {
if (node.type === 'group') {
return { ...node, zIndex: GROUP_Z, position: flowPosition }
}
if (!isAttachableType(node.type)) {
return { ...node, position: flowPosition, zIndex: CONTENT_Z }
}
const group = findGroupAtPoint(nodes, flowPosition)
if (!group) {
return { ...node, position: flowPosition, zIndex: CONTENT_Z }
}
const byId = new Map(nodes.map((n) => [n.id, n]))
const groupAbs = absolutePosition(group, byId)
return {
...node,
parentId: group.id,
extent: 'parent',
position: {
x: flowPosition.x - groupAbs.x,
y: flowPosition.y - groupAbs.y,
},
zIndex: CONTENT_Z,
}
}
/** Родители перед детьми (стабильный порядок для RF). */
export function sortParentsFirst(nodes: TopologyFlowNode[]): TopologyFlowNode[] {
const byId = new Map(nodes.map((n) => [n.id, n]))
const depth = (n: TopologyFlowNode): number => {
let d = 0
let p = n.parentId
const guard = new Set<string>()
while (p && byId.has(p) && !guard.has(p)) {
guard.add(p)
d += 1
p = byId.get(p)?.parentId
}
return d
}
return [...nodes].sort((a, b) => depth(a) - depth(b))
}
@@ -19,8 +19,8 @@ import {
useReactFlow,
type Connection,
type Edge,
type Node,
type OnConnect,
type OnNodeDrag,
type OnNodesChange,
type OnEdgesChange,
type Viewport,
@@ -31,6 +31,7 @@ import { useTheme } from 'next-themes'
import { toast } from 'sonner'
import { cn } from '@cfdm/ui/lib/utils'
import { topologyNodeTypes } from './node-types'
import { topologyEdgeTypes } from './edge-types'
import { TopologyPalette, parsePaletteDrag, shapeLabel } from './palette'
import { TopologyToolbar } from './toolbar'
import { AddVpsSheet } from './add-vps-sheet'
@@ -38,6 +39,12 @@ import { VpsDetailSheet } from './vps-detail-sheet'
import { ElementEditSheet, type EditableElement } from './element-edit-sheet'
import { EdgeEditSheet } from './edge-edit-sheet'
import { applyEdgeVisuals, createConnectedEdge } from './edge-utils'
import {
normalizeGroupLayers,
placeWithOptionalParent,
reconcileNodeParenting,
sortParentsFirst,
} from './group-utils'
import {
defaultEdgeData,
isGroupNodeData,
@@ -50,11 +57,11 @@ import {
type PaletteItem,
type ShapeNodeData,
type TopologyEdgeData,
type TopologyNodeData,
type TopologyFlowNode,
type TopologyNodeType,
} from './types'
type FlowNode = Node<TopologyNodeData, TopologyNodeType>
type FlowNode = TopologyFlowNode
type FlowEdge = Edge<TopologyEdgeData>
interface TopologyEditorProps {
@@ -88,7 +95,9 @@ function TopologyEditorInner({
const { screenToFlowPosition, fitView, zoomIn, zoomOut, getViewport, setViewport } =
useReactFlow()
const [nodes, setNodes, onNodesChangeBase] = useNodesState<FlowNode>(initialNodes)
const [nodes, setNodes, onNodesChangeBase] = useNodesState<FlowNode>(
normalizeGroupLayers(sortParentsFirst(initialNodes)),
)
const [edges, setEdges, onEdgesChangeBase] = useEdgesState<FlowEdge>(
normalizeEdges(initialEdges),
)
@@ -107,7 +116,7 @@ function TopologyEditorInner({
useEffect(() => {
skipSave.current = true
hydrated.current = false
setNodes(initialNodes)
setNodes(normalizeGroupLayers(sortParentsFirst(initialNodes)))
setEdges(normalizeEdges(initialEdges))
setEditElement(null)
setElementOpen(false)
@@ -144,8 +153,17 @@ function TopologyEditorInner({
(changes) => {
if (locked) return
onNodesChangeBase(changes)
const touchesSelection = changes.some(
(c) => c.type === 'select' || c.type === 'dimensions' || c.type === 'replace',
)
if (touchesSelection) {
// Keep groups on back layer after select/resize updates settle
queueMicrotask(() => {
setNodes((ns) => normalizeGroupLayers(ns))
})
}
},
[locked, onNodesChangeBase],
[locked, onNodesChangeBase, setNodes],
)
const onEdgesChange: OnEdgesChange<FlowEdge> = useCallback(
@@ -175,6 +193,24 @@ function TopologyEditorInner({
[locked, setEdges],
)
const onNodeDragStop: OnNodeDrag<FlowNode> = useCallback(
(_e, node) => {
if (locked) return
setNodes((ns) => {
const current = ns.find((n) => n.id === node.id) ?? node
const next = reconcileNodeParenting(current, ns)
if (next === current && next.parentId === current.parentId) {
const samePos =
next.position.x === current.position.x && next.position.y === current.position.y
if (samePos) return normalizeGroupLayers(ns)
}
const updated = ns.map((n) => (n.id === next.id ? next : n))
return normalizeGroupLayers(sortParentsFirst(updated))
})
},
[locked, setNodes],
)
const existingVpsIds = useMemo(() => {
const ids = new Set<string>()
for (const n of nodes) {
@@ -188,36 +224,36 @@ function TopologyEditorInner({
setAddVpsOpen(true)
return
}
if (item.kind === 'shape') {
const node: FlowNode = {
id: newNodeId('shape'),
type: 'shape',
position,
data: { kind: item.shape, label: shapeLabel(item.shape) },
setNodes((ns) => {
let draft: FlowNode | null = null
if (item.kind === 'shape') {
draft = {
id: newNodeId('shape'),
type: 'shape',
position,
data: { kind: item.shape, label: shapeLabel(item.shape) },
}
} else if (item.kind === 'note') {
draft = {
id: newNodeId('note'),
type: 'note',
position,
data: { text: 'Заметка' },
}
} else if (item.kind === 'group') {
draft = {
id: newNodeId('group'),
type: 'group',
position,
style: { width: 320, height: 200 },
data: { label: 'Группа' },
zIndex: -1,
}
}
setNodes((ns) => [...ns, node])
return
}
if (item.kind === 'note') {
const node: FlowNode = {
id: newNodeId('note'),
type: 'note',
position,
data: { text: 'Заметка' },
}
setNodes((ns) => [...ns, node])
return
}
if (item.kind === 'group') {
const node: FlowNode = {
id: newNodeId('group'),
type: 'group',
position,
style: { width: 320, height: 200 },
data: { label: 'Группа' },
}
setNodes((ns) => [...ns, node])
}
if (!draft) return ns
const placed = placeWithOptionalParent(draft, position, ns)
return normalizeGroupLayers(sortParentsFirst([...ns, placed]))
})
}
function onDragOver(e: DragEvent) {
@@ -240,13 +276,22 @@ function TopologyEditorInner({
x: (wrapperRef.current?.clientWidth ?? 400) / 2 + 80,
y: (wrapperRef.current?.clientHeight ?? 300) / 2,
})
const created: FlowNode[] = vpsIds.map((vpsId, i) => ({
id: newNodeId('vps'),
type: 'vps' as const,
position: { x: origin.x + (i % 3) * 240, y: origin.y + Math.floor(i / 3) * 110 },
data: { vpsId },
}))
setNodes((ns) => [...ns, ...created])
setNodes((ns) => {
const created = vpsIds.map((vpsId, i) => {
const pos = {
x: origin.x + (i % 3) * 240,
y: origin.y + Math.floor(i / 3) * 110,
}
const draft: FlowNode = {
id: newNodeId('vps'),
type: 'vps',
position: pos,
data: { vpsId },
}
return placeWithOptionalParent(draft, pos, ns)
})
return normalizeGroupLayers(sortParentsFirst([...ns, ...created]))
})
}
function placeItemAtCenter(item: PaletteItem) {
@@ -351,6 +396,7 @@ function TopologyEditorInner({
onConnect={onConnect}
onNodeClick={onNodeClick}
onEdgeClick={onEdgeClick}
onNodeDragStop={onNodeDragStop}
onDrop={onDrop}
onDragOver={onDragOver}
onMoveEnd={(_, vp) => {
@@ -360,6 +406,7 @@ function TopologyEditorInner({
}
}}
nodeTypes={topologyNodeTypes}
edgeTypes={topologyEdgeTypes}
nodesDraggable={!locked}
nodesConnectable={!locked}
elementsSelectable={!locked}
@@ -368,7 +415,7 @@ function TopologyEditorInner({
fitView
colorMode={colorMode}
defaultEdgeOptions={{
type: 'smoothstep',
type: 'topology',
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16 },
}}
proOptions={{ hideAttribution: true }}
+33
View File
@@ -48,6 +48,10 @@ export type TopologyEdgeData = {
lineStyle?: 'solid' | 'dashed'
direction?: TopologyEdgeDirection
protocol?: string
/** Локальный IP VPN/туннеля (опционально) */
localIp?: string
/** Удалённый / peer IP VPN/туннеля (опционально) */
remoteIp?: string
notes?: string
}
@@ -104,6 +108,8 @@ export function defaultEdgeData(): TopologyEdgeData {
lineStyle: 'solid',
direction: 'forward',
protocol: '',
localIp: '',
remoteIp: '',
notes: '',
}
}
@@ -112,6 +118,33 @@ export function edgeRelationLabel(relation: TopologyEdgeRelation): string {
return EDGE_RELATION_OPTIONS.find((o) => o.value === relation)?.label ?? relation
}
/** Краткая подпись IP туннеля: `10.8.0.1 ↔ 10.8.0.2` или один адрес */
export function formatEdgeTunnelIps(data: Pick<TopologyEdgeData, 'localIp' | 'remoteIp'>): string {
const local = data.localIp?.trim() ?? ''
const remote = data.remoteIp?.trim() ?? ''
if (local && remote) return `${local}${remote}`
return local || remote
}
export function edgeRelationShortLabel(relation: TopologyEdgeRelation): string {
switch (relation) {
case 'network':
return 'Сеть'
case 'dependency':
return 'Зависимость'
case 'tunnel':
return 'Туннель'
case 'vpn':
return 'VPN'
case 'sync':
return 'Синк'
case 'custom':
return 'Связь'
default:
return relation
}
}
export function vpsSpecsLine(vps: Pick<Vps, 'vcpu' | 'ramGb' | 'diskGb' | 'diskType'>): string {
const disk = vps.diskType ? `${vps.diskGb} ГБ ${vps.diskType}` : `${vps.diskGb} ГБ`
return `${vps.vcpu} CPU · ${vps.ramGb} ГБ RAM · ${disk}`