feat(topology): редактирование параметров блоков, групп и связей
Docker / build (push) Failing after 23s
Docker / build (push) Failing after 23s
Клики по фигурам, заметкам, группам и рёбрам открывают FormSheet с параметрами, как у VPS-узлов. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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} />
|
<NodeResizer minWidth={200} minHeight={120} isVisible={selected} />
|
||||||
<div className="px-3 py-2 text-xs font-medium text-muted-foreground">
|
<div className="flex flex-col gap-0.5 px-3 py-2">
|
||||||
{data.label || 'Группа'}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,15 +35,27 @@ import { TopologyPalette, parsePaletteDrag, shapeLabel } from './palette'
|
|||||||
import { TopologyToolbar } from './toolbar'
|
import { TopologyToolbar } from './toolbar'
|
||||||
import { AddVpsSheet } from './add-vps-sheet'
|
import { AddVpsSheet } from './add-vps-sheet'
|
||||||
import { VpsDetailSheet } from './vps-detail-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 {
|
import {
|
||||||
|
defaultEdgeData,
|
||||||
|
isGroupNodeData,
|
||||||
|
isNoteNodeData,
|
||||||
|
isShapeNodeData,
|
||||||
isVpsNodeData,
|
isVpsNodeData,
|
||||||
newNodeId,
|
newNodeId,
|
||||||
|
type GroupNodeData,
|
||||||
|
type NoteNodeData,
|
||||||
type PaletteItem,
|
type PaletteItem,
|
||||||
|
type ShapeNodeData,
|
||||||
|
type TopologyEdgeData,
|
||||||
type TopologyNodeData,
|
type TopologyNodeData,
|
||||||
type TopologyNodeType,
|
type TopologyNodeType,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
type FlowNode = Node<TopologyNodeData, TopologyNodeType>
|
type FlowNode = Node<TopologyNodeData, TopologyNodeType>
|
||||||
|
type FlowEdge = Edge<TopologyEdgeData>
|
||||||
|
|
||||||
interface TopologyEditorProps {
|
interface TopologyEditorProps {
|
||||||
diagramId: string
|
diagramId: string
|
||||||
@@ -77,11 +89,18 @@ function TopologyEditorInner({
|
|||||||
useReactFlow()
|
useReactFlow()
|
||||||
|
|
||||||
const [nodes, setNodes, onNodesChangeBase] = useNodesState<FlowNode>(initialNodes)
|
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 [zoomPercent, setZoomPercent] = useState(100)
|
||||||
const [addVpsOpen, setAddVpsOpen] = useState(false)
|
const [addVpsOpen, setAddVpsOpen] = useState(false)
|
||||||
const [detailVpsId, setDetailVpsId] = useState<string | null>(null)
|
const [detailVpsId, setDetailVpsId] = useState<string | null>(null)
|
||||||
const [detailOpen, setDetailOpen] = useState(false)
|
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 skipSave = useRef(false)
|
||||||
const hydrated = useRef(false)
|
const hydrated = useRef(false)
|
||||||
|
|
||||||
@@ -89,7 +108,12 @@ function TopologyEditorInner({
|
|||||||
skipSave.current = true
|
skipSave.current = true
|
||||||
hydrated.current = false
|
hydrated.current = false
|
||||||
setNodes(initialNodes)
|
setNodes(initialNodes)
|
||||||
setEdges(initialEdges)
|
setEdges(normalizeEdges(initialEdges))
|
||||||
|
setEditElement(null)
|
||||||
|
setElementOpen(false)
|
||||||
|
setEditEdgeId(null)
|
||||||
|
setEditEdgeData(null)
|
||||||
|
setEdgeOpen(false)
|
||||||
if (initialViewport) {
|
if (initialViewport) {
|
||||||
void setViewport(initialViewport)
|
void setViewport(initialViewport)
|
||||||
setZoomPercent(Math.round((initialViewport.zoom || 1) * 100))
|
setZoomPercent(Math.round((initialViewport.zoom || 1) * 100))
|
||||||
@@ -124,7 +148,7 @@ function TopologyEditorInner({
|
|||||||
[locked, onNodesChangeBase],
|
[locked, onNodesChangeBase],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onEdgesChange: OnEdgesChange = useCallback(
|
const onEdgesChange: OnEdgesChange<FlowEdge> = useCallback(
|
||||||
(changes) => {
|
(changes) => {
|
||||||
if (locked) return
|
if (locked) return
|
||||||
onEdgesChangeBase(changes)
|
onEdgesChangeBase(changes)
|
||||||
@@ -135,13 +159,15 @@ function TopologyEditorInner({
|
|||||||
const onConnect: OnConnect = useCallback(
|
const onConnect: OnConnect = useCallback(
|
||||||
(connection: Connection) => {
|
(connection: Connection) => {
|
||||||
if (locked) return
|
if (locked) return
|
||||||
|
if (!connection.source || !connection.target) return
|
||||||
setEdges((eds) =>
|
setEdges((eds) =>
|
||||||
addEdge(
|
addEdge(
|
||||||
{
|
createConnectedEdge({
|
||||||
...connection,
|
source: connection.source,
|
||||||
type: 'smoothstep',
|
target: connection.target,
|
||||||
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16 },
|
sourceHandle: connection.sourceHandle,
|
||||||
},
|
targetHandle: connection.targetHandle,
|
||||||
|
}),
|
||||||
eds,
|
eds,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -237,7 +263,45 @@ function TopologyEditorInner({
|
|||||||
if (node.type === 'vps' && isVpsNodeData(node.data)) {
|
if (node.type === 'vps' && isVpsNodeData(node.data)) {
|
||||||
setDetailVpsId(node.data.vpsId)
|
setDetailVpsId(node.data.vpsId)
|
||||||
setDetailOpen(true)
|
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() {
|
async function handleExport() {
|
||||||
@@ -286,6 +350,7 @@ function TopologyEditorInner({
|
|||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
onNodeClick={onNodeClick}
|
onNodeClick={onNodeClick}
|
||||||
|
onEdgeClick={onEdgeClick}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
onMoveEnd={(_, vp) => {
|
onMoveEnd={(_, vp) => {
|
||||||
@@ -343,10 +408,32 @@ function TopologyEditorInner({
|
|||||||
open={detailOpen}
|
open={detailOpen}
|
||||||
onOpenChange={setDetailOpen}
|
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>
|
</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) {
|
export function TopologyEditor(props: TopologyEditorProps) {
|
||||||
return (
|
return (
|
||||||
<ReactFlowProvider>
|
<ReactFlowProvider>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export type NoteNodeData = {
|
|||||||
|
|
||||||
export type GroupNodeData = {
|
export type GroupNodeData = {
|
||||||
label: string
|
label: string
|
||||||
|
notes?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TopologyNodeData =
|
export type TopologyNodeData =
|
||||||
@@ -30,8 +31,28 @@ export type TopologyNodeData =
|
|||||||
| NoteNodeData
|
| NoteNodeData
|
||||||
| GroupNodeData
|
| 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 TopologyFlowNode = Node<TopologyNodeData, TopologyNodeType>
|
||||||
export type TopologyFlowEdge = Edge
|
export type TopologyFlowEdge = Edge<TopologyEdgeData>
|
||||||
|
|
||||||
export type PaletteItem =
|
export type PaletteItem =
|
||||||
| { kind: 'shape'; shape: ShapeKind; label: string }
|
| { kind: 'shape'; shape: ShapeKind; label: string }
|
||||||
@@ -39,10 +60,58 @@ export type PaletteItem =
|
|||||||
| { kind: 'group'; label: string }
|
| { kind: 'group'; label: string }
|
||||||
| { kind: 'vps-picker'; label: string }
|
| { kind: 'vps-picker'; label: string }
|
||||||
|
|
||||||
|
export const EDGE_RELATION_OPTIONS: { value: TopologyEdgeRelation; label: string }[] = [
|
||||||
|
{ value: 'network', label: 'Сеть / L2–L3' },
|
||||||
|
{ 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 {
|
export function isVpsNodeData(data: TopologyNodeData): data is VpsNodeData {
|
||||||
return 'vpsId' in data
|
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 {
|
export function vpsSpecsLine(vps: Pick<Vps, 'vcpu' | 'ramGb' | 'diskGb' | 'diskType'>): string {
|
||||||
const disk = vps.diskType ? `${vps.diskGb} ГБ ${vps.diskType}` : `${vps.diskGb} ГБ`
|
const disk = vps.diskType ? `${vps.diskGb} ГБ ${vps.diskType}` : `${vps.diskGb} ГБ`
|
||||||
return `${vps.vcpu} CPU · ${vps.ramGb} ГБ RAM · ${disk}`
|
return `${vps.vcpu} CPU · ${vps.ramGb} ГБ RAM · ${disk}`
|
||||||
|
|||||||
Reference in New Issue
Block a user