feat(topology): добавить схему инфраструктуры на React Flow
Docker / build (push) Failing after 19s

Редактор схем с VPS-нодами, связями и персистом в SQLite по space.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-07-18 16:52:36 +07:00
co-authored by Cursor
parent c90b42c9c4
commit da818fe104
30 changed files with 2129 additions and 1 deletions
@@ -13,6 +13,7 @@ import {
FolderKanbanIcon,
HistoryIcon,
UsersIcon,
Network,
} from 'lucide-react'
import {
@@ -80,6 +81,7 @@ const NAV_GROUPS: NavGroup[] = [
label: 'Инфраструктура',
items: [
{ to: '/vps', label: 'VPS', icon: Server },
{ to: '/topology', label: 'Схема', icon: Network },
{ to: '/tariffs', label: 'Активные тарифы', icon: ServerCog },
{ to: '/providers', label: 'Хостеры', icon: Building2 },
{ to: '/accounts', label: 'Аккаунты хостеров', icon: Wallet },
@@ -120,6 +122,7 @@ const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
const PARENT_ROUTE: Record<string, string> = {
'/vps': '/dashboard',
'/topology': '/dashboard',
'/tariffs': '/dashboard',
'/providers': '/dashboard',
'/accounts': '/dashboard',
@@ -13,3 +13,4 @@ export {
export { OpsDashboard } from './ops-dashboard'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
export { TopologyCanvas } from './topology-canvas'
@@ -0,0 +1,48 @@
import type { ReactNode } from 'react'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { cn } from '@cfdm/ui/lib/utils'
interface TopologyCanvasProps {
title?: string
description?: string
headerActions?: ReactNode
tabs?: ReactNode
children: ReactNode
className?: string
}
/** Frame shell for topology whiteboard. Preview surface: frame. */
export function TopologyCanvas({
title = 'Схема инфраструктуры',
description,
headerActions,
tabs,
children,
className,
}: TopologyCanvasProps) {
return (
<Frame dense spacing="sm" className={cn('flex min-h-0 flex-1 flex-col', className)}>
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-px">
<FrameTitle>{title}</FrameTitle>
{description ? <FrameDescription>{description}</FrameDescription> : null}
</div>
{headerActions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{headerActions}
</div>
) : null}
</FrameHeader>
{tabs ? <div className="border-b border-border px-1 pb-2">{tabs}</div> : null}
<FramePanel className="relative min-h-[560px] flex-1 overflow-hidden p-0!">
{children}
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,127 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import { FormSheet } from '@/components/form-sheet'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { vpsSpecsLine } from './types'
import type { Vps } from '@/types/entities'
interface AddVpsSheetProps {
open: boolean
onOpenChange: (open: boolean) => void
existingVpsIds: Set<string>
onAdd: (vpsIds: string[]) => void
}
export function AddVpsSheet({
open,
onOpenChange,
existingVpsIds,
onAdd,
}: AddVpsSheetProps) {
const { data: snapshot } = useQuery(snapshotQueryOptions())
const [q, setQ] = useState('')
const [selected, setSelected] = useState<Set<string>>(new Set())
const list = useMemo(() => {
const all = (snapshot?.vps ?? []) as Vps[]
const term = q.trim().toLowerCase()
return all
.filter((v) => v.status !== 'archived')
.filter((v) => {
if (!term) return true
return [v.dns, v.ip, v.purpose, v.project]
.filter(Boolean)
.some((s) => String(s).toLowerCase().includes(term))
})
.slice(0, 80)
}, [snapshot?.vps, q])
function toggle(id: string) {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
return (
<FormSheet
open={open}
onOpenChange={(v) => {
if (!v) {
setSelected(new Set())
setQ('')
}
onOpenChange(v)
}}
title="Добавить VPS на схему"
description="Сервер появится на канве. Позицию и связи можно настроить вручную."
submitLabel={`Добавить${selected.size ? ` (${selected.size})` : ''}`}
submitDisabled={selected.size === 0}
onSubmit={() => {
onAdd([...selected])
setSelected(new Set())
setQ('')
onOpenChange(false)
}}
>
<Input
placeholder="Поиск по IP, DNS, проекту…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<div className="flex max-h-[50vh] flex-col gap-1 overflow-y-auto">
{list.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">Нет подходящих VPS</p>
) : (
list.map((v) => {
const already = existingVpsIds.has(v.id)
const checked = selected.has(v.id)
return (
<button
key={v.id}
type="button"
disabled={already}
onClick={() => toggle(v.id)}
className="flex flex-col gap-0.5 rounded-md border border-transparent px-2 py-2 text-left hover:bg-muted disabled:opacity-50"
data-selected={checked || undefined}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium">
{v.dns || v.ip}
</span>
{already ? (
<span className="text-[10px] text-muted-foreground">уже на схеме</span>
) : (
<span
className={
checked
? 'size-2 rounded-full bg-primary'
: 'size-2 rounded-full border border-border'
}
/>
)}
</div>
<span className="font-mono text-[11px] text-muted-foreground">{v.ip}</span>
<span className="text-[11px] text-muted-foreground">{vpsSpecsLine(v)}</span>
</button>
)
})
)}
</div>
{selected.size > 0 ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setSelected(new Set())}
>
Сбросить выбор
</Button>
) : null}
</FormSheet>
)
}
@@ -0,0 +1,12 @@
import type { NodeTypes } from '@xyflow/react'
import { VpsNode } from './nodes/vps-node'
import { ShapeNode } from './nodes/shape-node'
import { NoteNode } from './nodes/note-node'
import { GroupNode } from './nodes/group-node'
export const topologyNodeTypes = {
vps: VpsNode,
shape: ShapeNode,
note: NoteNode,
group: GroupNode,
} satisfies NodeTypes
@@ -0,0 +1,22 @@
import { memo } from 'react'
import { type NodeProps, NodeResizer } from '@xyflow/react'
import { cn } from '@cfdm/ui/lib/utils'
import type { GroupNodeData } from '../types'
function GroupNodeComponent({ data, selected }: NodeProps & { data: GroupNodeData }) {
return (
<div
className={cn(
'h-full min-h-[160px] min-w-[280px] rounded-lg border-2 border-dashed bg-muted/20',
selected ? 'border-primary' : 'border-border',
)}
>
<NodeResizer minWidth={200} minHeight={120} isVisible={selected} />
<div className="px-3 py-2 text-xs font-medium text-muted-foreground">
{data.label || 'Группа'}
</div>
</div>
)
}
export const GroupNode = memo(GroupNodeComponent)
@@ -0,0 +1,20 @@
import { memo } from 'react'
import { type NodeProps, NodeResizer } from '@xyflow/react'
import { cn } from '@cfdm/ui/lib/utils'
import type { NoteNodeData } from '../types'
function NoteNodeComponent({ data, selected }: NodeProps & { data: NoteNodeData }) {
return (
<div
className={cn(
'min-h-[72px] min-w-[140px] rounded-md border border-dashed bg-warning/10 px-3 py-2 text-xs whitespace-pre-wrap',
selected ? 'border-primary ring-2 ring-primary/20' : 'border-warning/40',
)}
>
<NodeResizer minWidth={100} minHeight={56} isVisible={selected} />
{data.text || 'Заметка'}
</div>
)
}
export const NoteNode = memo(NoteNodeComponent)
@@ -0,0 +1,36 @@
import { memo } from 'react'
import { Handle, Position, type NodeProps, NodeResizer } from '@xyflow/react'
import { cn } from '@cfdm/ui/lib/utils'
import type { ShapeNodeData } from '../types'
function ShapeNodeComponent({ data, selected }: NodeProps & { data: ShapeNodeData }) {
const kind = data.kind ?? 'rect'
return (
<div
className={cn(
'flex min-h-[64px] min-w-[120px] items-center justify-center border bg-muted/40 px-3 py-2 text-sm',
kind === 'ellipse' && 'rounded-full',
kind === 'rect' && 'rounded-md',
kind === 'diamond' && 'rotate-45 rounded-sm',
selected ? 'border-primary ring-2 ring-primary/20' : 'border-border',
)}
>
<NodeResizer minWidth={80} minHeight={48} isVisible={selected} />
<Handle
type="target"
position={Position.Left}
className="!size-2.5 !border-background !bg-muted-foreground"
/>
<span className={cn('text-center text-xs font-medium', kind === 'diamond' && '-rotate-45')}>
{data.label || 'Блок'}
</span>
<Handle
type="source"
position={Position.Right}
className="!size-2.5 !border-background !bg-muted-foreground"
/>
</div>
)
}
export const ShapeNode = memo(ShapeNodeComponent)
@@ -0,0 +1,93 @@
import { memo } from 'react'
import { Handle, Position, type NodeProps } from '@xyflow/react'
import { ServerIcon } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { cn } from '@cfdm/ui/lib/utils'
import { StatusBadge } from '@/components/status-badge'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { vpsStatusLabel } from '@/lib/format'
import type { Vps } from '@/types/entities'
import { type VpsNodeData, vpsSpecsLine } from '../types'
function formatRate(vps: Vps): string | null {
if (vps.tariffType === 'monthly' && vps.monthlyRate != null) {
return `${vps.monthlyRate} ${vps.currency}/мес`
}
if (vps.dailyRate != null) {
return `${vps.dailyRate} ${vps.currency}/сут`
}
if (vps.monthlyRate != null) {
return `${vps.monthlyRate} ${vps.currency}/мес`
}
return null
}
function VpsNodeComponent({ data, selected }: NodeProps & { data: VpsNodeData }) {
const { data: snapshot } = useQuery(snapshotQueryOptions())
const vps = snapshot?.vps?.find((v) => v.id === data.vpsId) as Vps | undefined
const orphan = !vps
const name = vps?.dns || vps?.ip || data.label || 'VPS'
const rate = vps ? formatRate(vps) : null
return (
<div
className={cn(
'min-w-[220px] rounded-lg border bg-background px-3 py-2 shadow-sm',
selected ? 'border-primary ring-2 ring-primary/20' : 'border-border',
orphan && 'border-warning/60',
)}
>
<Handle
type="target"
position={Position.Left}
className="!size-2.5 !border-background !bg-muted-foreground"
/>
{vps?.ip ? (
<div className="mb-1 font-mono text-[10px] text-muted-foreground">{vps.ip}</div>
) : null}
<div className="flex items-start gap-2">
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted">
<ServerIcon className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium">{name}</span>
{vps ? (
<span
className={cn(
'size-1.5 shrink-0 rounded-full',
vps.status === 'active' ? 'bg-success' : 'bg-muted-foreground',
)}
title={vpsStatusLabel(vps.status)}
/>
) : (
<StatusBadge status="stale" label="Удалён" />
)}
</div>
{vps ? (
<div className="mt-0.5 text-[11px] text-muted-foreground">
{vpsSpecsLine(vps)}
</div>
) : (
<div className="mt-0.5 text-[11px] text-warning">VPS не найден в каталоге</div>
)}
{rate ? (
<div className="mt-0.5 text-[11px] text-muted-foreground">{rate}</div>
) : null}
{vps?.country || vps?.datacenter ? (
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">
{[vps.country, vps.city, vps.datacenter].filter(Boolean).join(' · ')}
</div>
) : null}
</div>
</div>
<Handle
type="source"
position={Position.Right}
className="!size-2.5 !border-background !bg-muted-foreground"
/>
</div>
)
}
export const VpsNode = memo(VpsNodeComponent)
@@ -0,0 +1,136 @@
import {
CircleIcon,
DiamondIcon,
FolderOpenIcon,
MousePointer2Icon,
SquareIcon,
StickyNoteIcon,
ServerIcon,
} from 'lucide-react'
import { Button } from '@cfdm/ui/components/button'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
import { cn } from '@cfdm/ui/lib/utils'
import type { PaletteItem, ShapeKind } from './types'
const ITEMS: { item: PaletteItem; icon: typeof SquareIcon; title: string }[] = [
{
item: { kind: 'shape', shape: 'rect', label: 'Прямоугольник' },
icon: SquareIcon,
title: 'Прямоугольник',
},
{
item: { kind: 'shape', shape: 'ellipse', label: 'Эллипс' },
icon: CircleIcon,
title: 'Эллипс',
},
{
item: { kind: 'shape', shape: 'diamond', label: 'Ромб' },
icon: DiamondIcon,
title: 'Ромб',
},
{
item: { kind: 'note', label: 'Заметка' },
icon: StickyNoteIcon,
title: 'Заметка',
},
{
item: { kind: 'group', label: 'Группа' },
icon: FolderOpenIcon,
title: 'Группа',
},
{
item: { kind: 'vps-picker', label: 'VPS' },
icon: ServerIcon,
title: 'Добавить VPS',
},
]
const DND_TYPE = 'application/topology-palette'
export function topologyDnDType(): string {
return DND_TYPE
}
export function parsePaletteDrag(dataTransfer: DataTransfer): PaletteItem | null {
const raw = dataTransfer.getData(DND_TYPE)
if (!raw) return null
try {
return JSON.parse(raw) as PaletteItem
} catch {
return null
}
}
interface TopologyPaletteProps {
className?: string
onPickVps: () => void
disabled?: boolean
}
export function TopologyPalette({ className, onPickVps, disabled }: TopologyPaletteProps) {
return (
<TooltipProvider>
<div
className={cn(
'flex flex-col gap-1 rounded-lg border border-border bg-background/95 p-1.5 shadow-sm backdrop-blur',
className,
)}
>
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
className="pointer-events-none opacity-60"
aria-label="Выделение"
/>
}
>
<MousePointer2Icon />
</TooltipTrigger>
<TooltipContent side="right">Выделение</TooltipContent>
</Tooltip>
{ITEMS.map(({ item, icon: Icon, title }) => (
<Tooltip key={title}>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={disabled}
draggable={!disabled && item.kind !== 'vps-picker'}
onDragStart={(e) => {
if (item.kind === 'vps-picker') return
e.dataTransfer.setData(DND_TYPE, JSON.stringify(item))
e.dataTransfer.effectAllowed = 'move'
}}
onClick={() => {
if (item.kind === 'vps-picker') onPickVps()
}}
aria-label={title}
/>
}
>
<Icon />
</TooltipTrigger>
<TooltipContent side="right">{title}</TooltipContent>
</Tooltip>
))}
</div>
</TooltipProvider>
)
}
export function shapeLabel(kind: ShapeKind): string {
if (kind === 'ellipse') return 'Эллипс'
if (kind === 'diamond') return 'Ромб'
return 'Блок'
}
@@ -0,0 +1,129 @@
import {
DownloadIcon,
ExpandIcon,
LockIcon,
LockOpenIcon,
MaximizeIcon,
MinusIcon,
PlusIcon,
} from 'lucide-react'
import { Button } from '@cfdm/ui/components/button'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
import { cn } from '@cfdm/ui/lib/utils'
interface TopologyToolbarProps {
zoomPercent: number
locked: boolean
className?: string
onZoomIn: () => void
onZoomOut: () => void
onFitView: () => void
onToggleLock: () => void
onFullscreen: () => void
onExport: () => void
}
export function TopologyToolbar({
zoomPercent,
locked,
className,
onZoomIn,
onZoomOut,
onFitView,
onToggleLock,
onFullscreen,
onExport,
}: TopologyToolbarProps) {
return (
<TooltipProvider>
<div
className={cn(
'flex items-center gap-0.5 rounded-full border border-border bg-background/95 px-1.5 py-1 shadow-sm backdrop-blur',
className,
)}
>
<Tooltip>
<TooltipTrigger
render={
<Button type="button" variant="ghost" size="icon-sm" onClick={onZoomOut} aria-label="Уменьшить" />
}
>
<MinusIcon />
</TooltipTrigger>
<TooltipContent>Уменьшить</TooltipContent>
</Tooltip>
<span className="min-w-10 px-1 text-center text-xs tabular-nums text-muted-foreground">
{zoomPercent}%
</span>
<Tooltip>
<TooltipTrigger
render={
<Button type="button" variant="ghost" size="icon-sm" onClick={onZoomIn} aria-label="Увеличить" />
}
>
<PlusIcon />
</TooltipTrigger>
<TooltipContent>Увеличить</TooltipContent>
</Tooltip>
<div className="mx-1 h-4 w-px bg-border" />
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={onToggleLock}
aria-label={locked ? 'Разблокировать' : 'Заблокировать'}
/>
}
>
{locked ? <LockIcon /> : <LockOpenIcon />}
</TooltipTrigger>
<TooltipContent>{locked ? 'Разблокировать' : 'Заблокировать'}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button type="button" variant="ghost" size="icon-sm" onClick={onFitView} aria-label="Вписать" />
}
>
<MaximizeIcon />
</TooltipTrigger>
<TooltipContent>Вписать в экран</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button type="button" variant="ghost" size="icon-sm" onClick={onExport} aria-label="Экспорт" />
}
>
<DownloadIcon />
</TooltipTrigger>
<TooltipContent>Экспорт PNG</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={onFullscreen}
aria-label="Полный экран"
/>
}
>
<ExpandIcon />
</TooltipTrigger>
<TooltipContent>Полный экран</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
)
}
@@ -0,0 +1,344 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type DragEvent,
type MouseEvent as ReactMouseEvent,
} from 'react'
import {
ReactFlow,
ReactFlowProvider,
Background,
BackgroundVariant,
MarkerType,
addEdge,
useEdgesState,
useNodesState,
useReactFlow,
type Connection,
type Edge,
type Node,
type OnConnect,
type OnNodesChange,
type OnEdgesChange,
type Viewport,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import { toPng } from 'html-to-image'
import { useTheme } from 'next-themes'
import { toast } from 'sonner'
import { cn } from '@cfdm/ui/lib/utils'
import { topologyNodeTypes } from './node-types'
import { TopologyPalette, parsePaletteDrag, shapeLabel } from './palette'
import { TopologyToolbar } from './toolbar'
import { AddVpsSheet } from './add-vps-sheet'
import { VpsDetailSheet } from './vps-detail-sheet'
import {
isVpsNodeData,
newNodeId,
type PaletteItem,
type TopologyNodeData,
type TopologyNodeType,
} from './types'
type FlowNode = Node<TopologyNodeData, TopologyNodeType>
interface TopologyEditorProps {
diagramId: string
initialNodes: FlowNode[]
initialEdges: Edge[]
initialViewport?: Viewport
locked: boolean
onDocumentChange: (doc: {
nodes: FlowNode[]
edges: Edge[]
viewport: Viewport
}) => void
onLockedChange: (locked: boolean) => void
className?: string
}
function TopologyEditorInner({
diagramId,
initialNodes,
initialEdges,
initialViewport,
locked,
onDocumentChange,
onLockedChange,
className,
}: TopologyEditorProps) {
const { resolvedTheme } = useTheme()
const colorMode = resolvedTheme === 'dark' ? 'dark' : 'light'
const wrapperRef = useRef<HTMLDivElement>(null)
const { screenToFlowPosition, fitView, zoomIn, zoomOut, getViewport, setViewport } =
useReactFlow()
const [nodes, setNodes, onNodesChangeBase] = useNodesState<FlowNode>(initialNodes)
const [edges, setEdges, onEdgesChangeBase] = useEdgesState(initialEdges)
const [zoomPercent, setZoomPercent] = useState(100)
const [addVpsOpen, setAddVpsOpen] = useState(false)
const [detailVpsId, setDetailVpsId] = useState<string | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const skipSave = useRef(false)
const hydrated = useRef(false)
useEffect(() => {
skipSave.current = true
hydrated.current = false
setNodes(initialNodes)
setEdges(initialEdges)
if (initialViewport) {
void setViewport(initialViewport)
setZoomPercent(Math.round((initialViewport.zoom || 1) * 100))
}
const t = window.setTimeout(() => {
skipSave.current = false
hydrated.current = true
}, 100)
return () => window.clearTimeout(t)
}, [diagramId]) // eslint-disable-line react-hooks/exhaustive-deps -- remount on diagram switch
const emitSave = useCallback(() => {
if (skipSave.current || !hydrated.current || locked) return
onDocumentChange({
nodes,
edges,
viewport: getViewport(),
})
}, [nodes, edges, getViewport, locked, onDocumentChange])
useEffect(() => {
if (!hydrated.current || locked) return
const t = window.setTimeout(emitSave, 700)
return () => window.clearTimeout(t)
}, [nodes, edges, emitSave, locked])
const onNodesChange: OnNodesChange<FlowNode> = useCallback(
(changes) => {
if (locked) return
onNodesChangeBase(changes)
},
[locked, onNodesChangeBase],
)
const onEdgesChange: OnEdgesChange = useCallback(
(changes) => {
if (locked) return
onEdgesChangeBase(changes)
},
[locked, onEdgesChangeBase],
)
const onConnect: OnConnect = useCallback(
(connection: Connection) => {
if (locked) return
setEdges((eds) =>
addEdge(
{
...connection,
type: 'smoothstep',
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16 },
},
eds,
),
)
},
[locked, setEdges],
)
const existingVpsIds = useMemo(() => {
const ids = new Set<string>()
for (const n of nodes) {
if (n.type === 'vps' && isVpsNodeData(n.data)) ids.add(n.data.vpsId)
}
return ids
}, [nodes])
function placeNode(item: PaletteItem, position: { x: number; y: number }) {
if (item.kind === 'vps-picker') {
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) => [...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])
}
}
function onDragOver(e: DragEvent) {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
}
function onDrop(e: DragEvent) {
e.preventDefault()
if (locked) return
const item = parsePaletteDrag(e.dataTransfer)
if (!item) return
const position = screenToFlowPosition({ x: e.clientX, y: e.clientY })
placeNode(item, position)
}
function handleAddVps(vpsIds: string[]) {
const origin = screenToFlowPosition({
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])
}
function onNodeClick(_e: ReactMouseEvent, node: FlowNode) {
if (node.type === 'vps' && isVpsNodeData(node.data)) {
setDetailVpsId(node.data.vpsId)
setDetailOpen(true)
}
}
async function handleExport() {
const el = wrapperRef.current?.querySelector('.react-flow__viewport') as HTMLElement | null
if (!el) {
toast.error('Не удалось экспортировать схему')
return
}
try {
const dataUrl = await toPng(el, {
backgroundColor: colorMode === 'dark' ? '#0a0a0a' : '#ffffff',
pixelRatio: 2,
})
const a = document.createElement('a')
a.href = dataUrl
a.download = `topology-${diagramId}.png`
a.click()
toast.success('PNG сохранён')
} catch {
toast.error('Ошибка экспорта PNG')
}
}
async function handleFullscreen() {
const el = wrapperRef.current
if (!el) return
try {
if (document.fullscreenElement) await document.exitFullscreen()
else await el.requestFullscreen()
} catch {
toast.error('Полный экран недоступен')
}
}
return (
<div
ref={wrapperRef}
className={cn('relative h-full min-h-[480px] w-full overflow-hidden rounded-lg', className)}
>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
onDrop={onDrop}
onDragOver={onDragOver}
onMoveEnd={(_, vp) => {
setZoomPercent(Math.round(vp.zoom * 100))
if (hydrated.current && !locked) {
onDocumentChange({ nodes, edges, viewport: vp })
}
}}
nodeTypes={topologyNodeTypes}
nodesDraggable={!locked}
nodesConnectable={!locked}
elementsSelectable={!locked}
edgesReconnectable={!locked}
deleteKeyCode={locked ? null : ['Backspace', 'Delete']}
fitView
colorMode={colorMode}
defaultEdgeOptions={{
type: 'smoothstep',
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16 },
}}
proOptions={{ hideAttribution: true }}
className="bg-muted/30"
>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
</ReactFlow>
<div className="pointer-events-none absolute inset-0">
<div className="pointer-events-auto absolute top-3 left-3">
<TopologyPalette
disabled={locked}
onPickVps={() => setAddVpsOpen(true)}
/>
</div>
<div className="pointer-events-auto absolute bottom-3 left-3">
<TopologyToolbar
zoomPercent={zoomPercent}
locked={locked}
onZoomIn={() => void zoomIn()}
onZoomOut={() => void zoomOut()}
onFitView={() => void fitView({ padding: 0.2 })}
onToggleLock={() => onLockedChange(!locked)}
onFullscreen={() => void handleFullscreen()}
onExport={() => void handleExport()}
/>
</div>
</div>
<AddVpsSheet
open={addVpsOpen}
onOpenChange={setAddVpsOpen}
existingVpsIds={existingVpsIds}
onAdd={handleAddVps}
/>
<VpsDetailSheet
vpsId={detailVpsId}
open={detailOpen}
onOpenChange={setDetailOpen}
/>
</div>
)
}
export function TopologyEditor(props: TopologyEditorProps) {
return (
<ReactFlowProvider>
<TopologyEditorInner {...props} />
</ReactFlowProvider>
)
}
+52
View File
@@ -0,0 +1,52 @@
import type { Edge, Node } from '@xyflow/react'
import type { Vps } from '@/types/entities'
export type TopologyNodeType = 'vps' | 'shape' | 'note' | 'group'
export type ShapeKind = 'rect' | 'ellipse' | 'diamond'
export type VpsNodeData = {
vpsId: string
label?: string
}
export type ShapeNodeData = {
kind: ShapeKind
label: string
}
export type NoteNodeData = {
text: string
}
export type GroupNodeData = {
label: string
}
export type TopologyNodeData =
| VpsNodeData
| ShapeNodeData
| NoteNodeData
| GroupNodeData
export type TopologyFlowNode = Node<TopologyNodeData, TopologyNodeType>
export type TopologyFlowEdge = Edge
export type PaletteItem =
| { kind: 'shape'; shape: ShapeKind; label: string }
| { kind: 'note'; label: string }
| { kind: 'group'; label: string }
| { kind: 'vps-picker'; label: string }
export function isVpsNodeData(data: TopologyNodeData): data is VpsNodeData {
return 'vpsId' in data
}
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}`
}
export function newNodeId(prefix: string): string {
return `${prefix}-${crypto.randomUUID().slice(0, 8)}`
}
@@ -0,0 +1,99 @@
import { Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Button } from '@cfdm/ui/components/button'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@cfdm/ui/components/sheet'
import { DetailPanel } from '@/components/reui-kit/detail-panel'
import { StatusBadge } from '@/components/status-badge'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { vpsStatusLabel, tariffTypeLabel } from '@/lib/format'
import { vpsSpecsLine } from './types'
import type { Vps } from '@/types/entities'
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-baseline justify-between gap-3 border-b border-border/60 py-2 last:border-0">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-right text-sm">{value}</span>
</div>
)
}
interface VpsDetailSheetProps {
vpsId: string | null
open: boolean
onOpenChange: (open: boolean) => void
}
export function VpsDetailSheet({ vpsId, open, onOpenChange }: VpsDetailSheetProps) {
const { data: snapshot } = useQuery(snapshotQueryOptions())
const vps = (snapshot?.vps ?? []).find((v) => v.id === vpsId) as Vps | undefined
const provider = snapshot?.providers?.find((p) => p.id === vps?.providerId)
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-md">
<SheetHeader>
<SheetTitle>{vps?.dns || vps?.ip || 'VPS'}</SheetTitle>
<SheetDescription>
{vps ? vpsSpecsLine(vps) : 'Сервер не найден в каталоге'}
</SheetDescription>
</SheetHeader>
{vps ? (
<div className="flex flex-col gap-4 overflow-y-auto p-4">
<div className="flex items-center gap-2">
<StatusBadge status={vps.status} label={vpsStatusLabel(vps.status)} />
<span className="text-xs text-muted-foreground">
{tariffTypeLabel(vps.tariffType)}
</span>
</div>
<DetailPanel>
<DetailPanel.Section title="Сеть">
<div className="rounded-lg border border-border px-3">
<Row label="IPv4" value={vps.ip || '—'} />
<Row label="IPv6" value={vps.ipv6 || '—'} />
<Row label="DNS" value={vps.dns || '—'} />
</div>
</DetailPanel.Section>
<DetailPanel.Section title="Конфигурация">
<div className="rounded-lg border border-border px-3">
<Row label="CPU" value={String(vps.vcpu)} />
<Row label="RAM" value={`${vps.ramGb} ГБ`} />
<Row
label="Диск"
value={`${vps.diskGb} ГБ${vps.diskType ? ` (${vps.diskType})` : ''}`}
/>
<Row label="ОС" value={vps.os || '—'} />
</div>
</DetailPanel.Section>
<DetailPanel.Section title="Размещение">
<div className="rounded-lg border border-border px-3">
<Row label="Провайдер" value={provider?.name || '—'} />
<Row
label="Локация"
value={
[vps.country, vps.city, vps.datacenter].filter(Boolean).join(', ') || '—'
}
/>
<Row label="Проект" value={vps.project || '—'} />
</div>
</DetailPanel.Section>
</DetailPanel>
<Button render={<Link to="/vps/$vpsId" params={{ vpsId: vps.id }} />}>
Открыть карточку VPS
</Button>
</div>
) : (
<p className="p-4 text-sm text-muted-foreground">
VPS удалён или недоступен в текущем пространстве.
</p>
)}
</SheetContent>
</Sheet>
)
}