Добавить apiLogin из credentials, сводку и health-индикаторы на /accounts, фильтры, раздельную форму логина и пароля, безопасное удаление с 409 и тесты API/repository. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,6 +2,16 @@ import type { FastifyPluginAsync } from 'fastify'
|
|||||||
import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accounts'
|
import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accounts'
|
||||||
import { providerAccountSchema } from '@cfdm/shared/contracts/provider-account'
|
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) => {
|
export const providerAccountsRoutes: FastifyPluginAsync = async (app) => {
|
||||||
app.get('/api/provider-accounts', async () => providerAccountsRepository.list())
|
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) => {
|
app.delete<{ Params: { id: string } }>('/api/provider-accounts/:id', async (req, reply) => {
|
||||||
const ok = providerAccountsRepository.delete(req.params.id)
|
const existing = providerAccountsRepository.get(req.params.id)
|
||||||
if (!ok) {
|
if (!existing) {
|
||||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
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()
|
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 { useMutation } from '@tanstack/react-query'
|
||||||
import { PlugIcon, RefreshCwIcon } from 'lucide-react'
|
import { PlugIcon, RefreshCwIcon } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { buildApiCredentials } from '@cfdm/shared/utils/api-credentials'
|
||||||
|
|
||||||
import { FormSheetRhf } from '@/components/form-sheet-rhf'
|
import { FormSheetRhf } from '@/components/form-sheet-rhf'
|
||||||
import { FormField } from '@/components/form-field'
|
import { FormField } from '@/components/form-field'
|
||||||
@@ -17,8 +18,8 @@ import { api, ApiError } from '@/lib/api-client'
|
|||||||
const EMPTY: ProviderAccountFormValues = {
|
const EMPTY: ProviderAccountFormValues = {
|
||||||
providerId: '',
|
providerId: '',
|
||||||
name: '',
|
name: '',
|
||||||
login: '',
|
apiLogin: '',
|
||||||
apiCredentials: '',
|
apiPassword: '',
|
||||||
billingMode: 'monthly',
|
billingMode: 'monthly',
|
||||||
balanceAlertBelow: '',
|
balanceAlertBelow: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
@@ -91,8 +92,10 @@ export function ProviderAccountEditSheet({
|
|||||||
const providerId = watch('providerId')
|
const providerId = watch('providerId')
|
||||||
const provider = providers.find((p) => p.id === providerId)
|
const provider = providers.find((p) => p.id === providerId)
|
||||||
const apiBaseUrl = (provider?.apiBaseUrl ?? '').trim()
|
const apiBaseUrl = (provider?.apiBaseUrl ?? '').trim()
|
||||||
const creds = watch('apiCredentials')?.trim() ?? ''
|
const apiLogin = watch('apiLogin')?.trim() ?? ''
|
||||||
const canTest = Boolean(apiBaseUrl && creds)
|
const apiPassword = watch('apiPassword')?.trim() ?? ''
|
||||||
|
const apiCredentials = buildApiCredentials(apiLogin, apiPassword)
|
||||||
|
const canTest = Boolean(apiBaseUrl && apiCredentials)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -108,15 +111,15 @@ export function ProviderAccountEditSheet({
|
|||||||
<FormField label="Название" htmlFor="acc-name" error={errors.name?.message} invalid={!!errors.name}>
|
<FormField label="Название" htmlFor="acc-name" error={errors.name?.message} invalid={!!errors.name}>
|
||||||
<Input id="acc-name" aria-invalid={!!errors.name} {...register('name')} />
|
<Input id="acc-name" aria-invalid={!!errors.name} {...register('name')} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Логин" htmlFor="acc-login">
|
<FormField label="Логин API" htmlFor="acc-login">
|
||||||
<Input id="acc-login" {...register('login')} />
|
<Input id="acc-login" autoComplete="off" {...register('apiLogin')} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField
|
<FormField
|
||||||
label={isEdit ? 'Новый API-пароль (необязательно)' : 'API-пароль (логин:пароль)'}
|
label={isEdit ? 'Пароль API (необязательно)' : 'Пароль API'}
|
||||||
htmlFor="acc-creds"
|
htmlFor="acc-password"
|
||||||
description="Оставьте пустым при редактировании, чтобы сохранить существующий"
|
description={isEdit ? 'Оставьте пустым, чтобы сохранить существующий пароль' : undefined}
|
||||||
>
|
>
|
||||||
<Input id="acc-creds" type="password" {...register('apiCredentials')} />
|
<Input id="acc-password" type="password" autoComplete="new-password" {...register('apiPassword')} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
@@ -125,7 +128,7 @@ export function ProviderAccountEditSheet({
|
|||||||
size="sm"
|
size="sm"
|
||||||
disabled={!canTest}
|
disabled={!canTest}
|
||||||
loading={testMut.isPending}
|
loading={testMut.isPending}
|
||||||
onClick={() => testMut.mutate({ apiBaseUrl, apiCredentials: creds })}
|
onClick={() => testMut.mutate({ apiBaseUrl, apiCredentials })}
|
||||||
>
|
>
|
||||||
<PlugIcon data-icon="inline-start" />
|
<PlugIcon data-icon="inline-start" />
|
||||||
Проверить подключение
|
Проверить подключение
|
||||||
|
|||||||
@@ -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) {
|
if (!res.ok) {
|
||||||
let message = res.statusText || 'API error'
|
let message = res.statusText || 'API error'
|
||||||
try {
|
try {
|
||||||
const data = (await res.json()) as { error?: string }
|
const data = (await res.json()) as {
|
||||||
if (data?.error) message = data.error
|
error?: string | { message?: string; code?: string }
|
||||||
|
}
|
||||||
|
if (typeof data?.error === 'string') message = data.error
|
||||||
|
else if (data?.error?.message) message = data.error.message
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,22 @@ export function billingModeLabel(mode: string): string {
|
|||||||
return BILLING_MODE_LABELS[mode] ?? mode
|
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> = {
|
const TARIFF_TYPE_LABELS: Record<string, string> = {
|
||||||
daily: 'Суточный', monthly: 'Месячный',
|
daily: 'Суточный', monthly: 'Месячный',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { billingModeSchema as sharedBillingModeSchema } from '@cfdm/shared/contracts/provider-account'
|
||||||
|
|
||||||
export const vpsStatusSchema = z.enum(['active', 'paused', 'archived'])
|
export const vpsStatusSchema = z.enum(['active', 'paused', 'archived'])
|
||||||
export const tariffTypeSchema = z.enum(['daily', 'monthly'])
|
export const tariffTypeSchema = z.enum(['daily', 'monthly'])
|
||||||
export const billingModeSchema = z.enum(['daily', 'monthly'])
|
export const billingModeSchema = sharedBillingModeSchema
|
||||||
export const paymentTypeSchema = z.enum([
|
export const paymentTypeSchema = z.enum([
|
||||||
'direct_vps_payment',
|
'direct_vps_payment',
|
||||||
'provider_balance_topup',
|
'provider_balance_topup',
|
||||||
@@ -30,8 +31,8 @@ export const providerAccountSchema = z.object({
|
|||||||
id: z.string().min(1).optional(),
|
id: z.string().min(1).optional(),
|
||||||
providerId: z.string().min(1, 'Выберите хостера'),
|
providerId: z.string().min(1, 'Выберите хостера'),
|
||||||
name: z.string().min(1, 'Название обязательно'),
|
name: z.string().min(1, 'Название обязательно'),
|
||||||
login: z.string().optional().default(''),
|
apiLogin: z.string().optional().default(''),
|
||||||
apiCredentials: z.string().optional().default(''),
|
apiPassword: z.string().optional().default(''),
|
||||||
billingMode: billingModeSchema.default('monthly'),
|
billingMode: billingModeSchema.default('monthly'),
|
||||||
balanceAlertBelow: z.union([z.coerce.number().min(0), z.literal('')]).optional(),
|
balanceAlertBelow: z.union([z.coerce.number().min(0), z.literal('')]).optional(),
|
||||||
notes: z.string().optional().default(''),
|
notes: z.string().optional().default(''),
|
||||||
|
|||||||
@@ -9,9 +9,13 @@ import {
|
|||||||
PlugIcon,
|
PlugIcon,
|
||||||
ReceiptIcon,
|
ReceiptIcon,
|
||||||
WalletIcon,
|
WalletIcon,
|
||||||
|
ActivityIcon,
|
||||||
|
ClockIcon,
|
||||||
|
ServerIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { buildApiCredentials } from '@cfdm/shared/utils/api-credentials'
|
||||||
|
|
||||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||||
import { api, ApiError } from '@/lib/api-client'
|
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 { CrudListPage } from '@/components/crud-list-page'
|
||||||
import { RowActions } from '@/components/row-actions'
|
import { RowActions } from '@/components/row-actions'
|
||||||
import { HealthModeBanner } from '@/components/health-mode-banner'
|
import { HealthModeBanner } from '@/components/health-mode-banner'
|
||||||
|
import { SectionCards } from '@/components/section-cards'
|
||||||
import {
|
import {
|
||||||
ProviderAccountEditSheet,
|
ProviderAccountEditSheet,
|
||||||
providerAccountFormDefaults,
|
providerAccountFormDefaults,
|
||||||
@@ -31,16 +36,38 @@ import type { ProviderAccountFormValues } from '@/lib/schemas'
|
|||||||
import { accountBalanceApi, accountBalanceCurrency } from '@/lib/account'
|
import { accountBalanceApi, accountBalanceCurrency } from '@/lib/account'
|
||||||
import type { ProviderAccount } from '@/types/entities'
|
import type { ProviderAccount } from '@/types/entities'
|
||||||
import { providerByIdMap, accountBillmanagerUiReady, billmanagerSyncableAccounts } from '@/lib/billmanager'
|
import { providerByIdMap, accountBillmanagerUiReady, billmanagerSyncableAccounts } from '@/lib/billmanager'
|
||||||
import { billingModeLabel, formatCurrency } from '@/lib/format'
|
import { billingModeLabel, formatCurrency, formatRelativeTime } from '@/lib/format'
|
||||||
import {
|
import {
|
||||||
getBalanceMismatchAccountIds,
|
getBalanceMismatchAccountIds,
|
||||||
getStaleSyncAccountIds,
|
getStaleSyncAccountIds,
|
||||||
|
lastOkSyncFinishedAt,
|
||||||
} from '@/lib/inventory-health'
|
} 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({
|
const accountsSearchSchema = z.object({
|
||||||
health: z.string().optional(),
|
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')({
|
export const Route = createFileRoute('/_auth/accounts')({
|
||||||
validateSearch: (search) => accountsSearchSchema.parse(search),
|
validateSearch: (search) => accountsSearchSchema.parse(search),
|
||||||
loader: ({ context: { queryClient } }) =>
|
loader: ({ context: { queryClient } }) =>
|
||||||
@@ -48,25 +75,31 @@ export const Route = createFileRoute('/_auth/accounts')({
|
|||||||
component: AccountsPage,
|
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() {
|
function AccountsPage() {
|
||||||
const { health } = Route.useSearch()
|
const { health } = Route.useSearch()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const [filters, setFilters] = useState<AccountFiltersState>(buildDefaultAccountFilters())
|
||||||
const [formDefaults, setFormDefaults] = useState<ProviderAccountFormValues>(
|
const [formDefaults, setFormDefaults] = useState<ProviderAccountFormValues>(
|
||||||
providerAccountFormDefaults(null, snapshot?.providers[0]?.id ?? ''),
|
providerAccountFormDefaults(null, snapshot?.providers[0]?.id ?? ''),
|
||||||
)
|
)
|
||||||
|
|
||||||
const saveMut = useMutation({
|
const saveMut = useMutation({
|
||||||
mutationFn: (r: ProviderAccountFormValues) => {
|
mutationFn: (r: ProviderAccountFormValues) => {
|
||||||
const { apiCredentials, balanceAlertBelow, ...rest } = r
|
const payload = buildSavePayload(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
|
|
||||||
return r.id
|
return r.id
|
||||||
? api.update<ProviderAccount>('providerAccounts', r.id, payload as unknown as Partial<ProviderAccount>)
|
? api.update<ProviderAccount>('providerAccounts', r.id, payload as unknown as Partial<ProviderAccount>)
|
||||||
: api.create('providerAccounts', payload as unknown as ProviderAccount)
|
: api.create('providerAccounts', payload as unknown as ProviderAccount)
|
||||||
@@ -126,8 +159,8 @@ function AccountsPage() {
|
|||||||
id: a.id,
|
id: a.id,
|
||||||
providerId: a.providerId,
|
providerId: a.providerId,
|
||||||
name: a.name,
|
name: a.name,
|
||||||
login: a.login ?? '',
|
apiLogin: a.apiLogin ?? a.login ?? '',
|
||||||
apiCredentials: '',
|
apiPassword: '',
|
||||||
billingMode: a.billingMode ?? 'monthly',
|
billingMode: a.billingMode ?? 'monthly',
|
||||||
balanceAlertBelow: ext.balanceAlertBelow != null ? ext.balanceAlertBelow : '',
|
balanceAlertBelow: ext.balanceAlertBelow != null ? ext.balanceAlertBelow : '',
|
||||||
notes: a.notes ?? '',
|
notes: a.notes ?? '',
|
||||||
@@ -141,21 +174,70 @@ function AccountsPage() {
|
|||||||
? billmanagerSyncableAccounts(snapshot.providerAccounts, snapshot.providers).length
|
? billmanagerSyncableAccounts(snapshot.providerAccounts, snapshot.providers).length
|
||||||
: 0
|
: 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 filteredAccounts = useMemo(() => {
|
||||||
const accounts = snapshot?.providerAccounts ?? []
|
const accounts = snapshot?.providerAccounts ?? []
|
||||||
if (!health || !snapshot) return accounts
|
let result = accounts
|
||||||
if (health === 'stale-sync') {
|
if (health && snapshot) {
|
||||||
const ids = new Set(
|
if (health === 'stale-sync') {
|
||||||
getStaleSyncAccountIds(snapshot.providerAccounts, snapshot.providers, snapshot.syncLog ?? []),
|
const ids = new Set(
|
||||||
)
|
getStaleSyncAccountIds(snapshot.providerAccounts, snapshot.providers, snapshot.syncLog ?? []),
|
||||||
return accounts.filter((a) => ids.has(a.id))
|
)
|
||||||
|
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') {
|
return applyAccountFilters(result, filters, snapshot?.providers ?? [], healthCtx)
|
||||||
const ids = new Set(getBalanceMismatchAccountIds(snapshot.providerAccounts, snapshot.balanceLedger))
|
}, [snapshot, health, filters, healthCtx])
|
||||||
return accounts.filter((a) => ids.has(a.id))
|
|
||||||
}
|
const summaryCards = useMemo(() => {
|
||||||
return accounts
|
if (!snapshot) return []
|
||||||
}, [snapshot, health])
|
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>[] = [
|
const columns: DataTableColumn<ProviderAccount>[] = [
|
||||||
{
|
{
|
||||||
@@ -168,7 +250,30 @@ function AccountsPage() {
|
|||||||
key: 'login',
|
key: 'login',
|
||||||
header: 'Логин',
|
header: 'Логин',
|
||||||
icon: KeyRoundIcon,
|
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',
|
key: 'creds',
|
||||||
@@ -186,6 +291,28 @@ function AccountsPage() {
|
|||||||
icon: ReceiptIcon,
|
icon: ReceiptIcon,
|
||||||
cell: (a) => <span>{billingModeLabel(a.billingMode ?? 'monthly')}</span>,
|
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',
|
key: 'balance',
|
||||||
header: 'Баланс (API)',
|
header: 'Баланс (API)',
|
||||||
@@ -283,15 +410,23 @@ function AccountsPage() {
|
|||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{( ) => (
|
{() => (
|
||||||
<div className="flex flex-col gap-4">
|
<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}
|
{health ? <HealthModeBanner health={health} exitTo="/accounts" /> : null}
|
||||||
<DataGridCard
|
<DataGridCard
|
||||||
columns={columnDefFromDataTable(columns)}
|
columns={columnDefFromDataTable(columns)}
|
||||||
data={filteredAccounts}
|
data={filteredAccounts}
|
||||||
rowId={(a) => a.id}
|
rowId={(a) => a.id}
|
||||||
pinLastColumn
|
pinLastColumn
|
||||||
emptyTitle={health ? 'Нет аккаунтов с этой проблемой' : 'Нет записей'}
|
emptyTitle={health || filters.search ? 'Нет аккаунтов с этими фильтрами' : 'Нет записей'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -33,13 +33,13 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@cfdm/ui/components/ta
|
|||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
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 { formatInBaseCurrency, normalizeRatesPayload, vpsStatusLabel } from '@/lib/format'
|
||||||
import { accountBalanceApi } from '@/lib/account'
|
|
||||||
import { exportActiveVpsCsv } from '@/lib/export-csv'
|
import { exportActiveVpsCsv } from '@/lib/export-csv'
|
||||||
import { MonthlyTrendChart, MonthlyExpenseChart } from '@/components/domain/charts'
|
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 =
|
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'
|
'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 }
|
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() {
|
function DashboardPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export interface ProviderAccount {
|
|||||||
id: string
|
id: string
|
||||||
providerId: string
|
providerId: string
|
||||||
name: string
|
name: string
|
||||||
|
apiLogin?: string
|
||||||
|
/** @deprecated используйте apiLogin */
|
||||||
login?: string
|
login?: string
|
||||||
apiCredentialsSet?: boolean
|
apiCredentialsSet?: boolean
|
||||||
billingMode?: BillingMode
|
billingMode?: BillingMode
|
||||||
|
|||||||
@@ -17,19 +17,26 @@
|
|||||||
"./repositories/*": {
|
"./repositories/*": {
|
||||||
"types": "./dist/repositories/*.d.ts",
|
"types": "./dist/repositories/*.d.ts",
|
||||||
"default": "./dist/repositories/*.js"
|
"default": "./dist/repositories/*.js"
|
||||||
|
},
|
||||||
|
"./test-setup": {
|
||||||
|
"types": "./dist/test-setup.d.ts",
|
||||||
|
"default": "./dist/test-setup.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json"
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"drizzle-orm": "^0.40.0",
|
"drizzle-orm": "^0.40.0",
|
||||||
"better-sqlite3": "^11.10.0"
|
"better-sqlite3": "^11.10.0",
|
||||||
|
"@cfdm/shared": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"drizzle-kit": "^0.30.0",
|
"drizzle-kit": "^0.30.0",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^22.10.0",
|
"@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 { getDb, schema } from '../index.js'
|
||||||
import { generateId } from './utils.js'
|
import { generateId } from './utils.js'
|
||||||
|
|
||||||
@@ -10,12 +11,25 @@ type AccountInsert = Partial<typeof schema.providerAccounts.$inferInsert> & {
|
|||||||
|
|
||||||
export interface PublicAccountRow extends Omit<AccountRow, 'apiCredentials'> {
|
export interface PublicAccountRow extends Omit<AccountRow, 'apiCredentials'> {
|
||||||
apiCredentialsSet: boolean
|
apiCredentialsSet: boolean
|
||||||
|
apiLogin: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountDependencyCounts {
|
||||||
|
vps: number
|
||||||
|
payments: number
|
||||||
|
balanceLedger: number
|
||||||
|
activeTariffs: number
|
||||||
|
syncLog: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitize(row: AccountRow | undefined): PublicAccountRow | undefined {
|
function sanitize(row: AccountRow | undefined): PublicAccountRow | undefined {
|
||||||
if (!row) return undefined
|
if (!row) return undefined
|
||||||
const { apiCredentials, ...rest } = row
|
const { apiCredentials, ...rest } = row
|
||||||
return { ...rest, apiCredentialsSet: Boolean(apiCredentials) }
|
return {
|
||||||
|
...rest,
|
||||||
|
apiCredentialsSet: Boolean(apiCredentials),
|
||||||
|
apiLogin: parseApiLogin(apiCredentials),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalize(input: Partial<AccountRow>) {
|
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 = {
|
export const providerAccountsRepository = {
|
||||||
list(): PublicAccountRow[] {
|
list(): PublicAccountRow[] {
|
||||||
const rows = getDb()
|
const rows = getDb()
|
||||||
@@ -62,6 +106,16 @@ export const providerAccountsRepository = {
|
|||||||
.get()
|
.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 {
|
create(input: AccountInsert, id?: string): PublicAccountRow {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const finalId = id ?? input.id ?? generateId('account')
|
const finalId = id ?? input.id ?? generateId('account')
|
||||||
@@ -76,8 +130,8 @@ export const providerAccountsRepository = {
|
|||||||
const existing = this.getWithCredentials(id)
|
const existing = this.getWithCredentials(id)
|
||||||
if (!existing) return undefined
|
if (!existing) return undefined
|
||||||
const apiCredentials =
|
const apiCredentials =
|
||||||
input.apiCredentials !== undefined
|
input.apiCredentials !== undefined && String(input.apiCredentials || '').trim() !== ''
|
||||||
? String(input.apiCredentials || '')
|
? String(input.apiCredentials)
|
||||||
: (existing.apiCredentials || '')
|
: (existing.apiCredentials || '')
|
||||||
|
|
||||||
let balanceAlertBelow = existing.balanceAlertBelow
|
let balanceAlertBelow = existing.balanceAlertBelow
|
||||||
@@ -112,3 +166,5 @@ export const providerAccountsRepository = {
|
|||||||
return res.changes > 0
|
return res.changes > 0
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { parseApiLogin }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -25,6 +25,10 @@
|
|||||||
"./geo/*": {
|
"./geo/*": {
|
||||||
"types": "./dist/geo/*.d.ts",
|
"types": "./dist/geo/*.d.ts",
|
||||||
"default": "./dist/geo/*.js"
|
"default": "./dist/geo/*.js"
|
||||||
|
},
|
||||||
|
"./utils/*": {
|
||||||
|
"types": "./dist/utils/*.d.ts",
|
||||||
|
"default": "./dist/utils/*.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,15 +1,29 @@
|
|||||||
import { z } from 'zod'
|
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(),
|
id: z.string().optional(),
|
||||||
providerId: z.string().min(1, 'Provider is required'),
|
providerId: z.string().min(1, 'Provider is required'),
|
||||||
name: z.string().min(1, 'Name is required'),
|
name: z.string().min(1, 'Name is required'),
|
||||||
panelUrl: z.string().optional().default(''),
|
panelUrl: z.string().optional().default(''),
|
||||||
currency: z.string().optional().default(''),
|
currency: z.string().optional().default(''),
|
||||||
billingMode: z.string().optional().default(''),
|
billingMode: billingModeSchema.optional().default('monthly'),
|
||||||
notes: z.string().optional().default(''),
|
notes: z.string().optional().default(''),
|
||||||
apiCredentials: z.string().optional().default(''),
|
apiCredentials: z.string().optional().default(''),
|
||||||
balanceAlertBelow: z.union([z.number(), z.null()]).optional(),
|
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,5 +1,6 @@
|
|||||||
export * from './contracts/provider.js'
|
export * from './contracts/provider.js'
|
||||||
export * from './contracts/provider-account.js'
|
export * from './contracts/provider-account.js'
|
||||||
|
export * from './utils/api-credentials.js'
|
||||||
export * from './contracts/vps.js'
|
export * from './contracts/vps.js'
|
||||||
export * from './contracts/payment.js'
|
export * from './contracts/payment.js'
|
||||||
export * from './contracts/balance-ledger.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}` : ''
|
||||||
|
}
|
||||||
Generated
+6
@@ -199,6 +199,9 @@ importers:
|
|||||||
|
|
||||||
packages/db:
|
packages/db:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@cfdm/shared':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../shared
|
||||||
better-sqlite3:
|
better-sqlite3:
|
||||||
specifier: ^11.10.0
|
specifier: ^11.10.0
|
||||||
version: 11.10.0
|
version: 11.10.0
|
||||||
@@ -218,6 +221,9 @@ importers:
|
|||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.9.2
|
specifier: ^5.9.2
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
vitest:
|
||||||
|
specifier: ^3.0.0
|
||||||
|
version: 3.2.6(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
|
||||||
|
|
||||||
packages/shared:
|
packages/shared:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user