Добавить apiLogin из credentials, сводку и health-индикаторы на /accounts, фильтры, раздельную форму логина и пароля, безопасное удаление с 409 и тесты API/repository. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import type {
|
||||
ProviderAccount,
|
||||
Provider,
|
||||
SyncLogRow,
|
||||
BalanceLedgerRow,
|
||||
} from '@/types/entities'
|
||||
import { accountBalanceApi } from '@/lib/account'
|
||||
import { accountBillmanagerUiReady } from '@/lib/billmanager'
|
||||
import {
|
||||
accountHasApiLedgerMismatch,
|
||||
getStaleSyncAccountIds,
|
||||
} from '@/lib/inventory-health'
|
||||
|
||||
export type AccountHealthFlag = 'stale-sync' | 'low-balance' | 'balance-mismatch' | 'no-creds'
|
||||
|
||||
export const ACCOUNT_HEALTH_LABELS: Record<AccountHealthFlag, string> = {
|
||||
'stale-sync': 'Устаревший синк',
|
||||
'low-balance': 'Низкий баланс',
|
||||
'balance-mismatch': 'Расхождение ledger',
|
||||
'no-creds': 'Нет API-доступа',
|
||||
}
|
||||
|
||||
export interface AccountHealthContext {
|
||||
providers: Provider[]
|
||||
syncLog?: SyncLogRow[]
|
||||
balanceLedger?: BalanceLedgerRow[]
|
||||
}
|
||||
|
||||
export interface AtRiskAccount {
|
||||
id: string
|
||||
name: string
|
||||
reason: string
|
||||
severity: 'warning' | 'destructive'
|
||||
}
|
||||
|
||||
export function getAccountHealthFlags(
|
||||
account: ProviderAccount,
|
||||
ctx: AccountHealthContext,
|
||||
): AccountHealthFlag[] {
|
||||
const provider = ctx.providers.find((p) => p.id === account.providerId)
|
||||
const flags: AccountHealthFlag[] = []
|
||||
|
||||
if (provider?.apiType === 'billmanager' && !account.apiCredentialsSet) {
|
||||
flags.push('no-creds')
|
||||
}
|
||||
|
||||
const staleIds = new Set(getStaleSyncAccountIds([account], ctx.providers, ctx.syncLog ?? []))
|
||||
if (staleIds.has(account.id)) flags.push('stale-sync')
|
||||
|
||||
const ext = account as ProviderAccount & { balanceAlertBelow?: number | null }
|
||||
const threshold = Number(ext.balanceAlertBelow ?? 0)
|
||||
const balance = accountBalanceApi(account)
|
||||
if (Number.isFinite(threshold) && threshold > 0 && balance != null && balance < threshold) {
|
||||
flags.push('low-balance')
|
||||
}
|
||||
|
||||
if (ctx.balanceLedger && accountHasApiLedgerMismatch(account, ctx.balanceLedger)) {
|
||||
flags.push('balance-mismatch')
|
||||
}
|
||||
|
||||
return flags
|
||||
}
|
||||
|
||||
export function accountHasHealthIssues(account: ProviderAccount, ctx: AccountHealthContext): boolean {
|
||||
return getAccountHealthFlags(account, ctx).length > 0
|
||||
}
|
||||
|
||||
export function buildAtRiskAccounts(
|
||||
accounts: ProviderAccount[],
|
||||
providers: Provider[],
|
||||
syncLog: SyncLogRow[] = [],
|
||||
): AtRiskAccount[] {
|
||||
const ctx: AccountHealthContext = { providers, syncLog }
|
||||
const rows: AtRiskAccount[] = []
|
||||
for (const a of accounts) {
|
||||
const flags = getAccountHealthFlags(a, ctx)
|
||||
if (flags.includes('low-balance')) {
|
||||
rows.push({ id: a.id, name: a.name, reason: ACCOUNT_HEALTH_LABELS['low-balance'], severity: 'destructive' })
|
||||
} else if (flags.includes('stale-sync')) {
|
||||
rows.push({ id: a.id, name: a.name, reason: ACCOUNT_HEALTH_LABELS['stale-sync'], severity: 'warning' })
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
export function countAccountsWithIssues(
|
||||
accounts: ProviderAccount[],
|
||||
ctx: AccountHealthContext,
|
||||
): number {
|
||||
return accounts.filter((a) => accountHasHealthIssues(a, ctx)).length
|
||||
}
|
||||
|
||||
export function countLowBalanceAccounts(
|
||||
accounts: ProviderAccount[],
|
||||
ctx: AccountHealthContext,
|
||||
): number {
|
||||
return accounts.filter((a) => getAccountHealthFlags(a, ctx).includes('low-balance')).length
|
||||
}
|
||||
|
||||
export function isAccountSyncable(account: ProviderAccount, providers: Provider[]): boolean {
|
||||
const provider = providers.find((p) => p.id === account.providerId)
|
||||
return accountBillmanagerUiReady(account, provider)
|
||||
}
|
||||
@@ -29,8 +29,11 @@ async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T>
|
||||
if (!res.ok) {
|
||||
let message = res.statusText || 'API error'
|
||||
try {
|
||||
const data = (await res.json()) as { error?: string }
|
||||
if (data?.error) message = data.error
|
||||
const data = (await res.json()) as {
|
||||
error?: string | { message?: string; code?: string }
|
||||
}
|
||||
if (typeof data?.error === 'string') message = data.error
|
||||
else if (data?.error?.message) message = data.error.message
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
@@ -96,6 +96,22 @@ export function billingModeLabel(mode: string): string {
|
||||
return BILLING_MODE_LABELS[mode] ?? mode
|
||||
}
|
||||
|
||||
/** Относительное время для дат синка и обновлений. */
|
||||
export function formatRelativeTime(isoOrMs: string | number | null | undefined): string {
|
||||
if (isoOrMs == null) return '—'
|
||||
const t = typeof isoOrMs === 'number' ? isoOrMs : new Date(isoOrMs).getTime()
|
||||
if (Number.isNaN(t)) return '—'
|
||||
const diffMs = Date.now() - t
|
||||
if (diffMs < 0) return 'только что'
|
||||
const mins = Math.floor(diffMs / 60_000)
|
||||
if (mins < 1) return 'только что'
|
||||
if (mins < 60) return `${mins} мин назад`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 48) return `${hours} ч назад`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days} дн назад`
|
||||
}
|
||||
|
||||
const TARIFF_TYPE_LABELS: Record<string, string> = {
|
||||
daily: 'Суточный', monthly: 'Месячный',
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { z } from 'zod'
|
||||
import { billingModeSchema as sharedBillingModeSchema } from '@cfdm/shared/contracts/provider-account'
|
||||
|
||||
export const vpsStatusSchema = z.enum(['active', 'paused', 'archived'])
|
||||
export const tariffTypeSchema = z.enum(['daily', 'monthly'])
|
||||
export const billingModeSchema = z.enum(['daily', 'monthly'])
|
||||
export const billingModeSchema = sharedBillingModeSchema
|
||||
export const paymentTypeSchema = z.enum([
|
||||
'direct_vps_payment',
|
||||
'provider_balance_topup',
|
||||
@@ -30,8 +31,8 @@ export const providerAccountSchema = z.object({
|
||||
id: z.string().min(1).optional(),
|
||||
providerId: z.string().min(1, 'Выберите хостера'),
|
||||
name: z.string().min(1, 'Название обязательно'),
|
||||
login: z.string().optional().default(''),
|
||||
apiCredentials: z.string().optional().default(''),
|
||||
apiLogin: z.string().optional().default(''),
|
||||
apiPassword: z.string().optional().default(''),
|
||||
billingMode: billingModeSchema.default('monthly'),
|
||||
balanceAlertBelow: z.union([z.coerce.number().min(0), z.literal('')]).optional(),
|
||||
notes: z.string().optional().default(''),
|
||||
|
||||
Reference in New Issue
Block a user