From 5961233dedf79653a69b0db619fad7d3d6747d85 Mon Sep 17 00:00:00 2001 From: Brian Luo <57960778+law-chain-hot@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:52:41 +0800 Subject: [PATCH 1/2] feat(usage): add metering ledger --- apps/api/src/app.module.ts | 2 + .../pre-deploy/1782700000000-migration.ts | 71 +++ .../entities/usage-period-archive.entity.ts | 87 ++++ .../src/usage/entities/usage-period.entity.ts | 59 +++ .../usage/metering/usage-period-math.spec.ts | 131 ++++++ .../src/usage/metering/usage-period-math.ts | 93 ++++ apps/api/src/usage/usage.controller.spec.ts | 47 ++ apps/api/src/usage/usage.controller.ts | 64 +++ apps/api/src/usage/usage.module.ts | 22 + apps/api/src/usage/usage.service.spec.ts | 436 ++++++++++++++++++ apps/api/src/usage/usage.service.ts | 376 +++++++++++++++ apps/dashboard/src/App.tsx | 2 + apps/dashboard/src/api/apiClient.ts | 2 +- .../src/billing-api/billingApiClient.ts | 25 + .../billing-api/types/OrganizationMetering.ts | 52 +++ apps/dashboard/src/billing-api/types/index.ts | 1 + apps/dashboard/src/components/Sidebar.tsx | 1 + apps/dashboard/src/enums/RoutePath.ts | 1 + apps/dashboard/src/hooks/queries/queryKeys.ts | 2 + .../queries/useOrganizationMeteringQuery.ts | 27 ++ apps/dashboard/src/pages/Metering.test.tsx | 146 ++++++ apps/dashboard/src/pages/Metering.tsx | 233 ++++++++++ apps/package.json | 2 + apps/scripts/metering-local-e2e.mjs | 390 ++++++++++++++++ apps/yarn.lock | 10 + 25 files changed, 2281 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/migrations/pre-deploy/1782700000000-migration.ts create mode 100644 apps/api/src/usage/entities/usage-period-archive.entity.ts create mode 100644 apps/api/src/usage/entities/usage-period.entity.ts create mode 100644 apps/api/src/usage/metering/usage-period-math.spec.ts create mode 100644 apps/api/src/usage/metering/usage-period-math.ts create mode 100644 apps/api/src/usage/usage.controller.spec.ts create mode 100644 apps/api/src/usage/usage.controller.ts create mode 100644 apps/api/src/usage/usage.module.ts create mode 100644 apps/api/src/usage/usage.service.spec.ts create mode 100644 apps/api/src/usage/usage.service.ts create mode 100644 apps/dashboard/src/billing-api/types/OrganizationMetering.ts create mode 100644 apps/dashboard/src/hooks/queries/useOrganizationMeteringQuery.ts create mode 100644 apps/dashboard/src/pages/Metering.test.tsx create mode 100644 apps/dashboard/src/pages/Metering.tsx create mode 100644 apps/scripts/metering-local-e2e.mjs diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 592aaa2f7..f9c15c124 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -44,6 +44,7 @@ import { AdminModule } from './admin/admin.module' import { ClickHouseModule } from './clickhouse/clickhouse.module' import { BoxTelemetryModule } from './box-telemetry/box-telemetry.module' import { BoxliteRestModule } from './boxlite-rest/boxlite-rest.module' +import { UsageModule } from './usage/usage.module' @Module({ imports: [ @@ -199,6 +200,7 @@ import { BoxliteRestModule } from './boxlite-rest/boxlite-rest.module' ClickHouseModule, BoxTelemetryModule, BoxliteRestModule, + UsageModule, OpenFeatureModule.forRoot({ contextFactory: (request: ExecutionContext) => { const req = request.switchToHttp().getRequest() diff --git a/apps/api/src/migrations/pre-deploy/1782700000000-migration.ts b/apps/api/src/migrations/pre-deploy/1782700000000-migration.ts new file mode 100644 index 000000000..743fe87c9 --- /dev/null +++ b/apps/api/src/migrations/pre-deploy/1782700000000-migration.ts @@ -0,0 +1,71 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class Migration1782700000000 implements MigrationInterface { + name = 'Migration1782700000000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "usage_period" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "boxId" character varying NOT NULL, + "organizationId" character varying NOT NULL, + "region" character varying, + "startAt" TIMESTAMP WITH TIME ZONE NOT NULL, + "endAt" TIMESTAMP WITH TIME ZONE, + "kind" character varying NOT NULL, + "cpu" double precision NOT NULL, + "gpu" double precision NOT NULL, + "mem" double precision NOT NULL, + "disk" double precision NOT NULL, + "actualCpuSeconds" double precision, + "actualRssAvgBytes" bigint, + "actualRssPeakBytes" bigint, + "sampleCount" integer, + CONSTRAINT "usage_period_id_pk" PRIMARY KEY ("id"), + CONSTRAINT "usage_period_end_after_start" CHECK ("endAt" IS NULL OR "endAt" >= "startAt") + )`, + ) + await queryRunner.query(`CREATE INDEX "usage_period_box_end_idx" ON "usage_period" ("boxId", "endAt")`) + await queryRunner.query(`CREATE INDEX "usage_period_org_start_idx" ON "usage_period" ("organizationId", "startAt")`) + await queryRunner.query( + `CREATE UNIQUE INDEX "usage_period_one_open_per_box_idx" ON "usage_period" ("boxId") WHERE "endAt" IS NULL`, + ) + + await queryRunner.query( + `CREATE TABLE "usage_period_archive" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "sourcePeriodId" uuid NOT NULL, + "boxId" character varying NOT NULL, + "organizationId" character varying NOT NULL, + "region" character varying, + "startAt" TIMESTAMP WITH TIME ZONE NOT NULL, + "endAt" TIMESTAMP WITH TIME ZONE NOT NULL, + "kind" character varying NOT NULL, + "cpu" double precision NOT NULL, + "gpu" double precision NOT NULL, + "mem" double precision NOT NULL, + "disk" double precision NOT NULL, + "actualCpuSeconds" double precision, + "actualRssAvgBytes" bigint, + "actualRssPeakBytes" bigint, + "sampleCount" integer, + CONSTRAINT "usage_period_archive_id_pk" PRIMARY KEY ("id"), + CONSTRAINT "usage_period_archive_end_after_start" CHECK ("endAt" >= "startAt") + )`, + ) + await queryRunner.query( + `CREATE UNIQUE INDEX "usage_period_archive_source_period_idx" ON "usage_period_archive" ("sourcePeriodId")`, + ) + await queryRunner.query( + `CREATE INDEX "usage_period_archive_org_start_idx" ON "usage_period_archive" ("organizationId", "startAt")`, + ) + await queryRunner.query( + `CREATE INDEX "usage_period_archive_box_start_idx" ON "usage_period_archive" ("boxId", "startAt")`, + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "usage_period_archive"`) + await queryRunner.query(`DROP TABLE "usage_period"`) + } +} diff --git a/apps/api/src/usage/entities/usage-period-archive.entity.ts b/apps/api/src/usage/entities/usage-period-archive.entity.ts new file mode 100644 index 000000000..1e4b1acfe --- /dev/null +++ b/apps/api/src/usage/entities/usage-period-archive.entity.ts @@ -0,0 +1,87 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { Check, Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm' +import type { UsagePeriodKind } from '../metering/usage-period-math' +import { UsagePeriod } from './usage-period.entity' + +@Entity('usage_period_archive') +@Index('usage_period_archive_source_period_idx', ['sourcePeriodId'], { unique: true }) +@Index('usage_period_archive_org_start_idx', ['organizationId', 'startAt']) +@Index('usage_period_archive_box_start_idx', ['boxId', 'startAt']) +@Check('usage_period_archive_end_after_start', '"endAt" >= "startAt"') +export class UsagePeriodArchive { + @PrimaryGeneratedColumn('uuid') + id: string + + @Column() + sourcePeriodId: string + + @Column() + boxId: string + + @Column() + organizationId: string + + @Column({ nullable: true }) + region: string | null + + @Column({ type: 'timestamp with time zone' }) + startAt: Date + + @Column({ type: 'timestamp with time zone' }) + endAt: Date + + @Column({ type: 'character varying' }) + kind: UsagePeriodKind + + @Column({ type: 'float' }) + cpu: number + + @Column({ type: 'float' }) + gpu: number + + @Column({ type: 'float' }) + mem: number + + @Column({ type: 'float' }) + disk: number + + @Column({ type: 'double precision', nullable: true }) + actualCpuSeconds: number | null + + @Column({ type: 'bigint', nullable: true }) + actualRssAvgBytes: string | null + + @Column({ type: 'bigint', nullable: true }) + actualRssPeakBytes: string | null + + @Column({ type: 'int', nullable: true }) + sampleCount: number | null + + static fromUsagePeriod(period: UsagePeriod): UsagePeriodArchive { + if (!period.endAt) { + throw new Error(`usage period ${period.id} cannot be archived while open`) + } + + const archived = new UsagePeriodArchive() + archived.sourcePeriodId = period.id + archived.boxId = period.boxId + archived.organizationId = period.organizationId + archived.region = period.region + archived.startAt = period.startAt + archived.endAt = period.endAt + archived.kind = period.kind + archived.cpu = period.cpu + archived.gpu = period.gpu + archived.mem = period.mem + archived.disk = period.disk + archived.actualCpuSeconds = period.actualCpuSeconds + archived.actualRssAvgBytes = period.actualRssAvgBytes + archived.actualRssPeakBytes = period.actualRssPeakBytes + archived.sampleCount = period.sampleCount + return archived + } +} diff --git a/apps/api/src/usage/entities/usage-period.entity.ts b/apps/api/src/usage/entities/usage-period.entity.ts new file mode 100644 index 000000000..00756a733 --- /dev/null +++ b/apps/api/src/usage/entities/usage-period.entity.ts @@ -0,0 +1,59 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { Check, Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm' +import type { UsagePeriodKind } from '../metering/usage-period-math' + +@Entity('usage_period') +@Index('usage_period_box_end_idx', ['boxId', 'endAt']) +@Index('usage_period_org_start_idx', ['organizationId', 'startAt']) +@Index('usage_period_one_open_per_box_idx', ['boxId'], { unique: true, where: '"endAt" IS NULL' }) +@Check('usage_period_end_after_start', '"endAt" IS NULL OR "endAt" >= "startAt"') +export class UsagePeriod { + @PrimaryGeneratedColumn('uuid') + id: string + + @Column() + boxId: string + + @Column() + organizationId: string + + @Column({ nullable: true }) + region: string | null + + @Column({ type: 'timestamp with time zone' }) + startAt: Date + + @Column({ type: 'timestamp with time zone', nullable: true }) + endAt: Date | null + + @Column({ type: 'character varying' }) + kind: UsagePeriodKind + + @Column({ type: 'float' }) + cpu: number + + @Column({ type: 'float' }) + gpu: number + + @Column({ type: 'float' }) + mem: number + + @Column({ type: 'float' }) + disk: number + + @Column({ type: 'double precision', nullable: true }) + actualCpuSeconds: number | null + + @Column({ type: 'bigint', nullable: true }) + actualRssAvgBytes: string | null + + @Column({ type: 'bigint', nullable: true }) + actualRssPeakBytes: string | null + + @Column({ type: 'int', nullable: true }) + sampleCount: number | null +} diff --git a/apps/api/src/usage/metering/usage-period-math.spec.ts b/apps/api/src/usage/metering/usage-period-math.spec.ts new file mode 100644 index 000000000..5eb388563 --- /dev/null +++ b/apps/api/src/usage/metering/usage-period-math.spec.ts @@ -0,0 +1,131 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { BoxDesiredState } from '../../box/enums/box-desired-state.enum' +import { BoxState } from '../../box/enums/box-state.enum' +import { + aggregateUsagePeriods, + planUsageTransition, + usagePeriodKind, + UsagePeriodLike, +} from './usage-period-math' + +describe('usagePeriodKind', () => { + it('maps STARTED to running compute plus disk', () => { + expect(usagePeriodKind(BoxState.STARTED, BoxDesiredState.STARTED)).toBe('running') + }) + + it('keeps hot resize as running when desired state is STARTED', () => { + expect(usagePeriodKind(BoxState.RESIZING, BoxDesiredState.STARTED)).toBe('running') + }) + + it('treats stopped and stopped resize as disk-only', () => { + expect(usagePeriodKind(BoxState.STOPPED, BoxDesiredState.STOPPED)).toBe('stopped') + expect(usagePeriodKind(BoxState.RESIZING, BoxDesiredState.STOPPED)).toBe('stopped') + }) + + it('maps destroy states to gone', () => { + expect(usagePeriodKind(BoxState.DESTROYING, BoxDesiredState.DESTROYED)).toBe('gone') + expect(usagePeriodKind(BoxState.DESTROYED, BoxDesiredState.DESTROYED)).toBe('gone') + }) +}) + +describe('planUsageTransition', () => { + it('opens a running period when there is no open period and the box starts', () => { + expect(planUsageTransition(null, BoxState.STARTED, BoxDesiredState.STARTED)).toEqual({ + closeOpen: false, + openKind: 'running', + }) + }) + + it('closes running and opens stopped when a box stops', () => { + expect(planUsageTransition('running', BoxState.STOPPED, BoxDesiredState.STOPPED)).toEqual({ + closeOpen: true, + openKind: 'stopped', + }) + }) + + it('does not fragment same-kind transitions', () => { + expect(planUsageTransition('running', BoxState.RESIZING, BoxDesiredState.STARTED)).toEqual({ + closeOpen: false, + openKind: null, + }) + }) + + it('closes the open period and opens nothing when a box is destroyed', () => { + expect(planUsageTransition('stopped', BoxState.DESTROYED, BoxDesiredState.DESTROYED)).toEqual({ + closeOpen: true, + openKind: null, + }) + }) +}) + +describe('aggregateUsagePeriods', () => { + const from = new Date('2026-07-08T00:00:00Z') + const to = new Date('2026-07-08T00:01:00Z') + + it('bills cpu, memory, disk, and gpu for running periods', () => { + const periods: UsagePeriodLike[] = [ + { + startAt: new Date('2026-07-08T00:00:10Z'), + endAt: new Date('2026-07-08T00:00:20Z'), + kind: 'running', + cpu: 2, + mem: 4, + disk: 10, + gpu: 1, + }, + ] + + expect(aggregateUsagePeriods(periods, from, to)).toEqual({ + cpuSeconds: 20, + memGibSeconds: 40, + diskGibSeconds: 100, + gpuSeconds: 10, + }) + }) + + it('bills only disk for stopped periods', () => { + const periods: UsagePeriodLike[] = [ + { + startAt: new Date('2026-07-08T00:00:10Z'), + endAt: new Date('2026-07-08T00:00:20Z'), + kind: 'stopped', + cpu: 2, + mem: 4, + disk: 10, + gpu: 1, + }, + ] + + expect(aggregateUsagePeriods(periods, from, to)).toEqual({ + cpuSeconds: 0, + memGibSeconds: 0, + diskGibSeconds: 100, + gpuSeconds: 0, + }) + }) + + it('clips open and overlapping periods to the requested range', () => { + const periods: UsagePeriodLike[] = [ + { + startAt: new Date('2026-07-07T23:59:50Z'), + endAt: null, + kind: 'running', + cpu: 1, + mem: 1, + disk: 1, + gpu: 0, + }, + ] + + expect(aggregateUsagePeriods(periods, from, to)).toEqual({ + cpuSeconds: 60, + memGibSeconds: 60, + diskGibSeconds: 60, + gpuSeconds: 0, + }) + }) +}) diff --git a/apps/api/src/usage/metering/usage-period-math.ts b/apps/api/src/usage/metering/usage-period-math.ts new file mode 100644 index 000000000..f87e90140 --- /dev/null +++ b/apps/api/src/usage/metering/usage-period-math.ts @@ -0,0 +1,93 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { BoxDesiredState } from '../../box/enums/box-desired-state.enum' +import { BoxState } from '../../box/enums/box-state.enum' + +export type UsagePeriodKind = 'running' | 'stopped' +export type UsageLifecycleKind = UsagePeriodKind | 'gone' + +export interface UsageTransitionPlan { + closeOpen: boolean + openKind: UsagePeriodKind | null +} + +export interface UsagePeriodLike { + startAt: Date + endAt: Date | null + kind: UsagePeriodKind + cpu: number + mem: number + disk: number + gpu: number +} + +export interface UsageTotals { + cpuSeconds: number + memGibSeconds: number + diskGibSeconds: number + gpuSeconds: number +} + +export function usagePeriodKind(state: BoxState, desiredState?: BoxDesiredState): UsageLifecycleKind { + switch (state) { + case BoxState.STARTED: + return 'running' + case BoxState.RESIZING: + return desiredState === BoxDesiredState.STARTED ? 'running' : 'stopped' + case BoxState.DESTROYING: + case BoxState.DESTROYED: + return 'gone' + default: + return 'stopped' + } +} + +export function planUsageTransition( + openKind: UsagePeriodKind | null, + newState: BoxState, + desiredState?: BoxDesiredState, +): UsageTransitionPlan { + const nextKind = usagePeriodKind(newState, desiredState) + + if (nextKind === 'gone') { + return { closeOpen: openKind !== null, openKind: null } + } + + if (openKind === nextKind) { + return { closeOpen: false, openKind: null } + } + + return { closeOpen: openKind !== null, openKind: nextKind } +} + +export function aggregateUsagePeriods(periods: UsagePeriodLike[], from: Date, to: Date): UsageTotals { + const fromMs = from.getTime() + const toMs = to.getTime() + const totals: UsageTotals = { + cpuSeconds: 0, + memGibSeconds: 0, + diskGibSeconds: 0, + gpuSeconds: 0, + } + + for (const period of periods) { + const overlapStart = Math.max(period.startAt.getTime(), fromMs) + const overlapEnd = Math.min((period.endAt ?? to).getTime(), toMs) + if (overlapEnd <= overlapStart) { + continue + } + + const seconds = (overlapEnd - overlapStart) / 1000 + totals.diskGibSeconds += period.disk * seconds + if (period.kind === 'running') { + totals.cpuSeconds += period.cpu * seconds + totals.memGibSeconds += period.mem * seconds + totals.gpuSeconds += period.gpu * seconds + } + } + + return totals +} diff --git a/apps/api/src/usage/usage.controller.spec.ts b/apps/api/src/usage/usage.controller.spec.ts new file mode 100644 index 000000000..c4095d6f8 --- /dev/null +++ b/apps/api/src/usage/usage.controller.spec.ts @@ -0,0 +1,47 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { BadRequestException } from '@nestjs/common' +import { UsageController } from './usage.controller' +import { UsageService } from './usage.service' + +describe('UsageController', () => { + const usageService = { + getOrganizationMeteringView: jest.fn(), + } + let controller: UsageController + + beforeEach(() => { + jest.resetAllMocks() + controller = new UsageController(usageService as unknown as UsageService) + }) + + it('returns the organization metering view with parsed filters', async () => { + usageService.getOrganizationMeteringView.mockResolvedValue({ organizationId: 'org-1' }) + + await expect( + controller.getOrganizationMetering( + 'org-1', + '2026-07-08T00:00:00Z', + '2026-07-08T01:00:00Z', + '25', + 'box-1', + ), + ).resolves.toEqual({ organizationId: 'org-1' }) + + expect(usageService.getOrganizationMeteringView).toHaveBeenCalledWith('org-1', { + from: new Date('2026-07-08T00:00:00Z'), + to: new Date('2026-07-08T01:00:00Z'), + limit: 25, + boxId: 'box-1', + }) + }) + + it('rejects invalid date filters', async () => { + expect(() => controller.getOrganizationMetering('org-1', 'not-a-date', undefined, undefined, undefined)).toThrow( + BadRequestException, + ) + }) +}) diff --git a/apps/api/src/usage/usage.controller.ts b/apps/api/src/usage/usage.controller.ts new file mode 100644 index 000000000..caa3d7553 --- /dev/null +++ b/apps/api/src/usage/usage.controller.ts @@ -0,0 +1,64 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { BadRequestException, Controller, Get, Param, Query, UseGuards } from '@nestjs/common' +import { ApiBearerAuth, ApiOAuth2, ApiTags } from '@nestjs/swagger' +import { CombinedAuthGuard } from '../auth/combined-auth.guard' +import { AuthenticatedRateLimitGuard } from '../common/guards/authenticated-rate-limit.guard' +import { RequiredOrganizationMemberRole } from '../organization/decorators/required-organization-member-role.decorator' +import { OrganizationMemberRole } from '../organization/enums/organization-member-role.enum' +import { OrganizationActionGuard } from '../organization/guards/organization-action.guard' +import { UsageService } from './usage.service' + +@ApiTags('usage') +@ApiOAuth2(['openid', 'profile', 'email']) +@ApiBearerAuth() +@UseGuards(CombinedAuthGuard, AuthenticatedRateLimitGuard) +@Controller() +export class UsageController { + constructor(private readonly usageService: UsageService) {} + + @Get('/organization/:organizationId/metering') + @UseGuards(OrganizationActionGuard) + @RequiredOrganizationMemberRole(OrganizationMemberRole.OWNER) + getOrganizationMetering( + @Param('organizationId') organizationId: string, + @Query('from') from?: string, + @Query('to') to?: string, + @Query('limit') limit?: string, + @Query('boxId') boxId?: string, + ) { + return this.usageService.getOrganizationMeteringView(organizationId, { + from: this.parseOptionalDate('from', from), + to: this.parseOptionalDate('to', to), + limit: this.parseOptionalLimit(limit), + boxId, + }) + } + + private parseOptionalDate(label: string, value: string | undefined): Date | undefined { + if (!value) { + return undefined + } + + const date = new Date(value) + if (Number.isNaN(date.getTime())) { + throw new BadRequestException(`Invalid ${label} date`) + } + return date + } + + private parseOptionalLimit(value: string | undefined): number | undefined { + if (!value) { + return undefined + } + + const limit = Number(value) + if (!Number.isFinite(limit)) { + throw new BadRequestException('Invalid limit') + } + return limit + } +} diff --git a/apps/api/src/usage/usage.module.ts b/apps/api/src/usage/usage.module.ts new file mode 100644 index 000000000..764b17c4c --- /dev/null +++ b/apps/api/src/usage/usage.module.ts @@ -0,0 +1,22 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { Module } from '@nestjs/common' +import { TypeOrmModule } from '@nestjs/typeorm' +import { BoxModule } from '../box/box.module' +import { OrganizationActionGuard } from '../organization/guards/organization-action.guard' +import { OrganizationModule } from '../organization/organization.module' +import { UsagePeriodArchive } from './entities/usage-period-archive.entity' +import { UsagePeriod } from './entities/usage-period.entity' +import { UsageController } from './usage.controller' +import { UsageService } from './usage.service' + +@Module({ + imports: [BoxModule, OrganizationModule, TypeOrmModule.forFeature([UsagePeriod, UsagePeriodArchive])], + controllers: [UsageController], + providers: [UsageService, OrganizationActionGuard], + exports: [UsageService], +}) +export class UsageModule {} diff --git a/apps/api/src/usage/usage.service.spec.ts b/apps/api/src/usage/usage.service.spec.ts new file mode 100644 index 000000000..c91d1bf19 --- /dev/null +++ b/apps/api/src/usage/usage.service.spec.ts @@ -0,0 +1,436 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { Box } from '../box/entities/box.entity' +import { BoxDesiredState } from '../box/enums/box-desired-state.enum' +import { BoxState } from '../box/enums/box-state.enum' +import { UsagePeriodArchive } from './entities/usage-period-archive.entity' +import { UsagePeriod } from './entities/usage-period.entity' +import { UsageService } from './usage.service' + +function matchesWhereValue(actual: unknown, expected: unknown): boolean { + const op = expected as { _type?: string; _value?: unknown } + if (op?._type === 'isNull') { + return actual === null + } + if (op?._type === 'lessThan') { + return actual instanceof Date && op._value instanceof Date && actual.getTime() < op._value.getTime() + } + if (op?._type === 'moreThan') { + return actual instanceof Date && op._value instanceof Date && actual.getTime() > op._value.getTime() + } + if (op?._type === 'not') { + return !matchesWhereValue(actual, op._value) + } + if (expected === null) { + return actual === null + } + return actual === expected +} + +class FakeUsagePeriodRepository { + rows: UsagePeriod[] = [] + archivedRows: UsagePeriodArchive[] = [] + failNextSave: Error | null = null + manager = { + transaction: jest.fn(async (fn: (manager: FakeTransactionManager) => Promise) => { + await fn(new FakeTransactionManager(this)) + }), + } + + create(input: Partial): UsagePeriod { + return input as UsagePeriod + } + + async save(row: UsagePeriod | UsagePeriod[]): Promise { + if (this.failNextSave) { + const err = this.failNextSave + this.failNextSave = null + throw err + } + + if (Array.isArray(row)) { + row.forEach((item) => this.upsert(item)) + return row + } + this.upsert(row) + return row + } + + async findOne(opts: { where: Partial }): Promise { + const match = this.rows.find((row) => { + return Object.entries(opts.where).every(([key, value]) => { + return matchesWhereValue((row as unknown as Record)[key], value) + }) + }) + return match ?? null + } + + async find(opts: { + where?: Partial | Partial[] + order?: { startAt?: 'ASC' | 'DESC' } + take?: number + }): Promise { + const where = Array.isArray(opts.where) ? opts.where : [opts.where ?? {}] + const rows = this.rows.filter((row) => + where.some((whereItem) => + Object.entries(whereItem).every(([key, value]) => { + return matchesWhereValue((row as unknown as Record)[key], value) + }), + ), + ) + + if (opts.order?.startAt === 'DESC') { + rows.sort((left, right) => right.startAt.getTime() - left.startAt.getTime()) + } else if (opts.order?.startAt === 'ASC') { + rows.sort((left, right) => left.startAt.getTime() - right.startAt.getTime()) + } + + return rows.slice(0, opts.take) + } + + async delete(ids: string[]): Promise { + this.rows = this.rows.filter((row) => !ids.includes(row.id)) + } + + private upsert(row: UsagePeriod): void { + if (!row.id) { + row.id = `period-${this.rows.length + 1}` + this.rows.push(row) + return + } + + const existing = this.rows.findIndex((item) => item.id === row.id) + if (existing === -1) { + this.rows.push(row) + } else { + this.rows[existing] = row + } + } +} + +class FakeTransactionManager { + constructor(private readonly periods: FakeUsagePeriodRepository) {} + + async save(_entity: typeof UsagePeriodArchive, rows: UsagePeriodArchive[]): Promise { + rows.forEach((row) => { + row.id = row.id ?? `archive-${this.periods.archivedRows.length + 1}` + this.periods.archivedRows.push(row) + }) + return rows + } + + async delete(_entity: typeof UsagePeriod, ids: string[]): Promise { + await this.periods.delete(ids) + } +} + +class FakeArchiveRepository { + rows: UsagePeriodArchive[] = [] + + create(input: Partial): UsagePeriodArchive { + return input as UsagePeriodArchive + } + + async find(opts: { + where?: Partial | Partial[] + order?: { startAt?: 'ASC' | 'DESC' } + take?: number + }): Promise { + const where = Array.isArray(opts.where) ? opts.where : [opts.where ?? {}] + const rows = this.rows.filter((row) => + where.some((whereItem) => + Object.entries(whereItem).every(([key, value]) => { + return matchesWhereValue((row as unknown as Record)[key], value) + }), + ), + ) + + if (opts.order?.startAt === 'DESC') { + rows.sort((left, right) => right.startAt.getTime() - left.startAt.getTime()) + } else if (opts.order?.startAt === 'ASC') { + rows.sort((left, right) => left.startAt.getTime() - right.startAt.getTime()) + } + + return rows.slice(0, opts.take) + } + + async save(rows: UsagePeriodArchive[]): Promise { + rows.forEach((row) => { + row.id = row.id ?? `archive-${this.rows.length + 1}` + this.rows.push(row) + }) + return rows + } +} + +class FakeLockProvider { + unlocks: string[] = [] + + async waitForLock(): Promise {} + async lock(): Promise { + return true + } + async unlock(key: string): Promise { + this.unlocks.push(key) + } +} + +class FakeBoxRepository { + boxes = new Map() + + async findOne(opts: { where: { id: string } }): Promise { + return this.boxes.get(opts.where.id) ?? null + } +} + +function makeBox(state: BoxState, desiredState: BoxDesiredState = BoxDesiredState.STARTED): Box { + return { + id: 'box-1', + organizationId: 'org-1', + region: 'us', + state, + desiredState, + cpu: 2, + mem: 4, + disk: 10, + gpu: 1, + } as Box +} + +describe('UsageService', () => { + let periods: FakeUsagePeriodRepository + let archives: FakeArchiveRepository + let locks: FakeLockProvider + let boxes: FakeBoxRepository + let service: UsageService + + beforeEach(() => { + periods = new FakeUsagePeriodRepository() + periods.archivedRows = [] + archives = new FakeArchiveRepository() + locks = new FakeLockProvider() + boxes = new FakeBoxRepository() + service = new UsageService(periods as never, archives as never, locks as never, boxes as never) + }) + + it('opens running periods and switches to disk-only periods when the box stops', async () => { + const started = makeBox(BoxState.STARTED, BoxDesiredState.STARTED) + const stopped = makeBox(BoxState.STOPPED, BoxDesiredState.STOPPED) + + await service.applyTransition(started, BoxState.STARTED, BoxDesiredState.STARTED, new Date('2026-07-08T00:00:00Z')) + await service.applyTransition(stopped, BoxState.STOPPED, BoxDesiredState.STOPPED, new Date('2026-07-08T00:00:10Z')) + + expect(periods.rows).toHaveLength(2) + expect(periods.rows[0]).toMatchObject({ kind: 'running', endAt: new Date('2026-07-08T00:00:10Z') }) + expect(periods.rows[1]).toMatchObject({ kind: 'stopped', endAt: null, cpu: 0, mem: 0, gpu: 0, disk: 10 }) + }) + + it('does not fragment the open period on same-kind transitions', async () => { + const box = makeBox(BoxState.STARTED, BoxDesiredState.STARTED) + + await service.applyTransition(box, BoxState.STARTED, BoxDesiredState.STARTED, new Date('2026-07-08T00:00:00Z')) + await service.applyTransition(box, BoxState.RESIZING, BoxDesiredState.STARTED, new Date('2026-07-08T00:00:05Z')) + + expect(periods.rows).toHaveLength(1) + expect(periods.rows[0]).toMatchObject({ kind: 'running', endAt: null }) + }) + + it('reopens the period when billable resources change without changing lifecycle kind', async () => { + const started = makeBox(BoxState.STARTED, BoxDesiredState.STARTED) + const resized = { + ...started, + cpu: 4, + mem: 8, + disk: 20, + gpu: 2, + } as Box + + await service.applyTransition(started, BoxState.STARTED, BoxDesiredState.STARTED, new Date('2026-07-08T00:00:00Z')) + await service.applyTransition(resized, BoxState.STARTED, BoxDesiredState.STARTED, new Date('2026-07-08T00:00:30Z')) + + expect(periods.rows).toHaveLength(2) + expect(periods.rows[0]).toMatchObject({ + kind: 'running', + endAt: new Date('2026-07-08T00:00:30Z'), + cpu: 2, + mem: 4, + disk: 10, + gpu: 1, + }) + expect(periods.rows[1]).toMatchObject({ + kind: 'running', + endAt: null, + cpu: 4, + mem: 8, + disk: 20, + gpu: 2, + }) + }) + + it('reopens the period under the new organization when a box is reassigned', async () => { + const started = makeBox(BoxState.STARTED, BoxDesiredState.STARTED) + const reassigned = { + ...started, + organizationId: 'org-2', + } as Box + + await service.applyTransition(started, BoxState.STARTED, BoxDesiredState.STARTED, new Date('2026-07-08T00:00:00Z')) + await service.handleBoxOrganizationUpdated({ + box: reassigned, + oldOrganizationId: 'org-1', + newOrganizationId: 'org-2', + } as never) + + expect(periods.rows).toHaveLength(2) + expect(periods.rows[0]).toMatchObject({ + kind: 'running', + organizationId: 'org-1', + endAt: expect.any(Date), + }) + expect(periods.rows[1]).toMatchObject({ + kind: 'running', + organizationId: 'org-2', + endAt: null, + }) + }) + + it('returns a raw organization metering view across active and archived periods', async () => { + periods.rows.push({ + id: 'period-open', + boxId: 'box-1', + organizationId: 'org-1', + region: 'us', + startAt: new Date('2026-07-08T00:00:00Z'), + endAt: null, + kind: 'running', + cpu: 2, + mem: 4, + disk: 10, + gpu: 1, + actualCpuSeconds: null, + actualRssAvgBytes: null, + actualRssPeakBytes: null, + sampleCount: null, + } as UsagePeriod) + archives.rows.push({ + id: 'archive-1', + sourcePeriodId: 'period-closed', + boxId: 'box-2', + organizationId: 'org-1', + region: 'us', + startAt: new Date('2026-07-07T23:00:00Z'), + endAt: new Date('2026-07-08T00:00:00Z'), + kind: 'stopped', + cpu: 0, + mem: 0, + disk: 20, + gpu: 0, + actualCpuSeconds: null, + actualRssAvgBytes: null, + actualRssPeakBytes: null, + sampleCount: null, + } as UsagePeriodArchive) + + const view = await service.getOrganizationMeteringView('org-1', { + from: new Date('2026-07-07T23:00:00Z'), + to: new Date('2026-07-08T01:00:00Z'), + limit: 10, + }) + + expect(view.organizationId).toBe('org-1') + expect(view.activePeriods).toHaveLength(1) + expect(view.archivedPeriods).toHaveLength(1) + expect(view.totals).toEqual({ + cpuSeconds: 7200, + memGibSeconds: 14400, + diskGibSeconds: 108000, + gpuSeconds: 3600, + }) + expect(view.activePeriods[0]).toMatchObject({ + source: 'usage_period', + active: true, + durationSeconds: 3600, + }) + expect(view.archivedPeriods[0]).toMatchObject({ + source: 'usage_period_archive', + active: false, + durationSeconds: 3600, + }) + }) + + it('ignores stale transitions that arrive before the open period start', async () => { + const warn = jest.spyOn((service as unknown as { logger: { warn: jest.Mock } }).logger, 'warn').mockImplementation() + const started = makeBox(BoxState.STARTED, BoxDesiredState.STARTED) + const stopped = makeBox(BoxState.STOPPED, BoxDesiredState.STOPPED) + + await service.applyTransition(started, BoxState.STARTED, BoxDesiredState.STARTED, new Date('2026-07-08T00:00:10Z')) + await service.applyTransition(stopped, BoxState.STOPPED, BoxDesiredState.STOPPED, new Date('2026-07-08T00:00:05Z')) + + expect(periods.rows).toHaveLength(1) + expect(periods.rows[0]).toMatchObject({ kind: 'running', startAt: new Date('2026-07-08T00:00:10Z'), endAt: null }) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('ignoring stale usage transition')) + }) + + it('rolls open periods older than 24 hours and reopens the same billable kind', async () => { + const box = makeBox(BoxState.STARTED, BoxDesiredState.STARTED) + boxes.boxes.set(box.id, box) + await service.applyTransition(box, BoxState.STARTED, BoxDesiredState.STARTED, new Date('2026-07-06T00:00:00Z')) + + await service.closeAndReopenUsagePeriods(new Date('2026-07-08T00:00:00Z')) + + expect(periods.rows).toHaveLength(2) + expect(periods.rows[0].endAt).toEqual(new Date('2026-07-08T00:00:00Z')) + expect(periods.rows[1]).toMatchObject({ kind: 'running', startAt: new Date('2026-07-08T00:00:00Z'), endAt: null }) + }) + + it('archives closed periods and removes them from the active table', async () => { + const box = makeBox(BoxState.STARTED, BoxDesiredState.STARTED) + await service.applyTransition(box, BoxState.STARTED, BoxDesiredState.STARTED, new Date('2026-07-08T00:00:00Z')) + await service.applyTransition( + makeBox(BoxState.DESTROYED, BoxDesiredState.DESTROYED), + BoxState.DESTROYED, + BoxDesiredState.DESTROYED, + new Date('2026-07-08T00:00:10Z'), + ) + + await service.archiveUsagePeriods() + + expect(periods.rows).toHaveLength(0) + expect(periods.archivedRows).toHaveLength(1) + expect(periods.archivedRows[0]).toMatchObject({ + boxId: 'box-1', + organizationId: 'org-1', + endAt: new Date('2026-07-08T00:00:10Z'), + }) + }) + + it('does not propagate usage ledger failures from lifecycle event handlers', async () => { + jest.spyOn((service as unknown as { logger: { error: jest.Mock } }).logger, 'error').mockImplementation() + periods.failNextSave = new Error('database unavailable') + + await expect(service.handleBoxCreated({ box: makeBox(BoxState.STARTED, BoxDesiredState.STARTED) } as never)).resolves.toBeUndefined() + + expect(locks.unlocks).toContain('usage-period:box-1') + }) + + it('archives closed periods in a single transaction', async () => { + const box = makeBox(BoxState.STARTED, BoxDesiredState.STARTED) + await service.applyTransition(box, BoxState.STARTED, BoxDesiredState.STARTED, new Date('2026-07-08T00:00:00Z')) + await service.applyTransition( + makeBox(BoxState.DESTROYED, BoxDesiredState.DESTROYED), + BoxState.DESTROYED, + BoxDesiredState.DESTROYED, + new Date('2026-07-08T00:00:10Z'), + ) + + await service.archiveUsagePeriods() + + expect(periods.manager.transaction).toHaveBeenCalledTimes(1) + expect(periods.rows).toHaveLength(0) + expect(periods.archivedRows).toHaveLength(1) + expect(archives.rows).toHaveLength(0) + }) +}) diff --git a/apps/api/src/usage/usage.service.ts b/apps/api/src/usage/usage.service.ts new file mode 100644 index 000000000..a0502ee7d --- /dev/null +++ b/apps/api/src/usage/usage.service.ts @@ -0,0 +1,376 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { Injectable, Logger } from '@nestjs/common' +import { Cron, CronExpression } from '@nestjs/schedule' +import { InjectRepository } from '@nestjs/typeorm' +import { EntityManager, FindOptionsWhere, IsNull, LessThan, MoreThan, Not, Repository } from 'typeorm' +import { BoxEvents } from '../box/constants/box-events.constants' +import { RedisLockProvider } from '../box/common/redis-lock.provider' +import { Box } from '../box/entities/box.entity' +import { BoxCreatedEvent } from '../box/events/box-create.event' +import { BoxDesiredStateUpdatedEvent } from '../box/events/box-desired-state-updated.event' +import { BoxStateUpdatedEvent } from '../box/events/box-state-updated.event' +import { BoxDesiredState } from '../box/enums/box-desired-state.enum' +import { BoxState } from '../box/enums/box-state.enum' +import { BoxOrganizationUpdatedEvent } from '../box/events/box-organization-updated.event' +import { BoxRepository } from '../box/repositories/box.repository' +import { OnAsyncEvent } from '../common/decorators/on-async-event.decorator' +import { + UsagePeriodKind, + UsageTotals, + aggregateUsagePeriods, + planUsageTransition, + usagePeriodKind, +} from './metering/usage-period-math' +import { UsagePeriodArchive } from './entities/usage-period-archive.entity' +import { UsagePeriod } from './entities/usage-period.entity' + +const ONE_DAY_MS = 24 * 60 * 60 * 1000 +const DEFAULT_METERING_LOOKBACK_MS = 30 * ONE_DAY_MS +const DEFAULT_METERING_LIMIT = 250 +const MAX_METERING_LIMIT = 1000 + +export interface MeteringQueryOptions { + from?: Date + to?: Date + limit?: number + boxId?: string +} + +export interface MeteringPeriodView { + id: string + source: 'usage_period' | 'usage_period_archive' + sourcePeriodId?: string + boxId: string + organizationId: string + region: string | null + startAt: Date + endAt: Date | null + kind: UsagePeriodKind + cpu: number + mem: number + disk: number + gpu: number + durationSeconds: number + active: boolean + actualCpuSeconds: number | null + actualRssAvgBytes: string | null + actualRssPeakBytes: string | null + sampleCount: number | null +} + +export interface OrganizationMeteringView { + organizationId: string + from: Date + to: Date + activePeriods: MeteringPeriodView[] + archivedPeriods: MeteringPeriodView[] + totals: UsageTotals +} + +@Injectable() +export class UsageService { + private readonly logger = new Logger(UsageService.name) + + constructor( + @InjectRepository(UsagePeriod) + private readonly periods: Repository, + @InjectRepository(UsagePeriodArchive) + private readonly archives: Repository, + private readonly redisLockProvider: RedisLockProvider, + private readonly boxRepository: BoxRepository, + ) {} + + @OnAsyncEvent({ event: BoxEvents.CREATED }) + async handleBoxCreated(event: BoxCreatedEvent): Promise { + await this.recordLifecycleTransition(event.box, event.box.state, event.box.desiredState) + } + + @OnAsyncEvent({ event: BoxEvents.STATE_UPDATED }) + async handleBoxStateUpdated(event: BoxStateUpdatedEvent): Promise { + await this.recordLifecycleTransition(event.box, event.newState, event.box.desiredState) + } + + @OnAsyncEvent({ event: BoxEvents.DESIRED_STATE_UPDATED }) + async handleBoxDesiredStateUpdated(event: BoxDesiredStateUpdatedEvent): Promise { + if (event.newDesiredState !== BoxDesiredState.DESTROYED) { + return + } + + await this.recordLifecycleTransition(event.box, event.box.state, event.newDesiredState) + } + + @OnAsyncEvent({ event: BoxEvents.ORGANIZATION_UPDATED }) + async handleBoxOrganizationUpdated(event: BoxOrganizationUpdatedEvent): Promise { + await this.recordLifecycleTransition(event.box, event.box.state, event.box.desiredState) + } + + async applyTransition(box: Box, state: BoxState, desiredState: BoxDesiredState, now: Date): Promise { + const open = await this.findOpenPeriod(box.id) + if (open && now.getTime() < open.startAt.getTime()) { + this.logger.warn( + `ignoring stale usage transition for box ${box.id}: ${now.toISOString()} before ${open.startAt.toISOString()}`, + ) + return + } + const openKind = open?.kind ?? null + const plan = planUsageTransition(openKind, state, desiredState) + const nextKind = usagePeriodKind(state, desiredState) + const resourcesChanged = + Boolean(open) && + nextKind !== 'gone' && + open?.kind === nextKind && + !this.openPeriodMatchesBox(open, box, nextKind) + + if ((plan.closeOpen || resourcesChanged) && open) { + open.endAt = now + await this.periods.save(open) + } + + const openKindToCreate = plan.openKind ?? (resourcesChanged ? (nextKind as UsagePeriodKind) : null) + if (openKindToCreate) { + await this.periods.save(this.createPeriod(box, openKindToCreate, now)) + } + } + + async getOrganizationMeteringView( + organizationId: string, + options: MeteringQueryOptions = {}, + now: Date = new Date(), + ): Promise { + const to = options.to ?? now + const from = options.from ?? new Date(to.getTime() - DEFAULT_METERING_LOOKBACK_MS) + const limit = this.normalizeMeteringLimit(options.limit) + const activeWhere = this.periodOverlapWhere(organizationId, from, to, options.boxId) + const archivedWhere = this.periodOverlapWhere(organizationId, from, to, options.boxId) + + const [activeRows, archivedRows] = await Promise.all([ + this.periods.find({ + where: activeWhere, + order: { startAt: 'DESC' }, + take: limit, + }), + this.archives.find({ + where: archivedWhere, + order: { startAt: 'DESC' }, + take: limit, + }), + ]) + + const activePeriods = activeRows.map((period) => this.toMeteringPeriodView(period, 'usage_period', to)) + const archivedPeriods = archivedRows.map((period) => + this.toMeteringPeriodView(period, 'usage_period_archive', to), + ) + + return { + organizationId, + from, + to, + activePeriods, + archivedPeriods, + totals: aggregateUsagePeriods([...activeRows, ...archivedRows], from, to), + } + } + + @Cron(CronExpression.EVERY_MINUTE, { name: 'close-and-reopen-usage-periods' }) + async closeAndReopenUsagePeriods(now: Date = new Date()): Promise { + const lockKey = 'usage-periods:rollover' + if (!(await this.redisLockProvider.lock(lockKey, 60))) { + return + } + + try { + const oldOpenPeriods = await this.periods.find({ + where: { + endAt: IsNull(), + startAt: LessThan(new Date(now.getTime() - ONE_DAY_MS)), + }, + take: 100, + }) + + for (const period of oldOpenPeriods) { + if (!(await this.redisLockProvider.lock(this.boxLockKey(period.boxId), 60))) { + continue + } + + try { + const box = await this.boxRepository.findOne({ where: { id: period.boxId } }) + period.endAt = now + await this.periods.save(period) + + if (!box) { + continue + } + + const plan = planUsageTransition(null, box.state, box.desiredState) + if (plan.openKind) { + await this.periods.save(this.createPeriod(box, plan.openKind, now)) + } + } finally { + await this.redisLockProvider.unlock(this.boxLockKey(period.boxId)) + } + } + } finally { + await this.redisLockProvider.unlock(lockKey) + } + } + + @Cron(CronExpression.EVERY_5_SECONDS, { name: 'archive-usage-periods' }) + async archiveUsagePeriods(): Promise { + const lockKey = 'usage-periods:archive' + if (!(await this.redisLockProvider.lock(lockKey, 60))) { + return + } + + try { + const closed = await this.periods.find({ + where: { endAt: Not(IsNull()) }, + take: 5000, + }) + + if (closed.length === 0) { + return + } + + await this.archiveClosedPeriods(closed) + } finally { + await this.redisLockProvider.unlock(lockKey) + } + } + + private async findOpenPeriod(boxId: string): Promise { + return this.periods.findOne({ + where: { boxId, endAt: IsNull() }, + }) + } + + private createPeriod(box: Box, kind: UsagePeriodKind, startAt: Date): UsagePeriod { + const running = kind === 'running' + return this.periods.create({ + boxId: box.id, + organizationId: box.organizationId, + region: box.region, + startAt, + endAt: null, + kind, + cpu: running ? box.cpu : 0, + mem: running ? box.mem : 0, + gpu: running ? box.gpu : 0, + disk: box.disk, + actualCpuSeconds: null, + actualRssAvgBytes: null, + actualRssPeakBytes: null, + sampleCount: null, + }) + } + + private openPeriodMatchesBox(period: UsagePeriod, box: Box, kind: UsagePeriodKind): boolean { + const running = kind === 'running' + return ( + period.organizationId === box.organizationId && + period.region === box.region && + period.cpu === (running ? box.cpu : 0) && + period.mem === (running ? box.mem : 0) && + period.gpu === (running ? box.gpu : 0) && + period.disk === box.disk + ) + } + + private periodOverlapWhere( + organizationId: string, + from: Date, + to: Date, + boxId?: string, + ): FindOptionsWhere[] { + const base = { + organizationId, + ...(boxId ? { boxId } : {}), + startAt: LessThan(to), + } as FindOptionsWhere + + return [{ ...base, endAt: IsNull() }, { ...base, endAt: MoreThan(from) }] + } + + private normalizeMeteringLimit(limit: number | undefined): number { + if (limit === undefined || Number.isNaN(limit)) { + return DEFAULT_METERING_LIMIT + } + + return Math.min(Math.max(Math.trunc(limit), 1), MAX_METERING_LIMIT) + } + + private toMeteringPeriodView( + period: UsagePeriod | UsagePeriodArchive, + source: MeteringPeriodView['source'], + to: Date, + ): MeteringPeriodView { + const endAt = period.endAt ?? null + const effectiveEnd = endAt ?? to + return { + id: period.id, + source, + sourcePeriodId: source === 'usage_period_archive' ? (period as UsagePeriodArchive).sourcePeriodId : undefined, + boxId: period.boxId, + organizationId: period.organizationId, + region: period.region, + startAt: period.startAt, + endAt, + kind: period.kind, + cpu: period.cpu, + mem: period.mem, + disk: period.disk, + gpu: period.gpu, + durationSeconds: Math.max(0, (effectiveEnd.getTime() - period.startAt.getTime()) / 1000), + active: endAt === null, + actualCpuSeconds: period.actualCpuSeconds, + actualRssAvgBytes: period.actualRssAvgBytes, + actualRssPeakBytes: period.actualRssPeakBytes, + sampleCount: period.sampleCount, + } + } + + private async recordLifecycleTransition( + box: Box, + state: BoxState, + desiredState: BoxDesiredState, + now: Date = new Date(), + ): Promise { + try { + await this.withBoxLock(box.id, async () => { + await this.applyTransition(box, state, desiredState, now) + }) + } catch (err) { + this.logger.error( + `usage ledger update failed for box ${box.id}`, + err instanceof Error ? err.stack : String(err), + ) + } + } + + private async archiveClosedPeriods(closed: UsagePeriod[]): Promise { + await this.periods.manager.transaction(async (manager: EntityManager) => { + await manager.save(UsagePeriodArchive, closed.map(UsagePeriodArchive.fromUsagePeriod)) + await manager.delete( + UsagePeriod, + closed.map((period) => period.id), + ) + }) + } + + private async withBoxLock(boxId: string, fn: () => Promise): Promise { + const lockKey = this.boxLockKey(boxId) + await this.redisLockProvider.waitForLock(lockKey, 60) + try { + return await fn() + } finally { + await this.redisLockProvider.unlock(lockKey) + } + } + + private boxLockKey(boxId: string): string { + return `usage-period:${boxId}` + } +} diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index b5e277545..41565e17b 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -39,6 +39,7 @@ import Boxes from './pages/Boxes' // recharts/terminal deps. Boxes + the Dashboard shell stay eager (first paint). const Keys = React.lazy(() => import('./pages/Keys')) const Billing = React.lazy(() => import('./pages/Billing')) +const Metering = React.lazy(() => import('./pages/Metering')) const Admin = React.lazy(() => import('./pages/Admin')) const EmailVerify = React.lazy(() => import('./pages/EmailVerify')) const OrganizationSettings = React.lazy(() => import('@/pages/OrganizationSettings')) @@ -187,6 +188,7 @@ function App() { } /> } /> } /> + } /> } /> } /> {/* TODO(image-rewrite): legacy /dashboard/templates route removed with the templates page. */} diff --git a/apps/dashboard/src/api/apiClient.ts b/apps/dashboard/src/api/apiClient.ts index f67727ad8..8349db426 100644 --- a/apps/dashboard/src/api/apiClient.ts +++ b/apps/dashboard/src/api/apiClient.ts @@ -131,7 +131,7 @@ export class ApiClient { this._userApi = new UsersApi(this.config, undefined, axiosInstance) this._apiKeyApi = new ApiKeysApi(this.config, undefined, axiosInstance) this._organizationsApi = new OrganizationsApi(this.config, undefined, axiosInstance) - this._billingApi = new BillingApiClient(config.billingApiUrl || window.location.origin, accessToken) + this._billingApi = new BillingApiClient(config.billingApiUrl || config.apiUrl, accessToken) this._volumeApi = new VolumesApi(this.config, undefined, axiosInstance) this._auditApi = new AuditApi(this.config, undefined, axiosInstance) this._regionsApi = new RegionsApi(this.config, undefined, axiosInstance) diff --git a/apps/dashboard/src/billing-api/billingApiClient.ts b/apps/dashboard/src/billing-api/billingApiClient.ts index 9a32f9569..4398c94fa 100644 --- a/apps/dashboard/src/billing-api/billingApiClient.ts +++ b/apps/dashboard/src/billing-api/billingApiClient.ts @@ -9,6 +9,8 @@ import axios, { AxiosInstance } from 'axios' import { AutomaticTopUp, OrganizationEmail, + OrganizationMetering, + OrganizationMeteringParams, OrganizationTier, OrganizationUsage, OrganizationWallet, @@ -51,6 +53,29 @@ export class BillingApiClient { return response.data } + public async getOrganizationMetering( + organizationId: string, + params: OrganizationMeteringParams = {}, + ): Promise { + const searchParams = new URLSearchParams() + if (params.from) { + searchParams.set('from', params.from) + } + if (params.to) { + searchParams.set('to', params.to) + } + if (params.limit !== undefined) { + searchParams.set('limit', String(params.limit)) + } + if (params.boxId) { + searchParams.set('boxId', params.boxId) + } + + const query = searchParams.toString() + const response = await this.axiosInstance.get(`/organization/${organizationId}/metering${query ? `?${query}` : ''}`) + return response.data + } + public async getOrganizationWallet(organizationId: string): Promise { const response = await this.axiosInstance.get(`/organization/${organizationId}/wallet`) return response.data diff --git a/apps/dashboard/src/billing-api/types/OrganizationMetering.ts b/apps/dashboard/src/billing-api/types/OrganizationMetering.ts new file mode 100644 index 000000000..c2ca23e8c --- /dev/null +++ b/apps/dashboard/src/billing-api/types/OrganizationMetering.ts @@ -0,0 +1,52 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +export type MeteringPeriodKind = 'running' | 'stopped' +export type MeteringPeriodSource = 'usage_period' | 'usage_period_archive' + +export interface MeteringTotals { + cpuSeconds: number + memGibSeconds: number + diskGibSeconds: number + gpuSeconds: number +} + +export interface MeteringPeriod { + id: string + source: MeteringPeriodSource + sourcePeriodId?: string + boxId: string + organizationId: string + region: string | null + startAt: string + endAt: string | null + kind: MeteringPeriodKind + cpu: number + mem: number + disk: number + gpu: number + durationSeconds: number + active: boolean + actualCpuSeconds: number | null + actualRssAvgBytes: string | null + actualRssPeakBytes: string | null + sampleCount: number | null +} + +export interface OrganizationMetering { + organizationId: string + from: string + to: string + activePeriods: MeteringPeriod[] + archivedPeriods: MeteringPeriod[] + totals: MeteringTotals +} + +export interface OrganizationMeteringParams { + from?: string + to?: string + limit?: number + boxId?: string +} diff --git a/apps/dashboard/src/billing-api/types/index.ts b/apps/dashboard/src/billing-api/types/index.ts index 06709f6a1..f81814dbf 100644 --- a/apps/dashboard/src/billing-api/types/index.ts +++ b/apps/dashboard/src/billing-api/types/index.ts @@ -7,6 +7,7 @@ export * from './OrganizationTier' export * from './OrganizationUsage' export * from './OrganizationWallet' +export * from './OrganizationMetering' export * from './tier' export * from './OrganizationEmail' export * from './Invoice' diff --git a/apps/dashboard/src/components/Sidebar.tsx b/apps/dashboard/src/components/Sidebar.tsx index f9045cebe..401492a18 100644 --- a/apps/dashboard/src/components/Sidebar.tsx +++ b/apps/dashboard/src/components/Sidebar.tsx @@ -182,6 +182,7 @@ export function Sidebar({ isBannerVisible }: SidebarProps) { () => [ { label: 'Boxes', path: RoutePath.BOXES }, { label: 'Billing', path: RoutePath.BILLING }, + { label: 'Usage', path: RoutePath.METERING }, ...(canViewAdmin ? [{ label: 'Admin', path: RoutePath.ADMIN }] : []), ], [canViewAdmin], diff --git a/apps/dashboard/src/enums/RoutePath.ts b/apps/dashboard/src/enums/RoutePath.ts index fb6b115c8..6abdb9045 100644 --- a/apps/dashboard/src/enums/RoutePath.ts +++ b/apps/dashboard/src/enums/RoutePath.ts @@ -20,6 +20,7 @@ export enum RoutePath { KEYS = '/dashboard/keys', BOXES = '/dashboard/boxes', BILLING = '/dashboard/billing', + METERING = '/dashboard/metering', PRICING = '/dashboard/pricing', ADMIN = '/dashboard/admin', IMAGES = '/dashboard/images', diff --git a/apps/dashboard/src/hooks/queries/queryKeys.ts b/apps/dashboard/src/hooks/queries/queryKeys.ts index 5a7c8745c..ce0a42908 100644 --- a/apps/dashboard/src/hooks/queries/queryKeys.ts +++ b/apps/dashboard/src/hooks/queries/queryKeys.ts @@ -33,6 +33,8 @@ export const queryKeys = { current: (organizationId: string) => [...queryKeys.organization.all, organizationId, 'usage', 'current'] as const, past: (organizationId: string) => [...queryKeys.organization.all, organizationId, 'usage', 'past'] as const, }, + metering: (organizationId: string, params: object) => + [...queryKeys.organization.all, organizationId, 'metering', params] as const, tier: (organizationId: string) => [...queryKeys.organization.all, organizationId, 'tier'] as const, wallet: (organizationId: string) => [...queryKeys.organization.all, organizationId, 'wallet'] as const, diff --git a/apps/dashboard/src/hooks/queries/useOrganizationMeteringQuery.ts b/apps/dashboard/src/hooks/queries/useOrganizationMeteringQuery.ts new file mode 100644 index 000000000..f9314860c --- /dev/null +++ b/apps/dashboard/src/hooks/queries/useOrganizationMeteringQuery.ts @@ -0,0 +1,27 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import type { OrganizationMetering, OrganizationMeteringParams } from '@/billing-api' +import { useQuery } from '@tanstack/react-query' +import { useApi } from '../useApi' +import { queryKeys } from './queryKeys' + +export const useOrganizationMeteringQuery = ({ + organizationId, + enabled = true, + params = {}, +}: { + organizationId: string + enabled?: boolean + params?: OrganizationMeteringParams +}) => { + const { billingApi } = useApi() + + return useQuery({ + queryKey: queryKeys.organization.metering(organizationId, params), + queryFn: () => billingApi.getOrganizationMetering(organizationId, params), + enabled: Boolean(enabled && organizationId), + }) +} diff --git a/apps/dashboard/src/pages/Metering.test.tsx b/apps/dashboard/src/pages/Metering.test.tsx new file mode 100644 index 000000000..34331ffef --- /dev/null +++ b/apps/dashboard/src/pages/Metering.test.tsx @@ -0,0 +1,146 @@ +// @vitest-environment jsdom +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { act } from 'react' +import type { ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import Metering from './Metering' + +const meteringQueryMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/components/PageLayout', () => ({ + PageLayout: ({ children }: { children: ReactNode }) =>
{children}
, + PageHeader: ({ children }: { children: ReactNode }) =>
{children}
, + PageContent: ({ children }: { children: ReactNode }) =>
{children}
, + PageTitle: ({ children }: { children: ReactNode }) =>

{children}

, +})) + +vi.mock('@/hooks/useSelectedOrganization', () => ({ + useSelectedOrganization: () => ({ + selectedOrganization: { id: 'org-1', name: 'Acme' }, + }), +})) + +vi.mock('@/hooks/queries/useOrganizationMeteringQuery', () => ({ + useOrganizationMeteringQuery: (args: unknown) => meteringQueryMock(args), +})) + +async function flushReactWork() { + await act(async () => { + await Promise.resolve() + }) +} + +describe('Metering page', () => { + let root: Root | null = null + + beforeAll(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + beforeEach(() => { + vi.clearAllMocks() + meteringQueryMock.mockReturnValue({ + data: { + organizationId: 'org-1', + from: '2026-07-07T23:00:00.000Z', + to: '2026-07-08T01:00:00.000Z', + totals: { + cpuSeconds: 7200, + memGibSeconds: 14400, + diskGibSeconds: 108000, + gpuSeconds: 3600, + }, + activePeriods: [ + { + id: 'period-open', + source: 'usage_period', + boxId: 'box-1', + organizationId: 'org-1', + region: 'us', + startAt: '2026-07-08T00:00:00.000Z', + endAt: null, + kind: 'stopped', + cpu: 0, + mem: 0, + disk: 10, + gpu: 0, + durationSeconds: 3600, + active: true, + actualCpuSeconds: null, + actualRssAvgBytes: null, + actualRssPeakBytes: null, + sampleCount: null, + }, + ], + archivedPeriods: [ + { + id: 'archive-1', + source: 'usage_period_archive', + sourcePeriodId: 'period-closed', + boxId: 'box-2', + organizationId: 'org-1', + region: 'us', + startAt: '2026-07-07T23:00:00.000Z', + endAt: '2026-07-08T00:00:00.000Z', + kind: 'stopped', + cpu: 0, + mem: 0, + disk: 20, + gpu: 0, + durationSeconds: 3600, + active: false, + actualCpuSeconds: null, + actualRssAvgBytes: null, + actualRssPeakBytes: null, + sampleCount: null, + }, + ], + }, + isLoading: false, + isError: false, + refetch: vi.fn(), + isFetching: false, + }) + }) + + afterEach(() => { + act(() => { + root?.unmount() + }) + root = null + document.body.innerHTML = '' + }) + + async function renderMetering() { + const host = document.createElement('div') + document.body.appendChild(host) + + await act(async () => { + root = createRoot(host) + root.render() + }) + + await flushReactWork() + } + + it('renders raw metering totals and period rows', async () => { + await renderMetering() + + expect(document.body.textContent).toContain('Metering') + expect(document.body.textContent).toContain('Open ledger periods') + expect(document.body.textContent).toContain('Closed usage periods') + expect(document.body.textContent).toContain('Open stopped rows keep metering disk only.') + expect(document.body.textContent).toContain('disk only') + expect(document.body.textContent).toContain('2.00 vCPU·hr') + expect(document.body.textContent).toContain('4.00 GiB·hr') + expect(document.body.textContent).toContain('box-1') + expect(document.body.textContent).toContain('open ledger') + expect(document.body.textContent).toContain('box-2') + expect(document.body.textContent).toContain('closed ledger') + }) +}) diff --git a/apps/dashboard/src/pages/Metering.tsx b/apps/dashboard/src/pages/Metering.tsx new file mode 100644 index 000000000..78d5b629e --- /dev/null +++ b/apps/dashboard/src/pages/Metering.tsx @@ -0,0 +1,233 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import type { MeteringPeriod, MeteringTotals } from '@/billing-api' +import { PageContent, PageHeader, PageLayout, PageTitle } from '@/components/PageLayout' +import { Button } from '@/components/ui/button' +import { Database, RefreshCw } from '@/components/ui/icon' +import { useOrganizationMeteringQuery } from '@/hooks/queries/useOrganizationMeteringQuery' +import { useSelectedOrganization } from '@/hooks/useSelectedOrganization' +import { cn } from '@/lib/utils' + +function hours(value: number): string { + return value.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) +} + +function duration(value: number): string { + if (value < 60) { + return `${Math.round(value)}s` + } + if (value < 3600) { + return `${hours(value / 60)}m` + } + return `${hours(value / 3600)}h` +} + +function formatDate(value: string | null): string { + if (!value) { + return 'open' + } + return new Date(value).toLocaleString('en-US', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }) +} + +function truncateId(value: string): string { + return value.length > 12 ? `${value.slice(0, 8)}…${value.slice(-4)}` : value +} + +function SectionTitle({ title, count }: { title: string; count?: string }) { + return ( +
+ + {title} + + {count ? {count} : null} +
+ ) +} + +function StatCard({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +function totalsToCards(totals: MeteringTotals | undefined) { + const safeTotals = totals ?? { cpuSeconds: 0, memGibSeconds: 0, diskGibSeconds: 0, gpuSeconds: 0 } + return [ + { label: 'CPU allocation', value: `${hours(safeTotals.cpuSeconds / 3600)} vCPU·hr` }, + { label: 'Memory allocation', value: `${hours(safeTotals.memGibSeconds / 3600)} GiB·hr` }, + { label: 'Disk allocation', value: `${hours(safeTotals.diskGibSeconds / 3600)} GiB·hr` }, + { label: 'GPU allocation', value: `${hours(safeTotals.gpuSeconds / 3600)} GPU·hr` }, + ] +} + +function sourceLabel(source: MeteringPeriod['source']): string { + return source === 'usage_period' ? 'open ledger' : 'closed ledger' +} + +function periodNote(period: MeteringPeriod): string | null { + if (period.kind !== 'stopped' || period.endAt) { + return null + } + + return 'disk only' +} + +function PeriodTable({ title, description, periods }: { title: string; description: string; periods: MeteringPeriod[] }) { + return ( +
+ +

{description}

+
+
+ + + + + + + + + + + + + + + + + {periods.length === 0 ? ( + + + + ) : ( + periods.map((period) => ( + + + + + + + + + + + + + )) + )} + +
BoxKindWindowDurationCPUMemoryDiskGPUSourceNote
+ No periods +
{truncateId(period.boxId)} + + {period.kind} + + + {formatDate(period.startAt)} → {formatDate(period.endAt)} + {duration(period.durationSeconds)}{period.cpu}{period.mem} GiB{period.disk} GiB{period.gpu}{sourceLabel(period.source)}{periodNote(period) ?? '—'}
+
+
+
+ ) +} + +function Metering() { + const { selectedOrganization } = useSelectedOrganization() + const organizationId = selectedOrganization?.id ?? '' + const meteringQuery = useOrganizationMeteringQuery({ + organizationId, + enabled: Boolean(organizationId), + params: { limit: 500 }, + }) + const data = meteringQuery.data + const cards = totalsToCards(data?.totals) + + return ( + + + + Metering + + + + + + {!selectedOrganization ? ( +
+ Select an organization to view metering. +
+ ) : meteringQuery.isLoading ? ( +
+ Loading metering data… +
+ ) : meteringQuery.isError ? ( +
+ Metering data failed to load. +
+ ) : ( + <> +
+ {cards.map((card) => ( + + ))} +
+ +
+ + Organization {truncateId(data?.organizationId ?? organizationId)} + Window {formatDate(data?.from ?? null)} → {formatDate(data?.to ?? null)} + + Periods {(data?.activePeriods.length ?? 0) + (data?.archivedPeriods.length ?? 0)} + + Open stopped rows keep metering disk only. +
+ + + + + )} +
+
+ ) +} + +export default Metering diff --git a/apps/package.json b/apps/package.json index 06406c147..0b837a8c9 100644 --- a/apps/package.json +++ b/apps/package.json @@ -16,6 +16,7 @@ "start:storybook": "nx run dashboard:storybook", "dev:dex": "node scripts/dev-dex.mjs", "e2e:local": "node scripts/e2e-local.mjs", + "e2e:metering-local": "node scripts/metering-local-e2e.mjs", "e2e:dev": "node scripts/e2e-dev-smoke.mjs", "observability:agent-smoke": "node scripts/admin-observability-agent-smoke.mjs", "format": "nx run-many --target=format --all --parallel=$(getconf _NPROCESSORS_ONLN) && prettier --write \"*.{ts,js,json,yaml}\"", @@ -301,6 +302,7 @@ "markdownlint-cli2": "^0.17.2", "nx": "21.4.1", "pino-pretty": "^13.1.2", + "playwright-core": "^1.61.1", "postcss": "8.5.10", "prettier": "^3.5.0", "prettier-plugin-astro": "^0.14.1", diff --git a/apps/scripts/metering-local-e2e.mjs b/apps/scripts/metering-local-e2e.mjs new file mode 100644 index 000000000..91c8fdc82 --- /dev/null +++ b/apps/scripts/metering-local-e2e.mjs @@ -0,0 +1,390 @@ +#!/usr/bin/env node + +import crypto from 'node:crypto' +import fs from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import pg from 'pg' +import Redis from 'ioredis' +import { chromium } from 'playwright-core' + +const { Pool } = pg + +const scriptsRoot = path.dirname(fileURLToPath(import.meta.url)) +const repoRoot = path.resolve(scriptsRoot, '..', '..') +const apiUrl = stripTrailingSlash(process.env.BOXLITE_E2E_API_URL || 'http://localhost:3001/api') +const dashboardUrl = stripTrailingSlash(process.env.BOXLITE_E2E_BASE_URL || 'http://localhost:3000') +const loginEmail = process.env.BOXLITE_E2E_LOGIN_EMAIL || 'admin@boxlite.dev' +const loginPassword = process.env.BOXLITE_E2E_LOGIN_PASSWORD || 'password' +const timeoutMs = Number(process.env.METERING_E2E_TIMEOUT_MS || 20_000) +const keepRows = process.env.METERING_E2E_KEEP_ROWS === '1' +const chromeExecutablePath = + process.env.CHROME_EXECUTABLE_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' +const artifactsDir = process.env.METERING_E2E_ARTIFACTS || path.join(repoRoot, '.apps-local', 'logs') +const apiKeyValue = + process.env.METERING_E2E_API_KEY || + `blk_test_meteringe2e${Date.now().toString(36)}${crypto.randomBytes(12).toString('hex')}` +const apiKeyName = process.env.METERING_E2E_API_KEY_NAME || 'metering-local-e2e' +const runId = crypto.randomBytes(4).toString('hex') +const activeBoxId = `mtr${runId}` +const archivedBoxId = `arc${runId}` +const activePeriodId = crypto.randomUUID() +const archivedPeriodId = crypto.randomUUID() +const sourcePeriodId = crypto.randomUUID() + +const pool = new Pool({ + host: process.env.DB_HOST || '127.0.0.1', + port: Number(process.env.DB_PORT || 25432), + user: process.env.DB_USERNAME || 'boxlite', + password: process.env.DB_PASSWORD || 'boxlite', + database: process.env.DB_DATABASE || 'boxlite', + max: 4, +}) + +const redis = new Redis({ + host: process.env.REDIS_HOST || '127.0.0.1', + port: Number(process.env.REDIS_PORT || 26379), + lazyConnect: true, + maxRetriesPerRequest: 1, +}) + +let organizationId +let userId + +try { + await fs.mkdir(artifactsDir, { recursive: true }) + ;({ organizationId, userId } = await ensureApiKey()) + const window = await seedMeteringRows() + await verifyApi(window) + await verifyDashboard() + + console.log( + JSON.stringify( + { + ok: true, + apiUrl, + dashboardUrl, + organizationId, + activeBoxId, + archivedBoxId, + keepRows, + screenshot: path.join(artifactsDir, 'metering-local-e2e.png'), + }, + null, + 2, + ), + ) +} finally { + await cleanupApiKey().catch((error) => { + console.warn(`api key cleanup warning: ${error.message}`) + }) + if (!keepRows) { + await cleanupMeteringRows().catch((error) => { + console.warn(`metering cleanup warning: ${error.message}`) + }) + } + await redis.quit().catch(() => {}) + await pool.end().catch(() => {}) +} + +async function ensureApiKey() { + const { rows } = await pool.query( + ` + select u.id as "userId", ou."organizationId" as "organizationId" + from "user" u + join organization_user ou on ou."userId" = u.id + where u.email = $1 and ou.role = 'owner' + limit 1 + `, + [loginEmail], + ) + if (rows.length === 0) { + throw new Error(`No owner organization found for ${loginEmail}`) + } + + const row = rows[0] + const keyHash = hashApiKey(apiKeyValue) + await pool.query('delete from api_key where "organizationId" = $1 and "userId" = $2 and name = $3', [ + row.organizationId, + row.userId, + apiKeyName, + ]) + await pool.query( + ` + insert into api_key ( + "organizationId", + "userId", + name, + "keyHash", + "keyPrefix", + "keySuffix", + permissions, + "createdAt" + ) + values ($1, $2, $3, $4, $5, $6, ARRAY[]::api_key_permissions_enum[], now()) + `, + [row.organizationId, row.userId, apiKeyName, keyHash, displayPrefix(apiKeyValue), apiKeyValue.slice(-3)], + ) + + await redis.connect().catch(() => {}) + await redis.del(`api-key:validation:${keyHash}`).catch(() => {}) + await redis.del(`api-key:user:${row.userId}`).catch(() => {}) + + return row +} + +async function seedMeteringRows() { + const to = new Date() + to.setMilliseconds(0) + const activeStart = new Date(to.getTime() - 60 * 60 * 1000) + const archivedStart = new Date(to.getTime() - 2 * 60 * 60 * 1000) + + await cleanupMeteringRows() + await pool.query( + ` + insert into usage_period ( + id, + "boxId", + "organizationId", + region, + "startAt", + "endAt", + kind, + cpu, + gpu, + mem, + disk, + "actualCpuSeconds", + "actualRssAvgBytes", + "actualRssPeakBytes", + "sampleCount" + ) + values ($1, $2, $3, $4, $5, null, 'running', 2, 1, 4, 10, null, null, null, null) + `, + [activePeriodId, activeBoxId, organizationId, 'local', activeStart], + ) + await pool.query( + ` + insert into usage_period_archive ( + id, + "sourcePeriodId", + "boxId", + "organizationId", + region, + "startAt", + "endAt", + kind, + cpu, + gpu, + mem, + disk, + "actualCpuSeconds", + "actualRssAvgBytes", + "actualRssPeakBytes", + "sampleCount" + ) + values ($1, $2, $3, $4, $5, $6, $7, 'stopped', 0, 0, 0, 20, null, null, null, null) + `, + [archivedPeriodId, sourcePeriodId, archivedBoxId, organizationId, 'local', archivedStart, activeStart], + ) + + return { from: archivedStart.toISOString(), to: to.toISOString() } +} + +async function verifyApi(window) { + const activeData = await fetchMetering(window, activeBoxId) + const archivedData = await fetchMetering(window, archivedBoxId) + + if (!activeData.activePeriods?.some((period) => period.boxId === activeBoxId && period.kind === 'running')) { + throw new Error(`active seeded period missing from API response: ${JSON.stringify(activeData).slice(0, 400)}`) + } + if ( + !archivedData.archivedPeriods?.some((period) => period.boxId === archivedBoxId && period.kind === 'stopped') + ) { + throw new Error(`archived seeded period missing from API response: ${JSON.stringify(archivedData).slice(0, 400)}`) + } + assertNear(activeData.totals.cpuSeconds, 7200, 'active cpuSeconds') + assertNear(activeData.totals.memGibSeconds, 14400, 'active memGibSeconds') + assertNear(activeData.totals.diskGibSeconds, 36000, 'active diskGibSeconds') + assertNear(activeData.totals.gpuSeconds, 3600, 'active gpuSeconds') + assertNear(archivedData.totals.cpuSeconds, 0, 'archived cpuSeconds') + assertNear(archivedData.totals.memGibSeconds, 0, 'archived memGibSeconds') + assertNear(archivedData.totals.diskGibSeconds, 72000, 'archived diskGibSeconds') + assertNear(archivedData.totals.gpuSeconds, 0, 'archived gpuSeconds') +} + +async function fetchMetering(window, boxId) { + const params = new URLSearchParams({ from: window.from, to: window.to, limit: '50', boxId }) + const response = await fetchWithTimeout(`${apiUrl}/organization/${organizationId}/metering?${params}`, { + headers: { + Authorization: `Bearer ${apiKeyValue}`, + Accept: 'application/json', + }, + }) + const body = await response.text() + if (!response.ok) { + throw new Error(`metering API failed for ${boxId}: HTTP ${response.status}: ${body.slice(0, 240)}`) + } + + return JSON.parse(body) +} + +async function verifyDashboard() { + const browser = await chromium.launch({ + headless: process.env.HEADLESS !== 'false', + executablePath: chromeExecutablePath, + }) + try { + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }) + page.setDefaultTimeout(timeoutMs) + await signIn(page) + await page.goto(`${dashboardUrl}/dashboard/metering`, { waitUntil: 'domcontentloaded' }) + await assertBodyText(page, [ + 'Metering', + 'OPEN LEDGER PERIODS', + 'CLOSED USAGE PERIODS', + 'Open stopped rows keep metering disk only.', + activeBoxId, + archivedBoxId, + 'RUNNING', + 'STOPPED', + ]) + await page.screenshot({ path: path.join(artifactsDir, 'metering-local-e2e.png'), fullPage: true }) + } finally { + await browser.close() + } +} + +async function signIn(page) { + await page.goto(`${dashboardUrl}/dashboard/metering`, { waitUntil: 'domcontentloaded' }) + await settleAuthState(page) + + for (let attempt = 0; attempt < 3; attempt += 1) { + if (await isVisible(page.locator('#login'))) { + await page.locator('#login').fill(loginEmail) + await page.locator('#password').fill(loginPassword) + await page.locator('#submit-login').click() + await settleAuthState(page, { allowDashboardOrigin: true }) + continue + } + + const grantButton = page.getByRole('button', { name: 'Grant Access' }) + if (await isVisible(grantButton)) { + await grantButton.click() + await settleAuthState(page, { allowDashboardOrigin: true }) + continue + } + + break + } + + if (await isVisible(page.locator('#login'))) { + throw new Error('Dex login is still visible after sign-in attempts') + } + if (await isVisible(page.getByRole('button', { name: 'Grant Access' }))) { + throw new Error('Dex approval is still visible after sign-in attempts') + } +} + +async function settleAuthState(page, { allowDashboardOrigin = false } = {}) { + const dashboardOrigin = new URL(dashboardUrl).origin + let dashboardSeenAt = 0 + let lastBodyText = '' + + for (let attempt = 0; attempt < 100; attempt += 1) { + const currentUrl = page.url() + const bodyText = await page.locator('body').innerText().catch(() => '') + lastBodyText = bodyText + if (/Log in to Your Account|Grant Access|Metering|Boxes|Billing/.test(bodyText)) { + return + } + if (allowDashboardOrigin && currentUrl.startsWith(dashboardOrigin)) { + dashboardSeenAt ||= Date.now() + if (Date.now() - dashboardSeenAt > 3_000) { + return + } + } else { + dashboardSeenAt = 0 + } + await delay(250) + } + throw new Error( + `Timed out waiting for dashboard or Dex state at ${page.url()}; body=${lastBodyText.slice(0, 240)}`, + ) +} + +async function assertBodyText(page, expectedTexts) { + let missing = expectedTexts + let lastBodyText = '' + for (let attempt = 0; attempt < 80; attempt += 1) { + const bodyText = await page.locator('body').innerText() + lastBodyText = bodyText + missing = expectedTexts.filter((text) => !bodyText.includes(text)) + if (missing.length === 0) { + return + } + await delay(250) + } + throw new Error(`Missing expected text: ${missing.join(', ')}; body=${lastBodyText.slice(0, 240)}`) +} + +async function cleanupApiKey() { + if (!organizationId || !userId) { + return + } + const keyHash = hashApiKey(apiKeyValue) + await pool.query('delete from api_key where "organizationId" = $1 and "userId" = $2 and name = $3', [ + organizationId, + userId, + apiKeyName, + ]) + await redis.del(`api-key:validation:${keyHash}`).catch(() => {}) +} + +async function cleanupMeteringRows() { + await pool.query(`delete from usage_period where "boxId" like 'mtr%'`) + await pool.query(`delete from usage_period_archive where "boxId" like 'arc%'`) +} + +async function fetchWithTimeout(url, options) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + try { + return await fetch(url, { ...options, signal: controller.signal }) + } finally { + clearTimeout(timeout) + } +} + +async function isVisible(locator) { + try { + return await locator.isVisible() + } catch { + return false + } +} + +function hashApiKey(value) { + return crypto.createHash('sha256').update(value).digest('hex') +} + +function displayPrefix(value) { + const first = value.indexOf('_') + const second = first === -1 ? -1 : value.indexOf('_', first + 1) + return second === -1 ? value.slice(0, 3) : value.slice(0, second + 1) +} + +function assertNear(actual, expected, label) { + if (Math.abs(Number(actual) - expected) > 0.001) { + throw new Error(`${label} expected ${expected}, got ${actual}`) + } +} + +function stripTrailingSlash(value) { + return value.replace(/\/+$/, '') +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/apps/yarn.lock b/apps/yarn.lock index 6e4ecb436..cbbc5ac7a 100644 --- a/apps/yarn.lock +++ b/apps/yarn.lock @@ -16901,6 +16901,7 @@ __metadata: pino: "npm:^9.13.1" pino-http: "npm:^10.5.0" pino-pretty: "npm:^13.1.2" + playwright-core: "npm:^1.61.1" postcss: "npm:8.5.10" posthog-js: "npm:^1.217.6" posthog-node: "npm:^4.10.1" @@ -30959,6 +30960,15 @@ __metadata: languageName: node linkType: hard +"playwright-core@npm:^1.61.1": + version: 1.61.1 + resolution: "playwright-core@npm:1.61.1" + bin: + playwright-core: cli.js + checksum: 10c0/c28896ba82a602182e240ed4f9c467fb0dd9cb57d510f8aba9f25183fe1cde3a0105725a29baffa4157fe885bbb11e228032a2aad0424111584fde45303f07bd + languageName: node + linkType: hard + "pluralize@npm:8.0.0": version: 8.0.0 resolution: "pluralize@npm:8.0.0" From eb3feffbc31c184dd3e5302c62af437be8593a5d Mon Sep 17 00:00:00 2001 From: Brian Luo <57960778+law-chain-hot@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:02:47 +0800 Subject: [PATCH 2/2] refactor(usage): align metering with Daytona --- .../pre-deploy/1782700000000-migration.ts | 34 +- .../entities/usage-period-archive.entity.ts | 20 +- .../src/usage/entities/usage-period.entity.ts | 25 +- apps/api/src/usage/services/usage.service.ts | 294 ++++++++++++++ .../src/usage/usage-metering.service.spec.ts | 79 ++++ apps/api/src/usage/usage-metering.service.ts | 150 +++++++ apps/api/src/usage/usage.controller.spec.ts | 12 +- apps/api/src/usage/usage.controller.ts | 6 +- apps/api/src/usage/usage.module.ts | 8 +- apps/api/src/usage/usage.service.spec.ts | 315 +++++---------- apps/api/src/usage/usage.service.ts | 376 ------------------ .../billing-api/types/OrganizationMetering.ts | 2 +- apps/dashboard/src/pages/Metering.test.tsx | 4 +- apps/dashboard/src/pages/Metering.tsx | 16 +- apps/scripts/metering-local-e2e.mjs | 21 +- 15 files changed, 719 insertions(+), 643 deletions(-) create mode 100644 apps/api/src/usage/services/usage.service.ts create mode 100644 apps/api/src/usage/usage-metering.service.spec.ts create mode 100644 apps/api/src/usage/usage-metering.service.ts delete mode 100644 apps/api/src/usage/usage.service.ts diff --git a/apps/api/src/migrations/pre-deploy/1782700000000-migration.ts b/apps/api/src/migrations/pre-deploy/1782700000000-migration.ts index 743fe87c9..ff032222b 100644 --- a/apps/api/src/migrations/pre-deploy/1782700000000-migration.ts +++ b/apps/api/src/migrations/pre-deploy/1782700000000-migration.ts @@ -5,7 +5,7 @@ export class Migration1782700000000 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( - `CREATE TABLE "usage_period" ( + `CREATE TABLE "box_usage_period" ( "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "boxId" character varying NOT NULL, "organizationId" character varying NOT NULL, @@ -21,18 +21,22 @@ export class Migration1782700000000 implements MigrationInterface { "actualRssAvgBytes" bigint, "actualRssPeakBytes" bigint, "sampleCount" integer, - CONSTRAINT "usage_period_id_pk" PRIMARY KEY ("id"), - CONSTRAINT "usage_period_end_after_start" CHECK ("endAt" IS NULL OR "endAt" >= "startAt") + "boxClass" character varying NOT NULL DEFAULT 'small', + "regionType" character varying NOT NULL DEFAULT 'shared', + CONSTRAINT "box_usage_period_id_pk" PRIMARY KEY ("id"), + CONSTRAINT "box_usage_period_end_after_start" CHECK ("endAt" IS NULL OR "endAt" >= "startAt") )`, ) - await queryRunner.query(`CREATE INDEX "usage_period_box_end_idx" ON "usage_period" ("boxId", "endAt")`) - await queryRunner.query(`CREATE INDEX "usage_period_org_start_idx" ON "usage_period" ("organizationId", "startAt")`) + await queryRunner.query(`CREATE INDEX "box_usage_period_box_end_idx" ON "box_usage_period" ("boxId", "endAt")`) await queryRunner.query( - `CREATE UNIQUE INDEX "usage_period_one_open_per_box_idx" ON "usage_period" ("boxId") WHERE "endAt" IS NULL`, + `CREATE INDEX "box_usage_period_org_start_idx" ON "box_usage_period" ("organizationId", "startAt")`, + ) + await queryRunner.query( + `CREATE UNIQUE INDEX "box_usage_period_one_open_per_box_idx" ON "box_usage_period" ("boxId") WHERE "endAt" IS NULL`, ) await queryRunner.query( - `CREATE TABLE "usage_period_archive" ( + `CREATE TABLE "box_usage_period_archive" ( "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "sourcePeriodId" uuid NOT NULL, "boxId" character varying NOT NULL, @@ -49,23 +53,25 @@ export class Migration1782700000000 implements MigrationInterface { "actualRssAvgBytes" bigint, "actualRssPeakBytes" bigint, "sampleCount" integer, - CONSTRAINT "usage_period_archive_id_pk" PRIMARY KEY ("id"), - CONSTRAINT "usage_period_archive_end_after_start" CHECK ("endAt" >= "startAt") + "boxClass" character varying NOT NULL DEFAULT 'small', + "regionType" character varying NOT NULL DEFAULT 'shared', + CONSTRAINT "box_usage_period_archive_id_pk" PRIMARY KEY ("id"), + CONSTRAINT "box_usage_period_archive_end_after_start" CHECK ("endAt" >= "startAt") )`, ) await queryRunner.query( - `CREATE UNIQUE INDEX "usage_period_archive_source_period_idx" ON "usage_period_archive" ("sourcePeriodId")`, + `CREATE UNIQUE INDEX "box_usage_period_archive_source_period_idx" ON "box_usage_period_archive" ("sourcePeriodId")`, ) await queryRunner.query( - `CREATE INDEX "usage_period_archive_org_start_idx" ON "usage_period_archive" ("organizationId", "startAt")`, + `CREATE INDEX "box_usage_period_archive_org_start_idx" ON "box_usage_period_archive" ("organizationId", "startAt")`, ) await queryRunner.query( - `CREATE INDEX "usage_period_archive_box_start_idx" ON "usage_period_archive" ("boxId", "startAt")`, + `CREATE INDEX "box_usage_period_archive_box_start_idx" ON "box_usage_period_archive" ("boxId", "startAt")`, ) } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP TABLE "usage_period_archive"`) - await queryRunner.query(`DROP TABLE "usage_period"`) + await queryRunner.query(`DROP TABLE "box_usage_period_archive"`) + await queryRunner.query(`DROP TABLE "box_usage_period"`) } } diff --git a/apps/api/src/usage/entities/usage-period-archive.entity.ts b/apps/api/src/usage/entities/usage-period-archive.entity.ts index 1e4b1acfe..ddfde5ef8 100644 --- a/apps/api/src/usage/entities/usage-period-archive.entity.ts +++ b/apps/api/src/usage/entities/usage-period-archive.entity.ts @@ -4,14 +4,16 @@ */ import { Check, Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm' +import { BoxClass } from '../../box/enums/box-class.enum' +import { RegionType } from '../../region/enums/region-type.enum' import type { UsagePeriodKind } from '../metering/usage-period-math' import { UsagePeriod } from './usage-period.entity' -@Entity('usage_period_archive') -@Index('usage_period_archive_source_period_idx', ['sourcePeriodId'], { unique: true }) -@Index('usage_period_archive_org_start_idx', ['organizationId', 'startAt']) -@Index('usage_period_archive_box_start_idx', ['boxId', 'startAt']) -@Check('usage_period_archive_end_after_start', '"endAt" >= "startAt"') +@Entity('box_usage_period_archive') +@Index('box_usage_period_archive_source_period_idx', ['sourcePeriodId'], { unique: true }) +@Index('box_usage_period_archive_org_start_idx', ['organizationId', 'startAt']) +@Index('box_usage_period_archive_box_start_idx', ['boxId', 'startAt']) +@Check('box_usage_period_archive_end_after_start', '"endAt" >= "startAt"') export class UsagePeriodArchive { @PrimaryGeneratedColumn('uuid') id: string @@ -61,6 +63,12 @@ export class UsagePeriodArchive { @Column({ type: 'int', nullable: true }) sampleCount: number | null + @Column({ type: 'character varying', default: BoxClass.SMALL }) + boxClass: BoxClass = BoxClass.SMALL + + @Column({ type: 'character varying', default: RegionType.SHARED }) + regionType: string = RegionType.SHARED + static fromUsagePeriod(period: UsagePeriod): UsagePeriodArchive { if (!period.endAt) { throw new Error(`usage period ${period.id} cannot be archived while open`) @@ -82,6 +90,8 @@ export class UsagePeriodArchive { archived.actualRssAvgBytes = period.actualRssAvgBytes archived.actualRssPeakBytes = period.actualRssPeakBytes archived.sampleCount = period.sampleCount + archived.boxClass = period.boxClass + archived.regionType = period.regionType return archived } } diff --git a/apps/api/src/usage/entities/usage-period.entity.ts b/apps/api/src/usage/entities/usage-period.entity.ts index 00756a733..8887cfea1 100644 --- a/apps/api/src/usage/entities/usage-period.entity.ts +++ b/apps/api/src/usage/entities/usage-period.entity.ts @@ -4,13 +4,15 @@ */ import { Check, Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm' +import { BoxClass } from '../../box/enums/box-class.enum' +import { RegionType } from '../../region/enums/region-type.enum' import type { UsagePeriodKind } from '../metering/usage-period-math' -@Entity('usage_period') -@Index('usage_period_box_end_idx', ['boxId', 'endAt']) -@Index('usage_period_org_start_idx', ['organizationId', 'startAt']) -@Index('usage_period_one_open_per_box_idx', ['boxId'], { unique: true, where: '"endAt" IS NULL' }) -@Check('usage_period_end_after_start', '"endAt" IS NULL OR "endAt" >= "startAt"') +@Entity('box_usage_period') +@Index('box_usage_period_box_end_idx', ['boxId', 'endAt']) +@Index('box_usage_period_org_start_idx', ['organizationId', 'startAt']) +@Index('box_usage_period_one_open_per_box_idx', ['boxId'], { unique: true, where: '"endAt" IS NULL' }) +@Check('box_usage_period_end_after_start', '"endAt" IS NULL OR "endAt" >= "startAt"') export class UsagePeriod { @PrimaryGeneratedColumn('uuid') id: string @@ -56,4 +58,17 @@ export class UsagePeriod { @Column({ type: 'int', nullable: true }) sampleCount: number | null + + @Column({ type: 'character varying', default: BoxClass.SMALL }) + boxClass: BoxClass = BoxClass.SMALL + + @Column({ type: 'character varying', default: RegionType.SHARED }) + regionType: string = RegionType.SHARED + + static fromUsagePeriod(period: UsagePeriod): UsagePeriod { + const next = new UsagePeriod() + Object.assign(next, period) + delete (next as Partial).id + return next + } } diff --git a/apps/api/src/usage/services/usage.service.ts b/apps/api/src/usage/services/usage.service.ts new file mode 100644 index 000000000..c4ea7737b --- /dev/null +++ b/apps/api/src/usage/services/usage.service.ts @@ -0,0 +1,294 @@ +/* + * Copyright 2025 Daytona Platforms Inc. + * Modified by BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { Injectable, Logger, OnApplicationShutdown } from '@nestjs/common' +import { InjectRepository } from '@nestjs/typeorm' +import { IsNull, LessThan, Not, Repository } from 'typeorm' +import { UsagePeriod } from '../entities/usage-period.entity' +import { OnEvent } from '@nestjs/event-emitter' +import { BoxStateUpdatedEvent } from '../../box/events/box-state-updated.event' +import { BoxState } from '../../box/enums/box-state.enum' +import { BoxEvents } from '../../box/constants/box-events.constants' +import { Cron, CronExpression } from '@nestjs/schedule' +import { RedisLockProvider } from '../../box/common/redis-lock.provider' +import { BOX_WARM_POOL_UNASSIGNED_ORGANIZATION } from '../../box/constants/box.constants' +import { UsagePeriodArchive } from '../entities/usage-period-archive.entity' +import { TrackableJobExecutions } from '../../common/interfaces/trackable-job-executions' +import { TrackJobExecution } from '../../common/decorators/track-job-execution.decorator' +import { setTimeout as sleep } from 'timers/promises' +import { LogExecution } from '../../common/decorators/log-execution.decorator' +import { WithInstrumentation } from '../../common/decorators/otel.decorator' +import { BoxRepository } from '../../box/repositories/box.repository' +import { BoxDesiredStateUpdatedEvent } from '../../box/events/box-desired-state-updated.event' +import { BoxDesiredState } from '../../box/enums/box-desired-state.enum' +import { Region } from '../../region/entities/region.entity' +import { RegionType } from '../../region/enums/region-type.enum' +import { BoxClass } from '../../box/enums/box-class.enum' + +@Injectable() +export class UsageService implements TrackableJobExecutions, OnApplicationShutdown { + activeJobs = new Set() + private readonly logger = new Logger(UsageService.name) + + constructor( + @InjectRepository(UsagePeriod) + private usagePeriodRepository: Repository, + private readonly redisLockProvider: RedisLockProvider, + private readonly boxRepository: BoxRepository, + @InjectRepository(Region) + private readonly regionRepository: Repository, + ) {} + + async onApplicationShutdown() { + // Wait for all active jobs to finish. + while (this.activeJobs.size > 0) { + this.logger.log(`Waiting for ${this.activeJobs.size} active jobs to finish`) + await sleep(1000) + } + } + + @OnEvent(BoxEvents.DESIRED_STATE_UPDATED) + @TrackJobExecution() + async handleBoxDesiredStateUpdate(event: BoxDesiredStateUpdatedEvent) { + await this.waitForLock(event.box.id) + + try { + switch (event.newDesiredState) { + case BoxDesiredState.DESTROYED: { + await this.closeUsagePeriod(event.box.id) + break + } + } + } finally { + this.releaseLock(event.box.id).catch((error) => { + this.logger.error(`Error releasing lock for box ${event.box.id}`, error) + }) + } + } + + @OnEvent(BoxEvents.STATE_UPDATED) + @TrackJobExecution() + async handleBoxStateUpdate(event: BoxStateUpdatedEvent) { + await this.waitForLock(event.box.id) + + try { + switch (event.newState) { + case BoxState.STARTED: { + await this.closeUsagePeriod(event.box.id) + await this.createUsagePeriod(event) + break + } + case BoxState.STOPPING: + await this.closeUsagePeriod(event.box.id) + await this.createUsagePeriod(event, true) + break + // Safeguard if STOPPING state is skipped. + case BoxState.STOPPED: { + const cpuUsagePeriod = await this.usagePeriodRepository.findOne({ + where: { + boxId: event.box.id, + endAt: IsNull(), + cpu: Not(0), + }, + }) + if (cpuUsagePeriod) { + await this.closeUsagePeriod(event.box.id) + await this.createUsagePeriod(event, true) + } + break + } + case BoxState.ERROR: + case BoxState.ARCHIVED: + case BoxState.DESTROYING: + case BoxState.DESTROYED: { + await this.closeUsagePeriod(event.box.id) + break + } + } + } finally { + this.releaseLock(event.box.id).catch((error) => { + this.logger.error(`Error releasing lock for box ${event.box.id}`, error) + }) + } + } + + private async createUsagePeriod(event: BoxStateUpdatedEvent, diskOnly = false) { + const usagePeriod = new UsagePeriod() + usagePeriod.boxId = event.box.id + usagePeriod.startAt = new Date() + usagePeriod.endAt = null + usagePeriod.kind = diskOnly ? 'stopped' : 'running' + if (!diskOnly) { + usagePeriod.cpu = event.box.cpu + usagePeriod.gpu = event.box.gpu + usagePeriod.mem = event.box.mem + } else { + usagePeriod.cpu = 0 + usagePeriod.gpu = 0 + usagePeriod.mem = 0 + } + usagePeriod.disk = event.box.disk + usagePeriod.organizationId = event.box.organizationId + usagePeriod.region = event.box.region + usagePeriod.boxClass = event.box.class ?? BoxClass.SMALL + usagePeriod.regionType = await this.getRegionType(event.box.region) + usagePeriod.actualCpuSeconds = null + usagePeriod.actualRssAvgBytes = null + usagePeriod.actualRssPeakBytes = null + usagePeriod.sampleCount = null + + await this.usagePeriodRepository.save(usagePeriod) + } + + private async closeUsagePeriod(boxId: string) { + const lastUsagePeriod = await this.usagePeriodRepository.findOne({ + where: { + boxId, + endAt: IsNull(), + }, + }) + + if (lastUsagePeriod) { + lastUsagePeriod.endAt = new Date() + await this.usagePeriodRepository.save(lastUsagePeriod) + } + } + + @Cron(CronExpression.EVERY_MINUTE, { name: 'close-and-reopen-usage-periods' }) + @TrackJobExecution() + @LogExecution('close-and-reopen-usage-periods') + @WithInstrumentation() + async closeAndReopenUsagePeriods(now: Date = new Date()) { + if (!(await this.redisLockProvider.lock('close-and-reopen-usage-periods', 60))) { + return + } + + const usagePeriods = await this.usagePeriodRepository.find({ + where: { + endAt: IsNull(), + startAt: LessThan(new Date(now.getTime() - 1000 * 60 * 60 * 24)), + organizationId: Not(BOX_WARM_POOL_UNASSIGNED_ORGANIZATION), + }, + order: { + startAt: 'ASC', + }, + take: 100, + }) + + for (const usagePeriod of usagePeriods) { + if (!(await this.aquireLock(usagePeriod.boxId))) { + continue + } + + try { + const box = await this.boxRepository.findOne({ + where: { + id: usagePeriod.boxId, + }, + }) + + await this.usagePeriodRepository.manager.transaction(async (transactionalEntityManager) => { + const closeTime = now + usagePeriod.endAt = closeTime + await transactionalEntityManager.save(usagePeriod) + + if ( + box && + (box.state === BoxState.STARTED || box.state === BoxState.STOPPED || box.state === BoxState.STOPPING) + ) { + const newUsagePeriod = UsagePeriod.fromUsagePeriod(usagePeriod) + newUsagePeriod.startAt = closeTime + newUsagePeriod.endAt = null + if (box.state === BoxState.STOPPED) { + newUsagePeriod.kind = 'stopped' + newUsagePeriod.cpu = 0 + newUsagePeriod.gpu = 0 + newUsagePeriod.mem = 0 + } + await transactionalEntityManager.save(newUsagePeriod) + } + }) + } catch (error) { + this.logger.error(`Error closing and reopening usage period ${usagePeriod.boxId}`, error) + } finally { + await this.releaseLock(usagePeriod.boxId) + } + } + + await this.redisLockProvider.unlock('close-and-reopen-usage-periods') + } + + @Cron(CronExpression.EVERY_5_SECONDS, { name: 'archive-usage-periods' }) + @TrackJobExecution() + @LogExecution('archive-usage-periods') + @WithInstrumentation() + async archiveUsagePeriods() { + const lockKey = 'archive-usage-periods' + if (!(await this.redisLockProvider.lock(lockKey, 60))) { + return + } + + await this.usagePeriodRepository.manager.transaction(async (transactionalEntityManager) => { + const usagePeriods = await transactionalEntityManager.find(UsagePeriod, { + where: { + endAt: Not(IsNull()), + }, + order: { + startAt: 'ASC', + }, + take: 5000, + }) + + if (usagePeriods.length === 0) { + return + } + + this.logger.debug(`Found ${usagePeriods.length} usage periods to archive`) + + await transactionalEntityManager.delete( + UsagePeriod, + usagePeriods.map((usagePeriod) => usagePeriod.id), + ) + await transactionalEntityManager.save(usagePeriods.map(UsagePeriodArchive.fromUsagePeriod)) + }) + + await this.redisLockProvider.unlock(lockKey) + } + + private async waitForLock(boxId: string) { + while (!(await this.aquireLock(boxId))) { + await new Promise((resolve) => setTimeout(resolve, 500)) + } + } + + private async aquireLock(boxId: string): Promise { + return await this.redisLockProvider.lock(`usage-period-${boxId}`, 60) + } + + private async releaseLock(boxId: string) { + await this.redisLockProvider.unlock(`usage-period-${boxId}`) + } + + private async getRegionType(regionId: string): Promise { + try { + const region = await this.regionRepository.findOne({ + select: ['regionType'], + where: { + id: regionId, + }, + cache: { + id: `region-type-${regionId}`, + milliseconds: 1000 * 60 * 60, + }, + }) + + return region?.regionType ?? RegionType.SHARED + } catch (error) { + this.logger.error(`Error fetching region type for region ${regionId}`, error) + return RegionType.SHARED + } + } +} diff --git a/apps/api/src/usage/usage-metering.service.spec.ts b/apps/api/src/usage/usage-metering.service.spec.ts new file mode 100644 index 000000000..744870ca6 --- /dev/null +++ b/apps/api/src/usage/usage-metering.service.spec.ts @@ -0,0 +1,79 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { UsagePeriodArchive } from './entities/usage-period-archive.entity' +import { UsagePeriod } from './entities/usage-period.entity' +import { UsageMeteringService } from './usage-metering.service' + +class FakeRepository { + constructor(readonly rows: T[]) {} + + async find(): Promise { + return this.rows + } +} + +describe('UsageMeteringService', () => { + it('aggregates current and archived Box usage without changing the Daytona writer', async () => { + const active = { + id: 'period-open', + boxId: 'box-1', + organizationId: 'org-1', + region: 'us', + startAt: new Date('2026-07-08T00:00:00Z'), + endAt: null, + kind: 'running', + cpu: 2, + mem: 4, + disk: 10, + gpu: 1, + actualCpuSeconds: null, + actualRssAvgBytes: null, + actualRssPeakBytes: null, + sampleCount: null, + } as UsagePeriod + const archived = { + id: 'archive-1', + sourcePeriodId: 'period-closed', + boxId: 'box-2', + organizationId: 'org-1', + region: 'us', + startAt: new Date('2026-07-07T23:00:00Z'), + endAt: new Date('2026-07-08T00:00:00Z'), + kind: 'stopped', + cpu: 0, + mem: 0, + disk: 20, + gpu: 0, + actualCpuSeconds: null, + actualRssAvgBytes: null, + actualRssPeakBytes: null, + sampleCount: null, + } as UsagePeriodArchive + const service = new UsageMeteringService( + new FakeRepository([active]) as never, + new FakeRepository([archived]) as never, + ) + + const view = await service.getOrganizationMeteringView( + 'org-1', + { + from: new Date('2026-07-07T23:00:00Z'), + to: new Date('2026-07-08T01:00:00Z'), + limit: 10, + }, + new Date('2026-07-08T01:00:00Z'), + ) + + expect(view.totals).toEqual({ + cpuSeconds: 7200, + memGibSeconds: 14400, + diskGibSeconds: 108000, + gpuSeconds: 3600, + }) + expect(view.activePeriods[0]).toMatchObject({ source: 'box_usage_period', active: true }) + expect(view.archivedPeriods[0]).toMatchObject({ source: 'box_usage_period_archive', active: false }) + }) +}) diff --git a/apps/api/src/usage/usage-metering.service.ts b/apps/api/src/usage/usage-metering.service.ts new file mode 100644 index 000000000..af2cd326e --- /dev/null +++ b/apps/api/src/usage/usage-metering.service.ts @@ -0,0 +1,150 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { Injectable } from '@nestjs/common' +import { InjectRepository } from '@nestjs/typeorm' +import { FindOptionsWhere, IsNull, LessThan, MoreThan, Repository } from 'typeorm' +import { UsagePeriodArchive } from './entities/usage-period-archive.entity' +import { UsagePeriod } from './entities/usage-period.entity' +import { UsagePeriodKind, UsageTotals, aggregateUsagePeriods } from './metering/usage-period-math' + +const ONE_DAY_MS = 24 * 60 * 60 * 1000 +const DEFAULT_METERING_LOOKBACK_MS = 30 * ONE_DAY_MS +const DEFAULT_METERING_LIMIT = 250 +const MAX_METERING_LIMIT = 1000 + +export interface MeteringQueryOptions { + from?: Date + to?: Date + limit?: number + boxId?: string +} + +export interface MeteringPeriodView { + id: string + source: 'box_usage_period' | 'box_usage_period_archive' + sourcePeriodId?: string + boxId: string + organizationId: string + region: string | null + startAt: Date + endAt: Date | null + kind: UsagePeriodKind + cpu: number + mem: number + disk: number + gpu: number + durationSeconds: number + active: boolean + actualCpuSeconds: number | null + actualRssAvgBytes: string | null + actualRssPeakBytes: string | null + sampleCount: number | null +} + +export interface OrganizationMeteringView { + organizationId: string + from: Date + to: Date + activePeriods: MeteringPeriodView[] + archivedPeriods: MeteringPeriodView[] + totals: UsageTotals +} + +@Injectable() +export class UsageMeteringService { + constructor( + @InjectRepository(UsagePeriod) + private readonly periods: Repository, + @InjectRepository(UsagePeriodArchive) + private readonly archives: Repository, + ) {} + + async getOrganizationMeteringView( + organizationId: string, + options: MeteringQueryOptions = {}, + now: Date = new Date(), + ): Promise { + const to = options.to ?? now + const from = options.from ?? new Date(to.getTime() - DEFAULT_METERING_LOOKBACK_MS) + const limit = this.normalizeMeteringLimit(options.limit) + const activeWhere = this.periodOverlapWhere(organizationId, from, to, options.boxId) + const archivedWhere = this.periodOverlapWhere(organizationId, from, to, options.boxId) + + const [activeRows, archivedRows] = await Promise.all([ + this.periods.find({ where: activeWhere, order: { startAt: 'DESC' }, take: limit }), + this.archives.find({ where: archivedWhere, order: { startAt: 'DESC' }, take: limit }), + ]) + + const activePeriods = activeRows.map((period) => this.toMeteringPeriodView(period, 'box_usage_period', to)) + const archivedPeriods = archivedRows.map((period) => + this.toMeteringPeriodView(period, 'box_usage_period_archive', to), + ) + + return { + organizationId, + from, + to, + activePeriods, + archivedPeriods, + totals: aggregateUsagePeriods([...activeRows, ...archivedRows], from, to), + } + } + + private periodOverlapWhere( + organizationId: string, + from: Date, + to: Date, + boxId?: string, + ): FindOptionsWhere[] { + const base = { + organizationId, + ...(boxId ? { boxId } : {}), + startAt: LessThan(to), + } as FindOptionsWhere + + return [ + { ...base, endAt: IsNull() }, + { ...base, endAt: MoreThan(from) }, + ] + } + + private normalizeMeteringLimit(limit: number | undefined): number { + if (limit === undefined || Number.isNaN(limit)) { + return DEFAULT_METERING_LIMIT + } + return Math.min(Math.max(Math.trunc(limit), 1), MAX_METERING_LIMIT) + } + + private toMeteringPeriodView( + period: UsagePeriod | UsagePeriodArchive, + source: MeteringPeriodView['source'], + to: Date, + ): MeteringPeriodView { + const endAt = period.endAt ?? null + const effectiveEnd = endAt ?? to + return { + id: period.id, + source, + sourcePeriodId: source === 'box_usage_period_archive' ? (period as UsagePeriodArchive).sourcePeriodId : undefined, + boxId: period.boxId, + organizationId: period.organizationId, + region: period.region, + startAt: period.startAt, + endAt, + kind: period.kind, + cpu: period.cpu, + mem: period.mem, + disk: period.disk, + gpu: period.gpu, + durationSeconds: Math.max(0, (effectiveEnd.getTime() - period.startAt.getTime()) / 1000), + active: endAt === null, + actualCpuSeconds: period.actualCpuSeconds, + actualRssAvgBytes: period.actualRssAvgBytes, + actualRssPeakBytes: period.actualRssPeakBytes, + sampleCount: period.sampleCount, + } + } +} diff --git a/apps/api/src/usage/usage.controller.spec.ts b/apps/api/src/usage/usage.controller.spec.ts index c4095d6f8..2511d3837 100644 --- a/apps/api/src/usage/usage.controller.spec.ts +++ b/apps/api/src/usage/usage.controller.spec.ts @@ -5,7 +5,7 @@ import { BadRequestException } from '@nestjs/common' import { UsageController } from './usage.controller' -import { UsageService } from './usage.service' +import { UsageMeteringService } from './usage-metering.service' describe('UsageController', () => { const usageService = { @@ -15,20 +15,14 @@ describe('UsageController', () => { beforeEach(() => { jest.resetAllMocks() - controller = new UsageController(usageService as unknown as UsageService) + controller = new UsageController(usageService as unknown as UsageMeteringService) }) it('returns the organization metering view with parsed filters', async () => { usageService.getOrganizationMeteringView.mockResolvedValue({ organizationId: 'org-1' }) await expect( - controller.getOrganizationMetering( - 'org-1', - '2026-07-08T00:00:00Z', - '2026-07-08T01:00:00Z', - '25', - 'box-1', - ), + controller.getOrganizationMetering('org-1', '2026-07-08T00:00:00Z', '2026-07-08T01:00:00Z', '25', 'box-1'), ).resolves.toEqual({ organizationId: 'org-1' }) expect(usageService.getOrganizationMeteringView).toHaveBeenCalledWith('org-1', { diff --git a/apps/api/src/usage/usage.controller.ts b/apps/api/src/usage/usage.controller.ts index caa3d7553..383b16e78 100644 --- a/apps/api/src/usage/usage.controller.ts +++ b/apps/api/src/usage/usage.controller.ts @@ -10,7 +10,7 @@ import { AuthenticatedRateLimitGuard } from '../common/guards/authenticated-rate import { RequiredOrganizationMemberRole } from '../organization/decorators/required-organization-member-role.decorator' import { OrganizationMemberRole } from '../organization/enums/organization-member-role.enum' import { OrganizationActionGuard } from '../organization/guards/organization-action.guard' -import { UsageService } from './usage.service' +import { UsageMeteringService } from './usage-metering.service' @ApiTags('usage') @ApiOAuth2(['openid', 'profile', 'email']) @@ -18,7 +18,7 @@ import { UsageService } from './usage.service' @UseGuards(CombinedAuthGuard, AuthenticatedRateLimitGuard) @Controller() export class UsageController { - constructor(private readonly usageService: UsageService) {} + constructor(private readonly usageMeteringService: UsageMeteringService) {} @Get('/organization/:organizationId/metering') @UseGuards(OrganizationActionGuard) @@ -30,7 +30,7 @@ export class UsageController { @Query('limit') limit?: string, @Query('boxId') boxId?: string, ) { - return this.usageService.getOrganizationMeteringView(organizationId, { + return this.usageMeteringService.getOrganizationMeteringView(organizationId, { from: this.parseOptionalDate('from', from), to: this.parseOptionalDate('to', to), limit: this.parseOptionalLimit(limit), diff --git a/apps/api/src/usage/usage.module.ts b/apps/api/src/usage/usage.module.ts index 764b17c4c..ea5348fa4 100644 --- a/apps/api/src/usage/usage.module.ts +++ b/apps/api/src/usage/usage.module.ts @@ -11,12 +11,14 @@ import { OrganizationModule } from '../organization/organization.module' import { UsagePeriodArchive } from './entities/usage-period-archive.entity' import { UsagePeriod } from './entities/usage-period.entity' import { UsageController } from './usage.controller' -import { UsageService } from './usage.service' +import { UsageService } from './services/usage.service' +import { UsageMeteringService } from './usage-metering.service' +import { Region } from '../region/entities/region.entity' @Module({ - imports: [BoxModule, OrganizationModule, TypeOrmModule.forFeature([UsagePeriod, UsagePeriodArchive])], + imports: [BoxModule, OrganizationModule, TypeOrmModule.forFeature([UsagePeriod, UsagePeriodArchive, Region])], controllers: [UsageController], - providers: [UsageService, OrganizationActionGuard], + providers: [UsageService, UsageMeteringService, OrganizationActionGuard], exports: [UsageService], }) export class UsageModule {} diff --git a/apps/api/src/usage/usage.service.spec.ts b/apps/api/src/usage/usage.service.spec.ts index c91d1bf19..58bc49ca4 100644 --- a/apps/api/src/usage/usage.service.spec.ts +++ b/apps/api/src/usage/usage.service.spec.ts @@ -4,11 +4,15 @@ */ import { Box } from '../box/entities/box.entity' +import { BOX_WARM_POOL_UNASSIGNED_ORGANIZATION } from '../box/constants/box.constants' import { BoxDesiredState } from '../box/enums/box-desired-state.enum' import { BoxState } from '../box/enums/box-state.enum' +import { BoxClass } from '../box/enums/box-class.enum' +import { RegionType } from '../region/enums/region-type.enum' +import { getMetadataArgsStorage } from 'typeorm' import { UsagePeriodArchive } from './entities/usage-period-archive.entity' import { UsagePeriod } from './entities/usage-period.entity' -import { UsageService } from './usage.service' +import { UsageService } from './services/usage.service' function matchesWhereValue(actual: unknown, expected: unknown): boolean { const op = expected as { _type?: string; _value?: unknown } @@ -33,6 +37,7 @@ function matchesWhereValue(actual: unknown, expected: unknown): boolean { class FakeUsagePeriodRepository { rows: UsagePeriod[] = [] archivedRows: UsagePeriodArchive[] = [] + transactionOperations: string[] = [] failNextSave: Error | null = null manager = { transaction: jest.fn(async (fn: (manager: FakeTransactionManager) => Promise) => { @@ -114,7 +119,28 @@ class FakeUsagePeriodRepository { class FakeTransactionManager { constructor(private readonly periods: FakeUsagePeriodRepository) {} - async save(_entity: typeof UsagePeriodArchive, rows: UsagePeriodArchive[]): Promise { + async find( + _entity: typeof UsagePeriod, + opts: { + where?: Partial + order?: { startAt?: 'ASC' | 'DESC' } + take?: number + }, + ): Promise { + return this.periods.find(opts) + } + + async save( + entityOrInput: typeof UsagePeriod | typeof UsagePeriodArchive | UsagePeriod | UsagePeriod[] | UsagePeriodArchive[], + input?: UsagePeriod | UsagePeriod[] | UsagePeriodArchive[], + ): Promise { + this.periods.transactionOperations.push('save') + const value = input ?? (entityOrInput as UsagePeriod | UsagePeriod[] | UsagePeriodArchive[]) + if (!Array.isArray(value) || value[0] instanceof UsagePeriod) { + return this.periods.save(value as UsagePeriod | UsagePeriod[]) + } + + const rows = value as UsagePeriodArchive[] rows.forEach((row) => { row.id = row.id ?? `archive-${this.periods.archivedRows.length + 1}` this.periods.archivedRows.push(row) @@ -123,54 +149,18 @@ class FakeTransactionManager { } async delete(_entity: typeof UsagePeriod, ids: string[]): Promise { + this.periods.transactionOperations.push('delete') await this.periods.delete(ids) } } -class FakeArchiveRepository { - rows: UsagePeriodArchive[] = [] - - create(input: Partial): UsagePeriodArchive { - return input as UsagePeriodArchive - } - - async find(opts: { - where?: Partial | Partial[] - order?: { startAt?: 'ASC' | 'DESC' } - take?: number - }): Promise { - const where = Array.isArray(opts.where) ? opts.where : [opts.where ?? {}] - const rows = this.rows.filter((row) => - where.some((whereItem) => - Object.entries(whereItem).every(([key, value]) => { - return matchesWhereValue((row as unknown as Record)[key], value) - }), - ), - ) - - if (opts.order?.startAt === 'DESC') { - rows.sort((left, right) => right.startAt.getTime() - left.startAt.getTime()) - } else if (opts.order?.startAt === 'ASC') { - rows.sort((left, right) => left.startAt.getTime() - right.startAt.getTime()) - } - - return rows.slice(0, opts.take) - } - - async save(rows: UsagePeriodArchive[]): Promise { - rows.forEach((row) => { - row.id = row.id ?? `archive-${this.rows.length + 1}` - this.rows.push(row) - }) - return rows - } -} - class FakeLockProvider { + locks: string[] = [] unlocks: string[] = [] async waitForLock(): Promise {} - async lock(): Promise { + async lock(key: string): Promise { + this.locks.push(key) return true } async unlock(key: string): Promise { @@ -186,6 +176,12 @@ class FakeBoxRepository { } } +class FakeRegionRepository { + async findOne(): Promise<{ regionType: RegionType }> { + return { regionType: RegionType.SHARED } + } +} + function makeBox(state: BoxState, desiredState: BoxDesiredState = BoxDesiredState.STARTED): Box { return { id: 'box-1', @@ -197,204 +193,87 @@ function makeBox(state: BoxState, desiredState: BoxDesiredState = BoxDesiredStat mem: 4, disk: 10, gpu: 1, + class: BoxClass.SMALL, } as Box } describe('UsageService', () => { let periods: FakeUsagePeriodRepository - let archives: FakeArchiveRepository let locks: FakeLockProvider let boxes: FakeBoxRepository + let regions: FakeRegionRepository let service: UsageService beforeEach(() => { periods = new FakeUsagePeriodRepository() periods.archivedRows = [] - archives = new FakeArchiveRepository() locks = new FakeLockProvider() boxes = new FakeBoxRepository() - service = new UsageService(periods as never, archives as never, locks as never, boxes as never) + regions = new FakeRegionRepository() + service = new UsageService(periods as never, locks as never, boxes as never, regions as never) }) it('opens running periods and switches to disk-only periods when the box stops', async () => { const started = makeBox(BoxState.STARTED, BoxDesiredState.STARTED) - const stopped = makeBox(BoxState.STOPPED, BoxDesiredState.STOPPED) + const stopping = makeBox(BoxState.STOPPING, BoxDesiredState.STOPPED) - await service.applyTransition(started, BoxState.STARTED, BoxDesiredState.STARTED, new Date('2026-07-08T00:00:00Z')) - await service.applyTransition(stopped, BoxState.STOPPED, BoxDesiredState.STOPPED, new Date('2026-07-08T00:00:10Z')) + await service.handleBoxStateUpdate({ box: started, newState: BoxState.STARTED } as never) + await service.handleBoxStateUpdate({ box: stopping, newState: BoxState.STOPPING } as never) expect(periods.rows).toHaveLength(2) - expect(periods.rows[0]).toMatchObject({ kind: 'running', endAt: new Date('2026-07-08T00:00:10Z') }) + expect(periods.rows[0]).toMatchObject({ kind: 'running', endAt: expect.any(Date) }) expect(periods.rows[1]).toMatchObject({ kind: 'stopped', endAt: null, cpu: 0, mem: 0, gpu: 0, disk: 10 }) }) - it('does not fragment the open period on same-kind transitions', async () => { + it('restarts the open period on every STARTED event like Daytona', async () => { const box = makeBox(BoxState.STARTED, BoxDesiredState.STARTED) - await service.applyTransition(box, BoxState.STARTED, BoxDesiredState.STARTED, new Date('2026-07-08T00:00:00Z')) - await service.applyTransition(box, BoxState.RESIZING, BoxDesiredState.STARTED, new Date('2026-07-08T00:00:05Z')) - - expect(periods.rows).toHaveLength(1) - expect(periods.rows[0]).toMatchObject({ kind: 'running', endAt: null }) - }) - - it('reopens the period when billable resources change without changing lifecycle kind', async () => { - const started = makeBox(BoxState.STARTED, BoxDesiredState.STARTED) - const resized = { - ...started, - cpu: 4, - mem: 8, - disk: 20, - gpu: 2, - } as Box - - await service.applyTransition(started, BoxState.STARTED, BoxDesiredState.STARTED, new Date('2026-07-08T00:00:00Z')) - await service.applyTransition(resized, BoxState.STARTED, BoxDesiredState.STARTED, new Date('2026-07-08T00:00:30Z')) + await service.handleBoxStateUpdate({ box, newState: BoxState.STARTED } as never) + await service.handleBoxStateUpdate({ box, newState: BoxState.STARTED } as never) expect(periods.rows).toHaveLength(2) - expect(periods.rows[0]).toMatchObject({ - kind: 'running', - endAt: new Date('2026-07-08T00:00:30Z'), - cpu: 2, - mem: 4, - disk: 10, - gpu: 1, - }) - expect(periods.rows[1]).toMatchObject({ - kind: 'running', - endAt: null, - cpu: 4, - mem: 8, - disk: 20, - gpu: 2, - }) - }) - - it('reopens the period under the new organization when a box is reassigned', async () => { - const started = makeBox(BoxState.STARTED, BoxDesiredState.STARTED) - const reassigned = { - ...started, - organizationId: 'org-2', - } as Box - - await service.applyTransition(started, BoxState.STARTED, BoxDesiredState.STARTED, new Date('2026-07-08T00:00:00Z')) - await service.handleBoxOrganizationUpdated({ - box: reassigned, - oldOrganizationId: 'org-1', - newOrganizationId: 'org-2', - } as never) - - expect(periods.rows).toHaveLength(2) - expect(periods.rows[0]).toMatchObject({ - kind: 'running', - organizationId: 'org-1', - endAt: expect.any(Date), - }) - expect(periods.rows[1]).toMatchObject({ - kind: 'running', - organizationId: 'org-2', - endAt: null, - }) - }) - - it('returns a raw organization metering view across active and archived periods', async () => { - periods.rows.push({ - id: 'period-open', - boxId: 'box-1', - organizationId: 'org-1', - region: 'us', - startAt: new Date('2026-07-08T00:00:00Z'), - endAt: null, - kind: 'running', - cpu: 2, - mem: 4, - disk: 10, - gpu: 1, - actualCpuSeconds: null, - actualRssAvgBytes: null, - actualRssPeakBytes: null, - sampleCount: null, - } as UsagePeriod) - archives.rows.push({ - id: 'archive-1', - sourcePeriodId: 'period-closed', - boxId: 'box-2', - organizationId: 'org-1', - region: 'us', - startAt: new Date('2026-07-07T23:00:00Z'), - endAt: new Date('2026-07-08T00:00:00Z'), - kind: 'stopped', - cpu: 0, - mem: 0, - disk: 20, - gpu: 0, - actualCpuSeconds: null, - actualRssAvgBytes: null, - actualRssPeakBytes: null, - sampleCount: null, - } as UsagePeriodArchive) - - const view = await service.getOrganizationMeteringView('org-1', { - from: new Date('2026-07-07T23:00:00Z'), - to: new Date('2026-07-08T01:00:00Z'), - limit: 10, - }) - - expect(view.organizationId).toBe('org-1') - expect(view.activePeriods).toHaveLength(1) - expect(view.archivedPeriods).toHaveLength(1) - expect(view.totals).toEqual({ - cpuSeconds: 7200, - memGibSeconds: 14400, - diskGibSeconds: 108000, - gpuSeconds: 3600, - }) - expect(view.activePeriods[0]).toMatchObject({ - source: 'usage_period', - active: true, - durationSeconds: 3600, - }) - expect(view.archivedPeriods[0]).toMatchObject({ - source: 'usage_period_archive', - active: false, - durationSeconds: 3600, - }) - }) - - it('ignores stale transitions that arrive before the open period start', async () => { - const warn = jest.spyOn((service as unknown as { logger: { warn: jest.Mock } }).logger, 'warn').mockImplementation() - const started = makeBox(BoxState.STARTED, BoxDesiredState.STARTED) - const stopped = makeBox(BoxState.STOPPED, BoxDesiredState.STOPPED) - - await service.applyTransition(started, BoxState.STARTED, BoxDesiredState.STARTED, new Date('2026-07-08T00:00:10Z')) - await service.applyTransition(stopped, BoxState.STOPPED, BoxDesiredState.STOPPED, new Date('2026-07-08T00:00:05Z')) - - expect(periods.rows).toHaveLength(1) - expect(periods.rows[0]).toMatchObject({ kind: 'running', startAt: new Date('2026-07-08T00:00:10Z'), endAt: null }) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('ignoring stale usage transition')) + expect(periods.rows[0]).toMatchObject({ kind: 'running', endAt: expect.any(Date) }) + expect(periods.rows[1]).toMatchObject({ kind: 'running', endAt: null }) }) it('rolls open periods older than 24 hours and reopens the same billable kind', async () => { const box = makeBox(BoxState.STARTED, BoxDesiredState.STARTED) boxes.boxes.set(box.id, box) - await service.applyTransition(box, BoxState.STARTED, BoxDesiredState.STARTED, new Date('2026-07-06T00:00:00Z')) + await service.handleBoxStateUpdate({ box, newState: BoxState.STARTED } as never) + periods.rows[0].startAt = new Date('2026-07-06T00:00:00Z') await service.closeAndReopenUsagePeriods(new Date('2026-07-08T00:00:00Z')) expect(periods.rows).toHaveLength(2) expect(periods.rows[0].endAt).toEqual(new Date('2026-07-08T00:00:00Z')) expect(periods.rows[1]).toMatchObject({ kind: 'running', startAt: new Date('2026-07-08T00:00:00Z'), endAt: null }) + expect(periods.manager.transaction).toHaveBeenCalledTimes(1) + expect(locks.locks).toContain('close-and-reopen-usage-periods') + }) + + it('does not roll over unassigned warm-pool periods', async () => { + const warmPoolBox = { + ...makeBox(BoxState.STARTED), + organizationId: BOX_WARM_POOL_UNASSIGNED_ORGANIZATION, + } as Box + boxes.boxes.set(warmPoolBox.id, warmPoolBox) + await service.handleBoxStateUpdate({ box: warmPoolBox, newState: BoxState.STARTED } as never) + periods.rows[0].startAt = new Date('2026-07-06T00:00:00Z') + + await service.closeAndReopenUsagePeriods(new Date('2026-07-08T00:00:00Z')) + + expect(periods.rows).toHaveLength(1) + expect(periods.rows[0].endAt).toBeNull() }) it('archives closed periods and removes them from the active table', async () => { const box = makeBox(BoxState.STARTED, BoxDesiredState.STARTED) - await service.applyTransition(box, BoxState.STARTED, BoxDesiredState.STARTED, new Date('2026-07-08T00:00:00Z')) - await service.applyTransition( - makeBox(BoxState.DESTROYED, BoxDesiredState.DESTROYED), - BoxState.DESTROYED, - BoxDesiredState.DESTROYED, - new Date('2026-07-08T00:00:10Z'), - ) + await service.handleBoxStateUpdate({ box, newState: BoxState.STARTED } as never) + await service.handleBoxStateUpdate({ + box: makeBox(BoxState.DESTROYED, BoxDesiredState.DESTROYED), + newState: BoxState.DESTROYED, + } as never) + const closedAt = periods.rows[0].endAt await service.archiveUsagePeriods() @@ -403,34 +282,50 @@ describe('UsageService', () => { expect(periods.archivedRows[0]).toMatchObject({ boxId: 'box-1', organizationId: 'org-1', - endAt: new Date('2026-07-08T00:00:10Z'), + endAt: closedAt, }) + expect(locks.locks).toContain('archive-usage-periods') }) - it('does not propagate usage ledger failures from lifecycle event handlers', async () => { - jest.spyOn((service as unknown as { logger: { error: jest.Mock } }).logger, 'error').mockImplementation() + it('propagates usage ledger failures from lifecycle event handlers and releases the Daytona lock', async () => { periods.failNextSave = new Error('database unavailable') - await expect(service.handleBoxCreated({ box: makeBox(BoxState.STARTED, BoxDesiredState.STARTED) } as never)).resolves.toBeUndefined() + await expect( + service.handleBoxStateUpdate({ box: makeBox(BoxState.STARTED), newState: BoxState.STARTED } as never), + ).rejects.toThrow('database unavailable') - expect(locks.unlocks).toContain('usage-period:box-1') + expect(locks.unlocks).toContain('usage-period-box-1') }) it('archives closed periods in a single transaction', async () => { const box = makeBox(BoxState.STARTED, BoxDesiredState.STARTED) - await service.applyTransition(box, BoxState.STARTED, BoxDesiredState.STARTED, new Date('2026-07-08T00:00:00Z')) - await service.applyTransition( - makeBox(BoxState.DESTROYED, BoxDesiredState.DESTROYED), - BoxState.DESTROYED, - BoxDesiredState.DESTROYED, - new Date('2026-07-08T00:00:10Z'), - ) + await service.handleBoxStateUpdate({ box, newState: BoxState.STARTED } as never) + await service.handleBoxStateUpdate({ + box: makeBox(BoxState.DESTROYED, BoxDesiredState.DESTROYED), + newState: BoxState.DESTROYED, + } as never) await service.archiveUsagePeriods() expect(periods.manager.transaction).toHaveBeenCalledTimes(1) expect(periods.rows).toHaveLength(0) expect(periods.archivedRows).toHaveLength(1) - expect(archives.rows).toHaveLength(0) + expect(periods.transactionOperations).toEqual(['delete', 'save']) + }) + + it('tracks active Daytona jobs for graceful shutdown', () => { + expect((service as unknown as { activeJobs?: Set }).activeJobs).toEqual(new Set()) + }) +}) + +describe('usage period persistence', () => { + it('uses the Box-specific table names', () => { + const tables = getMetadataArgsStorage().tables + + expect(tables.find((table) => table.target === UsagePeriod)?.name).toBe('box_usage_period') + expect(tables.find((table) => table.target === UsagePeriodArchive)?.name).toBe('box_usage_period_archive') + + const columns = getMetadataArgsStorage().columns.filter((column) => column.target === UsagePeriod) + expect(columns.map((column) => column.propertyName)).toEqual(expect.arrayContaining(['boxClass', 'regionType'])) }) }) diff --git a/apps/api/src/usage/usage.service.ts b/apps/api/src/usage/usage.service.ts deleted file mode 100644 index a0502ee7d..000000000 --- a/apps/api/src/usage/usage.service.ts +++ /dev/null @@ -1,376 +0,0 @@ -/* - * Copyright BoxLite AI, 2026 - * SPDX-License-Identifier: AGPL-3.0 - */ - -import { Injectable, Logger } from '@nestjs/common' -import { Cron, CronExpression } from '@nestjs/schedule' -import { InjectRepository } from '@nestjs/typeorm' -import { EntityManager, FindOptionsWhere, IsNull, LessThan, MoreThan, Not, Repository } from 'typeorm' -import { BoxEvents } from '../box/constants/box-events.constants' -import { RedisLockProvider } from '../box/common/redis-lock.provider' -import { Box } from '../box/entities/box.entity' -import { BoxCreatedEvent } from '../box/events/box-create.event' -import { BoxDesiredStateUpdatedEvent } from '../box/events/box-desired-state-updated.event' -import { BoxStateUpdatedEvent } from '../box/events/box-state-updated.event' -import { BoxDesiredState } from '../box/enums/box-desired-state.enum' -import { BoxState } from '../box/enums/box-state.enum' -import { BoxOrganizationUpdatedEvent } from '../box/events/box-organization-updated.event' -import { BoxRepository } from '../box/repositories/box.repository' -import { OnAsyncEvent } from '../common/decorators/on-async-event.decorator' -import { - UsagePeriodKind, - UsageTotals, - aggregateUsagePeriods, - planUsageTransition, - usagePeriodKind, -} from './metering/usage-period-math' -import { UsagePeriodArchive } from './entities/usage-period-archive.entity' -import { UsagePeriod } from './entities/usage-period.entity' - -const ONE_DAY_MS = 24 * 60 * 60 * 1000 -const DEFAULT_METERING_LOOKBACK_MS = 30 * ONE_DAY_MS -const DEFAULT_METERING_LIMIT = 250 -const MAX_METERING_LIMIT = 1000 - -export interface MeteringQueryOptions { - from?: Date - to?: Date - limit?: number - boxId?: string -} - -export interface MeteringPeriodView { - id: string - source: 'usage_period' | 'usage_period_archive' - sourcePeriodId?: string - boxId: string - organizationId: string - region: string | null - startAt: Date - endAt: Date | null - kind: UsagePeriodKind - cpu: number - mem: number - disk: number - gpu: number - durationSeconds: number - active: boolean - actualCpuSeconds: number | null - actualRssAvgBytes: string | null - actualRssPeakBytes: string | null - sampleCount: number | null -} - -export interface OrganizationMeteringView { - organizationId: string - from: Date - to: Date - activePeriods: MeteringPeriodView[] - archivedPeriods: MeteringPeriodView[] - totals: UsageTotals -} - -@Injectable() -export class UsageService { - private readonly logger = new Logger(UsageService.name) - - constructor( - @InjectRepository(UsagePeriod) - private readonly periods: Repository, - @InjectRepository(UsagePeriodArchive) - private readonly archives: Repository, - private readonly redisLockProvider: RedisLockProvider, - private readonly boxRepository: BoxRepository, - ) {} - - @OnAsyncEvent({ event: BoxEvents.CREATED }) - async handleBoxCreated(event: BoxCreatedEvent): Promise { - await this.recordLifecycleTransition(event.box, event.box.state, event.box.desiredState) - } - - @OnAsyncEvent({ event: BoxEvents.STATE_UPDATED }) - async handleBoxStateUpdated(event: BoxStateUpdatedEvent): Promise { - await this.recordLifecycleTransition(event.box, event.newState, event.box.desiredState) - } - - @OnAsyncEvent({ event: BoxEvents.DESIRED_STATE_UPDATED }) - async handleBoxDesiredStateUpdated(event: BoxDesiredStateUpdatedEvent): Promise { - if (event.newDesiredState !== BoxDesiredState.DESTROYED) { - return - } - - await this.recordLifecycleTransition(event.box, event.box.state, event.newDesiredState) - } - - @OnAsyncEvent({ event: BoxEvents.ORGANIZATION_UPDATED }) - async handleBoxOrganizationUpdated(event: BoxOrganizationUpdatedEvent): Promise { - await this.recordLifecycleTransition(event.box, event.box.state, event.box.desiredState) - } - - async applyTransition(box: Box, state: BoxState, desiredState: BoxDesiredState, now: Date): Promise { - const open = await this.findOpenPeriod(box.id) - if (open && now.getTime() < open.startAt.getTime()) { - this.logger.warn( - `ignoring stale usage transition for box ${box.id}: ${now.toISOString()} before ${open.startAt.toISOString()}`, - ) - return - } - const openKind = open?.kind ?? null - const plan = planUsageTransition(openKind, state, desiredState) - const nextKind = usagePeriodKind(state, desiredState) - const resourcesChanged = - Boolean(open) && - nextKind !== 'gone' && - open?.kind === nextKind && - !this.openPeriodMatchesBox(open, box, nextKind) - - if ((plan.closeOpen || resourcesChanged) && open) { - open.endAt = now - await this.periods.save(open) - } - - const openKindToCreate = plan.openKind ?? (resourcesChanged ? (nextKind as UsagePeriodKind) : null) - if (openKindToCreate) { - await this.periods.save(this.createPeriod(box, openKindToCreate, now)) - } - } - - async getOrganizationMeteringView( - organizationId: string, - options: MeteringQueryOptions = {}, - now: Date = new Date(), - ): Promise { - const to = options.to ?? now - const from = options.from ?? new Date(to.getTime() - DEFAULT_METERING_LOOKBACK_MS) - const limit = this.normalizeMeteringLimit(options.limit) - const activeWhere = this.periodOverlapWhere(organizationId, from, to, options.boxId) - const archivedWhere = this.periodOverlapWhere(organizationId, from, to, options.boxId) - - const [activeRows, archivedRows] = await Promise.all([ - this.periods.find({ - where: activeWhere, - order: { startAt: 'DESC' }, - take: limit, - }), - this.archives.find({ - where: archivedWhere, - order: { startAt: 'DESC' }, - take: limit, - }), - ]) - - const activePeriods = activeRows.map((period) => this.toMeteringPeriodView(period, 'usage_period', to)) - const archivedPeriods = archivedRows.map((period) => - this.toMeteringPeriodView(period, 'usage_period_archive', to), - ) - - return { - organizationId, - from, - to, - activePeriods, - archivedPeriods, - totals: aggregateUsagePeriods([...activeRows, ...archivedRows], from, to), - } - } - - @Cron(CronExpression.EVERY_MINUTE, { name: 'close-and-reopen-usage-periods' }) - async closeAndReopenUsagePeriods(now: Date = new Date()): Promise { - const lockKey = 'usage-periods:rollover' - if (!(await this.redisLockProvider.lock(lockKey, 60))) { - return - } - - try { - const oldOpenPeriods = await this.periods.find({ - where: { - endAt: IsNull(), - startAt: LessThan(new Date(now.getTime() - ONE_DAY_MS)), - }, - take: 100, - }) - - for (const period of oldOpenPeriods) { - if (!(await this.redisLockProvider.lock(this.boxLockKey(period.boxId), 60))) { - continue - } - - try { - const box = await this.boxRepository.findOne({ where: { id: period.boxId } }) - period.endAt = now - await this.periods.save(period) - - if (!box) { - continue - } - - const plan = planUsageTransition(null, box.state, box.desiredState) - if (plan.openKind) { - await this.periods.save(this.createPeriod(box, plan.openKind, now)) - } - } finally { - await this.redisLockProvider.unlock(this.boxLockKey(period.boxId)) - } - } - } finally { - await this.redisLockProvider.unlock(lockKey) - } - } - - @Cron(CronExpression.EVERY_5_SECONDS, { name: 'archive-usage-periods' }) - async archiveUsagePeriods(): Promise { - const lockKey = 'usage-periods:archive' - if (!(await this.redisLockProvider.lock(lockKey, 60))) { - return - } - - try { - const closed = await this.periods.find({ - where: { endAt: Not(IsNull()) }, - take: 5000, - }) - - if (closed.length === 0) { - return - } - - await this.archiveClosedPeriods(closed) - } finally { - await this.redisLockProvider.unlock(lockKey) - } - } - - private async findOpenPeriod(boxId: string): Promise { - return this.periods.findOne({ - where: { boxId, endAt: IsNull() }, - }) - } - - private createPeriod(box: Box, kind: UsagePeriodKind, startAt: Date): UsagePeriod { - const running = kind === 'running' - return this.periods.create({ - boxId: box.id, - organizationId: box.organizationId, - region: box.region, - startAt, - endAt: null, - kind, - cpu: running ? box.cpu : 0, - mem: running ? box.mem : 0, - gpu: running ? box.gpu : 0, - disk: box.disk, - actualCpuSeconds: null, - actualRssAvgBytes: null, - actualRssPeakBytes: null, - sampleCount: null, - }) - } - - private openPeriodMatchesBox(period: UsagePeriod, box: Box, kind: UsagePeriodKind): boolean { - const running = kind === 'running' - return ( - period.organizationId === box.organizationId && - period.region === box.region && - period.cpu === (running ? box.cpu : 0) && - period.mem === (running ? box.mem : 0) && - period.gpu === (running ? box.gpu : 0) && - period.disk === box.disk - ) - } - - private periodOverlapWhere( - organizationId: string, - from: Date, - to: Date, - boxId?: string, - ): FindOptionsWhere[] { - const base = { - organizationId, - ...(boxId ? { boxId } : {}), - startAt: LessThan(to), - } as FindOptionsWhere - - return [{ ...base, endAt: IsNull() }, { ...base, endAt: MoreThan(from) }] - } - - private normalizeMeteringLimit(limit: number | undefined): number { - if (limit === undefined || Number.isNaN(limit)) { - return DEFAULT_METERING_LIMIT - } - - return Math.min(Math.max(Math.trunc(limit), 1), MAX_METERING_LIMIT) - } - - private toMeteringPeriodView( - period: UsagePeriod | UsagePeriodArchive, - source: MeteringPeriodView['source'], - to: Date, - ): MeteringPeriodView { - const endAt = period.endAt ?? null - const effectiveEnd = endAt ?? to - return { - id: period.id, - source, - sourcePeriodId: source === 'usage_period_archive' ? (period as UsagePeriodArchive).sourcePeriodId : undefined, - boxId: period.boxId, - organizationId: period.organizationId, - region: period.region, - startAt: period.startAt, - endAt, - kind: period.kind, - cpu: period.cpu, - mem: period.mem, - disk: period.disk, - gpu: period.gpu, - durationSeconds: Math.max(0, (effectiveEnd.getTime() - period.startAt.getTime()) / 1000), - active: endAt === null, - actualCpuSeconds: period.actualCpuSeconds, - actualRssAvgBytes: period.actualRssAvgBytes, - actualRssPeakBytes: period.actualRssPeakBytes, - sampleCount: period.sampleCount, - } - } - - private async recordLifecycleTransition( - box: Box, - state: BoxState, - desiredState: BoxDesiredState, - now: Date = new Date(), - ): Promise { - try { - await this.withBoxLock(box.id, async () => { - await this.applyTransition(box, state, desiredState, now) - }) - } catch (err) { - this.logger.error( - `usage ledger update failed for box ${box.id}`, - err instanceof Error ? err.stack : String(err), - ) - } - } - - private async archiveClosedPeriods(closed: UsagePeriod[]): Promise { - await this.periods.manager.transaction(async (manager: EntityManager) => { - await manager.save(UsagePeriodArchive, closed.map(UsagePeriodArchive.fromUsagePeriod)) - await manager.delete( - UsagePeriod, - closed.map((period) => period.id), - ) - }) - } - - private async withBoxLock(boxId: string, fn: () => Promise): Promise { - const lockKey = this.boxLockKey(boxId) - await this.redisLockProvider.waitForLock(lockKey, 60) - try { - return await fn() - } finally { - await this.redisLockProvider.unlock(lockKey) - } - } - - private boxLockKey(boxId: string): string { - return `usage-period:${boxId}` - } -} diff --git a/apps/dashboard/src/billing-api/types/OrganizationMetering.ts b/apps/dashboard/src/billing-api/types/OrganizationMetering.ts index c2ca23e8c..a0ead61e4 100644 --- a/apps/dashboard/src/billing-api/types/OrganizationMetering.ts +++ b/apps/dashboard/src/billing-api/types/OrganizationMetering.ts @@ -4,7 +4,7 @@ */ export type MeteringPeriodKind = 'running' | 'stopped' -export type MeteringPeriodSource = 'usage_period' | 'usage_period_archive' +export type MeteringPeriodSource = 'box_usage_period' | 'box_usage_period_archive' export interface MeteringTotals { cpuSeconds: number diff --git a/apps/dashboard/src/pages/Metering.test.tsx b/apps/dashboard/src/pages/Metering.test.tsx index 34331ffef..1b7dd81b8 100644 --- a/apps/dashboard/src/pages/Metering.test.tsx +++ b/apps/dashboard/src/pages/Metering.test.tsx @@ -58,7 +58,7 @@ describe('Metering page', () => { activePeriods: [ { id: 'period-open', - source: 'usage_period', + source: 'box_usage_period', boxId: 'box-1', organizationId: 'org-1', region: 'us', @@ -80,7 +80,7 @@ describe('Metering page', () => { archivedPeriods: [ { id: 'archive-1', - source: 'usage_period_archive', + source: 'box_usage_period_archive', sourcePeriodId: 'period-closed', boxId: 'box-2', organizationId: 'org-1', diff --git a/apps/dashboard/src/pages/Metering.tsx b/apps/dashboard/src/pages/Metering.tsx index 78d5b629e..3cbfb0766 100644 --- a/apps/dashboard/src/pages/Metering.tsx +++ b/apps/dashboard/src/pages/Metering.tsx @@ -76,7 +76,7 @@ function totalsToCards(totals: MeteringTotals | undefined) { } function sourceLabel(source: MeteringPeriod['source']): string { - return source === 'usage_period' ? 'open ledger' : 'closed ledger' + return source === 'box_usage_period' ? 'open ledger' : 'closed ledger' } function periodNote(period: MeteringPeriod): string | null { @@ -87,7 +87,15 @@ function periodNote(period: MeteringPeriod): string | null { return 'disk only' } -function PeriodTable({ title, description, periods }: { title: string; description: string; periods: MeteringPeriod[] }) { +function PeriodTable({ + title, + description, + periods, +}: { + title: string + description: string + periods: MeteringPeriod[] +}) { return (
@@ -206,10 +214,10 @@ function Metering() {
Organization {truncateId(data?.organizationId ?? organizationId)} - Window {formatDate(data?.from ?? null)} → {formatDate(data?.to ?? null)} - Periods {(data?.activePeriods.length ?? 0) + (data?.archivedPeriods.length ?? 0)} + Window {formatDate(data?.from ?? null)} → {formatDate(data?.to ?? null)} + Periods {(data?.activePeriods.length ?? 0) + (data?.archivedPeriods.length ?? 0)} Open stopped rows keep metering disk only.
diff --git a/apps/scripts/metering-local-e2e.mjs b/apps/scripts/metering-local-e2e.mjs index 91c8fdc82..8364c5117 100644 --- a/apps/scripts/metering-local-e2e.mjs +++ b/apps/scripts/metering-local-e2e.mjs @@ -143,7 +143,7 @@ async function seedMeteringRows() { await cleanupMeteringRows() await pool.query( ` - insert into usage_period ( + insert into box_usage_period ( id, "boxId", "organizationId", @@ -166,7 +166,7 @@ async function seedMeteringRows() { ) await pool.query( ` - insert into usage_period_archive ( + insert into box_usage_period_archive ( id, "sourcePeriodId", "boxId", @@ -199,9 +199,7 @@ async function verifyApi(window) { if (!activeData.activePeriods?.some((period) => period.boxId === activeBoxId && period.kind === 'running')) { throw new Error(`active seeded period missing from API response: ${JSON.stringify(activeData).slice(0, 400)}`) } - if ( - !archivedData.archivedPeriods?.some((period) => period.boxId === archivedBoxId && period.kind === 'stopped') - ) { + if (!archivedData.archivedPeriods?.some((period) => period.boxId === archivedBoxId && period.kind === 'stopped')) { throw new Error(`archived seeded period missing from API response: ${JSON.stringify(archivedData).slice(0, 400)}`) } assertNear(activeData.totals.cpuSeconds, 7200, 'active cpuSeconds') @@ -294,7 +292,10 @@ async function settleAuthState(page, { allowDashboardOrigin = false } = {}) { for (let attempt = 0; attempt < 100; attempt += 1) { const currentUrl = page.url() - const bodyText = await page.locator('body').innerText().catch(() => '') + const bodyText = await page + .locator('body') + .innerText() + .catch(() => '') lastBodyText = bodyText if (/Log in to Your Account|Grant Access|Metering|Boxes|Billing/.test(bodyText)) { return @@ -309,9 +310,7 @@ async function settleAuthState(page, { allowDashboardOrigin = false } = {}) { } await delay(250) } - throw new Error( - `Timed out waiting for dashboard or Dex state at ${page.url()}; body=${lastBodyText.slice(0, 240)}`, - ) + throw new Error(`Timed out waiting for dashboard or Dex state at ${page.url()}; body=${lastBodyText.slice(0, 240)}`) } async function assertBodyText(page, expectedTexts) { @@ -343,8 +342,8 @@ async function cleanupApiKey() { } async function cleanupMeteringRows() { - await pool.query(`delete from usage_period where "boxId" like 'mtr%'`) - await pool.query(`delete from usage_period_archive where "boxId" like 'arc%'`) + await pool.query(`delete from box_usage_period where "boxId" like 'mtr%'`) + await pool.query(`delete from box_usage_period_archive where "boxId" like 'arc%'`) } async function fetchWithTimeout(url, options) {