diff --git a/apps/api/src/routes/provider-accounts.test.ts b/apps/api/src/routes/provider-accounts.test.ts new file mode 100644 index 0000000..07289c0 --- /dev/null +++ b/apps/api/src/routes/provider-accounts.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { closeDb } from '@cfdm/db' +import { resetTestDb, seedTestProvider } from '@cfdm/db/test-setup' +import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accounts' +import { buildApp } from '../index.js' +import { getSqlite } from '@cfdm/db' + +describe('provider-accounts routes', () => { + let app: Awaited> + + beforeEach(async () => { + resetTestDb() + seedTestProvider() + app = await buildApp() + }) + + afterEach(async () => { + await app.close() + closeDb() + }) + + it('creates account and returns apiLogin', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/provider-accounts', + payload: { + providerId: 'prov-1', + name: 'Primary', + apiCredentials: 'login:password', + billingMode: 'monthly', + }, + }) + expect(res.statusCode).toBe(201) + const body = res.json() as { apiLogin?: string; apiCredentialsSet?: boolean } + expect(body.apiLogin).toBe('login') + expect(body.apiCredentialsSet).toBe(true) + }) + + it('returns 409 when deleting account with VPS', async () => { + providerAccountsRepository.create({ + id: 'acc-del', + providerId: 'prov-1', + name: 'Bound', + }) + getSqlite() + .prepare( + `INSERT INTO vps (id, ip, providerId, providerAccountId, status) VALUES ('vps-x', '2.2.2.2', 'prov-1', 'acc-del', 'active')`, + ) + .run() + + const res = await app.inject({ + method: 'DELETE', + url: '/api/provider-accounts/acc-del', + }) + expect(res.statusCode).toBe(409) + const body = res.json() as { error?: { code?: string; dependencies?: { vps?: number } } } + expect(body.error?.code).toBe('CONFLICT') + expect(body.error?.dependencies?.vps).toBe(1) + }) + + it('deletes account without dependencies', async () => { + providerAccountsRepository.create({ + id: 'acc-free', + providerId: 'prov-1', + name: 'Free', + }) + const res = await app.inject({ + method: 'DELETE', + url: '/api/provider-accounts/acc-free', + }) + expect(res.statusCode).toBe(204) + expect(providerAccountsRepository.get('acc-free')).toBeUndefined() + }) +}) diff --git a/apps/api/src/routes/provider-accounts.ts b/apps/api/src/routes/provider-accounts.ts index 8fc71dc..efa2934 100644 --- a/apps/api/src/routes/provider-accounts.ts +++ b/apps/api/src/routes/provider-accounts.ts @@ -2,6 +2,16 @@ import type { FastifyPluginAsync } from 'fastify' import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accounts' import { providerAccountSchema } from '@cfdm/shared/contracts/provider-account' +function formatDependencyMessage(deps: ReturnType): string { + const parts: string[] = [] + if (deps.vps) parts.push(`VPS: ${deps.vps}`) + if (deps.payments) parts.push(`платежи: ${deps.payments}`) + if (deps.balanceLedger) parts.push(`журнал баланса: ${deps.balanceLedger}`) + if (deps.activeTariffs) parts.push(`тарифы: ${deps.activeTariffs}`) + if (deps.syncLog) parts.push(`записи синка: ${deps.syncLog}`) + return parts.length ? `Аккаунт используется (${parts.join(', ')})` : 'Аккаунт используется связанными записями' +} + export const providerAccountsRoutes: FastifyPluginAsync = async (app) => { app.get('/api/provider-accounts', async () => providerAccountsRepository.list()) @@ -27,10 +37,27 @@ export const providerAccountsRoutes: FastifyPluginAsync = async (app) => { }) app.delete<{ Params: { id: string } }>('/api/provider-accounts/:id', async (req, reply) => { - const ok = providerAccountsRepository.delete(req.params.id) - if (!ok) { + const existing = providerAccountsRepository.get(req.params.id) + if (!existing) { return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } }) } + const dependencies = providerAccountsRepository.getDependencyCounts(req.params.id) + const total = + dependencies.vps + + dependencies.payments + + dependencies.balanceLedger + + dependencies.activeTariffs + + dependencies.syncLog + if (total > 0) { + return reply.code(409).send({ + error: { + code: 'CONFLICT', + message: formatDependencyMessage(dependencies), + dependencies, + }, + }) + } + providerAccountsRepository.delete(req.params.id) return reply.code(204).send() }) } diff --git a/apps/web/src/components/account-filters-toolbar.tsx b/apps/web/src/components/account-filters-toolbar.tsx new file mode 100644 index 0000000..3aee75f --- /dev/null +++ b/apps/web/src/components/account-filters-toolbar.tsx @@ -0,0 +1,95 @@ +import { SearchIcon, XIcon } from 'lucide-react' + +import { Input } from '@cfdm/ui/components/input' +import { Button } from '@cfdm/ui/components/button' +import { Checkbox } from '@cfdm/ui/components/checkbox' +import { SelectField } from '@/components/select-field' +import { + type AccountFiltersState, + buildDefaultAccountFilters, + hasActiveAccountFilters, +} from '@/components/account-filters' +import { billingModeLabel } from '@/lib/format' +import type { Provider } from '@/types/entities' + +interface AccountFiltersToolbarProps { + filters: AccountFiltersState + onChange: (next: AccountFiltersState) => void + providers: Provider[] +} + +export function AccountFiltersToolbar({ filters, onChange, providers }: AccountFiltersToolbarProps) { + const active = hasActiveAccountFilters(filters) + + return ( +
+
+
+ + onChange({ ...filters, search: e.target.value })} + /> +
+ onChange({ ...filters, providerIds: v ? [v] : [] })} + options={providers.map((p) => ({ value: p.id, label: p.name }))} + /> + + onChange({ + ...filters, + billingMode: (v === 'daily' || v === 'monthly' ? v : '') as AccountFiltersState['billingMode'], + }) + } + options={[ + { value: 'monthly', label: billingModeLabel('monthly') }, + { value: 'daily', label: billingModeLabel('daily') }, + ]} + /> + {active ? ( + + ) : null} +
+
+ + + +
+
+ ) +} diff --git a/apps/web/src/components/account-filters.ts b/apps/web/src/components/account-filters.ts new file mode 100644 index 0000000..5d80917 --- /dev/null +++ b/apps/web/src/components/account-filters.ts @@ -0,0 +1,59 @@ +import type { ProviderAccount, Provider } from '@/types/entities' +import { + getAccountHealthFlags, + isAccountSyncable, + type AccountHealthContext, +} from '@/lib/account-health' + +export interface AccountFiltersState { + search: string + providerIds: string[] + billingMode: '' | 'daily' | 'monthly' + syncableOnly: boolean + issuesOnly: boolean + lowBalanceOnly: boolean +} + +export function buildDefaultAccountFilters(): AccountFiltersState { + return { + search: '', + providerIds: [], + billingMode: '', + syncableOnly: false, + issuesOnly: false, + lowBalanceOnly: false, + } +} + +export function hasActiveAccountFilters(filters: AccountFiltersState): boolean { + return Boolean( + filters.search.trim() || + filters.providerIds.length || + filters.billingMode || + filters.syncableOnly || + filters.issuesOnly || + filters.lowBalanceOnly, + ) +} + +export function applyAccountFilters( + accounts: ProviderAccount[], + filters: AccountFiltersState, + providers: Provider[], + healthCtx: AccountHealthContext, +): ProviderAccount[] { + const q = filters.search.trim().toLowerCase() + return accounts.filter((account) => { + if (q) { + const hay = `${account.name} ${account.apiLogin ?? ''}`.toLowerCase() + if (!hay.includes(q)) return false + } + if (filters.providerIds.length && !filters.providerIds.includes(account.providerId)) return false + if (filters.billingMode && (account.billingMode ?? 'monthly') !== filters.billingMode) return false + if (filters.syncableOnly && !isAccountSyncable(account, providers)) return false + const flags = getAccountHealthFlags(account, healthCtx) + if (filters.issuesOnly && flags.length === 0) return false + if (filters.lowBalanceOnly && !flags.includes('low-balance')) return false + return true + }) +} diff --git a/apps/web/src/components/domain/account-edit-sheet.tsx b/apps/web/src/components/domain/account-edit-sheet.tsx index 26cfd54..d7f21d5 100644 --- a/apps/web/src/components/domain/account-edit-sheet.tsx +++ b/apps/web/src/components/domain/account-edit-sheet.tsx @@ -1,6 +1,7 @@ import { useMutation } from '@tanstack/react-query' import { PlugIcon, RefreshCwIcon } from 'lucide-react' import { toast } from 'sonner' +import { buildApiCredentials } from '@cfdm/shared/utils/api-credentials' import { FormSheetRhf } from '@/components/form-sheet-rhf' import { FormField } from '@/components/form-field' @@ -17,8 +18,8 @@ import { api, ApiError } from '@/lib/api-client' const EMPTY: ProviderAccountFormValues = { providerId: '', name: '', - login: '', - apiCredentials: '', + apiLogin: '', + apiPassword: '', billingMode: 'monthly', balanceAlertBelow: '', notes: '', @@ -91,8 +92,10 @@ export function ProviderAccountEditSheet({ const providerId = watch('providerId') const provider = providers.find((p) => p.id === providerId) const apiBaseUrl = (provider?.apiBaseUrl ?? '').trim() - const creds = watch('apiCredentials')?.trim() ?? '' - const canTest = Boolean(apiBaseUrl && creds) + const apiLogin = watch('apiLogin')?.trim() ?? '' + const apiPassword = watch('apiPassword')?.trim() ?? '' + const apiCredentials = buildApiCredentials(apiLogin, apiPassword) + const canTest = Boolean(apiBaseUrl && apiCredentials) return ( <> @@ -108,15 +111,15 @@ export function ProviderAccountEditSheet({ - - + + - +
testMut.mutate({ apiBaseUrl, apiCredentials: creds })} + onClick={() => testMut.mutate({ apiBaseUrl, apiCredentials })} > Проверить подключение diff --git a/apps/web/src/lib/account-health.ts b/apps/web/src/lib/account-health.ts new file mode 100644 index 0000000..542a15f --- /dev/null +++ b/apps/web/src/lib/account-health.ts @@ -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 = { + '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) +} diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 8378670..158c7e2 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -29,8 +29,11 @@ async function fetchApi(path: string, options: RequestInit = {}): Promise 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 */ } diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts index 5ebd8da..10abb58 100644 --- a/apps/web/src/lib/format.ts +++ b/apps/web/src/lib/format.ts @@ -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 = { daily: 'Суточный', monthly: 'Месячный', } diff --git a/apps/web/src/lib/schemas.ts b/apps/web/src/lib/schemas.ts index 72549d2..2da59bf 100644 --- a/apps/web/src/lib/schemas.ts +++ b/apps/web/src/lib/schemas.ts @@ -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(''), diff --git a/apps/web/src/routes/_auth/accounts.tsx b/apps/web/src/routes/_auth/accounts.tsx index 6a4119a..6e61f8e 100644 --- a/apps/web/src/routes/_auth/accounts.tsx +++ b/apps/web/src/routes/_auth/accounts.tsx @@ -9,9 +9,13 @@ import { PlugIcon, ReceiptIcon, WalletIcon, + ActivityIcon, + ClockIcon, + ServerIcon, } from 'lucide-react' import { toast } from 'sonner' import { z } from 'zod' +import { buildApiCredentials } from '@cfdm/shared/utils/api-credentials' import { snapshotQueryOptions } from '@/queries/snapshot' import { api, ApiError } from '@/lib/api-client' @@ -23,6 +27,7 @@ import { dataGridCellStack } from '@/components/data-grid-cells' import { CrudListPage } from '@/components/crud-list-page' import { RowActions } from '@/components/row-actions' import { HealthModeBanner } from '@/components/health-mode-banner' +import { SectionCards } from '@/components/section-cards' import { ProviderAccountEditSheet, providerAccountFormDefaults, @@ -31,16 +36,38 @@ import type { ProviderAccountFormValues } from '@/lib/schemas' import { accountBalanceApi, accountBalanceCurrency } from '@/lib/account' import type { ProviderAccount } from '@/types/entities' import { providerByIdMap, accountBillmanagerUiReady, billmanagerSyncableAccounts } from '@/lib/billmanager' -import { billingModeLabel, formatCurrency } from '@/lib/format' +import { billingModeLabel, formatCurrency, formatRelativeTime } from '@/lib/format' import { getBalanceMismatchAccountIds, getStaleSyncAccountIds, + lastOkSyncFinishedAt, } from '@/lib/inventory-health' +import { + ACCOUNT_HEALTH_LABELS, + buildAtRiskAccounts, + countAccountsWithIssues, + countLowBalanceAccounts, + getAccountHealthFlags, + type AccountHealthFlag, +} from '@/lib/account-health' +import { + applyAccountFilters, + buildDefaultAccountFilters, + type AccountFiltersState, +} from '@/components/account-filters' +import { AccountFiltersToolbar } from '@/components/account-filters-toolbar' const accountsSearchSchema = z.object({ health: z.string().optional(), }) +const HEALTH_BADGE_VARIANT: Record = { + 'stale-sync': 'secondary', + 'low-balance': 'destructive', + 'balance-mismatch': 'outline', + 'no-creds': 'outline', +} + export const Route = createFileRoute('/_auth/accounts')({ validateSearch: (search) => accountsSearchSchema.parse(search), loader: ({ context: { queryClient } }) => @@ -48,25 +75,31 @@ export const Route = createFileRoute('/_auth/accounts')({ component: AccountsPage, }) +function buildSavePayload(r: ProviderAccountFormValues) { + const { apiLogin, apiPassword, balanceAlertBelow, ...rest } = r + const alertRaw = balanceAlertBelow === '' || balanceAlertBelow == null ? '' : String(balanceAlertBelow) + const alertNum = alertRaw ? Number(alertRaw) : null + const base = { + ...rest, + balanceAlertBelow: Number.isFinite(alertNum) ? alertNum : null, + } + const creds = buildApiCredentials(apiLogin ?? '', apiPassword ?? '') + return creds ? { ...base, apiCredentials: creds } : base +} + function AccountsPage() { const { health } = Route.useSearch() const queryClient = useQueryClient() const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions()) const [open, setOpen] = useState(false) + const [filters, setFilters] = useState(buildDefaultAccountFilters()) const [formDefaults, setFormDefaults] = useState( providerAccountFormDefaults(null, snapshot?.providers[0]?.id ?? ''), ) const saveMut = useMutation({ mutationFn: (r: ProviderAccountFormValues) => { - const { apiCredentials, balanceAlertBelow, ...rest } = r - const alertRaw = balanceAlertBelow === '' || balanceAlertBelow == null ? '' : String(balanceAlertBelow) - const alertNum = alertRaw ? Number(alertRaw) : null - const base = { - ...rest, - balanceAlertBelow: Number.isFinite(alertNum) ? alertNum : null, - } - const payload = apiCredentials ? { ...base, apiCredentials } : base + const payload = buildSavePayload(r) return r.id ? api.update('providerAccounts', r.id, payload as unknown as Partial) : api.create('providerAccounts', payload as unknown as ProviderAccount) @@ -126,8 +159,8 @@ function AccountsPage() { id: a.id, providerId: a.providerId, name: a.name, - login: a.login ?? '', - apiCredentials: '', + apiLogin: a.apiLogin ?? a.login ?? '', + apiPassword: '', billingMode: a.billingMode ?? 'monthly', balanceAlertBelow: ext.balanceAlertBelow != null ? ext.balanceAlertBelow : '', notes: a.notes ?? '', @@ -141,21 +174,70 @@ function AccountsPage() { ? billmanagerSyncableAccounts(snapshot.providerAccounts, snapshot.providers).length : 0 + const healthCtx = useMemo( + () => ({ + providers: snapshot?.providers ?? [], + syncLog: snapshot?.syncLog ?? [], + balanceLedger: snapshot?.balanceLedger ?? [], + }), + [snapshot], + ) + + const vpsCountByAccount = useMemo(() => { + const map = new Map() + for (const v of snapshot?.vps ?? []) { + if (!v.providerAccountId) continue + map.set(v.providerAccountId, (map.get(v.providerAccountId) ?? 0) + 1) + } + return map + }, [snapshot?.vps]) + const filteredAccounts = useMemo(() => { const accounts = snapshot?.providerAccounts ?? [] - if (!health || !snapshot) return accounts - if (health === 'stale-sync') { - const ids = new Set( - getStaleSyncAccountIds(snapshot.providerAccounts, snapshot.providers, snapshot.syncLog ?? []), - ) - return accounts.filter((a) => ids.has(a.id)) + let result = accounts + if (health && snapshot) { + if (health === 'stale-sync') { + const ids = new Set( + getStaleSyncAccountIds(snapshot.providerAccounts, snapshot.providers, snapshot.syncLog ?? []), + ) + result = accounts.filter((a) => ids.has(a.id)) + } else if (health === 'balance-mismatch') { + const ids = new Set(getBalanceMismatchAccountIds(snapshot.providerAccounts, snapshot.balanceLedger)) + result = accounts.filter((a) => ids.has(a.id)) + } } - if (health === 'balance-mismatch') { - const ids = new Set(getBalanceMismatchAccountIds(snapshot.providerAccounts, snapshot.balanceLedger)) - return accounts.filter((a) => ids.has(a.id)) - } - return accounts - }, [snapshot, health]) + return applyAccountFilters(result, filters, snapshot?.providers ?? [], healthCtx) + }, [snapshot, health, filters, healthCtx]) + + const summaryCards = useMemo(() => { + if (!snapshot) return [] + const accounts = snapshot.providerAccounts + const atRisk = buildAtRiskAccounts(accounts, snapshot.providers, snapshot.syncLog ?? []) + return [ + { + label: 'Всего аккаунтов', + value: accounts.length, + onClick: () => setFilters(buildDefaultAccountFilters()), + }, + { + label: 'Готовы к синку', + value: syncableCount, + onClick: () => setFilters({ ...buildDefaultAccountFilters(), syncableOnly: true }), + }, + { + label: 'С проблемами', + value: countAccountsWithIssues(accounts, healthCtx), + variant: atRisk.length ? ('warning' as const) : ('default' as const), + onClick: () => setFilters({ ...buildDefaultAccountFilters(), issuesOnly: true }), + }, + { + label: 'Низкий баланс', + value: countLowBalanceAccounts(accounts, healthCtx), + variant: countLowBalanceAccounts(accounts, healthCtx) ? ('destructive' as const) : ('default' as const), + onClick: () => setFilters({ ...buildDefaultAccountFilters(), lowBalanceOnly: true }), + }, + ] + }, [snapshot, syncableCount, healthCtx]) const columns: DataTableColumn[] = [ { @@ -168,7 +250,30 @@ function AccountsPage() { key: 'login', header: 'Логин', icon: KeyRoundIcon, - cell: (a) => {a.login || '—'}, + cell: (a) => ( + {a.apiLogin ?? a.login ?? '—'} + ), + }, + { + key: 'health', + header: 'Статус', + icon: ActivityIcon, + sortable: false, + cell: (a) => { + const flags = getAccountHealthFlags(a, healthCtx) + if (!flags.length) { + return OK + } + return ( +
+ {flags.map((flag) => ( + + {ACCOUNT_HEALTH_LABELS[flag]} + + ))} +
+ ) + }, }, { key: 'creds', @@ -186,6 +291,28 @@ function AccountsPage() { icon: ReceiptIcon, cell: (a) => {billingModeLabel(a.billingMode ?? 'monthly')}, }, + { + key: 'vps', + header: 'VPS', + icon: ServerIcon, + headerClassName: 'text-right', + className: 'text-right tabular-nums', + sortValue: (a) => vpsCountByAccount.get(a.id) ?? 0, + cell: (a) => { + const count = vpsCountByAccount.get(a.id) ?? 0 + return count ? {count} : 0 + }, + }, + { + key: 'sync', + header: 'Последний синк', + icon: ClockIcon, + sortValue: (a) => lastOkSyncFinishedAt(a.id, snapshot?.syncLog ?? []) ?? 0, + cell: (a) => { + const t = lastOkSyncFinishedAt(a.id, snapshot?.syncLog ?? []) + return {formatRelativeTime(t)} + }, + }, { key: 'balance', header: 'Баланс (API)', @@ -283,15 +410,23 @@ function AccountsPage() { ) : null } > - {( ) => ( + {() => (
+ {snapshot ? : null} + {snapshot ? ( + + ) : null} {health ? : null} a.id} pinLastColumn - emptyTitle={health ? 'Нет аккаунтов с этой проблемой' : 'Нет записей'} + emptyTitle={health || filters.search ? 'Нет аккаунтов с этими фильтрами' : 'Нет записей'} />
)} diff --git a/apps/web/src/routes/_auth/dashboard.tsx b/apps/web/src/routes/_auth/dashboard.tsx index fc7c7d0..52b7243 100644 --- a/apps/web/src/routes/_auth/dashboard.tsx +++ b/apps/web/src/routes/_auth/dashboard.tsx @@ -33,13 +33,13 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@cfdm/ui/components/ta import { StatusBadge } from '@/components/status-badge' import { cn } from '@cfdm/ui/lib/utils' -import { computeInventoryHealth, getStaleSyncAccountIds } from '@/lib/inventory-health' +import { computeInventoryHealth } from '@/lib/inventory-health' +import { buildAtRiskAccounts, type AtRiskAccount } from '@/lib/account-health' import { formatInBaseCurrency, normalizeRatesPayload, vpsStatusLabel } from '@/lib/format' -import { accountBalanceApi } from '@/lib/account' import { exportActiveVpsCsv } from '@/lib/export-csv' import { MonthlyTrendChart, MonthlyExpenseChart } from '@/components/domain/charts' -import type { Vps, ProviderAccount, Provider, SyncLogRow } from '@/types/entities' +import type { Vps } from '@/types/entities' const DASHBOARD_TAB_TRIGGER_CLASS = 'flex-none rounded-none border-0 border-b-2 border-transparent px-3 pb-2.5 pt-2 shadow-none after:hidden data-active:border-foreground data-active:bg-transparent data-active:shadow-none dark:data-active:border-foreground dark:data-active:bg-transparent' @@ -55,33 +55,6 @@ export const Route = createFileRoute('/_auth/dashboard')({ type InventoryIssue = { key: string; title: string; count: number; to: string; hint?: string } -interface AtRiskAccount { - id: string - name: string - reason: string - severity: 'warning' | 'destructive' -} - -function buildAtRiskAccounts( - accounts: ProviderAccount[], - providers: Provider[], - syncLog: SyncLogRow[] = [], -): AtRiskAccount[] { - const staleIds = new Set(getStaleSyncAccountIds(accounts, providers, syncLog)) - const rows: AtRiskAccount[] = [] - for (const a of accounts) { - const ext = a as ProviderAccount & { balanceAlertBelow?: number | null } - const threshold = Number(ext.balanceAlertBelow ?? 0) - const balance = accountBalanceApi(a) - if (Number.isFinite(threshold) && threshold > 0 && balance != null && balance < threshold) { - rows.push({ id: a.id, name: a.name, reason: 'Низкий баланс', severity: 'destructive' }) - } else if (staleIds.has(a.id)) { - rows.push({ id: a.id, name: a.name, reason: 'Устаревший синк', severity: 'warning' }) - } - } - return rows -} - function DashboardPage() { const navigate = useNavigate() const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions()) diff --git a/apps/web/src/types/entities.ts b/apps/web/src/types/entities.ts index ea348d4..6c2a5f3 100644 --- a/apps/web/src/types/entities.ts +++ b/apps/web/src/types/entities.ts @@ -27,6 +27,8 @@ export interface ProviderAccount { id: string providerId: string name: string + apiLogin?: string + /** @deprecated используйте apiLogin */ login?: string apiCredentialsSet?: boolean billingMode?: BillingMode diff --git a/packages/db/package.json b/packages/db/package.json index a8e7ad4..3b66924 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -17,19 +17,26 @@ "./repositories/*": { "types": "./dist/repositories/*.d.ts", "default": "./dist/repositories/*.js" + }, + "./test-setup": { + "types": "./dist/test-setup.d.ts", + "default": "./dist/test-setup.js" } }, "scripts": { - "build": "tsc -p tsconfig.json" + "build": "tsc -p tsconfig.json", + "test": "vitest run" }, "dependencies": { "drizzle-orm": "^0.40.0", - "better-sqlite3": "^11.10.0" + "better-sqlite3": "^11.10.0", + "@cfdm/shared": "workspace:*" }, "devDependencies": { "drizzle-kit": "^0.30.0", "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.10.0", - "typescript": "^5.9.2" + "typescript": "^5.9.2", + "vitest": "^3.0.0" } } diff --git a/packages/db/src/repositories/provider-accounts.test.ts b/packages/db/src/repositories/provider-accounts.test.ts new file mode 100644 index 0000000..abc9c50 --- /dev/null +++ b/packages/db/src/repositories/provider-accounts.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { parseApiLogin } from '@cfdm/shared/utils/api-credentials' +import { providerAccountsRepository } from './provider-accounts.js' +import { resetTestDb, seedTestProvider } from '../test-setup.js' +import { getSqlite } from '../index.js' + +describe('parseApiLogin', () => { + it('extracts login before colon', () => { + expect(parseApiLogin('user:secret')).toBe('user') + expect(parseApiLogin(' admin:pass ')).toBe('admin') + }) + + it('returns empty for invalid credentials', () => { + expect(parseApiLogin('')).toBe('') + expect(parseApiLogin('nocolon')).toBe('') + expect(parseApiLogin(':onlypass')).toBe('') + }) +}) + +describe('providerAccountsRepository', () => { + beforeEach(() => { + resetTestDb() + seedTestProvider() + }) + + it('returns apiLogin without exposing password', () => { + const created = providerAccountsRepository.create({ + providerId: 'prov-1', + name: 'Main', + apiCredentials: 'apiuser:apipass', + }) + expect(created.apiLogin).toBe('apiuser') + expect(created.apiCredentialsSet).toBe(true) + expect('apiCredentials' in created).toBe(false) + }) + + it('preserves credentials on update when apiCredentials empty', () => { + providerAccountsRepository.create({ + id: 'acc-1', + providerId: 'prov-1', + name: 'Main', + apiCredentials: 'keep:me', + }) + const updated = providerAccountsRepository.update('acc-1', { name: 'Renamed', apiCredentials: '' }) + expect(updated?.name).toBe('Renamed') + expect(updated?.apiLogin).toBe('keep') + }) + + it('counts dependencies before delete', () => { + providerAccountsRepository.create({ + id: 'acc-1', + providerId: 'prov-1', + name: 'Main', + }) + getSqlite() + .prepare(`INSERT INTO vps (id, ip, providerId, providerAccountId, status) VALUES ('vps-1', '1.1.1.1', 'prov-1', 'acc-1', 'active')`) + .run() + const deps = providerAccountsRepository.getDependencyCounts('acc-1') + expect(deps.vps).toBe(1) + expect(deps.payments).toBe(0) + }) +}) diff --git a/packages/db/src/repositories/provider-accounts.ts b/packages/db/src/repositories/provider-accounts.ts index cdffea5..01df203 100644 --- a/packages/db/src/repositories/provider-accounts.ts +++ b/packages/db/src/repositories/provider-accounts.ts @@ -1,4 +1,5 @@ -import { asc, eq } from 'drizzle-orm' +import { asc, count, eq } from 'drizzle-orm' +import { parseApiLogin } from '@cfdm/shared' import { getDb, schema } from '../index.js' import { generateId } from './utils.js' @@ -10,12 +11,25 @@ type AccountInsert = Partial & { export interface PublicAccountRow extends Omit { apiCredentialsSet: boolean + apiLogin: string +} + +export interface AccountDependencyCounts { + vps: number + payments: number + balanceLedger: number + activeTariffs: number + syncLog: number } function sanitize(row: AccountRow | undefined): PublicAccountRow | undefined { if (!row) return undefined const { apiCredentials, ...rest } = row - return { ...rest, apiCredentialsSet: Boolean(apiCredentials) } + return { + ...rest, + apiCredentialsSet: Boolean(apiCredentials), + apiLogin: parseApiLogin(apiCredentials), + } } function normalize(input: Partial) { @@ -35,6 +49,36 @@ function normalize(input: Partial) { } } +function countVpsForAccount(id: string): number { + return Number( + getDb().select({ count: count() }).from(schema.vps).where(eq(schema.vps.providerAccountId, id)).get()?.count ?? 0, + ) +} + +function countPaymentsForAccount(id: string): number { + return Number( + getDb().select({ count: count() }).from(schema.payments).where(eq(schema.payments.providerAccountId, id)).get()?.count ?? 0, + ) +} + +function countLedgerForAccount(id: string): number { + return Number( + getDb().select({ count: count() }).from(schema.balanceLedger).where(eq(schema.balanceLedger.providerAccountId, id)).get()?.count ?? 0, + ) +} + +function countTariffsForAccount(id: string): number { + return Number( + getDb().select({ count: count() }).from(schema.activeTariffs).where(eq(schema.activeTariffs.providerAccountId, id)).get()?.count ?? 0, + ) +} + +function countSyncLogForAccount(id: string): number { + return Number( + getDb().select({ count: count() }).from(schema.syncLog).where(eq(schema.syncLog.accountId, id)).get()?.count ?? 0, + ) +} + export const providerAccountsRepository = { list(): PublicAccountRow[] { const rows = getDb() @@ -62,6 +106,16 @@ export const providerAccountsRepository = { .get() }, + getDependencyCounts(id: string): AccountDependencyCounts { + return { + vps: countVpsForAccount(id), + payments: countPaymentsForAccount(id), + balanceLedger: countLedgerForAccount(id), + activeTariffs: countTariffsForAccount(id), + syncLog: countSyncLogForAccount(id), + } + }, + create(input: AccountInsert, id?: string): PublicAccountRow { const db = getDb() const finalId = id ?? input.id ?? generateId('account') @@ -76,8 +130,8 @@ export const providerAccountsRepository = { const existing = this.getWithCredentials(id) if (!existing) return undefined const apiCredentials = - input.apiCredentials !== undefined - ? String(input.apiCredentials || '') + input.apiCredentials !== undefined && String(input.apiCredentials || '').trim() !== '' + ? String(input.apiCredentials) : (existing.apiCredentials || '') let balanceAlertBelow = existing.balanceAlertBelow @@ -112,3 +166,5 @@ export const providerAccountsRepository = { return res.changes > 0 }, } + +export { parseApiLogin } diff --git a/packages/db/src/test-setup.ts b/packages/db/src/test-setup.ts new file mode 100644 index 0000000..d8b2665 --- /dev/null +++ b/packages/db/src/test-setup.ts @@ -0,0 +1,104 @@ +import { closeDb, getSqlite } from './index.js' + +const TEST_SCHEMA = ` +CREATE TABLE IF NOT EXISTS providers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + website TEXT, + contact TEXT, + baseCurrency TEXT, + usdRate TEXT, + eurRate TEXT, + notes TEXT, + apiType TEXT, + apiBaseUrl TEXT +); + +CREATE TABLE IF NOT EXISTS provider_accounts ( + id TEXT PRIMARY KEY, + providerId TEXT NOT NULL, + name TEXT NOT NULL, + panelUrl TEXT, + currency TEXT, + billingMode TEXT, + notes TEXT, + apiType TEXT, + apiBaseUrl TEXT, + apiCredentials TEXT, + balance_api REAL, + balance_currency TEXT, + balance_updated_at TEXT, + enoughmoneyto TEXT, + balance_alert_below REAL, + FOREIGN KEY (providerId) REFERENCES providers(id) +); + +CREATE TABLE IF NOT EXISTS vps ( + id TEXT PRIMARY KEY, + ip TEXT, + providerId TEXT, + providerAccountId TEXT, + status TEXT, + FOREIGN KEY (providerId) REFERENCES providers(id), + FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id) +); + +CREATE TABLE IF NOT EXISTS payments ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + date TEXT NOT NULL, + amount REAL NOT NULL, + currency TEXT, + providerAccountId TEXT, + vpsId TEXT, + note TEXT, + FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id) +); + +CREATE TABLE IF NOT EXISTS balance_ledger ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + date TEXT NOT NULL, + amount REAL NOT NULL, + currency TEXT, + direction TEXT, + providerAccountId TEXT, + vpsId TEXT, + note TEXT, + FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id) +); + +CREATE TABLE IF NOT EXISTS sync_log ( + id TEXT PRIMARY KEY, + accountId TEXT NOT NULL, + startedAt TEXT NOT NULL, + finishedAt TEXT, + status TEXT, + FOREIGN KEY (accountId) REFERENCES provider_accounts(id) +); + +CREATE TABLE IF NOT EXISTS active_tariffs ( + id TEXT PRIMARY KEY, + providerAccountId TEXT NOT NULL, + providerId TEXT NOT NULL, + externalId TEXT NOT NULL, + FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id), + FOREIGN KEY (providerId) REFERENCES providers(id) +); +` + +export function resetTestDb(): void { + closeDb() + process.env.DB_PATH = ':memory:' + const sqlite = getSqlite() + sqlite.exec(TEST_SCHEMA) +} + +export function seedTestProvider(id = 'prov-1'): void { + const sqlite = getSqlite() + sqlite + .prepare( + `INSERT INTO providers (id, name, apiType, apiBaseUrl) VALUES (?, 'Test Host', 'billmanager', 'https://bm.test')`, + ) + .run(id) +} diff --git a/packages/shared/package.json b/packages/shared/package.json index 61cd5b0..e10d57e 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -25,6 +25,10 @@ "./geo/*": { "types": "./dist/geo/*.d.ts", "default": "./dist/geo/*.js" + }, + "./utils/*": { + "types": "./dist/utils/*.d.ts", + "default": "./dist/utils/*.js" } }, "scripts": { diff --git a/packages/shared/src/contracts/provider-account.ts b/packages/shared/src/contracts/provider-account.ts index 1f998be..a3826ff 100644 --- a/packages/shared/src/contracts/provider-account.ts +++ b/packages/shared/src/contracts/provider-account.ts @@ -1,15 +1,29 @@ import { z } from 'zod' -export const providerAccountSchema = z.object({ +export const billingModeSchema = z.enum(['daily', 'monthly']) + +export const providerAccountInputSchema = z.object({ id: z.string().optional(), providerId: z.string().min(1, 'Provider is required'), name: z.string().min(1, 'Name is required'), panelUrl: z.string().optional().default(''), currency: z.string().optional().default(''), - billingMode: z.string().optional().default(''), + billingMode: billingModeSchema.optional().default('monthly'), notes: z.string().optional().default(''), apiCredentials: z.string().optional().default(''), balanceAlertBelow: z.union([z.number(), z.null()]).optional(), }) -export type ProviderAccount = z.infer +export const providerAccountPublicSchema = providerAccountInputSchema + .omit({ apiCredentials: true }) + .extend({ + apiCredentialsSet: z.boolean().optional(), + apiLogin: z.string().optional(), + }) + +/** @deprecated Используйте providerAccountInputSchema */ +export const providerAccountSchema = providerAccountInputSchema + +export type ProviderAccountInput = z.infer +export type ProviderAccountPublic = z.infer +export type ProviderAccount = ProviderAccountInput diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0c4c881..610352c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,5 +1,6 @@ export * from './contracts/provider.js' export * from './contracts/provider-account.js' +export * from './utils/api-credentials.js' export * from './contracts/vps.js' export * from './contracts/payment.js' export * from './contracts/balance-ledger.js' diff --git a/packages/shared/src/utils/api-credentials.ts b/packages/shared/src/utils/api-credentials.ts new file mode 100644 index 0000000..782bccb --- /dev/null +++ b/packages/shared/src/utils/api-credentials.ts @@ -0,0 +1,15 @@ +/** Логин из BILLmanager-кредов формата `login:password`. */ +export function parseApiLogin(credentials: string | null | undefined): string { + const cred = String(credentials ?? '').trim() + const idx = cred.indexOf(':') + return idx > 0 ? cred.slice(0, idx) : '' +} + +/** Собрать креды для API из отдельных полей формы. */ +export function buildApiCredentials(login: string, password: string): string { + const l = login.trim() + const p = password + if (!l && !p) return '' + if (!l) return p + return p ? `${l}:${p}` : '' +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2acb419..b7710cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -199,6 +199,9 @@ importers: packages/db: dependencies: + '@cfdm/shared': + specifier: workspace:* + version: link:../shared better-sqlite3: specifier: ^11.10.0 version: 11.10.0 @@ -218,6 +221,9 @@ importers: typescript: specifier: ^5.9.2 version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.6(@types/node@22.20.0)(happy-dom@18.0.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4) packages/shared: dependencies: