From da818fe10482d67bcdc1eb143fbd5c62d231f6b2 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 18 Jul 2026 16:52:36 +0700 Subject: [PATCH] =?UTF-8?q?feat(topology):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=20=D1=81=D1=85=D0=B5=D0=BC=D1=83=20?= =?UTF-8?q?=D0=B8=D0=BD=D1=84=D1=80=D0=B0=D1=81=D1=82=D1=80=D1=83=D0=BA?= =?UTF-8?q?=D1=82=D1=83=D1=80=D1=8B=20=D0=BD=D0=B0=20React=20Flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Редактор схем с VPS-нодами, связями и персистом в SQLite по space. Co-authored-by: Cursor --- apps/api/src/index.ts | 2 + apps/api/src/lib/permissions.ts | 5 +- apps/api/src/routes/topology.test.ts | 87 +++++ apps/api/src/routes/topology.ts | 88 +++++ apps/web/package.json | 2 + apps/web/src/components/layout/app-shell.tsx | 3 + apps/web/src/components/reui-kit/index.ts | 1 + .../components/reui-kit/topology-canvas.tsx | 48 +++ .../src/components/topology/add-vps-sheet.tsx | 127 +++++++ .../web/src/components/topology/node-types.ts | 12 + .../components/topology/nodes/group-node.tsx | 22 ++ .../components/topology/nodes/note-node.tsx | 20 + .../components/topology/nodes/shape-node.tsx | 36 ++ .../components/topology/nodes/vps-node.tsx | 93 +++++ apps/web/src/components/topology/palette.tsx | 136 +++++++ apps/web/src/components/topology/toolbar.tsx | 129 +++++++ .../components/topology/topology-editor.tsx | 344 +++++++++++++++++ apps/web/src/components/topology/types.ts | 52 +++ .../components/topology/vps-detail-sheet.tsx | 99 +++++ apps/web/src/lib/api-client.ts | 29 ++ apps/web/src/lib/auth.ts | 1 + apps/web/src/queries/topology.ts | 36 ++ apps/web/src/routeTree.gen.ts | 21 ++ apps/web/src/routes/_auth/topology.tsx | 351 ++++++++++++++++++ packages/db/src/repositories/topology.ts | 151 ++++++++ packages/db/src/runtime-migrate.ts | 10 + packages/db/src/schema/index.ts | 13 + packages/shared/src/contracts/topology.ts | 56 +++ packages/shared/src/index.ts | 1 + pnpm-lock.yaml | 155 ++++++++ 30 files changed, 2129 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/routes/topology.test.ts create mode 100644 apps/api/src/routes/topology.ts create mode 100644 apps/web/src/components/reui-kit/topology-canvas.tsx create mode 100644 apps/web/src/components/topology/add-vps-sheet.tsx create mode 100644 apps/web/src/components/topology/node-types.ts create mode 100644 apps/web/src/components/topology/nodes/group-node.tsx create mode 100644 apps/web/src/components/topology/nodes/note-node.tsx create mode 100644 apps/web/src/components/topology/nodes/shape-node.tsx create mode 100644 apps/web/src/components/topology/nodes/vps-node.tsx create mode 100644 apps/web/src/components/topology/palette.tsx create mode 100644 apps/web/src/components/topology/toolbar.tsx create mode 100644 apps/web/src/components/topology/topology-editor.tsx create mode 100644 apps/web/src/components/topology/types.ts create mode 100644 apps/web/src/components/topology/vps-detail-sheet.tsx create mode 100644 apps/web/src/queries/topology.ts create mode 100644 apps/web/src/routes/_auth/topology.tsx create mode 100644 packages/db/src/repositories/topology.ts create mode 100644 packages/shared/src/contracts/topology.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index bf6db1d..650d28f 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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) diff --git a/apps/api/src/lib/permissions.ts b/apps/api/src/lib/permissions.ts index e67e07a..9ddb9e0 100644 --- a/apps/api/src/lib/permissions.ts +++ b/apps/api/src/lib/permissions.ts @@ -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', }, { diff --git a/apps/api/src/routes/topology.test.ts b/apps/api/src/routes/topology.test.ts new file mode 100644 index 0000000..b668ebb --- /dev/null +++ b/apps/api/src/routes/topology.test.ts @@ -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> + + 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() + }) +}) diff --git a/apps/api/src/routes/topology.ts b/apps/api/src/routes/topology.ts new file mode 100644 index 0000000..6086f56 --- /dev/null +++ b/apps/api/src/routes/topology.ts @@ -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() + }) +} diff --git a/apps/web/package.json b/apps/web/package.json index e4888e0..0cc7fa1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx index ceb0616..24a0923 100644 --- a/apps/web/src/components/layout/app-shell.tsx +++ b/apps/web/src/components/layout/app-shell.tsx @@ -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 = Object.fromEntries( const PARENT_ROUTE: Record = { '/vps': '/dashboard', + '/topology': '/dashboard', '/tariffs': '/dashboard', '/providers': '/dashboard', '/accounts': '/dashboard', diff --git a/apps/web/src/components/reui-kit/index.ts b/apps/web/src/components/reui-kit/index.ts index 02a5915..8614611 100644 --- a/apps/web/src/components/reui-kit/index.ts +++ b/apps/web/src/components/reui-kit/index.ts @@ -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' diff --git a/apps/web/src/components/reui-kit/topology-canvas.tsx b/apps/web/src/components/reui-kit/topology-canvas.tsx new file mode 100644 index 0000000..8ac1cde --- /dev/null +++ b/apps/web/src/components/reui-kit/topology-canvas.tsx @@ -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 ( + + +
+ {title} + {description ? {description} : null} +
+ {headerActions ? ( +
+ {headerActions} +
+ ) : null} +
+ {tabs ?
{tabs}
: null} + + {children} + + + ) +} diff --git a/apps/web/src/components/topology/add-vps-sheet.tsx b/apps/web/src/components/topology/add-vps-sheet.tsx new file mode 100644 index 0000000..a559f8f --- /dev/null +++ b/apps/web/src/components/topology/add-vps-sheet.tsx @@ -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 + 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>(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 ( + { + 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) + }} + > + setQ(e.target.value)} + /> +
+ {list.length === 0 ? ( +

Нет подходящих VPS

+ ) : ( + list.map((v) => { + const already = existingVpsIds.has(v.id) + const checked = selected.has(v.id) + return ( + + ) + }) + )} +
+ {selected.size > 0 ? ( + + ) : null} +
+ ) +} diff --git a/apps/web/src/components/topology/node-types.ts b/apps/web/src/components/topology/node-types.ts new file mode 100644 index 0000000..10ea836 --- /dev/null +++ b/apps/web/src/components/topology/node-types.ts @@ -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 diff --git a/apps/web/src/components/topology/nodes/group-node.tsx b/apps/web/src/components/topology/nodes/group-node.tsx new file mode 100644 index 0000000..ea01bc5 --- /dev/null +++ b/apps/web/src/components/topology/nodes/group-node.tsx @@ -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 ( +
+ +
+ {data.label || 'Группа'} +
+
+ ) +} + +export const GroupNode = memo(GroupNodeComponent) diff --git a/apps/web/src/components/topology/nodes/note-node.tsx b/apps/web/src/components/topology/nodes/note-node.tsx new file mode 100644 index 0000000..f1679fc --- /dev/null +++ b/apps/web/src/components/topology/nodes/note-node.tsx @@ -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 ( +
+ + {data.text || 'Заметка'} +
+ ) +} + +export const NoteNode = memo(NoteNodeComponent) diff --git a/apps/web/src/components/topology/nodes/shape-node.tsx b/apps/web/src/components/topology/nodes/shape-node.tsx new file mode 100644 index 0000000..e246130 --- /dev/null +++ b/apps/web/src/components/topology/nodes/shape-node.tsx @@ -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 ( +
+ + + + {data.label || 'Блок'} + + +
+ ) +} + +export const ShapeNode = memo(ShapeNodeComponent) diff --git a/apps/web/src/components/topology/nodes/vps-node.tsx b/apps/web/src/components/topology/nodes/vps-node.tsx new file mode 100644 index 0000000..bc6b5a1 --- /dev/null +++ b/apps/web/src/components/topology/nodes/vps-node.tsx @@ -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 ( +
+ + {vps?.ip ? ( +
{vps.ip}
+ ) : null} +
+
+ +
+
+
+ {name} + {vps ? ( + + ) : ( + + )} +
+ {vps ? ( +
+ {vpsSpecsLine(vps)} +
+ ) : ( +
VPS не найден в каталоге
+ )} + {rate ? ( +
{rate}
+ ) : null} + {vps?.country || vps?.datacenter ? ( +
+ {[vps.country, vps.city, vps.datacenter].filter(Boolean).join(' · ')} +
+ ) : null} +
+
+ +
+ ) +} + +export const VpsNode = memo(VpsNodeComponent) diff --git a/apps/web/src/components/topology/palette.tsx b/apps/web/src/components/topology/palette.tsx new file mode 100644 index 0000000..f5d1577 --- /dev/null +++ b/apps/web/src/components/topology/palette.tsx @@ -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 ( + +
+ + + } + > + + + Выделение + + {ITEMS.map(({ item, icon: Icon, title }) => ( + + { + 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} + /> + } + > + + + {title} + + ))} +
+
+ ) +} + +export function shapeLabel(kind: ShapeKind): string { + if (kind === 'ellipse') return 'Эллипс' + if (kind === 'diamond') return 'Ромб' + return 'Блок' +} diff --git a/apps/web/src/components/topology/toolbar.tsx b/apps/web/src/components/topology/toolbar.tsx new file mode 100644 index 0000000..377c462 --- /dev/null +++ b/apps/web/src/components/topology/toolbar.tsx @@ -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 ( + +
+ + + } + > + + + Уменьшить + + + {zoomPercent}% + + + + } + > + + + Увеличить + +
+ + + } + > + {locked ? : } + + {locked ? 'Разблокировать' : 'Заблокировать'} + + + + } + > + + + Вписать в экран + + + + } + > + + + Экспорт PNG + + + + } + > + + + Полный экран + +
+ + ) +} diff --git a/apps/web/src/components/topology/topology-editor.tsx b/apps/web/src/components/topology/topology-editor.tsx new file mode 100644 index 0000000..b015763 --- /dev/null +++ b/apps/web/src/components/topology/topology-editor.tsx @@ -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 + +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(null) + const { screenToFlowPosition, fitView, zoomIn, zoomOut, getViewport, setViewport } = + useReactFlow() + + const [nodes, setNodes, onNodesChangeBase] = useNodesState(initialNodes) + const [edges, setEdges, onEdgesChangeBase] = useEdgesState(initialEdges) + const [zoomPercent, setZoomPercent] = useState(100) + const [addVpsOpen, setAddVpsOpen] = useState(false) + const [detailVpsId, setDetailVpsId] = useState(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 = 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() + 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 ( +
+ { + 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" + > + + + +
+
+ setAddVpsOpen(true)} + /> +
+
+ void zoomIn()} + onZoomOut={() => void zoomOut()} + onFitView={() => void fitView({ padding: 0.2 })} + onToggleLock={() => onLockedChange(!locked)} + onFullscreen={() => void handleFullscreen()} + onExport={() => void handleExport()} + /> +
+
+ + + +
+ ) +} + +export function TopologyEditor(props: TopologyEditorProps) { + return ( + + + + ) +} diff --git a/apps/web/src/components/topology/types.ts b/apps/web/src/components/topology/types.ts new file mode 100644 index 0000000..974608a --- /dev/null +++ b/apps/web/src/components/topology/types.ts @@ -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 +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): 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)}` +} diff --git a/apps/web/src/components/topology/vps-detail-sheet.tsx b/apps/web/src/components/topology/vps-detail-sheet.tsx new file mode 100644 index 0000000..663b14e --- /dev/null +++ b/apps/web/src/components/topology/vps-detail-sheet.tsx @@ -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 ( +
+ {label} + {value} +
+ ) +} + +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 ( + + + + {vps?.dns || vps?.ip || 'VPS'} + + {vps ? vpsSpecsLine(vps) : 'Сервер не найден в каталоге'} + + + {vps ? ( +
+
+ + + {tariffTypeLabel(vps.tariffType)} + +
+ + +
+ + + +
+
+ +
+ + + + +
+
+ +
+ + + +
+
+
+ +
+ ) : ( +

+ VPS удалён или недоступен в текущем пространстве. +

+ )} +
+
+ ) +} diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index fc623d7..1172727 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -302,6 +302,35 @@ export const api = { deleteProject: (id: string) => fetchApi(`/api/projects/${encodeURIComponent(id)}`, { method: 'DELETE' }), + fetchTopologyList: () => + fetchApi('/api/topology'), + + fetchTopology: (id: string) => + fetchApi( + `/api/topology/${encodeURIComponent(id)}`, + ), + + createTopology: (payload: import('@cfdm/shared/contracts/topology').TopologyCreateInput) => + fetchApi('/api/topology', { + method: 'POST', + body: JSON.stringify(payload), + }), + + updateTopology: ( + id: string, + payload: import('@cfdm/shared/contracts/topology').TopologyUpdateInput, + ) => + fetchApi( + `/api/topology/${encodeURIComponent(id)}`, + { + method: 'PUT', + body: JSON.stringify(payload), + }, + ), + + deleteTopology: (id: string) => + fetchApi(`/api/topology/${encodeURIComponent(id)}`, { method: 'DELETE' }), + fetchAuditLog: (limit = 100) => fetchApi ['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 => 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 => api.fetchTopology(diagramId), + staleTime: 5_000, + enabled: Boolean(diagramId), + } +} + +export type { TopologyCreateInput, TopologyDiagram, TopologyDiagramListItem, TopologyUpdateInput } diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 0c732a0..da11e8f 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -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, } diff --git a/apps/web/src/routes/_auth/topology.tsx b/apps/web/src/routes/_auth/topology.tsx new file mode 100644 index 0000000..2f9b631 --- /dev/null +++ b/apps/web/src/routes/_auth/topology.tsx @@ -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 + +function TopologyPage() { + const spaceId = getStoredSpaceId() + const qc = useQueryClient() + const listQuery = useQuery(topologyListQueryOptions(spaceId)) + const [activeId, setActiveId] = useState(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 ( +
+ {items.map((d) => ( + + ))} + +
+ ) + }, [listQuery.data, activeId, createMutation]) + + if (listQuery.isLoading) { + return ( +
+ + +
+ ) + } + + if (listQuery.isError) { + return ( + void listQuery.refetch()}> + Повторить + + } + /> + ) + } + + const diagrams = listQuery.data ?? [] + + if (diagrams.length === 0) { + return ( +
+ createMutation.mutate('Мастер')} + disabled={createMutation.isPending} + > + + Создать схему + + } + /> +
+ ) + } + + 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 ( +
+ + + +
+ ) : null + } + > + {detailQuery.isLoading || !diagram || !activeId ? ( + + ) : ( + + updateMutation.mutate({ + id: diagram.id, + locked, + expectedUpdatedAt: updatedAtRef.current, + }) + } + /> + )} + + + + + + Переименовать схему + + setRenameValue(e.target.value)} + maxLength={120} + /> + + + + + + + + + + + Удалить схему? + + Схема и расположение узлов будут удалены безвозвратно. + + + + Отмена + { + if (diagram) deleteMutation.mutate(diagram.id) + }} + > + Удалить + + + + +
+ ) +} diff --git a/packages/db/src/repositories/topology.ts b/packages/db/src/repositories/topology.ts new file mode 100644 index 0000000..d666127 --- /dev/null +++ b/packages/db/src/repositories/topology.ts @@ -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 + }, +} diff --git a/packages/db/src/runtime-migrate.ts b/packages/db/src/runtime-migrate.ts index 5536f01..3561073 100644 --- a/packages/db/src/runtime-migrate.ts +++ b/packages/db/src/runtime-migrate.ts @@ -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 { diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index c241969..c1d6549 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -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'))` diff --git a/packages/shared/src/contracts/topology.ts b/packages/shared/src/contracts/topology.ts new file mode 100644 index 0000000..cef7011 --- /dev/null +++ b/packages/shared/src/contracts/topology.ts @@ -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 + +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 + +export const topologyDiagramListItemSchema = topologyDiagramSchema.omit({ + document: true, +}) + +export type TopologyDiagramListItem = z.infer + +export const topologyCreateSchema = z.object({ + name: z.string().min(1, 'Укажите название схемы').max(120), + document: topologyDocumentSchema.optional(), +}) + +export type TopologyCreateInput = z.infer + +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 diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 592371f..bcb64d8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84ef6aa..34ba4bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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