Переименован Runway в «Запас дней»; минимум дней до оплаты активных VPS. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user