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
+19
View File
@@ -150,6 +150,25 @@ export const api = {
method: 'POST',
body: JSON.stringify({ name }),
}),
updateProject: (id: string, patch: { name?: string; color?: string | null; notes?: string | null }) =>
fetchApi<{ id: string; name: string }>(`/api/projects/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(patch),
}),
deleteProject: (id: string) =>
fetchApi<void>(`/api/projects/${encodeURIComponent(id)}`, { method: 'DELETE' }),
fetchAuditLog: (limit = 100) =>
fetchApi<Array<{
id: string
entity: string
entityId: string
action: string
diff: Record<string, unknown> | null
createdAt: string
}>>(`/api/audit?limit=${limit}`),
}
export type {
+34
View File
@@ -0,0 +1,34 @@
export interface CustomFieldDef {
key: string
label: string
type?: 'text' | 'number' | 'bool'
}
export function parseCustomFieldDefs(raw: unknown): CustomFieldDef[] {
if (!Array.isArray(raw)) return []
return raw
.filter((item): item is Record<string, unknown> => item != null && typeof item === 'object')
.map((item) => ({
key: String(item.key ?? '').trim(),
label: String(item.label ?? item.key ?? '').trim(),
type: (item.type as CustomFieldDef['type']) ?? 'text',
}))
.filter((f) => f.key.length > 0)
}
export function parseCustomData(raw: unknown): Record<string, string | number | boolean> {
if (typeof raw === 'string' && raw.trim()) {
try {
const parsed = JSON.parse(raw) as unknown
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, string | number | boolean>
}
} catch {
return {}
}
}
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
return raw as Record<string, string | number | boolean>
}
return {}
}
+10
View File
@@ -57,6 +57,8 @@ export const vpsSchema = z.object({
paidUntil: z.string().optional().default(''),
project: z.string().optional().default(''),
notes: z.string().optional().default(''),
userOverrides: z.array(z.string()).optional().default([]),
customData: z.record(z.union([z.string(), z.number(), z.boolean()])).optional().default({}),
})
export const paymentSchema = z.object({
@@ -66,6 +68,7 @@ export const paymentSchema = z.object({
amount: z.coerce.number().min(0, 'Сумма должна быть ≥ 0'),
currency: z.string().min(1).default('RUB'),
providerAccountId: z.string().min(1, 'Выберите аккаунт'),
vpsId: z.string().optional().default(''),
note: z.string().optional().default(''),
})
@@ -88,14 +91,21 @@ export const settingsSchema = z.object({
syncTariffsIntervalMinutes: z.coerce.number().min(60).optional().default(1440),
telegramChatId: z.string().optional().default(''),
telegramBotToken: z.string().optional().default(''),
telegramMessageThreadId: z.string().optional().default(''),
notifyPaymentExpiryEnabled: z.boolean().optional().default(true),
notifyNewTariffsEnabled: z.boolean().optional().default(true),
notifyLowBalanceEnabled: z.boolean().optional().default(true),
notifySyncDigestEnabled: z.boolean().optional().default(true),
notifyVpsDownEnabled: z.boolean().optional().default(true),
webhookUrl: z.string().url('Невалидный URL').or(z.literal('')).optional().default(''),
webhookEnabled: z.boolean().optional().default(false),
customFieldsJson: z.string().optional().default('[]'),
})
export const projectSchema = z.object({
id: z.string().optional(),
name: z.string().min(1, 'Укажите название проекта').max(120),
color: z.string().optional().default(''),
})
export type ProjectFormValues = z.infer<typeof projectSchema>
+59
View File
@@ -0,0 +1,59 @@
import type { ActiveTariff, Vps } from '@/types/entities'
export interface TariffVpsDiff {
vpsId: string
vpsLabel: string
tariffName: string
issues: string[]
}
function normName(s: string | null | undefined): string {
return (s || '').trim().toLowerCase()
}
export function findMatchingTariff(
vps: Vps,
tariffs: ActiveTariff[],
): ActiveTariff | undefined {
const byName = tariffs.filter(
(t) =>
t.providerAccountId === vps.providerAccountId &&
normName(t.name) === normName(vps.tariffType),
)
if (byName.length === 1) return byName[0]
return tariffs.find(
(t) =>
t.providerAccountId === vps.providerAccountId &&
t.vcpu === vps.vcpu &&
t.ramGb === vps.ramGb &&
t.diskGb === vps.diskGb,
)
}
export function computeTariffDiffs(vpsList: Vps[], tariffs: ActiveTariff[]): TariffVpsDiff[] {
const active = vpsList.filter((v) => v.status === 'active')
const out: TariffVpsDiff[] = []
for (const v of active) {
const tariff = findMatchingTariff(v, tariffs)
if (!tariff) continue
const issues: string[] = []
if (tariff.vcpu != null && v.vcpu !== tariff.vcpu) {
issues.push(`vCPU: факт ${v.vcpu}, тариф ${tariff.vcpu}`)
}
if (tariff.ramGb != null && Number(v.ramGb) !== Number(tariff.ramGb)) {
issues.push(`RAM: факт ${v.ramGb} GB, тариф ${tariff.ramGb} GB`)
}
if (tariff.diskGb != null && v.diskGb !== tariff.diskGb) {
issues.push(`Disk: факт ${v.diskGb} GB, тариф ${tariff.diskGb} GB`)
}
if (issues.length) {
out.push({
vpsId: v.id,
vpsLabel: v.ip || v.dns || v.id,
tariffName: tariff.name || String(tariff.pricelistId ?? ''),
issues,
})
}
}
return out
}
+29
View File
@@ -0,0 +1,29 @@
/** Поля VPS, которые BILLmanager-синк может перезаписывать (см. sync.ts). */
export const VPS_SYNC_OVERRIDE_FIELDS = [
{ key: 'country', label: 'Страна' },
{ key: 'city', label: 'Город' },
{ key: 'datacenter', label: 'Дата-центр' },
{ key: 'os', label: 'ОС' },
{ key: 'notes', label: 'Заметки' },
{ key: 'status', label: 'Статус' },
{ key: 'tariffType', label: 'Тип тарифа' },
{ key: 'currency', label: 'Валюта' },
{ key: 'dailyRate', label: 'Ставка/день' },
{ key: 'monthlyRate', label: 'Ставка/мес' },
{ key: 'paidUntil', label: 'Оплачено до' },
] as const
export type VpsSyncOverrideField = (typeof VPS_SYNC_OVERRIDE_FIELDS)[number]['key']
export function parseUserOverrides(raw: unknown): string[] {
if (Array.isArray(raw)) return raw.filter((x) => typeof x === 'string')
if (typeof raw === 'string' && raw.trim()) {
try {
const parsed = JSON.parse(raw) as unknown
if (Array.isArray(parsed)) return parsed.filter((x) => typeof x === 'string')
} catch {
return []
}
}
return []
}