Добавлены bulk-операции VPS, карточка /vps/:id, CRUD проектов, Command Palette, календарь продлений, webhooks, uptime-проверки, audit log, кастомные поля и адаптеры провайдеров. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 []
|
||||
}
|
||||
Reference in New Issue
Block a user