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

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

Co-authored-by: Cursor <[email protected]>
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
+151
View File
@@ -0,0 +1,151 @@
import { randomUUID } from 'node:crypto'
import { and, asc, eq } from 'drizzle-orm'
import {
EMPTY_TOPOLOGY_DOCUMENT,
type TopologyDocument,
type TopologyUpdateInput,
} from '@cfdm/shared/contracts/topology'
import { getDb, schema } from '../index.js'
import { getCurrentSpaceId } from '../space-context.js'
function parseDocument(raw: string): TopologyDocument {
try {
const parsed = JSON.parse(raw) as TopologyDocument
if (!parsed || !Array.isArray(parsed.nodes) || !Array.isArray(parsed.edges)) {
return { ...EMPTY_TOPOLOGY_DOCUMENT }
}
return {
nodes: parsed.nodes,
edges: parsed.edges,
viewport: parsed.viewport ?? { x: 0, y: 0, zoom: 1 },
}
} catch {
return { ...EMPTY_TOPOLOGY_DOCUMENT }
}
}
function toDto(row: typeof schema.topologyDiagrams.$inferSelect) {
return {
id: row.id,
spaceId: row.spaceId,
name: row.name,
document: parseDocument(row.document),
locked: row.locked === 1,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}
}
function toListItem(row: typeof schema.topologyDiagrams.$inferSelect) {
return {
id: row.id,
spaceId: row.spaceId,
name: row.name,
locked: row.locked === 1,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}
}
export const topologyRepository = {
list() {
const spaceId = getCurrentSpaceId()
return getDb()
.select()
.from(schema.topologyDiagrams)
.where(eq(schema.topologyDiagrams.spaceId, spaceId))
.orderBy(asc(schema.topologyDiagrams.createdAt))
.all()
.map(toListItem)
},
get(id: string) {
const spaceId = getCurrentSpaceId()
const row = getDb()
.select()
.from(schema.topologyDiagrams)
.where(
and(
eq(schema.topologyDiagrams.id, id),
eq(schema.topologyDiagrams.spaceId, spaceId),
),
)
.get()
return row ? toDto(row) : undefined
},
create(input: { name: string; document?: TopologyDocument }) {
const id = `topo-${randomUUID()}`
const now = new Date().toISOString()
const document = input.document ?? { ...EMPTY_TOPOLOGY_DOCUMENT }
getDb()
.insert(schema.topologyDiagrams)
.values({
id,
spaceId: getCurrentSpaceId(),
name: input.name.trim(),
document: JSON.stringify(document),
locked: 0,
createdAt: now,
updatedAt: now,
})
.run()
return this.get(id)!
},
update(id: string, input: TopologyUpdateInput) {
const existing = this.get(id)
if (!existing) return { ok: false as const, reason: 'not_found' as const }
if (
input.expectedUpdatedAt &&
input.expectedUpdatedAt !== existing.updatedAt
) {
return { ok: false as const, reason: 'stale' as const, current: existing }
}
const now = new Date().toISOString()
getDb()
.update(schema.topologyDiagrams)
.set({
name: input.name !== undefined ? input.name.trim() : existing.name,
document:
input.document !== undefined
? JSON.stringify(input.document)
: JSON.stringify(existing.document),
locked:
input.locked !== undefined
? input.locked
? 1
: 0
: existing.locked
? 1
: 0,
updatedAt: now,
})
.where(
and(
eq(schema.topologyDiagrams.id, id),
eq(schema.topologyDiagrams.spaceId, existing.spaceId),
),
)
.run()
return { ok: true as const, diagram: this.get(id)! }
},
delete(id: string): boolean {
const existing = this.get(id)
if (!existing) return false
const r = getDb()
.delete(schema.topologyDiagrams)
.where(
and(
eq(schema.topologyDiagrams.id, id),
eq(schema.topologyDiagrams.spaceId, existing.spaceId),
),
)
.run()
return r.changes > 0
},
}
+10
View File
@@ -117,6 +117,15 @@ const TABLE_MIGRATIONS: string[] = [
createdAt TEXT NOT NULL,
UNIQUE(vpsId, toSpaceId)
)`,
`CREATE TABLE IF NOT EXISTS topology_diagrams (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main' REFERENCES spaces(id),
name TEXT NOT NULL,
document TEXT NOT NULL DEFAULT '{"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}',
locked INTEGER NOT NULL DEFAULT 0,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`,
]
const SPACE_BACKFILL_TABLES = [
@@ -135,6 +144,7 @@ const SPACE_BACKFILL_TABLES = [
'sync_log',
'active_tariffs',
'tariff_sync_options',
'topology_diagrams',
] as const
function ensureMainSpace(sqlite: Database.Database): void {
+13
View File
@@ -347,4 +347,17 @@ export const tariffSyncOptions = sqliteTable('tariff_sync_options', {
syncedAt: text('syncedAt'),
})
export const topologyDiagrams = sqliteTable('topology_diagrams', {
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
name: text('name').notNull(),
document: text('document').notNull().default('{"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}'),
locked: integer('locked').notNull().default(0),
createdAt: text('createdAt').notNull(),
updatedAt: text('updatedAt').notNull(),
})
export const now = sql`(datetime('now'))`