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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-07-18 16:52:36 +07:00
co-authored by Cursor
parent c90b42c9c4
commit da818fe104
30 changed files with 2129 additions and 1 deletions
+2
View File
@@ -17,6 +17,7 @@ import { balanceLedgerRoutes } from './routes/balance-ledger.js'
import { settingsRoutes } from './routes/settings.js'
import { syncRoutes } from './routes/sync.js'
import { projectsRoutes } from './routes/projects.js'
import { topologyRoutes } from './routes/topology.js'
import { backupRoutes } from './routes/backup.js'
import { ratesProxyRoutes } from './routes/rates-proxy.js'
import { migrateRoutes } from './routes/migrate.js'
@@ -62,6 +63,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
await app.register(settingsRoutes)
await app.register(syncRoutes)
await app.register(projectsRoutes)
await app.register(topologyRoutes)
await app.register(backupRoutes)
await app.register(ratesProxyRoutes)
await app.register(migrateRoutes)
+4 -1
View File
@@ -50,13 +50,16 @@ const RULES: Rule[] = [
p === '/api/vps' ||
p.startsWith('/api/vps/') ||
p.startsWith('/api/projects') ||
p.startsWith('/api/topology') ||
p.startsWith('/api/data'),
permission: 'vps:vps:read',
},
{
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
match: (p) =>
p.startsWith('/api/vps') || p.startsWith('/api/projects'),
p.startsWith('/api/vps') ||
p.startsWith('/api/projects') ||
p.startsWith('/api/topology'),
permission: 'vps:vps:write',
},
{
+87
View File
@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { closeDb } from '@cfdm/db'
import { resetTestDb } from '@cfdm/db/test-setup'
import { topologyRepository } from '@cfdm/db/repositories/topology'
import { buildApp } from '../index.js'
describe('topology routes', () => {
let app: Awaited<ReturnType<typeof buildApp>>
beforeEach(async () => {
resetTestDb()
app = await buildApp()
})
afterEach(async () => {
await app.close()
closeDb()
})
it('lists empty diagrams', async () => {
const res = await app.inject({ method: 'GET', url: '/api/topology' })
expect(res.statusCode).toBe(200)
expect(res.json()).toEqual([])
})
it('creates and gets diagram', async () => {
const create = await app.inject({
method: 'POST',
url: '/api/topology',
payload: { name: 'Мастер' },
})
expect(create.statusCode).toBe(201)
const created = create.json() as { id: string; name: string; document: unknown }
expect(created.name).toBe('Мастер')
expect(created.document).toMatchObject({
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
})
const get = await app.inject({
method: 'GET',
url: `/api/topology/${created.id}`,
})
expect(get.statusCode).toBe(200)
expect(get.json()).toMatchObject({ id: created.id, name: 'Мастер' })
})
it('updates document and returns 409 on stale expectedUpdatedAt', async () => {
const diagram = topologyRepository.create({ name: 'Схема 1' })
const put = await app.inject({
method: 'PUT',
url: `/api/topology/${diagram.id}`,
payload: {
document: {
nodes: [{ id: 'n1', position: { x: 0, y: 0 }, data: {} }],
edges: [],
viewport: { x: 0, y: 0, zoom: 1.2 },
},
expectedUpdatedAt: diagram.updatedAt,
},
})
expect(put.statusCode).toBe(200)
const updated = put.json() as { updatedAt: string; document: { nodes: unknown[] } }
expect(updated.document.nodes).toHaveLength(1)
const stale = await app.inject({
method: 'PUT',
url: `/api/topology/${diagram.id}`,
payload: {
name: 'Stale',
expectedUpdatedAt: diagram.updatedAt,
},
})
expect(stale.statusCode).toBe(409)
})
it('deletes diagram', async () => {
const diagram = topologyRepository.create({ name: 'Delete me' })
const res = await app.inject({
method: 'DELETE',
url: `/api/topology/${diagram.id}`,
})
expect(res.statusCode).toBe(204)
expect(topologyRepository.get(diagram.id)).toBeUndefined()
})
})
+88
View File
@@ -0,0 +1,88 @@
import type { FastifyPluginAsync } from 'fastify'
import {
topologyCreateSchema,
topologyUpdateSchema,
} from '@cfdm/shared/contracts/topology'
import { topologyRepository } from '@cfdm/db/repositories/topology'
import { canWriteInSpace, requireSpaceRole } from '../plugins/space.js'
export const topologyRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/topology', async () => topologyRepository.list())
app.get<{ Params: { id: string } }>('/api/topology/:id', async (req, reply) => {
const diagram = topologyRepository.get(req.params.id)
if (!diagram) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Схема не найдена' } })
}
return diagram
})
app.post('/api/topology', async (req, reply) => {
if (!requireSpaceRole(req, reply, 'member')) return
if (!canWriteInSpace(req)) {
return reply.code(403).send({
error: { code: 'FORBIDDEN', message: 'Нет прав на запись в пространстве' },
})
}
const parsed = topologyCreateSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({
error: {
code: 'VALIDATION',
message: parsed.error.issues[0]?.message ?? 'Некорректные данные',
},
})
}
const created = topologyRepository.create(parsed.data)
return reply.code(201).send(created)
})
app.put<{ Params: { id: string } }>('/api/topology/:id', async (req, reply) => {
if (!requireSpaceRole(req, reply, 'member')) return
if (!canWriteInSpace(req)) {
return reply.code(403).send({
error: { code: 'FORBIDDEN', message: 'Нет прав на запись в пространстве' },
})
}
const parsed = topologyUpdateSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({
error: {
code: 'VALIDATION',
message: parsed.error.issues[0]?.message ?? 'Некорректные данные',
},
})
}
const result = topologyRepository.update(req.params.id, parsed.data)
if (!result.ok && result.reason === 'not_found') {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Схема не найдена' } })
}
if (!result.ok && result.reason === 'stale') {
return reply.code(409).send({
error: {
code: 'CONFLICT',
message: 'Схема изменена на другом устройстве',
},
diagram: result.current,
})
}
if (!result.ok) {
return reply.code(500).send({ error: { code: 'INTERNAL_ERROR', message: 'Ошибка обновления' } })
}
return result.diagram
})
app.delete<{ Params: { id: string } }>('/api/topology/:id', async (req, reply) => {
if (!requireSpaceRole(req, reply, 'member')) return
if (!canWriteInSpace(req)) {
return reply.code(403).send({
error: { code: 'FORBIDDEN', message: 'Нет прав на запись в пространстве' },
})
}
const ok = topologyRepository.delete(req.params.id)
if (!ok) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Схема не найдена' } })
}
return reply.code(204).send()
})
}