feat(topology): редактирование параметров блоков, групп и связей
Docker / build (push) Failing after 23s
Docker / build (push) Failing after 23s
Клики по фигурам, заметкам, группам и рёбрам открывают FormSheet с параметрами, как у VPS-узлов. Co-authored-by: Cursor <cursoragent@cursor.com>
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} />
|
||||
<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>
|
||||
|
||||
@@ -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: 'Сеть / 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 {
|
||||
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}`
|
||||
|
||||
Reference in New Issue
Block a user