feat(topology): редактирование параметров блоков, групп и связей
Docker / build (push) Failing after 23s

Клики по фигурам, заметкам, группам и рёбрам открывают FormSheet с параметрами, как у VPS-узлов.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-07-18 17:27:47 +07:00
co-authored by Cursor
parent 1f24dbe2a5
commit 56556ea736
6 changed files with 531 additions and 11 deletions
@@ -0,0 +1,145 @@
import { useEffect, useState } from 'react'
import { Input } from '@cfdm/ui/components/input'
import { Textarea } from '@cfdm/ui/components/textarea'
import { FormSheet } from '@/components/form-sheet'
import { FormField } from '@/components/form-field'
import { SelectField } from '@/components/select-field'
import {
EDGE_DIRECTION_OPTIONS,
EDGE_RELATION_OPTIONS,
defaultEdgeData,
type TopologyEdgeData,
type TopologyEdgeDirection,
type TopologyEdgeRelation,
} from './types'
interface EdgeEditSheetProps {
edgeId: string | null
data: TopologyEdgeData | null
open: boolean
locked?: boolean
onOpenChange: (open: boolean) => void
onSave: (edgeId: string, data: TopologyEdgeData) => void
}
export function EdgeEditSheet({
edgeId,
data,
open,
locked,
onOpenChange,
onSave,
}: EdgeEditSheetProps) {
const [relation, setRelation] = useState<TopologyEdgeRelation>('network')
const [label, setLabel] = useState('')
const [lineStyle, setLineStyle] = useState<'solid' | 'dashed'>('solid')
const [direction, setDirection] = useState<TopologyEdgeDirection>('forward')
const [protocol, setProtocol] = useState('')
const [notes, setNotes] = useState('')
useEffect(() => {
const d = data ?? defaultEdgeData()
setRelation(d.relation ?? 'network')
setLabel(d.label ?? '')
setLineStyle(d.lineStyle ?? 'solid')
setDirection(d.direction ?? 'forward')
setProtocol(d.protocol ?? '')
setNotes(d.notes ?? '')
}, [data, edgeId, open])
return (
<FormSheet
open={open}
onOpenChange={onOpenChange}
title="Параметры связи"
description={
locked
? 'Схема заблокирована — только просмотр'
: 'Тип связи и подпись отображаются на схеме'
}
submitLabel="Сохранить"
submitDisabled={locked || !edgeId}
onSubmit={() => {
if (!edgeId || locked) return
onSave(edgeId, {
relation,
label: label.trim(),
lineStyle,
direction,
protocol: protocol.trim(),
notes: notes.trim(),
})
onOpenChange(false)
}}
>
<FormField label="Тип связи" htmlFor="edge-relation">
<SelectField
triggerId="edge-relation"
value={relation}
onValueChange={(v) => {
if (v && EDGE_RELATION_OPTIONS.some((o) => o.value === v)) {
setRelation(v as TopologyEdgeRelation)
}
}}
options={EDGE_RELATION_OPTIONS}
disabled={locked}
/>
</FormField>
<FormField label="Подпись на схеме" htmlFor="edge-label">
<Input
id="edge-label"
value={label}
onChange={(e) => setLabel(e.target.value)}
disabled={locked}
maxLength={60}
placeholder="Например: eth0 / 10 Gbit"
/>
</FormField>
<FormField label="Направление" htmlFor="edge-direction">
<SelectField
triggerId="edge-direction"
value={direction}
onValueChange={(v) => {
if (v === 'forward' || v === 'bidirectional' || v === 'none') setDirection(v)
}}
options={EDGE_DIRECTION_OPTIONS}
disabled={locked}
/>
</FormField>
<FormField label="Стиль линии" htmlFor="edge-style">
<SelectField
triggerId="edge-style"
value={lineStyle}
onValueChange={(v) => {
if (v === 'solid' || v === 'dashed') setLineStyle(v)
}}
options={[
{ value: 'solid', label: 'Сплошная' },
{ value: 'dashed', label: 'Пунктир' },
]}
disabled={locked}
/>
</FormField>
<FormField label="Протокол / порт" htmlFor="edge-protocol">
<Input
id="edge-protocol"
value={protocol}
onChange={(e) => setProtocol(e.target.value)}
disabled={locked}
maxLength={40}
placeholder="TCP/443, WireGuard, BGP…"
/>
</FormField>
<FormField label="Заметки" htmlFor="edge-notes">
<Textarea
id="edge-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
disabled={locked}
rows={3}
maxLength={500}
/>
</FormField>
</FormSheet>
)
}
@@ -0,0 +1,55 @@
import { MarkerType, type Edge } from '@xyflow/react'
import {
defaultEdgeData,
edgeRelationLabel,
type TopologyEdgeData,
} from './types'
export function applyEdgeVisuals(
edge: Edge<TopologyEdgeData>,
data: TopologyEdgeData,
): 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,
data,
style: {
...edge.style,
strokeDasharray: lineStyle === 'dashed' ? '6 4' : undefined,
},
markerEnd:
direction === 'none'
? undefined
: { type: MarkerType.ArrowClosed, width: 16, height: 16 },
markerStart:
direction === 'bidirectional'
? { type: MarkerType.ArrowClosed, width: 16, height: 16 }
: undefined,
}
}
export function createConnectedEdge(
connection: { source: string; target: string; sourceHandle?: string | null; targetHandle?: string | null },
): Edge<TopologyEdgeData> {
const data = defaultEdgeData()
return applyEdgeVisuals(
{
id: `e-${connection.source}-${connection.target}-${Date.now().toString(36)}`,
source: connection.source,
target: connection.target,
sourceHandle: connection.sourceHandle ?? undefined,
targetHandle: connection.targetHandle ?? undefined,
data,
},
data,
)
}
@@ -0,0 +1,157 @@
import { useEffect, useState } from 'react'
import { Input } from '@cfdm/ui/components/input'
import { Textarea } from '@cfdm/ui/components/textarea'
import { FormSheet } from '@/components/form-sheet'
import { FormField } from '@/components/form-field'
import { SelectField } from '@/components/select-field'
import {
SHAPE_KIND_OPTIONS,
type GroupNodeData,
type NoteNodeData,
type ShapeKind,
type ShapeNodeData,
type TopologyNodeType,
} from './types'
export type EditableElement =
| { kind: 'shape'; id: string; data: ShapeNodeData }
| { kind: 'note'; id: string; data: NoteNodeData }
| { kind: 'group'; id: string; data: GroupNodeData }
interface ElementEditSheetProps {
element: EditableElement | null
open: boolean
locked?: boolean
onOpenChange: (open: boolean) => void
onSave: (id: string, type: TopologyNodeType, data: ShapeNodeData | NoteNodeData | GroupNodeData) => void
}
export function ElementEditSheet({
element,
open,
locked,
onOpenChange,
onSave,
}: ElementEditSheetProps) {
const [label, setLabel] = useState('')
const [text, setText] = useState('')
const [notes, setNotes] = useState('')
const [kind, setKind] = useState<ShapeKind>('rect')
useEffect(() => {
if (!element) return
if (element.kind === 'shape') {
setLabel(element.data.label ?? '')
setKind(element.data.kind ?? 'rect')
setText('')
setNotes('')
} else if (element.kind === 'note') {
setText(element.data.text ?? '')
setLabel('')
setNotes('')
} else {
setLabel(element.data.label ?? '')
setNotes(element.data.notes ?? '')
setText('')
}
}, [element, open])
const title =
element?.kind === 'shape'
? 'Параметры блока'
: element?.kind === 'note'
? 'Параметры заметки'
: 'Параметры группы'
return (
<FormSheet
open={open}
onOpenChange={onOpenChange}
title={title}
description={
locked
? 'Схема заблокирована — только просмотр'
: 'Изменения сохранятся на схеме'
}
submitLabel="Сохранить"
submitDisabled={locked || !element}
onSubmit={() => {
if (!element || locked) return
if (element.kind === 'shape') {
onSave(element.id, 'shape', { kind, label: label.trim() || 'Блок' })
} else if (element.kind === 'note') {
onSave(element.id, 'note', { text: text.trim() || 'Заметка' })
} else {
onSave(element.id, 'group', {
label: label.trim() || 'Группа',
notes: notes.trim() || undefined,
})
}
onOpenChange(false)
}}
>
{element?.kind === 'shape' ? (
<>
<FormField label="Подпись" htmlFor="shape-label">
<Input
id="shape-label"
value={label}
onChange={(e) => setLabel(e.target.value)}
disabled={locked}
maxLength={80}
/>
</FormField>
<FormField label="Форма" htmlFor="shape-kind">
<SelectField
triggerId="shape-kind"
value={kind}
onValueChange={(v) => {
if (v === 'rect' || v === 'ellipse' || v === 'diamond') setKind(v)
}}
options={SHAPE_KIND_OPTIONS}
disabled={locked}
/>
</FormField>
</>
) : null}
{element?.kind === 'note' ? (
<FormField label="Текст" htmlFor="note-text">
<Textarea
id="note-text"
value={text}
onChange={(e) => setText(e.target.value)}
disabled={locked}
rows={6}
maxLength={2000}
/>
</FormField>
) : null}
{element?.kind === 'group' ? (
<>
<FormField label="Название" htmlFor="group-label">
<Input
id="group-label"
value={label}
onChange={(e) => setLabel(e.target.value)}
disabled={locked}
maxLength={80}
/>
</FormField>
<FormField label="Заметки" htmlFor="group-notes">
<Textarea
id="group-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
disabled={locked}
rows={4}
maxLength={1000}
placeholder="Например: subnet 192.168.0.0/24"
/>
</FormField>
</>
) : null}
</FormSheet>
)
}
@@ -12,8 +12,15 @@ function GroupNodeComponent({ data, selected }: NodeProps & { data: GroupNodeDat
)}
>
<NodeResizer minWidth={200} minHeight={120} isVisible={selected} />
<div className="px-3 py-2 text-xs font-medium text-muted-foreground">
{data.label || 'Группа'}
<div className="flex flex-col gap-0.5 px-3 py-2">
<div className="text-xs font-medium text-foreground">
{data.label || 'Группа'}
</div>
{data.notes ? (
<div className="text-[10px] text-muted-foreground whitespace-pre-wrap">
{data.notes}
</div>
) : null}
</div>
</div>
)
@@ -35,15 +35,27 @@ import { TopologyPalette, parsePaletteDrag, shapeLabel } from './palette'
import { TopologyToolbar } from './toolbar'
import { AddVpsSheet } from './add-vps-sheet'
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 {
defaultEdgeData,
isGroupNodeData,
isNoteNodeData,
isShapeNodeData,
isVpsNodeData,
newNodeId,
type GroupNodeData,
type NoteNodeData,
type PaletteItem,
type ShapeNodeData,
type TopologyEdgeData,
type TopologyNodeData,
type TopologyNodeType,
} from './types'
type FlowNode = Node<TopologyNodeData, TopologyNodeType>
type FlowEdge = Edge<TopologyEdgeData>
interface TopologyEditorProps {
diagramId: string
@@ -77,11 +89,18 @@ function TopologyEditorInner({
useReactFlow()
const [nodes, setNodes, onNodesChangeBase] = useNodesState<FlowNode>(initialNodes)
const [edges, setEdges, onEdgesChangeBase] = useEdgesState(initialEdges)
const [edges, setEdges, onEdgesChangeBase] = useEdgesState<FlowEdge>(
normalizeEdges(initialEdges),
)
const [zoomPercent, setZoomPercent] = useState(100)
const [addVpsOpen, setAddVpsOpen] = useState(false)
const [detailVpsId, setDetailVpsId] = useState<string | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [editElement, setEditElement] = useState<EditableElement | null>(null)
const [elementOpen, setElementOpen] = useState(false)
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
const [editEdgeData, setEditEdgeData] = useState<TopologyEdgeData | null>(null)
const [edgeOpen, setEdgeOpen] = useState(false)
const skipSave = useRef(false)
const hydrated = useRef(false)
@@ -89,7 +108,12 @@ function TopologyEditorInner({
skipSave.current = true
hydrated.current = false
setNodes(initialNodes)
setEdges(initialEdges)
setEdges(normalizeEdges(initialEdges))
setEditElement(null)
setElementOpen(false)
setEditEdgeId(null)
setEditEdgeData(null)
setEdgeOpen(false)
if (initialViewport) {
void setViewport(initialViewport)
setZoomPercent(Math.round((initialViewport.zoom || 1) * 100))
@@ -124,7 +148,7 @@ function TopologyEditorInner({
[locked, onNodesChangeBase],
)
const onEdgesChange: OnEdgesChange = useCallback(
const onEdgesChange: OnEdgesChange<FlowEdge> = useCallback(
(changes) => {
if (locked) return
onEdgesChangeBase(changes)
@@ -135,13 +159,15 @@ function TopologyEditorInner({
const onConnect: OnConnect = useCallback(
(connection: Connection) => {
if (locked) return
if (!connection.source || !connection.target) return
setEdges((eds) =>
addEdge(
{
...connection,
type: 'smoothstep',
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16 },
},
createConnectedEdge({
source: connection.source,
target: connection.target,
sourceHandle: connection.sourceHandle,
targetHandle: connection.targetHandle,
}),
eds,
),
)
@@ -237,7 +263,45 @@ function TopologyEditorInner({
if (node.type === 'vps' && isVpsNodeData(node.data)) {
setDetailVpsId(node.data.vpsId)
setDetailOpen(true)
return
}
if (node.type === 'shape' && isShapeNodeData(node.data)) {
setEditElement({ kind: 'shape', id: node.id, data: node.data })
setElementOpen(true)
return
}
if (node.type === 'note' && isNoteNodeData(node.data)) {
setEditElement({ kind: 'note', id: node.id, data: node.data })
setElementOpen(true)
return
}
if (node.type === 'group' && isGroupNodeData(node.data)) {
setEditElement({ kind: 'group', id: node.id, data: node.data })
setElementOpen(true)
}
}
function onEdgeClick(_e: ReactMouseEvent, edge: FlowEdge) {
const data = edge.data ?? defaultEdgeData()
setEditEdgeId(edge.id)
setEditEdgeData(data)
setEdgeOpen(true)
}
function handleSaveElement(
id: string,
type: TopologyNodeType,
data: ShapeNodeData | NoteNodeData | GroupNodeData,
) {
setNodes((ns) =>
ns.map((n) => (n.id === id && n.type === type ? { ...n, data } : n)),
)
}
function handleSaveEdge(edgeId: string, data: TopologyEdgeData) {
setEdges((eds) =>
eds.map((e) => (e.id === edgeId ? applyEdgeVisuals(e, data) : e)),
)
}
async function handleExport() {
@@ -286,6 +350,7 @@ function TopologyEditorInner({
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
onEdgeClick={onEdgeClick}
onDrop={onDrop}
onDragOver={onDragOver}
onMoveEnd={(_, vp) => {
@@ -343,10 +408,32 @@ function TopologyEditorInner({
open={detailOpen}
onOpenChange={setDetailOpen}
/>
<ElementEditSheet
element={editElement}
open={elementOpen}
locked={locked}
onOpenChange={setElementOpen}
onSave={handleSaveElement}
/>
<EdgeEditSheet
edgeId={editEdgeId}
data={editEdgeData}
open={edgeOpen}
locked={locked}
onOpenChange={setEdgeOpen}
onSave={handleSaveEdge}
/>
</div>
)
}
function normalizeEdges(edges: Edge[]): FlowEdge[] {
return edges.map((edge) => {
const data = { ...defaultEdgeData(), ...(edge.data as TopologyEdgeData | undefined) }
return applyEdgeVisuals({ ...edge, data }, data)
})
}
export function TopologyEditor(props: TopologyEditorProps) {
return (
<ReactFlowProvider>
+70 -1
View File
@@ -22,6 +22,7 @@ export type NoteNodeData = {
export type GroupNodeData = {
label: string
notes?: string
}
export type TopologyNodeData =
@@ -30,8 +31,28 @@ export type TopologyNodeData =
| NoteNodeData
| GroupNodeData
/** Тип связи на схеме инфраструктуры */
export type TopologyEdgeRelation =
| 'network'
| 'dependency'
| 'tunnel'
| 'vpn'
| 'sync'
| 'custom'
export type TopologyEdgeDirection = 'forward' | 'bidirectional' | 'none'
export type TopologyEdgeData = {
relation: TopologyEdgeRelation
label?: string
lineStyle?: 'solid' | 'dashed'
direction?: TopologyEdgeDirection
protocol?: string
notes?: string
}
export type TopologyFlowNode = Node<TopologyNodeData, TopologyNodeType>
export type TopologyFlowEdge = Edge
export type TopologyFlowEdge = Edge<TopologyEdgeData>
export type PaletteItem =
| { kind: 'shape'; shape: ShapeKind; label: string }
@@ -39,10 +60,58 @@ export type PaletteItem =
| { kind: 'group'; label: string }
| { kind: 'vps-picker'; label: string }
export const EDGE_RELATION_OPTIONS: { value: TopologyEdgeRelation; label: string }[] = [
{ value: 'network', label: 'Сеть / L2L3' },
{ value: 'dependency', label: 'Зависимость сервиса' },
{ value: 'tunnel', label: 'Туннель' },
{ value: 'vpn', label: 'VPN' },
{ value: 'sync', label: 'Синхронизация / репликация' },
{ value: 'custom', label: 'Произвольная' },
]
export const EDGE_DIRECTION_OPTIONS: { value: TopologyEdgeDirection; label: string }[] = [
{ value: 'forward', label: 'Односторонняя →' },
{ value: 'bidirectional', label: 'Двусторонняя ↔' },
{ value: 'none', label: 'Без стрелок' },
]
export const SHAPE_KIND_OPTIONS: { value: ShapeKind; label: string }[] = [
{ value: 'rect', label: 'Прямоугольник' },
{ value: 'ellipse', label: 'Эллипс' },
{ value: 'diamond', label: 'Ромб' },
]
export function isVpsNodeData(data: TopologyNodeData): data is VpsNodeData {
return 'vpsId' in data
}
export function isShapeNodeData(data: TopologyNodeData): data is ShapeNodeData {
return 'kind' in data && 'label' in data && !('vpsId' in data) && !('text' in data)
}
export function isNoteNodeData(data: TopologyNodeData): data is NoteNodeData {
return 'text' in data
}
export function isGroupNodeData(data: TopologyNodeData): data is GroupNodeData {
return 'label' in data && !('kind' in data) && !('vpsId' in data) && !('text' in data)
}
export function defaultEdgeData(): TopologyEdgeData {
return {
relation: 'network',
label: '',
lineStyle: 'solid',
direction: 'forward',
protocol: '',
notes: '',
}
}
export function edgeRelationLabel(relation: TopologyEdgeRelation): string {
return EDGE_RELATION_OPTIONS.find((o) => o.value === relation)?.label ?? 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}`