feat(accounts): доработать управление аккаунтами хостеров
Docker / build (push) Has been cancelled

Добавить apiLogin из credentials, сводку и health-индикаторы на /accounts, фильтры, раздельную форму логина и пароля, безопасное удаление с 409 и тесты API/repository.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-06-28 16:53:51 +07:00
co-authored by Cursor
parent 06f5b27e53
commit c80090ed08
21 changed files with 844 additions and 84 deletions
@@ -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<ReturnType<typeof buildApp>>
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()
})
})
+29 -2
View File
@@ -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<typeof providerAccountsRepository.getDependencyCounts>): 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()
})
}
@@ -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 (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-[12rem] flex-1 sm:max-w-xs">
<SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-8"
placeholder="Поиск по названию или логину"
value={filters.search}
onChange={(e) => onChange({ ...filters, search: e.target.value })}
/>
</div>
<SelectField
triggerClassName="w-full sm:w-48"
placeholder="Все хостеры"
value={filters.providerIds[0] ?? null}
onValueChange={(v) => onChange({ ...filters, providerIds: v ? [v] : [] })}
options={providers.map((p) => ({ value: p.id, label: p.name }))}
/>
<SelectField
triggerClassName="w-full sm:w-40"
placeholder="Любой биллинг"
value={filters.billingMode || null}
onValueChange={(v) =>
onChange({
...filters,
billingMode: (v === 'daily' || v === 'monthly' ? v : '') as AccountFiltersState['billingMode'],
})
}
options={[
{ value: 'monthly', label: billingModeLabel('monthly') },
{ value: 'daily', label: billingModeLabel('daily') },
]}
/>
{active ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onChange(buildDefaultAccountFilters())}
>
<XIcon data-icon="inline-start" />
Сбросить
</Button>
) : null}
</div>
<div className="flex flex-wrap items-center gap-4">
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={filters.syncableOnly}
onCheckedChange={(v) => onChange({ ...filters, syncableOnly: v === true })}
/>
<span>Готовы к синку</span>
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={filters.issuesOnly}
onCheckedChange={(v) => onChange({ ...filters, issuesOnly: v === true })}
/>
<span>С проблемами</span>
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={filters.lowBalanceOnly}
onCheckedChange={(v) => onChange({ ...filters, lowBalanceOnly: v === true })}
/>
<span>Низкий баланс</span>
</label>
</div>
</div>
)
}
@@ -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
})
}
@@ -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({
<FormField label="Название" htmlFor="acc-name" error={errors.name?.message} invalid={!!errors.name}>
<Input id="acc-name" aria-invalid={!!errors.name} {...register('name')} />
</FormField>
<FormField label="Логин" htmlFor="acc-login">
<Input id="acc-login" {...register('login')} />
<FormField label="Логин API" htmlFor="acc-login">
<Input id="acc-login" autoComplete="off" {...register('apiLogin')} />
</FormField>
<FormField
label={isEdit ? 'Новый API-пароль (необязательно)' : 'API-пароль (логин:пароль)'}
htmlFor="acc-creds"
description="Оставьте пустым при редактировании, чтобы сохранить существующий"
label={isEdit ? 'Пароль API (необязательно)' : 'Пароль API'}
htmlFor="acc-password"
description={isEdit ? 'Оставьте пустым, чтобы сохранить существующий пароль' : undefined}
>
<Input id="acc-creds" type="password" {...register('apiCredentials')} />
<Input id="acc-password" type="password" autoComplete="new-password" {...register('apiPassword')} />
</FormField>
<div className="flex flex-wrap gap-2">
<LoadingButton
@@ -125,7 +128,7 @@ export function ProviderAccountEditSheet({
size="sm"
disabled={!canTest}
loading={testMut.isPending}
onClick={() => testMut.mutate({ apiBaseUrl, apiCredentials: creds })}
onClick={() => testMut.mutate({ apiBaseUrl, apiCredentials })}
>
<PlugIcon data-icon="inline-start" />
Проверить подключение
+103
View File
@@ -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)
}
+5 -2
View File
@@ -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 */
}
+16
View File
@@ -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: 'Месячный',
}
+4 -3
View File
@@ -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(''),
+161 -26
View File
@@ -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<AccountHealthFlag, 'default' | 'secondary' | 'destructive' | 'outline'> = {
'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<AccountFiltersState>(buildDefaultAccountFilters())
const [formDefaults, setFormDefaults] = useState<ProviderAccountFormValues>(
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<ProviderAccount>('providerAccounts', r.id, payload as unknown as Partial<ProviderAccount>)
: 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<string, number>()
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<ProviderAccount>[] = [
{
@@ -168,7 +250,30 @@ function AccountsPage() {
key: 'login',
header: 'Логин',
icon: KeyRoundIcon,
cell: (a) => <span className="text-muted-foreground">{a.login || '—'}</span>,
cell: (a) => (
<span className="text-muted-foreground">{a.apiLogin ?? a.login ?? '—'}</span>
),
},
{
key: 'health',
header: 'Статус',
icon: ActivityIcon,
sortable: false,
cell: (a) => {
const flags = getAccountHealthFlags(a, healthCtx)
if (!flags.length) {
return <Badge variant="outline">OK</Badge>
}
return (
<div className="flex flex-wrap gap-1">
{flags.map((flag) => (
<Badge key={flag} variant={HEALTH_BADGE_VARIANT[flag]}>
{ACCOUNT_HEALTH_LABELS[flag]}
</Badge>
))}
</div>
)
},
},
{
key: 'creds',
@@ -186,6 +291,28 @@ function AccountsPage() {
icon: ReceiptIcon,
cell: (a) => <span>{billingModeLabel(a.billingMode ?? 'monthly')}</span>,
},
{
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 ? <Badge variant="secondary">{count}</Badge> : <span className="text-muted-foreground">0</span>
},
},
{
key: 'sync',
header: 'Последний синк',
icon: ClockIcon,
sortValue: (a) => lastOkSyncFinishedAt(a.id, snapshot?.syncLog ?? []) ?? 0,
cell: (a) => {
const t = lastOkSyncFinishedAt(a.id, snapshot?.syncLog ?? [])
return <span className="text-muted-foreground">{formatRelativeTime(t)}</span>
},
},
{
key: 'balance',
header: 'Баланс (API)',
@@ -283,15 +410,23 @@ function AccountsPage() {
) : null
}
>
{( ) => (
{() => (
<div className="flex flex-col gap-4">
{snapshot ? <SectionCards items={summaryCards} /> : null}
{snapshot ? (
<AccountFiltersToolbar
filters={filters}
onChange={setFilters}
providers={snapshot.providers}
/>
) : null}
{health ? <HealthModeBanner health={health} exitTo="/accounts" /> : null}
<DataGridCard
columns={columnDefFromDataTable(columns)}
data={filteredAccounts}
rowId={(a) => a.id}
pinLastColumn
emptyTitle={health ? 'Нет аккаунтов с этой проблемой' : 'Нет записей'}
emptyTitle={health || filters.search ? 'Нет аккаунтов с этими фильтрами' : 'Нет записей'}
/>
</div>
)}
+3 -30
View File
@@ -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())
+2
View File
@@ -27,6 +27,8 @@ export interface ProviderAccount {
id: string
providerId: string
name: string
apiLogin?: string
/** @deprecated используйте apiLogin */
login?: string
apiCredentialsSet?: boolean
billingMode?: BillingMode
+10 -3
View File
@@ -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"
}
}
@@ -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)
})
})
@@ -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<typeof schema.providerAccounts.$inferInsert> & {
export interface PublicAccountRow extends Omit<AccountRow, 'apiCredentials'> {
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<AccountRow>) {
@@ -35,6 +49,36 @@ function normalize(input: Partial<AccountRow>) {
}
}
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 }
+104
View File
@@ -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)
}
+4
View File
@@ -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": {
@@ -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<typeof providerAccountSchema>
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<typeof providerAccountInputSchema>
export type ProviderAccountPublic = z.infer<typeof providerAccountPublicSchema>
export type ProviderAccount = ProviderAccountInput
+1
View File
@@ -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'
@@ -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}` : ''
}
+6
View File
@@ -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: