Переименован 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 { countExpiringWithin7Days, countInventoryIssues } from '@cfdm/shared/utils/inventory-health'
|
||||||
import { accountBalanceApi } from '@cfdm/shared/utils/account-balance'
|
import { accountBalanceApi } from '@cfdm/shared/utils/account-balance'
|
||||||
import { isSyncApiType } from '@cfdm/shared/contracts/provider'
|
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
|
const STALE_SYNC_HOURS = 48
|
||||||
|
|
||||||
@@ -28,6 +34,32 @@ function lastOkSyncAt(accountId: string, syncLog: { accountId: string; status: s
|
|||||||
return best
|
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 {
|
export interface DashboardStats {
|
||||||
activeVpsCount: number
|
activeVpsCount: number
|
||||||
totalVpsCount: number
|
totalVpsCount: number
|
||||||
@@ -54,22 +86,12 @@ export function computeDashboardStats(): DashboardStats {
|
|||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
|
|
||||||
const burnByAccount = new Map<string, number>()
|
const paidUntilCtx = {
|
||||||
for (const v of activeVps) {
|
providerAccounts: snap.providerAccounts,
|
||||||
const burn = vpsBurnRate(v)
|
payments: snap.payments,
|
||||||
const accountId = v.providerAccountId
|
balanceLedger: snap.balanceLedger,
|
||||||
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 minRunwayDays = computeMinRunwayDays(snap.vps, paidUntilCtx, now)
|
||||||
|
|
||||||
const expiringWithin7Days = countExpiringWithin7Days(
|
const expiringWithin7Days = countExpiringWithin7Days(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export function SystemMonitorPopover() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'runway',
|
id: 'runway',
|
||||||
label: 'Runway',
|
label: 'Запас дней',
|
||||||
value: runwayDays != null ? String(runwayDays) : '—',
|
value: runwayDays != null ? String(runwayDays) : '—',
|
||||||
unit: runwayDays != null ? 'дн' : '',
|
unit: runwayDays != null ? 'дн' : '',
|
||||||
percent:
|
percent:
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ function DashboardPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'runway',
|
id: 'runway',
|
||||||
label: 'Runway',
|
label: 'Запас дней',
|
||||||
value: runwayDays != null ? `${runwayDays} дн` : '—',
|
value: runwayDays != null ? `${runwayDays} дн` : '—',
|
||||||
icon: <ClockIcon className="size-4" />,
|
icon: <ClockIcon className="size-4" />,
|
||||||
iconClassName: runwayLow ? 'text-warning' : 'text-muted-foreground',
|
iconClassName: runwayLow ? 'text-warning' : 'text-muted-foreground',
|
||||||
|
|||||||
Reference in New Issue
Block a user