feat: расширить инвентарь, ops и интеграции по roadmap
Docker / build (push) Has been cancelled

Добавлены bulk-операции VPS, карточка /vps/:id, CRUD проектов, Command Palette,
календарь продлений, webhooks, uptime-проверки, audit log, кастомные поля и адаптеры провайдеров.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-28 15:28:35 +07:00
co-authored by Cursor
parent 0ea92746e4
commit 11b67a1cef
43 changed files with 2085 additions and 79 deletions
+2
View File
@@ -3,6 +3,7 @@ import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import * as schema from './schema/index.js'
import { ensureRuntimeSchema } from './runtime-migrate.js'
export type Db = BetterSQLite3Database<typeof schema>
@@ -29,6 +30,7 @@ function openDatabase(): void {
_sqlite.pragma('journal_mode = WAL')
_sqlite.pragma('foreign_keys = ON')
_db = drizzle(_sqlite, { schema })
ensureRuntimeSchema(_sqlite)
}
export function getSqlite(): Database.Database {
+56
View File
@@ -0,0 +1,56 @@
import { randomUUID } from 'node:crypto'
import { and, desc, eq } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
export interface AuditEntryInput {
entity: string
entityId: string
action: 'create' | 'update' | 'delete'
diff?: Record<string, unknown>
}
function parseDiff(row: { diff: string | null }) {
if (!row.diff) return null
try {
return JSON.parse(row.diff) as Record<string, unknown>
} catch {
return null
}
}
export const auditLogRepository = {
append(input: AuditEntryInput): void {
getDb()
.insert(schema.auditLog)
.values({
id: `audit-${randomUUID()}`,
entity: input.entity,
entityId: input.entityId,
action: input.action,
diff: input.diff ? JSON.stringify(input.diff) : null,
createdAt: new Date().toISOString(),
})
.run()
},
list(limit = 100) {
return getDb()
.select()
.from(schema.auditLog)
.orderBy(desc(schema.auditLog.createdAt))
.limit(Math.min(500, Math.max(1, limit)))
.all()
.map((row) => ({ ...row, diff: parseDiff(row) }))
},
listForEntity(entity: string, entityId: string, limit = 50) {
return getDb()
.select()
.from(schema.auditLog)
.where(and(eq(schema.auditLog.entity, entity), eq(schema.auditLog.entityId, entityId)))
.orderBy(desc(schema.auditLog.createdAt))
.limit(limit)
.all()
.map((row) => ({ ...row, diff: parseDiff(row) }))
},
}
+19 -1
View File
@@ -3,7 +3,7 @@ import { getDb, schema } from '../index.js'
type Row = typeof schema.settings.$inferSelect
export type SettingsDto = Omit<Row, 'telegramBotToken' | 'autoConvert' | 'syncEnabled' | 'notifyPaymentExpiryEnabled' | 'notifyNewTariffsEnabled' | 'notifyLowBalanceEnabled' | 'notifySyncDigestEnabled' | 'customFields'> & {
export type SettingsDto = Omit<Row, 'telegramBotToken' | 'autoConvert' | 'syncEnabled' | 'notifyPaymentExpiryEnabled' | 'notifyNewTariffsEnabled' | 'notifyLowBalanceEnabled' | 'notifySyncDigestEnabled' | 'notifyVpsDownEnabled' | 'webhookEnabled' | 'customFields'> & {
telegramBotTokenSet: boolean
autoConvert: boolean
syncEnabled: boolean
@@ -11,6 +11,8 @@ export type SettingsDto = Omit<Row, 'telegramBotToken' | 'autoConvert' | 'syncEn
notifyNewTariffsEnabled: boolean
notifyLowBalanceEnabled: boolean
notifySyncDigestEnabled: boolean
notifyVpsDownEnabled: boolean
webhookEnabled: boolean
customFields: unknown[]
}
@@ -34,6 +36,8 @@ function toDto(row: Row | undefined): SettingsDto | undefined {
notifyNewTariffsEnabled: Boolean(row.notifyNewTariffsEnabled),
notifyLowBalanceEnabled: Boolean(row.notifyLowBalanceEnabled),
notifySyncDigestEnabled: Boolean(row.notifySyncDigestEnabled),
notifyVpsDownEnabled: Boolean(row.notifyVpsDownEnabled),
webhookEnabled: Boolean(row.webhookEnabled),
customFields: Array.isArray(customFields) ? customFields : [],
}
}
@@ -60,6 +64,9 @@ interface SettingsInput {
notifyNewTariffsEnabled?: boolean
notifyLowBalanceEnabled?: boolean
notifySyncDigestEnabled?: boolean
notifyVpsDownEnabled?: boolean
webhookUrl?: string
webhookEnabled?: boolean
customFields?: unknown
}
@@ -121,6 +128,17 @@ function buildValues(id: string, existing: Row | undefined, r: SettingsInput) {
: existing?.notifySyncDigestEnabled
? 1
: 0,
notifyVpsDownEnabled:
r.notifyVpsDownEnabled !== undefined
? r.notifyVpsDownEnabled
? 1
: 0
: existing?.notifyVpsDownEnabled
? 1
: 0,
webhookUrl: r.webhookUrl !== undefined ? r.webhookUrl || '' : existing?.webhookUrl ?? '',
webhookEnabled:
r.webhookEnabled !== undefined ? (r.webhookEnabled ? 1 : 0) : existing?.webhookEnabled ? 1 : 0,
customFields: serializeCustomFields(r.customFields ?? existing?.customFields),
}
}
+11
View File
@@ -81,6 +81,7 @@ interface VpsInput {
paidUntil?: string
notes?: string
userOverrides?: string[] | 'clear'
customData?: string | Record<string, unknown>
}
function projectColumnsForSave(projectInput: unknown): { project: string; projectId: string } {
@@ -99,6 +100,12 @@ function boolToInt(v: unknown): number {
return v ? 1 : 0
}
function serializeCustomData(v: unknown): string | null {
if (v == null) return null
if (typeof v === 'string') return v || null
return JSON.stringify(v)
}
export const vpsRepository = {
list(): VpsDto[] {
const rows = getDb().select().from(schema.vps).orderBy(desc(schema.vps.createdAt)).all()
@@ -153,6 +160,7 @@ export const vpsRepository = {
userOverrides: input.userOverrides && Array.isArray(input.userOverrides)
? JSON.stringify(input.userOverrides)
: '[]',
customData: serializeCustomData(input.customData),
})
.run()
return this.get(finalId)!
@@ -248,6 +256,9 @@ export const vpsRepository = {
paidUntil: input.paidUntil ?? '',
notes: input.notes ?? '',
userOverrides: userOverridesJson,
...(input.customData !== undefined
? { customData: serializeCustomData(input.customData) }
: {}),
})
.where(eq(schema.vps.id, id))
.run()
+46
View File
@@ -0,0 +1,46 @@
import type Database from 'better-sqlite3'
const COLUMN_MIGRATIONS: string[] = [
`ALTER TABLE vps ADD COLUMN customData TEXT`,
`ALTER TABLE vps ADD COLUMN last_health_status TEXT`,
`ALTER TABLE vps ADD COLUMN last_health_checked_at TEXT`,
`ALTER TABLE settings ADD COLUMN notifyVpsDownEnabled INTEGER`,
`ALTER TABLE settings ADD COLUMN webhookUrl TEXT`,
`ALTER TABLE settings ADD COLUMN webhookEnabled INTEGER`,
]
const TABLE_MIGRATIONS: string[] = [
`CREATE TABLE IF NOT EXISTS vps_health_checks (
id TEXT PRIMARY KEY,
vpsId TEXT NOT NULL REFERENCES vps(id),
checkedAt TEXT NOT NULL,
status TEXT NOT NULL,
latencyMs INTEGER,
error TEXT
)`,
`CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY,
entity TEXT NOT NULL,
entityId TEXT NOT NULL,
action TEXT NOT NULL,
diff TEXT,
createdAt TEXT NOT NULL
)`,
]
let migrated = false
export function ensureRuntimeSchema(sqlite: Database.Database): void {
if (migrated) return
for (const sql of TABLE_MIGRATIONS) {
sqlite.exec(sql)
}
for (const sql of COLUMN_MIGRATIONS) {
try {
sqlite.exec(sql)
} catch {
/* column exists */
}
}
migrated = true
}
+26
View File
@@ -78,6 +78,9 @@ export const vps = sqliteTable('vps', {
paidUntil: text('paidUntil'),
notes: text('notes'),
userOverrides: text('userOverrides'),
customData: text('customData'),
lastHealthStatus: text('last_health_status'),
lastHealthCheckedAt: text('last_health_checked_at'),
})
export const payments = sqliteTable('payments', {
@@ -120,6 +123,29 @@ export const settings = sqliteTable('settings', {
telegramMessageThreadId: text('telegramMessageThreadId'),
notifyLowBalanceEnabled: integer('notifyLowBalanceEnabled'),
notifySyncDigestEnabled: integer('notifySyncDigestEnabled'),
notifyVpsDownEnabled: integer('notifyVpsDownEnabled'),
webhookUrl: text('webhookUrl'),
webhookEnabled: integer('webhookEnabled'),
})
export const vpsHealthChecks = sqliteTable('vps_health_checks', {
id: text('id').primaryKey(),
vpsId: text('vpsId')
.notNull()
.references(() => vps.id),
checkedAt: text('checkedAt').notNull(),
status: text('status').notNull(),
latencyMs: integer('latencyMs'),
error: text('error'),
})
export const auditLog = sqliteTable('audit_log', {
id: text('id').primaryKey(),
entity: text('entity').notNull(),
entityId: text('entityId').notNull(),
action: text('action').notNull(),
diff: text('diff'),
createdAt: text('createdAt').notNull(),
})
export const syncLog = sqliteTable('sync_log', {
@@ -16,6 +16,9 @@ export const settingsSchema = z.object({
notifyNewTariffsEnabled: z.boolean().optional(),
notifyLowBalanceEnabled: z.boolean().optional(),
notifySyncDigestEnabled: z.boolean().optional(),
notifyVpsDownEnabled: z.boolean().optional(),
webhookUrl: z.string().url('Невалидный URL').or(z.literal('')).optional(),
webhookEnabled: z.boolean().optional(),
customFields: z.any().optional(),
})
+1
View File
@@ -35,6 +35,7 @@ export const vpsSchema = z.object({
paidUntil: z.string().optional().default(''),
notes: z.string().optional().default(''),
userOverrides: z.union([z.array(z.string()), z.literal('clear')]).optional(),
customData: z.union([z.string(), z.record(z.unknown())]).optional(),
})
export type VpsFormValues = z.infer<typeof vpsSchema>