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..ff032222b --- /dev/null +++ b/apps/api/src/migrations/pre-deploy/1782700000000-migration.ts @@ -0,0 +1,77 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class Migration1782700000000 implements MigrationInterface { + name = 'Migration1782700000000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "box_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, + "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 "box_usage_period_box_end_idx" ON "box_usage_period" ("boxId", "endAt")`) + await queryRunner.query( + `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 "box_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, + "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 "box_usage_period_archive_source_period_idx" ON "box_usage_period_archive" ("sourcePeriodId")`, + ) + await queryRunner.query( + `CREATE INDEX "box_usage_period_archive_org_start_idx" ON "box_usage_period_archive" ("organizationId", "startAt")`, + ) + await queryRunner.query( + `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 "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 new file mode 100644 index 000000000..ddfde5ef8 --- /dev/null +++ b/apps/api/src/usage/entities/usage-period-archive.entity.ts @@ -0,0 +1,97 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +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('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 + + @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 + + @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`) + } + + 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 + 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 new file mode 100644 index 000000000..8887cfea1 --- /dev/null +++ b/apps/api/src/usage/entities/usage-period.entity.ts @@ -0,0 +1,74 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +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('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 + + @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 + + @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/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/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 new file mode 100644 index 000000000..2511d3837 --- /dev/null +++ b/apps/api/src/usage/usage.controller.spec.ts @@ -0,0 +1,41 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { BadRequestException } from '@nestjs/common' +import { UsageController } from './usage.controller' +import { UsageMeteringService } from './usage-metering.service' + +describe('UsageController', () => { + const usageService = { + getOrganizationMeteringView: jest.fn(), + } + let controller: UsageController + + beforeEach(() => { + jest.resetAllMocks() + 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'), + ).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..383b16e78 --- /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 { UsageMeteringService } from './usage-metering.service' + +@ApiTags('usage') +@ApiOAuth2(['openid', 'profile', 'email']) +@ApiBearerAuth() +@UseGuards(CombinedAuthGuard, AuthenticatedRateLimitGuard) +@Controller() +export class UsageController { + constructor(private readonly usageMeteringService: UsageMeteringService) {} + + @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.usageMeteringService.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..ea5348fa4 --- /dev/null +++ b/apps/api/src/usage/usage.module.ts @@ -0,0 +1,24 @@ +/* + * 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 './services/usage.service' +import { UsageMeteringService } from './usage-metering.service' +import { Region } from '../region/entities/region.entity' + +@Module({ + imports: [BoxModule, OrganizationModule, TypeOrmModule.forFeature([UsagePeriod, UsagePeriodArchive, Region])], + controllers: [UsageController], + 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 new file mode 100644 index 000000000..58bc49ca4 --- /dev/null +++ b/apps/api/src/usage/usage.service.spec.ts @@ -0,0 +1,331 @@ +/* + * Copyright BoxLite AI, 2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +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 './services/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[] = [] + transactionOperations: string[] = [] + 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 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) + }) + return rows + } + + async delete(_entity: typeof UsagePeriod, ids: string[]): Promise { + this.periods.transactionOperations.push('delete') + await this.periods.delete(ids) + } +} + +class FakeLockProvider { + locks: string[] = [] + unlocks: string[] = [] + + async waitForLock(): Promise {} + async lock(key: string): Promise { + this.locks.push(key) + 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 + } +} + +class FakeRegionRepository { + async findOne(): Promise<{ regionType: RegionType }> { + return { regionType: RegionType.SHARED } + } +} + +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, + class: BoxClass.SMALL, + } as Box +} + +describe('UsageService', () => { + let periods: FakeUsagePeriodRepository + let locks: FakeLockProvider + let boxes: FakeBoxRepository + let regions: FakeRegionRepository + let service: UsageService + + beforeEach(() => { + periods = new FakeUsagePeriodRepository() + periods.archivedRows = [] + locks = new FakeLockProvider() + boxes = new FakeBoxRepository() + 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 stopping = makeBox(BoxState.STOPPING, BoxDesiredState.STOPPED) + + 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: expect.any(Date) }) + expect(periods.rows[1]).toMatchObject({ kind: 'stopped', endAt: null, cpu: 0, mem: 0, gpu: 0, disk: 10 }) + }) + + it('restarts the open period on every STARTED event like Daytona', async () => { + const box = makeBox(BoxState.STARTED, BoxDesiredState.STARTED) + + 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: 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.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.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() + + expect(periods.rows).toHaveLength(0) + expect(periods.archivedRows).toHaveLength(1) + expect(periods.archivedRows[0]).toMatchObject({ + boxId: 'box-1', + organizationId: 'org-1', + endAt: closedAt, + }) + expect(locks.locks).toContain('archive-usage-periods') + }) + + it('propagates usage ledger failures from lifecycle event handlers and releases the Daytona lock', async () => { + periods.failNextSave = new Error('database unavailable') + + 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') + }) + + it('archives closed periods in a single transaction', async () => { + const box = makeBox(BoxState.STARTED, BoxDesiredState.STARTED) + 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(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/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..a0ead61e4 --- /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 = 'box_usage_period' | 'box_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..1b7dd81b8 --- /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: 'box_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: 'box_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..3cbfb0766 --- /dev/null +++ b/apps/dashboard/src/pages/Metering.tsx @@ -0,0 +1,241 @@ +/* + * 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 === 'box_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..8364c5117 --- /dev/null +++ b/apps/scripts/metering-local-e2e.mjs @@ -0,0 +1,389 @@ +#!/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 box_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 box_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 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) { + 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"