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
+2
View File
@@ -17,6 +17,7 @@ import { balanceLedgerRoutes } from './routes/balance-ledger.js'
import { settingsRoutes } from './routes/settings.js'
import { syncRoutes } from './routes/sync.js'
import { projectsRoutes } from './routes/projects.js'
import { topologyRoutes } from './routes/topology.js'
import { backupRoutes } from './routes/backup.js'
import { ratesProxyRoutes } from './routes/rates-proxy.js'
import { migrateRoutes } from './routes/migrate.js'
@@ -62,6 +63,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
await app.register(settingsRoutes)
await app.register(syncRoutes)
await app.register(projectsRoutes)
await app.register(topologyRoutes)
await app.register(backupRoutes)
await app.register(ratesProxyRoutes)
await app.register(migrateRoutes)
+4 -1
View File
@@ -50,13 +50,16 @@ const RULES: Rule[] = [
p === '/api/vps' ||
p.startsWith('/api/vps/') ||
p.startsWith('/api/projects') ||
p.startsWith('/api/topology') ||
p.startsWith('/api/data'),
permission: 'vps:vps:read',
},
{
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
match: (p) =>
p.startsWith('/api/vps') || p.startsWith('/api/projects'),
p.startsWith('/api/vps') ||
p.startsWith('/api/projects') ||
p.startsWith('/api/topology'),
permission: 'vps:vps:write',
},
{
+87
View File
@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { closeDb } from '@cfdm/db'
import { resetTestDb } from '@cfdm/db/test-setup'
import { topologyRepository } from '@cfdm/db/repositories/topology'
import { buildApp } from '../index.js'
describe('topology routes', () => {
let app: Awaited<ReturnType<typeof buildApp>>
beforeEach(async () => {
resetTestDb()
app = await buildApp()
})
afterEach(async () => {
await app.close()
closeDb()
})
it('lists empty diagrams', async () => {
const res = await app.inject({ method: 'GET', url: '/api/topology' })
expect(res.statusCode).toBe(200)
expect(res.json()).toEqual([])
})
it('creates and gets diagram', async () => {
const create = await app.inject({
method: 'POST',
url: '/api/topology',
payload: { name: 'Мастер' },
})
expect(create.statusCode).toBe(201)
const created = create.json() as { id: string; name: string; document: unknown }
expect(created.name).toBe('Мастер')
expect(created.document).toMatchObject({
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
})
const get = await app.inject({
method: 'GET',
url: `/api/topology/${created.id}`,
})
expect(get.statusCode).toBe(200)
expect(get.json()).toMatchObject({ id: created.id, name: 'Мастер' })
})
it('updates document and returns 409 on stale expectedUpdatedAt', async () => {
const diagram = topologyRepository.create({ name: 'Схема 1' })
const put = await app.inject({
method: 'PUT',
url: `/api/topology/${diagram.id}`,
payload: {
document: {
nodes: [{ id: 'n1', position: { x: 0, y: 0 }, data: {} }],
edges: [],
viewport: { x: 0, y: 0, zoom: 1.2 },
},
expectedUpdatedAt: diagram.updatedAt,
},
})
expect(put.statusCode).toBe(200)
const updated = put.json() as { updatedAt: string; document: { nodes: unknown[] } }
expect(updated.document.nodes).toHaveLength(1)
const stale = await app.inject({
method: 'PUT',
url: `/api/topology/${diagram.id}`,
payload: {
name: 'Stale',
expectedUpdatedAt: diagram.updatedAt,
},
})
expect(stale.statusCode).toBe(409)
})
it('deletes diagram', async () => {
const diagram = topologyRepository.create({ name: 'Delete me' })
const res = await app.inject({
method: 'DELETE',
url: `/api/topology/${diagram.id}`,
})
expect(res.statusCode).toBe(204)
expect(topologyRepository.get(diagram.id)).toBeUndefined()
})
})
+88
View File
@@ -0,0 +1,88 @@
import type { FastifyPluginAsync } from 'fastify'
import {
topologyCreateSchema,
topologyUpdateSchema,
} from '@cfdm/shared/contracts/topology'
import { topologyRepository } from '@cfdm/db/repositories/topology'
import { canWriteInSpace, requireSpaceRole } from '../plugins/space.js'
export const topologyRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/topology', async () => topologyRepository.list())
app.get<{ Params: { id: string } }>('/api/topology/:id', async (req, reply) => {
const diagram = topologyRepository.get(req.params.id)
if (!diagram) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Схема не найдена' } })
}
return diagram
})
app.post('/api/topology', async (req, reply) => {
if (!requireSpaceRole(req, reply, 'member')) return
if (!canWriteInSpace(req)) {
return reply.code(403).send({
error: { code: 'FORBIDDEN', message: 'Нет прав на запись в пространстве' },
})
}
const parsed = topologyCreateSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({
error: {
code: 'VALIDATION',
message: parsed.error.issues[0]?.message ?? 'Некорректные данные',
},
})
}
const created = topologyRepository.create(parsed.data)
return reply.code(201).send(created)
})
app.put<{ Params: { id: string } }>('/api/topology/:id', async (req, reply) => {
if (!requireSpaceRole(req, reply, 'member')) return
if (!canWriteInSpace(req)) {
return reply.code(403).send({
error: { code: 'FORBIDDEN', message: 'Нет прав на запись в пространстве' },
})
}
const parsed = topologyUpdateSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({
error: {
code: 'VALIDATION',
message: parsed.error.issues[0]?.message ?? 'Некорректные данные',
},
})
}
const result = topologyRepository.update(req.params.id, parsed.data)
if (!result.ok && result.reason === 'not_found') {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Схема не найдена' } })
}
if (!result.ok && result.reason === 'stale') {
return reply.code(409).send({
error: {
code: 'CONFLICT',
message: 'Схема изменена на другом устройстве',
},
diagram: result.current,
})
}
if (!result.ok) {
return reply.code(500).send({ error: { code: 'INTERNAL_ERROR', message: 'Ошибка обновления' } })
}
return result.diagram
})
app.delete<{ Params: { id: string } }>('/api/topology/:id', async (req, reply) => {
if (!requireSpaceRole(req, reply, 'member')) return
if (!canWriteInSpace(req)) {
return reply.code(403).send({
error: { code: 'FORBIDDEN', message: 'Нет прав на запись в пространстве' },
})
}
const ok = topologyRepository.delete(req.params.id)
if (!ok) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Схема не найдена' } })
}
return reply.code(204).send()
})
}
+2
View File
@@ -25,9 +25,11 @@
"@tanstack/react-router-devtools": "^1.130.2",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.4",
"@xyflow/react": "^12.11.2",
"class-variance-authority": "^0.7.1",
"cmdk": "^1.1.1",
"date-fns": "^4.4.0",
"html-to-image": "^1.11.13",
"lucide-react": "^0.468.0",
"next-themes": "^0.4.6",
"react": "^19.2.0",
@@ -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>
)
}
+29
View File
@@ -302,6 +302,35 @@ export const api = {
deleteProject: (id: string) =>
fetchApi<void>(`/api/projects/${encodeURIComponent(id)}`, { method: 'DELETE' }),
fetchTopologyList: () =>
fetchApi<import('@cfdm/shared/contracts/topology').TopologyDiagramListItem[]>('/api/topology'),
fetchTopology: (id: string) =>
fetchApi<import('@cfdm/shared/contracts/topology').TopologyDiagram>(
`/api/topology/${encodeURIComponent(id)}`,
),
createTopology: (payload: import('@cfdm/shared/contracts/topology').TopologyCreateInput) =>
fetchApi<import('@cfdm/shared/contracts/topology').TopologyDiagram>('/api/topology', {
method: 'POST',
body: JSON.stringify(payload),
}),
updateTopology: (
id: string,
payload: import('@cfdm/shared/contracts/topology').TopologyUpdateInput,
) =>
fetchApi<import('@cfdm/shared/contracts/topology').TopologyDiagram>(
`/api/topology/${encodeURIComponent(id)}`,
{
method: 'PUT',
body: JSON.stringify(payload),
},
),
deleteTopology: (id: string) =>
fetchApi<void>(`/api/topology/${encodeURIComponent(id)}`, { method: 'DELETE' }),
fetchAuditLog: (limit = 100) =>
fetchApi<Array<{
id: string
+1
View File
@@ -226,6 +226,7 @@ export function permissionForPath(pathname: string): string | null {
if (pathname.startsWith('/dashboard')) return 'vps:dashboard:read'
if (
pathname.startsWith('/vps') ||
pathname.startsWith('/topology') ||
pathname.startsWith('/tariffs') ||
pathname.startsWith('/projects') ||
pathname.startsWith('/reports') ||
+36
View File
@@ -0,0 +1,36 @@
import { api } from '@/lib/api-client'
import { getStoredSpaceId } from '@/lib/space'
import type {
TopologyCreateInput,
TopologyDiagram,
TopologyDiagramListItem,
TopologyUpdateInput,
} from '@cfdm/shared/contracts/topology'
export const topologyKeys = {
all: ['topology'] as const,
list: (spaceId: string | null) => ['topology', 'list', spaceId ?? 'default'] as const,
detail: (spaceId: string | null, id: string) =>
['topology', 'detail', spaceId ?? 'default', id] as const,
}
export function topologyListQueryOptions(spaceId?: string | null) {
const id = spaceId === undefined ? getStoredSpaceId() : spaceId
return {
queryKey: topologyKeys.list(id),
queryFn: (): Promise<TopologyDiagramListItem[]> => api.fetchTopologyList(),
staleTime: 15_000,
}
}
export function topologyDetailQueryOptions(diagramId: string, spaceId?: string | null) {
const id = spaceId === undefined ? getStoredSpaceId() : spaceId
return {
queryKey: topologyKeys.detail(id, diagramId),
queryFn: (): Promise<TopologyDiagram> => api.fetchTopology(diagramId),
staleTime: 5_000,
enabled: Boolean(diagramId),
}
}
export type { TopologyCreateInput, TopologyDiagram, TopologyDiagramListItem, TopologyUpdateInput }
+21
View File
@@ -13,6 +13,7 @@ import { Route as AuthRouteImport } from './routes/_auth'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
import { Route as AuthVpsRouteImport } from './routes/_auth/vps'
import { Route as AuthTopologyRouteImport } from './routes/_auth/topology'
import { Route as AuthTariffsRouteImport } from './routes/_auth/tariffs'
import { Route as AuthSyncJournalRouteImport } from './routes/_auth/sync-journal'
import { Route as AuthSpacesRouteImport } from './routes/_auth/spaces'
@@ -51,6 +52,11 @@ const AuthVpsRoute = AuthVpsRouteImport.update({
path: '/vps',
getParentRoute: () => AuthRoute,
} as any)
const AuthTopologyRoute = AuthTopologyRouteImport.update({
id: '/topology',
path: '/topology',
getParentRoute: () => AuthRoute,
} as any)
const AuthTariffsRoute = AuthTariffsRouteImport.update({
id: '/tariffs',
path: '/tariffs',
@@ -159,6 +165,7 @@ export interface FileRoutesByFullPath {
'/spaces': typeof AuthSpacesRoute
'/sync-journal': typeof AuthSyncJournalRoute
'/tariffs': typeof AuthTariffsRoute
'/topology': typeof AuthTopologyRoute
'/vps': typeof AuthVpsRouteWithChildren
'/auth/callback': typeof AuthCallbackRoute
'/projects/$projectId': typeof AuthProjectsProjectIdRoute
@@ -181,6 +188,7 @@ export interface FileRoutesByTo {
'/spaces': typeof AuthSpacesRoute
'/sync-journal': typeof AuthSyncJournalRoute
'/tariffs': typeof AuthTariffsRoute
'/topology': typeof AuthTopologyRoute
'/vps': typeof AuthVpsRouteWithChildren
'/auth/callback': typeof AuthCallbackRoute
'/projects/$projectId': typeof AuthProjectsProjectIdRoute
@@ -206,6 +214,7 @@ export interface FileRoutesById {
'/_auth/spaces': typeof AuthSpacesRoute
'/_auth/sync-journal': typeof AuthSyncJournalRoute
'/_auth/tariffs': typeof AuthTariffsRoute
'/_auth/topology': typeof AuthTopologyRoute
'/_auth/vps': typeof AuthVpsRouteWithChildren
'/auth/callback': typeof AuthCallbackRoute
'/_auth/projects/$projectId': typeof AuthProjectsProjectIdRoute
@@ -231,6 +240,7 @@ export interface FileRouteTypes {
| '/spaces'
| '/sync-journal'
| '/tariffs'
| '/topology'
| '/vps'
| '/auth/callback'
| '/projects/$projectId'
@@ -253,6 +263,7 @@ export interface FileRouteTypes {
| '/spaces'
| '/sync-journal'
| '/tariffs'
| '/topology'
| '/vps'
| '/auth/callback'
| '/projects/$projectId'
@@ -277,6 +288,7 @@ export interface FileRouteTypes {
| '/_auth/spaces'
| '/_auth/sync-journal'
| '/_auth/tariffs'
| '/_auth/topology'
| '/_auth/vps'
| '/auth/callback'
| '/_auth/projects/$projectId'
@@ -321,6 +333,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthVpsRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/topology': {
id: '/_auth/topology'
path: '/topology'
fullPath: '/topology'
preLoaderRoute: typeof AuthTopologyRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/tariffs': {
id: '/_auth/tariffs'
path: '/tariffs'
@@ -501,6 +520,7 @@ interface AuthRouteChildren {
AuthSpacesRoute: typeof AuthSpacesRoute
AuthSyncJournalRoute: typeof AuthSyncJournalRoute
AuthTariffsRoute: typeof AuthTariffsRoute
AuthTopologyRoute: typeof AuthTopologyRoute
AuthVpsRoute: typeof AuthVpsRouteWithChildren
}
@@ -519,6 +539,7 @@ const AuthRouteChildren: AuthRouteChildren = {
AuthSpacesRoute: AuthSpacesRoute,
AuthSyncJournalRoute: AuthSyncJournalRoute,
AuthTariffsRoute: AuthTariffsRoute,
AuthTopologyRoute: AuthTopologyRoute,
AuthVpsRoute: AuthVpsRouteWithChildren,
}
+351
View File
@@ -0,0 +1,351 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { Edge, Node, Viewport } from '@xyflow/react'
import { LockIcon, NetworkIcon, PlusIcon, PencilIcon, Trash2Icon } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import { Skeleton } from '@cfdm/ui/components/skeleton'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@cfdm/ui/components/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@cfdm/ui/components/alert-dialog'
import { EmptyState } from '@/components/empty-state'
import { TopologyCanvas } from '@/components/reui-kit/topology-canvas'
import { TopologyEditor } from '@/components/topology/topology-editor'
import type { TopologyNodeData, TopologyNodeType } from '@/components/topology/types'
import { api, ApiError } from '@/lib/api-client'
import { getStoredSpaceId } from '@/lib/space'
import {
topologyDetailQueryOptions,
topologyKeys,
topologyListQueryOptions,
} from '@/queries/topology'
import { snapshotQueryOptions } from '@/queries/snapshot'
import type { TopologyDocument } from '@cfdm/shared/contracts/topology'
import { cn } from '@cfdm/ui/lib/utils'
export const Route = createFileRoute('/_auth/topology')({
component: TopologyPage,
})
type FlowNode = Node<TopologyNodeData, TopologyNodeType>
function TopologyPage() {
const spaceId = getStoredSpaceId()
const qc = useQueryClient()
const listQuery = useQuery(topologyListQueryOptions(spaceId))
const [activeId, setActiveId] = useState<string | null>(null)
const [renameOpen, setRenameOpen] = useState(false)
const [renameValue, setRenameValue] = useState('')
const [deleteOpen, setDeleteOpen] = useState(false)
useQuery(snapshotQueryOptions(spaceId))
useEffect(() => {
if (!listQuery.data?.length) {
setActiveId(null)
return
}
if (!activeId || !listQuery.data.some((d) => d.id === activeId)) {
setActiveId(listQuery.data[0]!.id)
}
}, [listQuery.data, activeId])
const detailQuery = useQuery({
...topologyDetailQueryOptions(activeId ?? '', spaceId),
enabled: Boolean(activeId),
})
const updatedAtRef = useRef(detailQuery.data?.updatedAt)
useEffect(() => {
updatedAtRef.current = detailQuery.data?.updatedAt
}, [detailQuery.data?.updatedAt])
const createMutation = useMutation({
mutationFn: (name: string) => api.createTopology({ name }),
onSuccess: async (created) => {
await qc.invalidateQueries({ queryKey: topologyKeys.list(spaceId) })
setActiveId(created.id)
toast.success('Схема создана')
},
onError: (err: Error) => toast.error(err.message || 'Не удалось создать схему'),
})
const updateMutation = useMutation({
mutationFn: ({
id,
...payload
}: {
id: string
name?: string
document?: TopologyDocument
locked?: boolean
expectedUpdatedAt?: string
}) => api.updateTopology(id, payload),
onSuccess: (updated) => {
updatedAtRef.current = updated.updatedAt
qc.setQueryData(topologyKeys.detail(spaceId, updated.id), updated)
void qc.invalidateQueries({ queryKey: topologyKeys.list(spaceId) })
},
onError: (err: Error) => {
if (err instanceof ApiError && err.status === 409) {
toast.error('Схема изменена на другом устройстве — обновляем')
void qc.invalidateQueries({
queryKey: topologyKeys.detail(spaceId, activeId ?? ''),
})
return
}
toast.error(err.message || 'Не удалось сохранить')
},
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.deleteTopology(id),
onSuccess: async () => {
setDeleteOpen(false)
setActiveId(null)
await qc.invalidateQueries({ queryKey: topologyKeys.list(spaceId) })
toast.success('Схема удалена')
},
onError: (err: Error) => toast.error(err.message || 'Не удалось удалить'),
})
const handleDocumentChange = useCallback(
(doc: { nodes: FlowNode[]; edges: Edge[]; viewport: Viewport }) => {
if (!detailQuery.data || updateMutation.isPending) return
updateMutation.mutate({
id: detailQuery.data.id,
document: {
nodes: doc.nodes as unknown as TopologyDocument['nodes'],
edges: doc.edges as unknown as TopologyDocument['edges'],
viewport: doc.viewport,
},
expectedUpdatedAt: updatedAtRef.current,
})
},
[detailQuery.data, updateMutation],
)
const tabs = useMemo(() => {
const items = listQuery.data ?? []
return (
<div className="flex flex-wrap items-center gap-1">
{items.map((d) => (
<button
key={d.id}
type="button"
onClick={() => setActiveId(d.id)}
className={cn(
'inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-sm transition-colors',
d.id === activeId
? 'bg-muted font-medium text-foreground'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
)}
>
{d.name}
{d.locked ? <LockIcon className="size-3 opacity-70" /> : null}
</button>
))}
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Новая схема"
disabled={createMutation.isPending}
onClick={() => {
const n = (listQuery.data?.length ?? 0) + 1
createMutation.mutate(n === 1 ? 'Мастер' : `Схема ${n}`)
}}
>
<PlusIcon />
</Button>
</div>
)
}, [listQuery.data, activeId, createMutation])
if (listQuery.isLoading) {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-[480px] w-full" />
</div>
)
}
if (listQuery.isError) {
return (
<EmptyState
icon={NetworkIcon}
title="Ошибка загрузки"
description={
listQuery.error instanceof Error
? listQuery.error.message
: 'Не удалось загрузить схемы'
}
action={
<Button variant="outline" onClick={() => void listQuery.refetch()}>
Повторить
</Button>
}
/>
)
}
const diagrams = listQuery.data ?? []
if (diagrams.length === 0) {
return (
<div className="flex flex-1 flex-col gap-4">
<EmptyState
icon={NetworkIcon}
title="Нет схем инфраструктуры"
description="Создайте первую схему и разместите на ней VPS, связи и группы."
action={
<Button
onClick={() => createMutation.mutate('Мастер')}
disabled={createMutation.isPending}
>
<PlusIcon data-icon="inline-start" />
Создать схему
</Button>
}
/>
</div>
)
}
const diagram = detailQuery.data
const initialNodes = (diagram?.document.nodes ?? []) as unknown as FlowNode[]
const initialEdges = (diagram?.document.edges ?? []) as unknown as Edge[]
const initialViewport = diagram?.document.viewport as Viewport | undefined
return (
<div className="flex min-h-0 flex-1 flex-col gap-4">
<TopologyCanvas
description="Размещайте VPS, рисуйте связи и группируйте узлы. Изменения сохраняются автоматически."
tabs={tabs}
headerActions={
diagram ? (
<div className="flex items-center gap-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setRenameValue(diagram.name)
setRenameOpen(true)
}}
>
<PencilIcon data-icon="inline-start" />
Переименовать
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setDeleteOpen(true)}
>
<Trash2Icon data-icon="inline-start" />
Удалить
</Button>
</div>
) : null
}
>
{detailQuery.isLoading || !diagram || !activeId ? (
<Skeleton className="h-full min-h-[480px] w-full rounded-none" />
) : (
<TopologyEditor
key={diagram.id}
diagramId={diagram.id}
initialNodes={initialNodes}
initialEdges={initialEdges}
initialViewport={initialViewport}
locked={diagram.locked}
onDocumentChange={handleDocumentChange}
onLockedChange={(locked) =>
updateMutation.mutate({
id: diagram.id,
locked,
expectedUpdatedAt: updatedAtRef.current,
})
}
/>
)}
</TopologyCanvas>
<Dialog open={renameOpen} onOpenChange={setRenameOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Переименовать схему</DialogTitle>
</DialogHeader>
<Input
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
maxLength={120}
/>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setRenameOpen(false)}>
Отмена
</Button>
<Button
type="button"
disabled={!renameValue.trim() || !diagram}
onClick={() => {
if (!diagram) return
updateMutation.mutate(
{
id: diagram.id,
name: renameValue.trim(),
expectedUpdatedAt: updatedAtRef.current,
},
{ onSuccess: () => setRenameOpen(false) },
)
}}
>
Сохранить
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить схему?</AlertDialogTitle>
<AlertDialogDescription>
Схема и расположение узлов будут удалены безвозвратно.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleteMutation.isPending}
onClick={() => {
if (diagram) deleteMutation.mutate(diagram.id)
}}
>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
+151
View File
@@ -0,0 +1,151 @@
import { randomUUID } from 'node:crypto'
import { and, asc, eq } from 'drizzle-orm'
import {
EMPTY_TOPOLOGY_DOCUMENT,
type TopologyDocument,
type TopologyUpdateInput,
} from '@cfdm/shared/contracts/topology'
import { getDb, schema } from '../index.js'
import { getCurrentSpaceId } from '../space-context.js'
function parseDocument(raw: string): TopologyDocument {
try {
const parsed = JSON.parse(raw) as TopologyDocument
if (!parsed || !Array.isArray(parsed.nodes) || !Array.isArray(parsed.edges)) {
return { ...EMPTY_TOPOLOGY_DOCUMENT }
}
return {
nodes: parsed.nodes,
edges: parsed.edges,
viewport: parsed.viewport ?? { x: 0, y: 0, zoom: 1 },
}
} catch {
return { ...EMPTY_TOPOLOGY_DOCUMENT }
}
}
function toDto(row: typeof schema.topologyDiagrams.$inferSelect) {
return {
id: row.id,
spaceId: row.spaceId,
name: row.name,
document: parseDocument(row.document),
locked: row.locked === 1,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}
}
function toListItem(row: typeof schema.topologyDiagrams.$inferSelect) {
return {
id: row.id,
spaceId: row.spaceId,
name: row.name,
locked: row.locked === 1,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}
}
export const topologyRepository = {
list() {
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.topologyDiagrams)
.where(eq(schema.topologyDiagrams.spaceId, spaceId))
.orderBy(asc(schema.topologyDiagrams.createdAt))
.all()
.map(toListItem)
},
get(id: string) {
const spaceId = getCurrentSpaceId()
const row = getDb()
.select()
.from(schema.topologyDiagrams)
.where(
and(
eq(schema.topologyDiagrams.id, id),
eq(schema.topologyDiagrams.spaceId, spaceId),
),
)
.get()
return row ? toDto(row) : undefined
},
create(input: { name: string; document?: TopologyDocument }) {
const id = `topo-${randomUUID()}`
const now = new Date().toISOString()
const document = input.document ?? { ...EMPTY_TOPOLOGY_DOCUMENT }
getDb()
.insert(schema.topologyDiagrams)
.values({
id,
spaceId: getCurrentSpaceId(),
name: input.name.trim(),
document: JSON.stringify(document),
locked: 0,
createdAt: now,
updatedAt: now,
})
.run()
return this.get(id)!
},
update(id: string, input: TopologyUpdateInput) {
const existing = this.get(id)
if (!existing) return { ok: false as const, reason: 'not_found' as const }
if (
input.expectedUpdatedAt &&
input.expectedUpdatedAt !== existing.updatedAt
) {
return { ok: false as const, reason: 'stale' as const, current: existing }
}
const now = new Date().toISOString()
getDb()
.update(schema.topologyDiagrams)
.set({
name: input.name !== undefined ? input.name.trim() : existing.name,
document:
input.document !== undefined
? JSON.stringify(input.document)
: JSON.stringify(existing.document),
locked:
input.locked !== undefined
? input.locked
? 1
: 0
: existing.locked
? 1
: 0,
updatedAt: now,
})
.where(
and(
eq(schema.topologyDiagrams.id, id),
eq(schema.topologyDiagrams.spaceId, existing.spaceId),
),
)
.run()
return { ok: true as const, diagram: this.get(id)! }
},
delete(id: string): boolean {
const existing = this.get(id)
if (!existing) return false
const r = getDb()
.delete(schema.topologyDiagrams)
.where(
and(
eq(schema.topologyDiagrams.id, id),
eq(schema.topologyDiagrams.spaceId, existing.spaceId),
),
)
.run()
return r.changes > 0
},
}
+10
View File
@@ -117,6 +117,15 @@ const TABLE_MIGRATIONS: string[] = [
createdAt TEXT NOT NULL,
UNIQUE(vpsId, toSpaceId)
)`,
`CREATE TABLE IF NOT EXISTS topology_diagrams (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main' REFERENCES spaces(id),
name TEXT NOT NULL,
document TEXT NOT NULL DEFAULT '{"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}',
locked INTEGER NOT NULL DEFAULT 0,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`,
]
const SPACE_BACKFILL_TABLES = [
@@ -135,6 +144,7 @@ const SPACE_BACKFILL_TABLES = [
'sync_log',
'active_tariffs',
'tariff_sync_options',
'topology_diagrams',
] as const
function ensureMainSpace(sqlite: Database.Database): void {
+13
View File
@@ -347,4 +347,17 @@ export const tariffSyncOptions = sqliteTable('tariff_sync_options', {
syncedAt: text('syncedAt'),
})
export const topologyDiagrams = sqliteTable('topology_diagrams', {
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
name: text('name').notNull(),
document: text('document').notNull().default('{"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}'),
locked: integer('locked').notNull().default(0),
createdAt: text('createdAt').notNull(),
updatedAt: text('updatedAt').notNull(),
})
export const now = sql`(datetime('now'))`
+56
View File
@@ -0,0 +1,56 @@
import { z } from 'zod'
export const topologyViewportSchema = z.object({
x: z.number(),
y: z.number(),
zoom: z.number(),
})
export const topologyDocumentSchema = z.object({
nodes: z.array(z.record(z.string(), z.unknown())),
edges: z.array(z.record(z.string(), z.unknown())),
viewport: topologyViewportSchema,
})
export type TopologyDocument = z.infer<typeof topologyDocumentSchema>
export const EMPTY_TOPOLOGY_DOCUMENT: TopologyDocument = {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
}
export const topologyDiagramSchema = z.object({
id: z.string(),
spaceId: z.string(),
name: z.string(),
document: topologyDocumentSchema,
locked: z.boolean(),
createdAt: z.string(),
updatedAt: z.string(),
})
export type TopologyDiagram = z.infer<typeof topologyDiagramSchema>
export const topologyDiagramListItemSchema = topologyDiagramSchema.omit({
document: true,
})
export type TopologyDiagramListItem = z.infer<typeof topologyDiagramListItemSchema>
export const topologyCreateSchema = z.object({
name: z.string().min(1, 'Укажите название схемы').max(120),
document: topologyDocumentSchema.optional(),
})
export type TopologyCreateInput = z.infer<typeof topologyCreateSchema>
export const topologyUpdateSchema = z.object({
name: z.string().min(1).max(120).optional(),
document: topologyDocumentSchema.optional(),
locked: z.boolean().optional(),
/** Client's last known updatedAt — 409 if stale */
expectedUpdatedAt: z.string().optional(),
})
export type TopologyUpdateInput = z.infer<typeof topologyUpdateSchema>
+1
View File
@@ -7,3 +7,4 @@ export * from './contracts/balance-ledger.js'
export * from './contracts/settings.js'
export * from './contracts/custom-fields.js'
export * from './contracts/project.js'
export * from './contracts/topology.js'
+155
View File
@@ -132,6 +132,9 @@ importers:
'@tanstack/react-virtual':
specifier: ^3.14.4
version: 3.14.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@xyflow/react':
specifier: ^12.11.2
version: 12.11.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(immer@11.1.8)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -141,6 +144,9 @@ importers:
date-fns:
specifier: ^4.4.0
version: 4.4.0
html-to-image:
specifier: ^1.11.13
version: 1.11.13
lucide-react:
specifier: ^0.468.0
version: 0.468.0(react@19.2.7)
@@ -1590,6 +1596,9 @@ packages:
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-drag@3.0.7':
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
@@ -1602,6 +1611,9 @@ packages:
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-selection@3.0.11':
resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
'@types/d3-shape@3.1.8':
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
@@ -1611,6 +1623,12 @@ packages:
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/d3-transition@3.0.9':
resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
'@types/d3-zoom@3.0.8':
resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
@@ -1699,6 +1717,22 @@ packages:
'@vitest/utils@3.2.6':
resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==}
'@xyflow/react@12.11.2':
resolution: {integrity: sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==}
peerDependencies:
'@types/react': '>=17'
'@types/react-dom': '>=17'
react: '>=17'
react-dom: '>=17'
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@xyflow/system@0.0.79':
resolution: {integrity: sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==}
abstract-logging@2.0.1:
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
@@ -1859,6 +1893,9 @@ packages:
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
classcat@5.0.5:
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -1927,6 +1964,14 @@ packages:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-dispatch@3.0.1:
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
engines: {node: '>=12'}
d3-drag@3.0.0:
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
@@ -1947,6 +1992,10 @@ packages:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-selection@3.0.0:
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
engines: {node: '>=12'}
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
@@ -1963,6 +2012,16 @@ packages:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
d3-transition@3.0.1:
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
engines: {node: '>=12'}
peerDependencies:
d3-selection: 2 - 3
d3-zoom@3.0.0:
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
engines: {node: '>=12'}
date-fns@4.4.0:
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
@@ -2453,6 +2512,9 @@ packages:
hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
html-to-image@1.11.13:
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
@@ -3508,6 +3570,21 @@ packages:
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
zustand@4.5.7:
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
engines: {node: '>=12.7.0'}
peerDependencies:
'@types/react': '>=16.8'
immer: '>=9.0.6'
react: '>=16.8'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
snapshots:
'@babel/code-frame@7.29.7':
@@ -4562,6 +4639,10 @@ snapshots:
'@types/d3-color@3.1.3': {}
'@types/d3-drag@3.0.7':
dependencies:
'@types/d3-selection': 3.0.11
'@types/d3-ease@3.0.2': {}
'@types/d3-interpolate@3.0.4':
@@ -4574,6 +4655,8 @@ snapshots:
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-selection@3.0.11': {}
'@types/d3-shape@3.1.8':
dependencies:
'@types/d3-path': 3.1.1
@@ -4582,6 +4665,15 @@ snapshots:
'@types/d3-timer@3.0.2': {}
'@types/d3-transition@3.0.9':
dependencies:
'@types/d3-selection': 3.0.11
'@types/d3-zoom@3.0.8':
dependencies:
'@types/d3-interpolate': 3.0.4
'@types/d3-selection': 3.0.11
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.9': {}
@@ -4703,6 +4795,31 @@ snapshots:
loupe: 3.2.1
tinyrainbow: 2.0.0
'@xyflow/react@12.11.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(immer@11.1.8)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@xyflow/system': 0.0.79
classcat: 5.0.5
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
zustand: 4.5.7(@types/react@19.2.17)(immer@11.1.8)(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
transitivePeerDependencies:
- immer
'@xyflow/system@0.0.79':
dependencies:
'@types/d3-drag': 3.0.7
'@types/d3-interpolate': 3.0.4
'@types/d3-selection': 3.0.11
'@types/d3-transition': 3.0.9
'@types/d3-zoom': 3.0.8
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-zoom: 3.0.0
abstract-logging@2.0.1: {}
accepts@1.3.8:
@@ -4882,6 +4999,8 @@ snapshots:
dependencies:
clsx: 2.1.1
classcat@5.0.5: {}
clsx@2.1.1: {}
cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
@@ -4941,6 +5060,13 @@ snapshots:
d3-color@3.1.0: {}
d3-dispatch@3.0.1: {}
d3-drag@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-selection: 3.0.0
d3-ease@3.0.1: {}
d3-format@3.1.2: {}
@@ -4959,6 +5085,8 @@ snapshots:
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-selection@3.0.0: {}
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
@@ -4973,6 +5101,23 @@ snapshots:
d3-timer@3.0.1: {}
d3-transition@3.0.1(d3-selection@3.0.0):
dependencies:
d3-color: 3.1.0
d3-dispatch: 3.0.1
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-timer: 3.0.1
d3-zoom@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
date-fns@4.4.0: {}
debug@2.6.9:
@@ -5498,6 +5643,8 @@ snapshots:
dependencies:
hermes-estree: 0.25.1
html-to-image@1.11.13: {}
http-errors@2.0.1:
dependencies:
depd: 2.0.0
@@ -6513,3 +6660,11 @@ snapshots:
zod@3.25.76: {}
zod@4.4.3: {}
zustand@4.5.7(@types/react@19.2.17)(immer@11.1.8)(react@19.2.7):
dependencies:
use-sync-external-store: 1.6.0(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
immer: 11.1.8
react: 19.2.7