fix(dashboard): запас дней по paidUntil вместо balance/burn
Docker / build (push) Failing after 20s

Переименован Runway в «Запас дней»; минимум дней до оплаты активных VPS.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-20 00:47:28 +07:00
co-authored by Cursor
parent b7a4a10333
commit d25beec289
4 changed files with 133 additions and 17 deletions
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest'
import { computeMinRunwayDays } from './dashboard-stats.js'
describe('computeMinRunwayDays', () => {
const now = new Date('2026-07-20T12:00:00Z')
const accounts = [{ id: 'acc-1', balanceApi: 0 }]
const emptyExtra = { payments: [], balanceLedger: [] }
it('prepaid: берёт ближайший paidUntil, игнорирует нулевой баланс', () => {
const days = computeMinRunwayDays(
[
{
id: 'v1',
status: 'active',
providerAccountId: 'acc-1',
tariffType: 'monthly',
monthlyRate: 500,
paidUntil: '2026-08-21',
},
{
id: 'v2',
status: 'active',
providerAccountId: 'acc-1',
tariffType: 'monthly',
monthlyRate: 300,
paidUntil: '2026-09-01',
},
],
{ providerAccounts: accounts, ...emptyExtra },
now,
)
// 2026-07-20 → 2026-08-21 = 32 дня
expect(days).toBe(32)
})
it('просроченный VPS → запас 0', () => {
const days = computeMinRunwayDays(
[
{
id: 'v1',
status: 'active',
providerAccountId: 'acc-1',
tariffType: 'monthly',
monthlyRate: 100,
paidUntil: '2026-07-01',
},
{
id: 'v2',
status: 'active',
providerAccountId: 'acc-1',
tariffType: 'monthly',
monthlyRate: 100,
paidUntil: '2026-09-01',
},
],
{ providerAccounts: accounts, ...emptyExtra },
now,
)
expect(days).toBe(0)
})
it('daily: запас из баланса / burn, а не из нулевого paidUntil-логики', () => {
const days = computeMinRunwayDays(
[
{
id: 'v1',
status: 'active',
providerAccountId: 'acc-1',
tariffType: 'daily',
dailyRate: 10,
paidUntil: '2026-07-21',
},
],
{
providerAccounts: [{ id: 'acc-1', balanceApi: 100, billingMode: 'daily' }],
...emptyExtra,
},
now,
)
// 100 / 10 = 10 дней покрытия
expect(days).toBe(10)
})
it('без активных с датой → null', () => {
expect(
computeMinRunwayDays(
[{ id: 'v1', status: 'stopped', paidUntil: '2026-08-01' }],
{ providerAccounts: accounts, ...emptyExtra },
now,
),
).toBeNull()
})
})
+37 -15
View File
@@ -2,6 +2,12 @@ import { getSnapshot } from '@cfdm/db/repositories/snapshot'
import { countExpiringWithin7Days, countInventoryIssues } from '@cfdm/shared/utils/inventory-health'
import { accountBalanceApi } from '@cfdm/shared/utils/account-balance'
import { isSyncApiType } from '@cfdm/shared/contracts/provider'
import {
getPaidUntilDate,
type PaidUntilAccount,
type PaidUntilContext,
type PaidUntilVps,
} from '@cfdm/shared/utils/paid-until'
const STALE_SYNC_HOURS = 48
@@ -28,6 +34,32 @@ function lastOkSyncAt(accountId: string, syncLog: { accountId: string; status: s
return best
}
/**
* Минимальный остаток дней до оплаты среди активных VPS.
* Prepaid: по `paidUntil`; daily / «завтра»: через `getPaidUntilDate` (баланс).
* Не путать с balance/burn — у prepaid баланс часто ≈0 при уже оплаченном периоде.
*/
export function computeMinRunwayDays(
vps: PaidUntilVps[],
ctx: Omit<PaidUntilContext, 'vps' | 'now'> & { providerAccounts: PaidUntilAccount[] },
now = new Date(),
): number | null {
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const fullCtx: PaidUntilContext = { ...ctx, vps, now }
let minDays: number | null = null
for (const item of vps) {
if (item.status !== 'active') continue
const until = getPaidUntilDate(item, fullCtx)
if (until == null) continue
const days = Math.floor((until.getTime() - todayStart.getTime()) / (24 * 60 * 60 * 1000))
const remaining = Math.max(0, days)
if (minDays == null || remaining < minDays) minDays = remaining
}
return minDays
}
export interface DashboardStats {
activeVpsCount: number
totalVpsCount: number
@@ -54,22 +86,12 @@ export function computeDashboardStats(): DashboardStats {
0,
)
const burnByAccount = new Map<string, number>()
for (const v of activeVps) {
const burn = vpsBurnRate(v)
const accountId = v.providerAccountId
if (!accountId) continue
burnByAccount.set(accountId, (burnByAccount.get(accountId) ?? 0) + burn)
}
let minRunwayDays: number | null = null
for (const account of snap.providerAccounts) {
const balance = Number(account.balanceApi ?? 0)
const burn = burnByAccount.get(account.id) ?? 0
if (balance <= 0 || burn <= 0) continue
const days = Math.floor((balance / burn) * 30)
if (minRunwayDays == null || days < minRunwayDays) minRunwayDays = days
const paidUntilCtx = {
providerAccounts: snap.providerAccounts,
payments: snap.payments,
balanceLedger: snap.balanceLedger,
}
const minRunwayDays = computeMinRunwayDays(snap.vps, paidUntilCtx, now)
const expiringWithin7Days = countExpiringWithin7Days(
{
@@ -142,7 +142,7 @@ export function SystemMonitorPopover() {
},
{
id: 'runway',
label: 'Runway',
label: 'Запас дней',
value: runwayDays != null ? String(runwayDays) : '—',
unit: runwayDays != null ? 'дн' : '',
percent:
+1 -1
View File
@@ -307,7 +307,7 @@ function DashboardPage() {
},
{
id: 'runway',
label: 'Runway',
label: 'Запас дней',
value: runwayDays != null ? `${runwayDays} дн` : '—',
icon: <ClockIcon className="size-4" />,
iconClassName: runwayLow ? 'text-warning' : 'text-muted-foreground',