Редактор схем с VPS-нодами, связями и персистом в SQLite по space. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -17,6 +17,7 @@ import { balanceLedgerRoutes } from './routes/balance-ledger.js'
|
|||||||
import { settingsRoutes } from './routes/settings.js'
|
import { settingsRoutes } from './routes/settings.js'
|
||||||
import { syncRoutes } from './routes/sync.js'
|
import { syncRoutes } from './routes/sync.js'
|
||||||
import { projectsRoutes } from './routes/projects.js'
|
import { projectsRoutes } from './routes/projects.js'
|
||||||
|
import { topologyRoutes } from './routes/topology.js'
|
||||||
import { backupRoutes } from './routes/backup.js'
|
import { backupRoutes } from './routes/backup.js'
|
||||||
import { ratesProxyRoutes } from './routes/rates-proxy.js'
|
import { ratesProxyRoutes } from './routes/rates-proxy.js'
|
||||||
import { migrateRoutes } from './routes/migrate.js'
|
import { migrateRoutes } from './routes/migrate.js'
|
||||||
@@ -62,6 +63,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
|||||||
await app.register(settingsRoutes)
|
await app.register(settingsRoutes)
|
||||||
await app.register(syncRoutes)
|
await app.register(syncRoutes)
|
||||||
await app.register(projectsRoutes)
|
await app.register(projectsRoutes)
|
||||||
|
await app.register(topologyRoutes)
|
||||||
await app.register(backupRoutes)
|
await app.register(backupRoutes)
|
||||||
await app.register(ratesProxyRoutes)
|
await app.register(ratesProxyRoutes)
|
||||||
await app.register(migrateRoutes)
|
await app.register(migrateRoutes)
|
||||||
|
|||||||
@@ -50,13 +50,16 @@ const RULES: Rule[] = [
|
|||||||
p === '/api/vps' ||
|
p === '/api/vps' ||
|
||||||
p.startsWith('/api/vps/') ||
|
p.startsWith('/api/vps/') ||
|
||||||
p.startsWith('/api/projects') ||
|
p.startsWith('/api/projects') ||
|
||||||
|
p.startsWith('/api/topology') ||
|
||||||
p.startsWith('/api/data'),
|
p.startsWith('/api/data'),
|
||||||
permission: 'vps:vps:read',
|
permission: 'vps:vps:read',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
|
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
|
||||||
match: (p) =>
|
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',
|
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-router-devtools": "^1.130.2",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tanstack/react-virtual": "^3.14.4",
|
"@tanstack/react-virtual": "^3.14.4",
|
||||||
|
"@xyflow/react": "^12.11.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
|
"html-to-image": "^1.11.13",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
FolderKanbanIcon,
|
FolderKanbanIcon,
|
||||||
HistoryIcon,
|
HistoryIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
|
Network,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -80,6 +81,7 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
label: 'Инфраструктура',
|
label: 'Инфраструктура',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/vps', label: 'VPS', icon: Server },
|
{ to: '/vps', label: 'VPS', icon: Server },
|
||||||
|
{ to: '/topology', label: 'Схема', icon: Network },
|
||||||
{ to: '/tariffs', label: 'Активные тарифы', icon: ServerCog },
|
{ to: '/tariffs', label: 'Активные тарифы', icon: ServerCog },
|
||||||
{ to: '/providers', label: 'Хостеры', icon: Building2 },
|
{ to: '/providers', label: 'Хостеры', icon: Building2 },
|
||||||
{ to: '/accounts', label: 'Аккаунты хостеров', icon: Wallet },
|
{ to: '/accounts', label: 'Аккаунты хостеров', icon: Wallet },
|
||||||
@@ -120,6 +122,7 @@ const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
|
|||||||
|
|
||||||
const PARENT_ROUTE: Record<string, string> = {
|
const PARENT_ROUTE: Record<string, string> = {
|
||||||
'/vps': '/dashboard',
|
'/vps': '/dashboard',
|
||||||
|
'/topology': '/dashboard',
|
||||||
'/tariffs': '/dashboard',
|
'/tariffs': '/dashboard',
|
||||||
'/providers': '/dashboard',
|
'/providers': '/dashboard',
|
||||||
'/accounts': '/dashboard',
|
'/accounts': '/dashboard',
|
||||||
|
|||||||
@@ -13,3 +13,4 @@ export {
|
|||||||
export { OpsDashboard } from './ops-dashboard'
|
export { OpsDashboard } from './ops-dashboard'
|
||||||
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||||
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
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) =>
|
deleteProject: (id: string) =>
|
||||||
fetchApi<void>(`/api/projects/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
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) =>
|
fetchAuditLog: (limit = 100) =>
|
||||||
fetchApi<Array<{
|
fetchApi<Array<{
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ export function permissionForPath(pathname: string): string | null {
|
|||||||
if (pathname.startsWith('/dashboard')) return 'vps:dashboard:read'
|
if (pathname.startsWith('/dashboard')) return 'vps:dashboard:read'
|
||||||
if (
|
if (
|
||||||
pathname.startsWith('/vps') ||
|
pathname.startsWith('/vps') ||
|
||||||
|
pathname.startsWith('/topology') ||
|
||||||
pathname.startsWith('/tariffs') ||
|
pathname.startsWith('/tariffs') ||
|
||||||
pathname.startsWith('/projects') ||
|
pathname.startsWith('/projects') ||
|
||||||
pathname.startsWith('/reports') ||
|
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 IndexRouteImport } from './routes/index'
|
||||||
import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
|
import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
|
||||||
import { Route as AuthVpsRouteImport } from './routes/_auth/vps'
|
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 AuthTariffsRouteImport } from './routes/_auth/tariffs'
|
||||||
import { Route as AuthSyncJournalRouteImport } from './routes/_auth/sync-journal'
|
import { Route as AuthSyncJournalRouteImport } from './routes/_auth/sync-journal'
|
||||||
import { Route as AuthSpacesRouteImport } from './routes/_auth/spaces'
|
import { Route as AuthSpacesRouteImport } from './routes/_auth/spaces'
|
||||||
@@ -51,6 +52,11 @@ const AuthVpsRoute = AuthVpsRouteImport.update({
|
|||||||
path: '/vps',
|
path: '/vps',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthTopologyRoute = AuthTopologyRouteImport.update({
|
||||||
|
id: '/topology',
|
||||||
|
path: '/topology',
|
||||||
|
getParentRoute: () => AuthRoute,
|
||||||
|
} as any)
|
||||||
const AuthTariffsRoute = AuthTariffsRouteImport.update({
|
const AuthTariffsRoute = AuthTariffsRouteImport.update({
|
||||||
id: '/tariffs',
|
id: '/tariffs',
|
||||||
path: '/tariffs',
|
path: '/tariffs',
|
||||||
@@ -159,6 +165,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/spaces': typeof AuthSpacesRoute
|
'/spaces': typeof AuthSpacesRoute
|
||||||
'/sync-journal': typeof AuthSyncJournalRoute
|
'/sync-journal': typeof AuthSyncJournalRoute
|
||||||
'/tariffs': typeof AuthTariffsRoute
|
'/tariffs': typeof AuthTariffsRoute
|
||||||
|
'/topology': typeof AuthTopologyRoute
|
||||||
'/vps': typeof AuthVpsRouteWithChildren
|
'/vps': typeof AuthVpsRouteWithChildren
|
||||||
'/auth/callback': typeof AuthCallbackRoute
|
'/auth/callback': typeof AuthCallbackRoute
|
||||||
'/projects/$projectId': typeof AuthProjectsProjectIdRoute
|
'/projects/$projectId': typeof AuthProjectsProjectIdRoute
|
||||||
@@ -181,6 +188,7 @@ export interface FileRoutesByTo {
|
|||||||
'/spaces': typeof AuthSpacesRoute
|
'/spaces': typeof AuthSpacesRoute
|
||||||
'/sync-journal': typeof AuthSyncJournalRoute
|
'/sync-journal': typeof AuthSyncJournalRoute
|
||||||
'/tariffs': typeof AuthTariffsRoute
|
'/tariffs': typeof AuthTariffsRoute
|
||||||
|
'/topology': typeof AuthTopologyRoute
|
||||||
'/vps': typeof AuthVpsRouteWithChildren
|
'/vps': typeof AuthVpsRouteWithChildren
|
||||||
'/auth/callback': typeof AuthCallbackRoute
|
'/auth/callback': typeof AuthCallbackRoute
|
||||||
'/projects/$projectId': typeof AuthProjectsProjectIdRoute
|
'/projects/$projectId': typeof AuthProjectsProjectIdRoute
|
||||||
@@ -206,6 +214,7 @@ export interface FileRoutesById {
|
|||||||
'/_auth/spaces': typeof AuthSpacesRoute
|
'/_auth/spaces': typeof AuthSpacesRoute
|
||||||
'/_auth/sync-journal': typeof AuthSyncJournalRoute
|
'/_auth/sync-journal': typeof AuthSyncJournalRoute
|
||||||
'/_auth/tariffs': typeof AuthTariffsRoute
|
'/_auth/tariffs': typeof AuthTariffsRoute
|
||||||
|
'/_auth/topology': typeof AuthTopologyRoute
|
||||||
'/_auth/vps': typeof AuthVpsRouteWithChildren
|
'/_auth/vps': typeof AuthVpsRouteWithChildren
|
||||||
'/auth/callback': typeof AuthCallbackRoute
|
'/auth/callback': typeof AuthCallbackRoute
|
||||||
'/_auth/projects/$projectId': typeof AuthProjectsProjectIdRoute
|
'/_auth/projects/$projectId': typeof AuthProjectsProjectIdRoute
|
||||||
@@ -231,6 +240,7 @@ export interface FileRouteTypes {
|
|||||||
| '/spaces'
|
| '/spaces'
|
||||||
| '/sync-journal'
|
| '/sync-journal'
|
||||||
| '/tariffs'
|
| '/tariffs'
|
||||||
|
| '/topology'
|
||||||
| '/vps'
|
| '/vps'
|
||||||
| '/auth/callback'
|
| '/auth/callback'
|
||||||
| '/projects/$projectId'
|
| '/projects/$projectId'
|
||||||
@@ -253,6 +263,7 @@ export interface FileRouteTypes {
|
|||||||
| '/spaces'
|
| '/spaces'
|
||||||
| '/sync-journal'
|
| '/sync-journal'
|
||||||
| '/tariffs'
|
| '/tariffs'
|
||||||
|
| '/topology'
|
||||||
| '/vps'
|
| '/vps'
|
||||||
| '/auth/callback'
|
| '/auth/callback'
|
||||||
| '/projects/$projectId'
|
| '/projects/$projectId'
|
||||||
@@ -277,6 +288,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_auth/spaces'
|
| '/_auth/spaces'
|
||||||
| '/_auth/sync-journal'
|
| '/_auth/sync-journal'
|
||||||
| '/_auth/tariffs'
|
| '/_auth/tariffs'
|
||||||
|
| '/_auth/topology'
|
||||||
| '/_auth/vps'
|
| '/_auth/vps'
|
||||||
| '/auth/callback'
|
| '/auth/callback'
|
||||||
| '/_auth/projects/$projectId'
|
| '/_auth/projects/$projectId'
|
||||||
@@ -321,6 +333,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthVpsRouteImport
|
preLoaderRoute: typeof AuthVpsRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthRoute
|
||||||
}
|
}
|
||||||
|
'/_auth/topology': {
|
||||||
|
id: '/_auth/topology'
|
||||||
|
path: '/topology'
|
||||||
|
fullPath: '/topology'
|
||||||
|
preLoaderRoute: typeof AuthTopologyRouteImport
|
||||||
|
parentRoute: typeof AuthRoute
|
||||||
|
}
|
||||||
'/_auth/tariffs': {
|
'/_auth/tariffs': {
|
||||||
id: '/_auth/tariffs'
|
id: '/_auth/tariffs'
|
||||||
path: '/tariffs'
|
path: '/tariffs'
|
||||||
@@ -501,6 +520,7 @@ interface AuthRouteChildren {
|
|||||||
AuthSpacesRoute: typeof AuthSpacesRoute
|
AuthSpacesRoute: typeof AuthSpacesRoute
|
||||||
AuthSyncJournalRoute: typeof AuthSyncJournalRoute
|
AuthSyncJournalRoute: typeof AuthSyncJournalRoute
|
||||||
AuthTariffsRoute: typeof AuthTariffsRoute
|
AuthTariffsRoute: typeof AuthTariffsRoute
|
||||||
|
AuthTopologyRoute: typeof AuthTopologyRoute
|
||||||
AuthVpsRoute: typeof AuthVpsRouteWithChildren
|
AuthVpsRoute: typeof AuthVpsRouteWithChildren
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,6 +539,7 @@ const AuthRouteChildren: AuthRouteChildren = {
|
|||||||
AuthSpacesRoute: AuthSpacesRoute,
|
AuthSpacesRoute: AuthSpacesRoute,
|
||||||
AuthSyncJournalRoute: AuthSyncJournalRoute,
|
AuthSyncJournalRoute: AuthSyncJournalRoute,
|
||||||
AuthTariffsRoute: AuthTariffsRoute,
|
AuthTariffsRoute: AuthTariffsRoute,
|
||||||
|
AuthTopologyRoute: AuthTopologyRoute,
|
||||||
AuthVpsRoute: AuthVpsRouteWithChildren,
|
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,
|
createdAt TEXT NOT NULL,
|
||||||
UNIQUE(vpsId, toSpaceId)
|
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 = [
|
const SPACE_BACKFILL_TABLES = [
|
||||||
@@ -135,6 +144,7 @@ const SPACE_BACKFILL_TABLES = [
|
|||||||
'sync_log',
|
'sync_log',
|
||||||
'active_tariffs',
|
'active_tariffs',
|
||||||
'tariff_sync_options',
|
'tariff_sync_options',
|
||||||
|
'topology_diagrams',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
function ensureMainSpace(sqlite: Database.Database): void {
|
function ensureMainSpace(sqlite: Database.Database): void {
|
||||||
|
|||||||
@@ -347,4 +347,17 @@ export const tariffSyncOptions = sqliteTable('tariff_sync_options', {
|
|||||||
syncedAt: text('syncedAt'),
|
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'))`
|
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/settings.js'
|
||||||
export * from './contracts/custom-fields.js'
|
export * from './contracts/custom-fields.js'
|
||||||
export * from './contracts/project.js'
|
export * from './contracts/project.js'
|
||||||
|
export * from './contracts/topology.js'
|
||||||
|
|||||||
Generated
+155
@@ -132,6 +132,9 @@ importers:
|
|||||||
'@tanstack/react-virtual':
|
'@tanstack/react-virtual':
|
||||||
specifier: ^3.14.4
|
specifier: ^3.14.4
|
||||||
version: 3.14.4([email protected]([email protected]))([email protected])
|
version: 3.14.4([email protected]([email protected]))([email protected])
|
||||||
|
'@xyflow/react':
|
||||||
|
specifier: ^12.11.2
|
||||||
|
version: 12.11.2(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -141,6 +144,9 @@ importers:
|
|||||||
date-fns:
|
date-fns:
|
||||||
specifier: ^4.4.0
|
specifier: ^4.4.0
|
||||||
version: 4.4.0
|
version: 4.4.0
|
||||||
|
html-to-image:
|
||||||
|
specifier: ^1.11.13
|
||||||
|
version: 1.11.13
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.468.0
|
specifier: ^0.468.0
|
||||||
version: 0.468.0([email protected])
|
version: 0.468.0([email protected])
|
||||||
@@ -1590,6 +1596,9 @@ packages:
|
|||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
|
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
|
||||||
|
|
||||||
@@ -1602,6 +1611,9 @@ packages:
|
|||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
|
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
|
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
|
||||||
|
|
||||||
@@ -1611,6 +1623,12 @@ packages:
|
|||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||||
|
|
||||||
@@ -1699,6 +1717,22 @@ packages:
|
|||||||
'@vitest/[email protected]':
|
'@vitest/[email protected]':
|
||||||
resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==}
|
resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==}
|
||||||
|
|
||||||
|
'@xyflow/[email protected]':
|
||||||
|
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/[email protected]':
|
||||||
|
resolution: {integrity: sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
|
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
|
||||||
|
|
||||||
@@ -1859,6 +1893,9 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1927,6 +1964,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -1947,6 +1992,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -1963,6 +2012,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
peerDependencies:
|
||||||
|
d3-selection: 2 - 3
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
|
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
|
||||||
|
|
||||||
@@ -2453,6 +2512,9 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
|
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -3508,6 +3570,21 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
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:
|
snapshots:
|
||||||
|
|
||||||
'@babel/[email protected]':
|
'@babel/[email protected]':
|
||||||
@@ -4562,6 +4639,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/[email protected]': {}
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-selection': 3.0.11
|
||||||
|
|
||||||
'@types/[email protected]': {}
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
@@ -4574,6 +4655,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/d3-time': 3.0.4
|
'@types/d3-time': 3.0.4
|
||||||
|
|
||||||
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/d3-path': 3.1.1
|
'@types/d3-path': 3.1.1
|
||||||
@@ -4582,6 +4665,15 @@ snapshots:
|
|||||||
|
|
||||||
'@types/[email protected]': {}
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-selection': 3.0.11
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-interpolate': 3.0.4
|
||||||
|
'@types/d3-selection': 3.0.11
|
||||||
|
|
||||||
'@types/[email protected]': {}
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
'@types/[email protected]': {}
|
'@types/[email protected]': {}
|
||||||
@@ -4703,6 +4795,31 @@ snapshots:
|
|||||||
loupe: 3.2.1
|
loupe: 3.2.1
|
||||||
tinyrainbow: 2.0.0
|
tinyrainbow: 2.0.0
|
||||||
|
|
||||||
|
'@xyflow/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])':
|
||||||
|
dependencies:
|
||||||
|
'@xyflow/system': 0.0.79
|
||||||
|
classcat: 5.0.5
|
||||||
|
react: 19.2.7
|
||||||
|
react-dom: 19.2.7([email protected])
|
||||||
|
zustand: 4.5.7(@types/[email protected])([email protected])([email protected])
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.17
|
||||||
|
'@types/react-dom': 19.2.3(@types/[email protected])
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- immer
|
||||||
|
|
||||||
|
'@xyflow/[email protected]':
|
||||||
|
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
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -4882,6 +4999,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]):
|
[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]):
|
||||||
@@ -4941,6 +5060,13 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
d3-dispatch: 3.0.1
|
||||||
|
d3-selection: 3.0.0
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -4959,6 +5085,8 @@ snapshots:
|
|||||||
d3-time: 3.1.0
|
d3-time: 3.1.0
|
||||||
d3-time-format: 4.1.0
|
d3-time-format: 4.1.0
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
d3-path: 3.1.0
|
d3-path: 3.1.0
|
||||||
@@ -4973,6 +5101,23 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]([email protected]):
|
||||||
|
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
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
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([email protected])
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -5498,6 +5643,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
hermes-estree: 0.25.1
|
hermes-estree: 0.25.1
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
depd: 2.0.0
|
depd: 2.0.0
|
||||||
@@ -6513,3 +6660,11 @@ snapshots:
|
|||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected](@types/[email protected])([email protected])([email protected]):
|
||||||
|
dependencies:
|
||||||
|
use-sync-external-store: 1.6.0([email protected])
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.17
|
||||||
|
immer: 11.1.8
|
||||||
|
react: 19.2.7
|
||||||
|
|||||||
Reference in New Issue
Block a user