Редактор схем с VPS-нодами, связями и персистом в SQLite по space. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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') ||
|
||||
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'))`
|
||||
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
|
||||
Generated
+155
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user