Phase7
This commit is contained in:
1
.turbo/cache/197df1a8280ccca4-manifest.json
vendored
Normal file
1
.turbo/cache/197df1a8280ccca4-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"packages/metering/.turbo/turbo-test.log":{"size":420,"mtime_nanos":1782472703875150900,"mode":420,"is_dir":false}},"order":["packages/metering/.turbo/turbo-test.log"]}
|
||||
1
.turbo/cache/197df1a8280ccca4-meta.json
vendored
Normal file
1
.turbo/cache/197df1a8280ccca4-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"197df1a8280ccca4","duration":3485,"sha":"49a98ee31dc00ff6e9ea8c520653a613e443a5b6","dirty_hash":"d6f95e4f5bda48cbff2c068d391f19cb1b01310d5ff19103fcea0de084e63e56"}
|
||||
BIN
.turbo/cache/197df1a8280ccca4.tar.zst
vendored
Normal file
BIN
.turbo/cache/197df1a8280ccca4.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/2192db9e3c33f3c3-manifest.json
vendored
Normal file
1
.turbo/cache/2192db9e3c33f3c3-manifest.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
.turbo/cache/2192db9e3c33f3c3-meta.json
vendored
Normal file
1
.turbo/cache/2192db9e3c33f3c3-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"2192db9e3c33f3c3","duration":6661,"sha":"49a98ee31dc00ff6e9ea8c520653a613e443a5b6","dirty_hash":"3eccc318fad7b05922f51c036e52850a9cc1f4a4e0fa07ed274eac721dbbf35e"}
|
||||
BIN
.turbo/cache/2192db9e3c33f3c3.tar.zst
vendored
Normal file
BIN
.turbo/cache/2192db9e3c33f3c3.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/2986ff6c9a1e2251-manifest.json
vendored
Normal file
1
.turbo/cache/2986ff6c9a1e2251-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"apps/web/.turbo/turbo-test.log":{"size":116,"mtime_nanos":1782472701514992300,"mode":420,"is_dir":false}},"order":["apps/web/.turbo/turbo-test.log"]}
|
||||
1
.turbo/cache/2986ff6c9a1e2251-meta.json
vendored
Normal file
1
.turbo/cache/2986ff6c9a1e2251-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"2986ff6c9a1e2251","duration":1136,"sha":"49a98ee31dc00ff6e9ea8c520653a613e443a5b6","dirty_hash":"d6f95e4f5bda48cbff2c068d391f19cb1b01310d5ff19103fcea0de084e63e56"}
|
||||
BIN
.turbo/cache/2986ff6c9a1e2251.tar.zst
vendored
Normal file
BIN
.turbo/cache/2986ff6c9a1e2251.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/2e4f0ac4f6283a26-manifest.json
vendored
Normal file
1
.turbo/cache/2e4f0ac4f6283a26-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"apps/web/.turbo/turbo-test.log":{"size":116,"mtime_nanos":1782472790994800500,"mode":420,"is_dir":false}},"order":["apps/web/.turbo/turbo-test.log"]}
|
||||
1
.turbo/cache/2e4f0ac4f6283a26-meta.json
vendored
Normal file
1
.turbo/cache/2e4f0ac4f6283a26-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"2e4f0ac4f6283a26","duration":1177,"sha":"49a98ee31dc00ff6e9ea8c520653a613e443a5b6","dirty_hash":"321980c702d82a44b534656bb14446f7f514f68894335d76ff0fb3c4d51c68dd"}
|
||||
BIN
.turbo/cache/2e4f0ac4f6283a26.tar.zst
vendored
Normal file
BIN
.turbo/cache/2e4f0ac4f6283a26.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/634f3d1fd7cebbd8-manifest.json
vendored
Normal file
1
.turbo/cache/634f3d1fd7cebbd8-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"apps/worker/.turbo/turbo-test.log":{"size":834,"mtime_nanos":1782472708576870000,"mode":420,"is_dir":false}},"order":["apps/worker/.turbo/turbo-test.log"]}
|
||||
1
.turbo/cache/634f3d1fd7cebbd8-meta.json
vendored
Normal file
1
.turbo/cache/634f3d1fd7cebbd8-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"634f3d1fd7cebbd8","duration":1466,"sha":"49a98ee31dc00ff6e9ea8c520653a613e443a5b6","dirty_hash":"d6f95e4f5bda48cbff2c068d391f19cb1b01310d5ff19103fcea0de084e63e56"}
|
||||
BIN
.turbo/cache/634f3d1fd7cebbd8.tar.zst
vendored
Normal file
BIN
.turbo/cache/634f3d1fd7cebbd8.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/696ebaf2e10978bd-manifest.json
vendored
Normal file
1
.turbo/cache/696ebaf2e10978bd-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"apps/api/.turbo/turbo-test.log":{"size":4348,"mtime_nanos":1782472799870590800,"mode":420,"is_dir":false}},"order":["apps/api/.turbo/turbo-test.log"]}
|
||||
1
.turbo/cache/696ebaf2e10978bd-meta.json
vendored
Normal file
1
.turbo/cache/696ebaf2e10978bd-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"696ebaf2e10978bd","duration":10031,"sha":"49a98ee31dc00ff6e9ea8c520653a613e443a5b6","dirty_hash":"321980c702d82a44b534656bb14446f7f514f68894335d76ff0fb3c4d51c68dd"}
|
||||
BIN
.turbo/cache/696ebaf2e10978bd.tar.zst
vendored
Normal file
BIN
.turbo/cache/696ebaf2e10978bd.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/6be92253143ede74-manifest.json
vendored
Normal file
1
.turbo/cache/6be92253143ede74-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"packages/contracts/.turbo/turbo-test.log":{"size":459,"mtime_nanos":1782472704075395000,"mode":420,"is_dir":false}},"order":["packages/contracts/.turbo/turbo-test.log"]}
|
||||
1
.turbo/cache/6be92253143ede74-meta.json
vendored
Normal file
1
.turbo/cache/6be92253143ede74-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"6be92253143ede74","duration":3680,"sha":"49a98ee31dc00ff6e9ea8c520653a613e443a5b6","dirty_hash":"d6f95e4f5bda48cbff2c068d391f19cb1b01310d5ff19103fcea0de084e63e56"}
|
||||
BIN
.turbo/cache/6be92253143ede74.tar.zst
vendored
Normal file
BIN
.turbo/cache/6be92253143ede74.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/823347377098d5c5-manifest.json
vendored
Normal file
1
.turbo/cache/823347377098d5c5-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"packages/database/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/.turbo/turbo-build.log":{"size":552,"mtime_nanos":1782472707096561600,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.d.ts.map":{"size":131,"mtime_nanos":1782466100085644700,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js":{"size":4338,"mtime_nanos":1782466100081295500,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.d.ts":{"size":56,"mtime_nanos":1782466100087324100,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts":{"size":824,"mtime_nanos":1782472681376271400,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts.map":{"size":602,"mtime_nanos":1782472681374899800,"mode":420,"is_dir":false},"packages/database/dist/cli":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist/index.js":{"size":962,"mtime_nanos":1782472681370221900,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js.map":{"size":3941,"mtime_nanos":1782466100078293500,"mode":420,"is_dir":false},"packages/database/dist/index.js.map":{"size":617,"mtime_nanos":1782472681368160300,"mode":420,"is_dir":false}},"order":["packages/database/.turbo/turbo-build.log","packages/database/dist","packages/database/dist/cli","packages/database/dist/cli/bootstrap-admin.d.ts","packages/database/dist/cli/bootstrap-admin.d.ts.map","packages/database/dist/cli/bootstrap-admin.js","packages/database/dist/cli/bootstrap-admin.js.map","packages/database/dist/index.d.ts","packages/database/dist/index.d.ts.map","packages/database/dist/index.js","packages/database/dist/index.js.map"]}
|
||||
1
.turbo/cache/823347377098d5c5-meta.json
vendored
Normal file
1
.turbo/cache/823347377098d5c5-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"823347377098d5c5","duration":6700,"sha":"49a98ee31dc00ff6e9ea8c520653a613e443a5b6","dirty_hash":"d6f95e4f5bda48cbff2c068d391f19cb1b01310d5ff19103fcea0de084e63e56"}
|
||||
BIN
.turbo/cache/823347377098d5c5.tar.zst
vendored
Normal file
BIN
.turbo/cache/823347377098d5c5.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/9da718874699f928-manifest.json
vendored
Normal file
1
.turbo/cache/9da718874699f928-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"packages/metering/dist/metering.test.d.ts.map":{"size":120,"mtime_nanos":1782472675801247900,"mode":420,"is_dir":false},"packages/metering/dist/metering.test.js.map":{"size":782,"mtime_nanos":1782472675796983900,"mode":420,"is_dir":false},"packages/metering/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/metering/dist/index.d.ts":{"size":561,"mtime_nanos":1782472675785473200,"mode":420,"is_dir":false},"packages/metering/dist/index.js":{"size":1548,"mtime_nanos":1782472675768970100,"mode":420,"is_dir":false},"packages/metering/.turbo/turbo-build.log":{"size":109,"mtime_nanos":1782472675879768300,"mode":420,"is_dir":false},"packages/metering/dist/metering.test.js":{"size":901,"mtime_nanos":1782472675798978000,"mode":420,"is_dir":false},"packages/metering/dist/index.d.ts.map":{"size":479,"mtime_nanos":1782472675783121900,"mode":420,"is_dir":false},"packages/metering/dist/metering.test.d.ts":{"size":54,"mtime_nanos":1782472675803120500,"mode":420,"is_dir":false},"packages/metering/dist/index.js.map":{"size":1220,"mtime_nanos":1782472675767816700,"mode":420,"is_dir":false}},"order":["packages/metering/.turbo/turbo-build.log","packages/metering/dist","packages/metering/dist/index.d.ts","packages/metering/dist/index.d.ts.map","packages/metering/dist/index.js","packages/metering/dist/index.js.map","packages/metering/dist/metering.test.d.ts","packages/metering/dist/metering.test.d.ts.map","packages/metering/dist/metering.test.js","packages/metering/dist/metering.test.js.map"]}
|
||||
1
.turbo/cache/9da718874699f928-meta.json
vendored
Normal file
1
.turbo/cache/9da718874699f928-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"9da718874699f928","duration":2997,"sha":"49a98ee31dc00ff6e9ea8c520653a613e443a5b6","dirty_hash":"3eccc318fad7b05922f51c036e52850a9cc1f4a4e0fa07ed274eac721dbbf35e"}
|
||||
BIN
.turbo/cache/9da718874699f928.tar.zst
vendored
Normal file
BIN
.turbo/cache/9da718874699f928.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/a8bd232d3ec1411e-manifest.json
vendored
Normal file
1
.turbo/cache/a8bd232d3ec1411e-manifest.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
.turbo/cache/a8bd232d3ec1411e-meta.json
vendored
Normal file
1
.turbo/cache/a8bd232d3ec1411e-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"a8bd232d3ec1411e","duration":2949,"sha":"49a98ee31dc00ff6e9ea8c520653a613e443a5b6","dirty_hash":"3eccc318fad7b05922f51c036e52850a9cc1f4a4e0fa07ed274eac721dbbf35e"}
|
||||
BIN
.turbo/cache/a8bd232d3ec1411e.tar.zst
vendored
Normal file
BIN
.turbo/cache/a8bd232d3ec1411e.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/abd2828dcb6eea19-manifest.json
vendored
Normal file
1
.turbo/cache/abd2828dcb6eea19-manifest.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
.turbo/cache/abd2828dcb6eea19-meta.json
vendored
Normal file
1
.turbo/cache/abd2828dcb6eea19-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"abd2828dcb6eea19","duration":1838,"sha":"49a98ee31dc00ff6e9ea8c520653a613e443a5b6","dirty_hash":"d6f95e4f5bda48cbff2c068d391f19cb1b01310d5ff19103fcea0de084e63e56"}
|
||||
BIN
.turbo/cache/abd2828dcb6eea19.tar.zst
vendored
Normal file
BIN
.turbo/cache/abd2828dcb6eea19.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/c63749023bdfc94b-manifest.json
vendored
Normal file
1
.turbo/cache/c63749023bdfc94b-manifest.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
.turbo/cache/c63749023bdfc94b-meta.json
vendored
Normal file
1
.turbo/cache/c63749023bdfc94b-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"c63749023bdfc94b","duration":37003,"sha":"49a98ee31dc00ff6e9ea8c520653a613e443a5b6","dirty_hash":"44b60676ebd06e68917ed54561fd2c0e75b38e86ee9002b047c654ba1027fb48"}
|
||||
BIN
.turbo/cache/c63749023bdfc94b.tar.zst
vendored
Normal file
BIN
.turbo/cache/c63749023bdfc94b.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/e8061f35e521ce42-manifest.json
vendored
Normal file
1
.turbo/cache/e8061f35e521ce42-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"packages/database/dist/cli":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist/cli/bootstrap-admin.d.ts.map":{"size":131,"mtime_nanos":1782466100085644700,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts.map":{"size":602,"mtime_nanos":1782472681374899800,"mode":420,"is_dir":false},"packages/database/.turbo/turbo-build.log":{"size":509,"mtime_nanos":1782472681485813000,"mode":420,"is_dir":false},"packages/database/dist/index.js":{"size":962,"mtime_nanos":1782472681370221900,"mode":420,"is_dir":false},"packages/database/dist/index.js.map":{"size":617,"mtime_nanos":1782472681368160300,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js":{"size":4338,"mtime_nanos":1782466100081295500,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.d.ts":{"size":56,"mtime_nanos":1782466100087324100,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts":{"size":824,"mtime_nanos":1782472681376271400,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js.map":{"size":3941,"mtime_nanos":1782466100078293500,"mode":420,"is_dir":false},"packages/database/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true}},"order":["packages/database/.turbo/turbo-build.log","packages/database/dist","packages/database/dist/cli","packages/database/dist/cli/bootstrap-admin.d.ts","packages/database/dist/cli/bootstrap-admin.d.ts.map","packages/database/dist/cli/bootstrap-admin.js","packages/database/dist/cli/bootstrap-admin.js.map","packages/database/dist/index.d.ts","packages/database/dist/index.d.ts.map","packages/database/dist/index.js","packages/database/dist/index.js.map"]}
|
||||
1
.turbo/cache/e8061f35e521ce42-meta.json
vendored
Normal file
1
.turbo/cache/e8061f35e521ce42-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"e8061f35e521ce42","duration":8589,"sha":"49a98ee31dc00ff6e9ea8c520653a613e443a5b6","dirty_hash":"3eccc318fad7b05922f51c036e52850a9cc1f4a4e0fa07ed274eac721dbbf35e"}
|
||||
BIN
.turbo/cache/e8061f35e521ce42.tar.zst
vendored
Normal file
BIN
.turbo/cache/e8061f35e521ce42.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/ee13503c61e1308d-manifest.json
vendored
Normal file
1
.turbo/cache/ee13503c61e1308d-manifest.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
.turbo/cache/ee13503c61e1308d-meta.json
vendored
Normal file
1
.turbo/cache/ee13503c61e1308d-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"ee13503c61e1308d","duration":4605,"sha":"49a98ee31dc00ff6e9ea8c520653a613e443a5b6","dirty_hash":"44b60676ebd06e68917ed54561fd2c0e75b38e86ee9002b047c654ba1027fb48"}
|
||||
BIN
.turbo/cache/ee13503c61e1308d.tar.zst
vendored
Normal file
BIN
.turbo/cache/ee13503c61e1308d.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/fe5d58f871b87672-manifest.json
vendored
Normal file
1
.turbo/cache/fe5d58f871b87672-manifest.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
.turbo/cache/fe5d58f871b87672-meta.json
vendored
Normal file
1
.turbo/cache/fe5d58f871b87672-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"fe5d58f871b87672","duration":11586,"sha":"49a98ee31dc00ff6e9ea8c520653a613e443a5b6","dirty_hash":"44b60676ebd06e68917ed54561fd2c0e75b38e86ee9002b047c654ba1027fb48"}
|
||||
BIN
.turbo/cache/fe5d58f871b87672.tar.zst
vendored
Normal file
BIN
.turbo/cache/fe5d58f871b87672.tar.zst
vendored
Normal file
Binary file not shown.
@@ -92,7 +92,7 @@ make build
|
||||
|
||||
## Aktueller Stand
|
||||
|
||||
**Phase 6** (Multi-Node & Queue) — abgeschlossen.
|
||||
**Phase 7** (Idle Shutdown & Free-Tier) — abgeschlossen.
|
||||
|
||||
Siehe [docs/IMPLEMENTATION_STATUS.md](docs/IMPLEMENTATION_STATUS.md) für Details.
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@hexahost/config": "workspace:*",
|
||||
"@hexahost/contracts": "workspace:*",
|
||||
"@hexahost/database": "workspace:*",
|
||||
"@hexahost/metering": "workspace:*",
|
||||
"@hexahost/scheduler": "workspace:*",
|
||||
"@hexahost/storage": "workspace:*",
|
||||
"@nestjs/common": "^11.0.7",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RequestIdMiddleware } from './common/middleware/request-id.middleware';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { BillingModule } from './billing/billing.module';
|
||||
import { CatalogModule } from './catalog/catalog.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { NodesModule } from './nodes/nodes.module';
|
||||
@@ -42,6 +43,7 @@ import { LoggerModule } from 'nestjs-pino';
|
||||
HealthModule,
|
||||
AuthModule,
|
||||
CatalogModule,
|
||||
BillingModule,
|
||||
ServersModule,
|
||||
NodesModule,
|
||||
AdminModule,
|
||||
|
||||
@@ -5,6 +5,7 @@ import Redis from 'ioredis';
|
||||
import { getConfig } from '@hexahost/config';
|
||||
|
||||
import { AppConfigModule } from '../config/app-config.module';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
|
||||
import { AuditService } from './audit.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
@@ -18,7 +19,7 @@ import { MeController } from './me.controller';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
@Module({
|
||||
imports: [AppConfigModule],
|
||||
imports: [AppConfigModule, BillingModule],
|
||||
controllers: [AuthController, MeController],
|
||||
providers: [
|
||||
AuthService,
|
||||
|
||||
@@ -44,6 +44,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
import { AuditService } from './audit.service';
|
||||
import { SessionService } from './session.service';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
|
||||
const LOCKOUT_ATTEMPTS = 5;
|
||||
const LOCKOUT_DURATION_MS = 15 * 60 * 1000;
|
||||
@@ -74,6 +75,7 @@ export class AuthService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sessionService: SessionService,
|
||||
private readonly auditService: AuditService,
|
||||
private readonly billingService: BillingService,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||
@Inject(NOTIFICATIONS_QUEUE) private readonly notificationsQueue: Queue,
|
||||
@@ -123,6 +125,8 @@ export class AuthService {
|
||||
return created;
|
||||
});
|
||||
|
||||
await this.billingService.ensureWalletForUser(user.id);
|
||||
|
||||
await this.createEmailVerificationToken(user.id, user.email);
|
||||
|
||||
await this.auditService.record({
|
||||
|
||||
32
apps/api/src/billing/billing.controller.ts
Normal file
32
apps/api/src/billing/billing.controller.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import type { UsageListResponse, WalletResponse } from '@hexahost/contracts';
|
||||
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { SessionGuard } from '../auth/guards/session.guard';
|
||||
|
||||
import { BillingService } from './billing.service';
|
||||
|
||||
@ApiTags('account')
|
||||
@Controller('account')
|
||||
@UseGuards(SessionGuard)
|
||||
export class BillingController {
|
||||
constructor(private readonly billingService: BillingService) {}
|
||||
|
||||
@Get('wallet')
|
||||
@ApiOperation({ summary: 'Get credit wallet balance and recent transactions' })
|
||||
getWallet(@CurrentUser() user: { id: string }): Promise<WalletResponse> {
|
||||
return this.billingService.getWallet(user.id);
|
||||
}
|
||||
|
||||
@Get('usage')
|
||||
@ApiOperation({ summary: 'List recent usage records' })
|
||||
listUsage(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Query('limit') limit?: string,
|
||||
): Promise<UsageListResponse> {
|
||||
const parsed = limit ? Number.parseInt(limit, 10) : 20;
|
||||
return this.billingService.listUsage(user.id, Number.isFinite(parsed) ? parsed : 20);
|
||||
}
|
||||
}
|
||||
14
apps/api/src/billing/billing.module.ts
Normal file
14
apps/api/src/billing/billing.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
import { BillingController } from './billing.controller';
|
||||
import { BillingService } from './billing.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [BillingController],
|
||||
providers: [BillingService],
|
||||
exports: [BillingService],
|
||||
})
|
||||
export class BillingModule {}
|
||||
190
apps/api/src/billing/billing.service.ts
Normal file
190
apps/api/src/billing/billing.service.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
|
||||
import type { UsageListResponse, WalletResponse } from '@hexahost/contracts';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async ensureWalletForUser(userId: string, grantCredits = 0): Promise<void> {
|
||||
const existing = await this.prisma.creditWallet.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const freePlan = await this.prisma.plan.findFirst({
|
||||
where: { slug: 'free', isActive: true },
|
||||
});
|
||||
|
||||
const initialGrant = grantCredits || freePlan?.monthlyCreditGrant || 0;
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const wallet = await tx.creditWallet.create({
|
||||
data: {
|
||||
userId,
|
||||
balance: initialGrant,
|
||||
},
|
||||
});
|
||||
|
||||
if (initialGrant > 0) {
|
||||
await tx.creditTransaction.create({
|
||||
data: {
|
||||
walletId: wallet.id,
|
||||
amount: initialGrant,
|
||||
type: 'GRANT',
|
||||
referenceType: 'signup',
|
||||
referenceId: userId,
|
||||
idempotencyKey: `grant:signup:${userId}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getWallet(userId: string): Promise<WalletResponse> {
|
||||
await this.ensureWalletForUser(userId);
|
||||
|
||||
const wallet = await this.prisma.creditWallet.findUniqueOrThrow({
|
||||
where: { userId },
|
||||
include: {
|
||||
transactions: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
balance: wallet.balance,
|
||||
periodStart: wallet.periodStart.toISOString(),
|
||||
transactions: wallet.transactions.map((tx) => ({
|
||||
id: tx.id,
|
||||
amount: tx.amount,
|
||||
type: tx.type,
|
||||
referenceType: tx.referenceType,
|
||||
referenceId: tx.referenceId,
|
||||
createdAt: tx.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async listUsage(userId: string, limit = 20): Promise<UsageListResponse> {
|
||||
const records = await this.prisma.usageRecord.findMany({
|
||||
where: { userId },
|
||||
orderBy: { periodStart: 'desc' },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return {
|
||||
records: records.map((record) => ({
|
||||
id: record.id,
|
||||
serverId: record.serverId,
|
||||
ramMb: record.ramMb,
|
||||
durationSeconds: record.durationSeconds,
|
||||
creditsCharged: record.creditsCharged,
|
||||
periodStart: record.periodStart.toISOString(),
|
||||
periodEnd: record.periodEnd.toISOString(),
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async assertCanStartServer(userId: string): Promise<void> {
|
||||
await this.ensureWalletForUser(userId);
|
||||
|
||||
const wallet = await this.prisma.creditWallet.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
if (!wallet || wallet.balance <= 0) {
|
||||
throw new ForbiddenException(
|
||||
'Insufficient credits to start a server. Wait for the next grant or upgrade your plan.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async assertCanCreateServer(
|
||||
userId: string,
|
||||
planId?: string | null,
|
||||
): Promise<void> {
|
||||
const plan = planId
|
||||
? await this.prisma.plan.findUnique({ where: { id: planId } })
|
||||
: await this.prisma.plan.findFirst({ where: { slug: 'free', isActive: true } });
|
||||
|
||||
if (!plan?.maxServersPerUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const count = await this.prisma.gameServer.count({
|
||||
where: {
|
||||
userId,
|
||||
status: { not: 'DELETED' },
|
||||
},
|
||||
});
|
||||
|
||||
if (count >= plan.maxServersPerUser) {
|
||||
throw new ForbiddenException(
|
||||
`Server quota reached (${plan.maxServersPerUser} servers on ${plan.name})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async grantMonthlyCredits(): Promise<number> {
|
||||
const plans = await this.prisma.plan.findMany({
|
||||
where: { isActive: true, monthlyCreditGrant: { gt: 0 } },
|
||||
});
|
||||
|
||||
let granted = 0;
|
||||
|
||||
for (const plan of plans) {
|
||||
const servers = await this.prisma.gameServer.findMany({
|
||||
where: { planId: plan.id },
|
||||
select: { userId: true },
|
||||
distinct: ['userId'],
|
||||
});
|
||||
|
||||
for (const { userId } of servers) {
|
||||
await this.ensureWalletForUser(userId);
|
||||
const key = `grant:monthly:${plan.slug}:${userId}:${new Date().toISOString().slice(0, 7)}`;
|
||||
|
||||
const wallet = await this.prisma.creditWallet.findUniqueOrThrow({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
const existing = await this.prisma.creditTransaction.findUnique({
|
||||
where: { idempotencyKey: key },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.creditWallet.update({
|
||||
where: { id: wallet.id },
|
||||
data: { balance: { increment: plan.monthlyCreditGrant } },
|
||||
}),
|
||||
this.prisma.creditTransaction.create({
|
||||
data: {
|
||||
walletId: wallet.id,
|
||||
amount: plan.monthlyCreditGrant,
|
||||
type: 'GRANT',
|
||||
referenceType: 'plan',
|
||||
referenceId: plan.id,
|
||||
idempotencyKey: key,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
granted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return granted;
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,18 @@ export class SchedulingService {
|
||||
actorId: string,
|
||||
correlationId: string,
|
||||
): Promise<{ server: GameServer; queuePosition: number }> {
|
||||
const fullServer = server.planId
|
||||
? server
|
||||
: await this.prisma.gameServer.findUniqueOrThrow({
|
||||
where: { id: server.id },
|
||||
});
|
||||
|
||||
const plan = fullServer.planId
|
||||
? await this.prisma.plan.findUnique({ where: { id: fullServer.planId } })
|
||||
: null;
|
||||
|
||||
const expiresAt = new Date(Date.now() + START_QUEUE_EXPIRY_MS);
|
||||
const priority = plan?.queuePriority ?? 0;
|
||||
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
await tx.serverStartQueueEntry.upsert({
|
||||
@@ -39,11 +50,13 @@ export class SchedulingService {
|
||||
serverId: server.id,
|
||||
nodeId: server.nodeId!,
|
||||
status: 'QUEUED',
|
||||
priority,
|
||||
correlationId,
|
||||
expiresAt,
|
||||
},
|
||||
update: {
|
||||
status: 'QUEUED',
|
||||
priority,
|
||||
correlationId,
|
||||
expiresAt,
|
||||
requestedAt: new Date(),
|
||||
|
||||
13
apps/api/src/servers/idle/idle.module.ts
Normal file
13
apps/api/src/servers/idle/idle.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { NodeBridgeModule } from '../../nodes/node-bridge.module';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
|
||||
import { IdleService } from './idle.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, NodeBridgeModule],
|
||||
providers: [IdleService],
|
||||
exports: [IdleService],
|
||||
})
|
||||
export class IdleModule {}
|
||||
79
apps/api/src/servers/idle/idle.service.ts
Normal file
79
apps/api/src/servers/idle/idle.service.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
|
||||
import type { IdleStatusResponse } from '@hexahost/contracts';
|
||||
import { parseMinecraftListOutput } from '@hexahost/metering';
|
||||
|
||||
import { NodeBridgeService } from '../../nodes/node-bridge.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { findOwnedServer } from '../shared/server-ownership.util';
|
||||
|
||||
@Injectable()
|
||||
export class IdleService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly nodeBridge: NodeBridgeService,
|
||||
) {}
|
||||
|
||||
async getIdleStatus(userId: string, serverId: string): Promise<IdleStatusResponse> {
|
||||
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||
|
||||
let playerCount: number | null = null;
|
||||
|
||||
if (server.status === 'RUNNING' && server.nodeId) {
|
||||
const response = await this.nodeBridge.sendAgentRequest(
|
||||
server.nodeId,
|
||||
'server.command',
|
||||
{
|
||||
serverId: server.id,
|
||||
generation: server.version,
|
||||
command: 'list',
|
||||
},
|
||||
10_000,
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
const output =
|
||||
typeof response.result === 'object' &&
|
||||
response.result !== null &&
|
||||
'output' in response.result &&
|
||||
typeof (response.result as { output: unknown }).output === 'string'
|
||||
? (response.result as { output: string }).output
|
||||
: typeof response.result === 'string'
|
||||
? response.result
|
||||
: '';
|
||||
|
||||
playerCount = parseMinecraftListOutput(output);
|
||||
}
|
||||
}
|
||||
|
||||
const secondsRemaining =
|
||||
server.idleStopAt && server.idleStopAt.getTime() > Date.now()
|
||||
? Math.ceil((server.idleStopAt.getTime() - Date.now()) / 1000)
|
||||
: null;
|
||||
|
||||
return {
|
||||
enabled: server.idleShutdownEnabled,
|
||||
playerCount,
|
||||
idleStopAt: server.idleStopAt?.toISOString() ?? null,
|
||||
secondsRemaining,
|
||||
lastPlayerSeenAt: server.lastPlayerSeenAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async extendIdleCountdown(userId: string, serverId: string): Promise<IdleStatusResponse> {
|
||||
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||
|
||||
if (!server.idleStopAt) {
|
||||
throw new BadRequestException('No idle shutdown countdown is active');
|
||||
}
|
||||
|
||||
const extended = new Date(server.idleStopAt.getTime() + 5 * 60_000);
|
||||
|
||||
await this.prisma.gameServer.update({
|
||||
where: { id: serverId },
|
||||
data: { idleStopAt: extended },
|
||||
});
|
||||
|
||||
return this.getIdleStatus(userId, serverId);
|
||||
}
|
||||
}
|
||||
@@ -19,12 +19,16 @@ import { SessionGuard } from '../auth/guards/session.guard';
|
||||
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
|
||||
|
||||
import { ServersService } from './servers.service';
|
||||
import { IdleService } from './idle/idle.service';
|
||||
|
||||
@ApiTags('servers')
|
||||
@Controller('servers')
|
||||
@UseGuards(SessionGuard)
|
||||
export class ServersController {
|
||||
constructor(private readonly serversService: ServersService) {}
|
||||
constructor(
|
||||
private readonly serversService: ServersService,
|
||||
private readonly idleService: IdleService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@@ -104,4 +108,23 @@ export class ServersController {
|
||||
) {
|
||||
return this.serversService.cancelQueuedStart(user.id, id);
|
||||
}
|
||||
|
||||
@Get(':id/idle')
|
||||
@ApiOperation({ summary: 'Get idle shutdown status' })
|
||||
idleStatus(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.idleService.getIdleStatus(user.id, id);
|
||||
}
|
||||
|
||||
@Post(':id/idle/extend')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Extend idle shutdown countdown by 5 minutes' })
|
||||
extendIdle(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.idleService.extendIdleCountdown(user.id, id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,14 @@ import { AuthModule } from '../auth/auth.module';
|
||||
import { NodeBridgeModule } from '../nodes/node-bridge.module';
|
||||
import { AppConfigModule } from '../config/app-config.module';
|
||||
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { SchedulingModule } from '../scheduling/scheduling.module';
|
||||
|
||||
import { ConsoleModule } from './console/console.module';
|
||||
import { AddonsModule } from './addons/addons.module';
|
||||
import { BackupsModule } from './backups/backups.module';
|
||||
import { FilesModule } from './files/files.module';
|
||||
import { IdleModule } from './idle/idle.module';
|
||||
import { WorldsModule } from './worlds/worlds.module';
|
||||
import { PlayersModule } from './players/players.module';
|
||||
import { PropertiesModule } from './properties/properties.module';
|
||||
@@ -33,6 +35,8 @@ import { PlansService } from './plans.service';
|
||||
AppConfigModule,
|
||||
AuthModule,
|
||||
SchedulingModule,
|
||||
BillingModule,
|
||||
IdleModule,
|
||||
forwardRef(() => ConsoleModule),
|
||||
forwardRef(() => AddonsModule),
|
||||
forwardRef(() => BackupsModule),
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { GameServer } from '@hexahost/database';
|
||||
type GameServerStatus = GameServer['status'];
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { SchedulingService } from '../scheduling/scheduling.service';
|
||||
|
||||
import { findOwnedServer } from './shared/server-ownership.util';
|
||||
@@ -36,6 +37,7 @@ export class ServersService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly serverState: ServerStateService,
|
||||
private readonly scheduling: SchedulingService,
|
||||
private readonly billing: BillingService,
|
||||
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||
@Inject(SERVER_LIFECYCLE_QUEUE)
|
||||
private readonly lifecycleQueue: Queue,
|
||||
@@ -47,6 +49,8 @@ export class ServersService {
|
||||
userId: string,
|
||||
input: CreateServerRequest,
|
||||
): Promise<ServerResponse> {
|
||||
await this.billing.assertCanCreateServer(userId, input.planId);
|
||||
|
||||
const server = await this.prisma.gameServer.create({
|
||||
data: {
|
||||
userId,
|
||||
@@ -110,6 +114,8 @@ export class ServersService {
|
||||
const correlationId = randomUUID();
|
||||
|
||||
if (targetStatus === 'STARTING') {
|
||||
await this.billing.assertCanStartServer(userId);
|
||||
|
||||
const canStart = await this.scheduling.canStartServer(server);
|
||||
if (!canStart) {
|
||||
const queued = await this.scheduling.enqueueStart(
|
||||
@@ -288,6 +294,21 @@ export class ServersService {
|
||||
data: {
|
||||
status: toStatus,
|
||||
version: { increment: 1 },
|
||||
...(toStatus === 'RUNNING'
|
||||
? {
|
||||
runningSince: new Date(),
|
||||
lastPlayerSeenAt: new Date(),
|
||||
lastMeteredAt: new Date(),
|
||||
idleStopAt: null,
|
||||
}
|
||||
: {}),
|
||||
...(toStatus === 'STOPPED' || toStatus === 'ERROR'
|
||||
? {
|
||||
runningSince: null,
|
||||
idleStopAt: null,
|
||||
lastMeteredAt: null,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -347,6 +368,8 @@ export class ServersService {
|
||||
dataPath: server.dataPath,
|
||||
activeWorldName: server.activeWorldName,
|
||||
ramMb: server.ramMb,
|
||||
idleShutdownEnabled: server.idleShutdownEnabled,
|
||||
idleStopAt: server.idleStopAt?.toISOString() ?? null,
|
||||
createdAt: server.createdAt.toISOString(),
|
||||
updatedAt: server.updatedAt.toISOString(),
|
||||
};
|
||||
|
||||
28
apps/api/test/phase7.test.js
Normal file
28
apps/api/test/phase7.test.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
describe('phase 7 api contract', () => {
|
||||
it('documents idle shutdown and billing routes', () => {
|
||||
const billingSource = readFileSync(
|
||||
join(process.cwd(), 'src', 'billing/billing.controller.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(billingSource.includes("'wallet'"));
|
||||
assert.ok(billingSource.includes("'usage'"));
|
||||
|
||||
const idleSource = readFileSync(
|
||||
join(process.cwd(), 'src', 'servers/idle/idle.service.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(idleSource.includes('getIdleStatus'));
|
||||
assert.ok(idleSource.includes('extendIdleCountdown'));
|
||||
|
||||
const serversSource = readFileSync(
|
||||
join(process.cwd(), 'src', 'servers/servers.controller.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(serversSource.includes(':id/idle'));
|
||||
});
|
||||
});
|
||||
@@ -108,6 +108,9 @@
|
||||
"allocatedRam": "Zugewiesener RAM",
|
||||
"activitySubtitle": "Übersicht",
|
||||
"totalServers": "Server gesamt",
|
||||
"credits": "Credits",
|
||||
"creditsSubtitle": "Laufzeit-Guthaben",
|
||||
"creditsRemaining": "Verbleibende Credits",
|
||||
"viewAllServers": "Alle Server anzeigen",
|
||||
"serversSummary": "Server in Ihrem Konto"
|
||||
},
|
||||
@@ -154,6 +157,7 @@
|
||||
"ram": "RAM",
|
||||
"start": "Starten",
|
||||
"stop": "Stoppen",
|
||||
"idleCountdown": "Auto-Stop in {seconds}s (keine Spieler)",
|
||||
"details": "Details",
|
||||
"detailSubtitle": "Serverdetails und Lifecycle-Steuerung",
|
||||
"backToList": "Zurück zur Serverliste",
|
||||
|
||||
@@ -108,6 +108,9 @@
|
||||
"allocatedRam": "Allocated RAM",
|
||||
"activitySubtitle": "Overview",
|
||||
"totalServers": "Total servers",
|
||||
"credits": "Credits",
|
||||
"creditsSubtitle": "Runtime balance",
|
||||
"creditsRemaining": "Credits remaining",
|
||||
"viewAllServers": "View all servers",
|
||||
"serversSummary": "Servers in your account"
|
||||
},
|
||||
@@ -154,6 +157,7 @@
|
||||
"ram": "RAM",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"idleCountdown": "Auto-stop in {seconds}s (no players)",
|
||||
"details": "Details",
|
||||
"detailSubtitle": "Server details and lifecycle controls",
|
||||
"backToList": "Back to servers",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslations } from "next-intl";
|
||||
import { Link, useRouter } from "@/i18n/navigation";
|
||||
import { EmptyState } from "@/components/dashboard/empty-state";
|
||||
import { DashboardSkeleton } from "@/components/dashboard/skeleton";
|
||||
import { getWallet } from "@/lib/api/billing";
|
||||
import { listServers } from "@/lib/api/servers";
|
||||
|
||||
export function DashboardContent() {
|
||||
@@ -15,6 +16,10 @@ export function DashboardContent() {
|
||||
queryKey: ["servers"],
|
||||
queryFn: listServers,
|
||||
});
|
||||
const { data: wallet } = useQuery({
|
||||
queryKey: ["wallet"],
|
||||
queryFn: getWallet,
|
||||
});
|
||||
|
||||
const servers = data?.servers ?? [];
|
||||
const activeCount = servers.filter((server) =>
|
||||
@@ -63,14 +68,14 @@ export function DashboardContent() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("activity")}</CardTitle>
|
||||
<CardDescription>{t("activitySubtitle")}</CardDescription>
|
||||
<CardTitle>{t("credits")}</CardTitle>
|
||||
<CardDescription>{t("creditsSubtitle")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-mono text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
{servers.length}
|
||||
{wallet?.balance ?? "—"}
|
||||
</p>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("totalServers")}</p>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("creditsRemaining")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { ApiError } from "@/lib/api/auth";
|
||||
import { getServerIdleStatus } from "@/lib/api/billing";
|
||||
import {
|
||||
type Server,
|
||||
startServer,
|
||||
@@ -23,6 +24,13 @@ export function ServerCard({ server, showDetailsLink = true }: ServerCardProps)
|
||||
const queryClient = useQueryClient();
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const { data: idleStatus } = useQuery({
|
||||
queryKey: ["server-idle", server.id],
|
||||
queryFn: () => getServerIdleStatus(server.id),
|
||||
enabled: server.status === "RUNNING",
|
||||
refetchInterval: server.status === "RUNNING" ? 15_000 : false,
|
||||
});
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: () => startServer(server.id),
|
||||
onSuccess: () => {
|
||||
@@ -47,9 +55,11 @@ export function ServerCard({ server, showDetailsLink = true }: ServerCardProps)
|
||||
},
|
||||
});
|
||||
|
||||
const canStart = server.status === "STOPPED";
|
||||
const canStart = server.status === "STOPPED" || server.status === "UNKNOWN";
|
||||
const canStop = server.status === "RUNNING";
|
||||
const isBusy = ["PROVISIONING", "INSTALLING", "STARTING", "STOPPING"].includes(server.status);
|
||||
const isBusy = ["PROVISIONING", "INSTALLING", "STARTING", "STOPPING", "QUEUED"].includes(
|
||||
server.status,
|
||||
);
|
||||
const address =
|
||||
server.hostPort !== null ? `localhost:${server.hostPort}` : t("addressPending");
|
||||
|
||||
@@ -76,6 +86,12 @@ export function ServerCard({ server, showDetailsLink = true }: ServerCardProps)
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{server.status === "RUNNING" && idleStatus?.secondsRemaining ? (
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300" role="status">
|
||||
{t("idleCountdown", { seconds: idleStatus.secondsRemaining })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{actionError ? (
|
||||
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
|
||||
{actionError}
|
||||
|
||||
@@ -8,9 +8,11 @@ const statusStyles: Record<GameServerStatus, string> = {
|
||||
PROVISIONING: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
|
||||
INSTALLING: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
|
||||
STOPPED: "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
|
||||
QUEUED: "bg-violet-100 text-violet-800 dark:bg-violet-950 dark:text-violet-300",
|
||||
STARTING: "bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300",
|
||||
RUNNING: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300",
|
||||
STOPPING: "bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300",
|
||||
UNKNOWN: "bg-orange-100 text-orange-800 dark:bg-orange-950 dark:text-orange-300",
|
||||
ERROR: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",
|
||||
DELETING: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",
|
||||
DELETED: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-500",
|
||||
|
||||
30
apps/web/src/lib/api/billing.ts
Normal file
30
apps/web/src/lib/api/billing.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { apiFetch } from "./auth";
|
||||
|
||||
export interface WalletResponse {
|
||||
balance: number;
|
||||
periodStart: string;
|
||||
transactions: Array<{
|
||||
id: string;
|
||||
amount: number;
|
||||
type: string;
|
||||
referenceType: string | null;
|
||||
referenceId: string | null;
|
||||
createdAt: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface IdleStatusResponse {
|
||||
enabled: boolean;
|
||||
playerCount: number | null;
|
||||
idleStopAt: string | null;
|
||||
secondsRemaining: number | null;
|
||||
lastPlayerSeenAt: string | null;
|
||||
}
|
||||
|
||||
export async function getWallet(): Promise<WalletResponse> {
|
||||
return apiFetch<WalletResponse>("/account/wallet");
|
||||
}
|
||||
|
||||
export async function getServerIdleStatus(serverId: string): Promise<IdleStatusResponse> {
|
||||
return apiFetch<IdleStatusResponse>(`/servers/${serverId}/idle`);
|
||||
}
|
||||
@@ -5,9 +5,11 @@ export type GameServerStatus =
|
||||
| "PROVISIONING"
|
||||
| "INSTALLING"
|
||||
| "STOPPED"
|
||||
| "QUEUED"
|
||||
| "STARTING"
|
||||
| "RUNNING"
|
||||
| "STOPPING"
|
||||
| "UNKNOWN"
|
||||
| "ERROR"
|
||||
| "DELETING"
|
||||
| "DELETED";
|
||||
@@ -38,6 +40,8 @@ export interface Server {
|
||||
containerId: string | null;
|
||||
dataPath: string | null;
|
||||
ramMb: number;
|
||||
idleShutdownEnabled: boolean;
|
||||
idleStopAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -49,6 +53,7 @@ export interface ServerListResponse {
|
||||
export interface ServerActionResponse {
|
||||
server: Server;
|
||||
jobId?: string;
|
||||
queuePosition?: number;
|
||||
}
|
||||
|
||||
export interface CreateServerPayload {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@hexahost/catalog": "workspace:*",
|
||||
"@hexahost/config": "workspace:*",
|
||||
"@hexahost/database": "workspace:*",
|
||||
"@hexahost/metering": "workspace:*",
|
||||
"@hexahost/scheduler": "workspace:*",
|
||||
"@hexahost/storage": "workspace:*",
|
||||
"bullmq": "^5.34.10",
|
||||
|
||||
273
apps/worker/src/handlers/idle-billing.ts
Normal file
273
apps/worker/src/handlers/idle-billing.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { prisma } from '@hexahost/database';
|
||||
import {
|
||||
calculateUsageCredits,
|
||||
parseMinecraftListOutput,
|
||||
resolveIdlePolicy,
|
||||
} from '@hexahost/metering';
|
||||
import { Queue } from 'bullmq';
|
||||
|
||||
import { sendAgentRequest } from '../agent-bridge';
|
||||
import { logger } from '../logger';
|
||||
import { getRedisPublisher } from '../redis';
|
||||
import { QUEUE_SERVER_LIFECYCLE } from '../queues';
|
||||
|
||||
let lifecycleQueue: Queue | null = null;
|
||||
|
||||
function getLifecycleQueue(): Queue {
|
||||
if (!lifecycleQueue) {
|
||||
lifecycleQueue = new Queue(QUEUE_SERVER_LIFECYCLE, {
|
||||
connection: getRedisPublisher(),
|
||||
});
|
||||
}
|
||||
|
||||
return lifecycleQueue;
|
||||
}
|
||||
|
||||
async function fetchPlayerCount(server: {
|
||||
id: string;
|
||||
nodeId: string | null;
|
||||
version: number;
|
||||
}): Promise<number | null> {
|
||||
if (!server.nodeId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await sendAgentRequest(
|
||||
server.nodeId,
|
||||
'server.command',
|
||||
{
|
||||
serverId: server.id,
|
||||
generation: server.version,
|
||||
command: 'list',
|
||||
},
|
||||
10_000,
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const output =
|
||||
typeof response.result === 'object' &&
|
||||
response.result !== null &&
|
||||
'output' in response.result &&
|
||||
typeof (response.result as { output: unknown }).output === 'string'
|
||||
? (response.result as { output: string }).output
|
||||
: typeof response.result === 'string'
|
||||
? response.result
|
||||
: '';
|
||||
|
||||
return parseMinecraftListOutput(output);
|
||||
}
|
||||
|
||||
export async function processIdleShutdownTick(): Promise<void> {
|
||||
const servers = await prisma.gameServer.findMany({
|
||||
where: {
|
||||
status: 'RUNNING',
|
||||
idleShutdownEnabled: true,
|
||||
},
|
||||
include: { plan: true },
|
||||
});
|
||||
|
||||
for (const server of servers) {
|
||||
try {
|
||||
await processServerIdle(server);
|
||||
} catch (error) {
|
||||
logger.error({ err: error, serverId: server.id }, 'Idle shutdown check failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function processServerIdle(
|
||||
server: Awaited<ReturnType<typeof prisma.gameServer.findMany>>[number] & {
|
||||
plan: {
|
||||
idleTimeoutMinutes: number;
|
||||
idleGraceAfterStartMinutes: number;
|
||||
idleCountdownSeconds: number;
|
||||
} | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
const activeBackup = await prisma.serverBackup.findFirst({
|
||||
where: {
|
||||
serverId: server.id,
|
||||
status: { in: ['PENDING', 'RUNNING'] },
|
||||
},
|
||||
});
|
||||
|
||||
if (activeBackup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeRestore = await prisma.backupRestore.findFirst({
|
||||
where: {
|
||||
serverId: server.id,
|
||||
status: { in: ['PENDING', 'RUNNING'] },
|
||||
},
|
||||
});
|
||||
|
||||
if (activeRestore) {
|
||||
return;
|
||||
}
|
||||
|
||||
const policy = resolveIdlePolicy(server.plan);
|
||||
const now = Date.now();
|
||||
const playerCount = await fetchPlayerCount(server);
|
||||
|
||||
if (playerCount !== null && playerCount > 0) {
|
||||
await prisma.gameServer.update({
|
||||
where: { id: server.id },
|
||||
data: {
|
||||
lastPlayerSeenAt: new Date(),
|
||||
idleStopAt: null,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const runningSince = server.runningSince?.getTime() ?? now;
|
||||
if (now - runningSince < policy.idleGraceAfterStartMinutes * 60_000) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastActive = server.lastPlayerSeenAt?.getTime() ?? runningSince;
|
||||
const idleMs = now - lastActive;
|
||||
|
||||
if (idleMs < policy.idleTimeoutMinutes * 60_000) {
|
||||
if (server.idleStopAt) {
|
||||
await prisma.gameServer.update({
|
||||
where: { id: server.id },
|
||||
data: { idleStopAt: null },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!server.idleStopAt) {
|
||||
await prisma.gameServer.update({
|
||||
where: { id: server.id },
|
||||
data: {
|
||||
idleStopAt: new Date(now + policy.idleCountdownSeconds * 1000),
|
||||
},
|
||||
});
|
||||
logger.info({ serverId: server.id }, 'Idle stop countdown started');
|
||||
return;
|
||||
}
|
||||
|
||||
if (server.idleStopAt.getTime() <= now) {
|
||||
const correlationId = randomUUID();
|
||||
await getLifecycleQueue().add(
|
||||
'stop-server',
|
||||
{ serverId: server.id, correlationId, reason: 'idle_shutdown' },
|
||||
{ jobId: correlationId },
|
||||
);
|
||||
|
||||
await prisma.gameServer.update({
|
||||
where: { id: server.id },
|
||||
data: { idleStopAt: null },
|
||||
});
|
||||
|
||||
logger.info({ serverId: server.id }, 'Idle shutdown stop enqueued');
|
||||
}
|
||||
}
|
||||
|
||||
export async function processUsageMeteringTick(): Promise<void> {
|
||||
const servers = await prisma.gameServer.findMany({
|
||||
where: { status: 'RUNNING' },
|
||||
include: { plan: true },
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
|
||||
for (const server of servers) {
|
||||
try {
|
||||
await meterServerUsage(server, now);
|
||||
} catch (error) {
|
||||
logger.error({ err: error, serverId: server.id }, 'Usage metering failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function meterServerUsage(
|
||||
server: {
|
||||
id: string;
|
||||
userId: string;
|
||||
ramMb: number;
|
||||
lastMeteredAt: Date | null;
|
||||
runningSince: Date | null;
|
||||
},
|
||||
now: Date,
|
||||
): Promise<void> {
|
||||
const periodStart = server.lastMeteredAt ?? server.runningSince ?? now;
|
||||
const durationSeconds = Math.floor((now.getTime() - periodStart.getTime()) / 1000);
|
||||
|
||||
if (durationSeconds < 30) {
|
||||
return;
|
||||
}
|
||||
|
||||
const credits = calculateUsageCredits(server.ramMb, durationSeconds);
|
||||
const idempotencyKey = `usage:${server.id}:${periodStart.toISOString()}`;
|
||||
|
||||
const wallet = await prisma.creditWallet.findUnique({
|
||||
where: { userId: server.userId },
|
||||
});
|
||||
|
||||
if (!wallet) {
|
||||
await prisma.gameServer.update({
|
||||
where: { id: server.id },
|
||||
data: { lastMeteredAt: now },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.usageRecord.findUnique({
|
||||
where: { idempotencyKey },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
await tx.usageRecord.create({
|
||||
data: {
|
||||
serverId: server.id,
|
||||
userId: server.userId,
|
||||
ramMb: server.ramMb,
|
||||
durationSeconds,
|
||||
creditsCharged: credits,
|
||||
periodStart,
|
||||
periodEnd: now,
|
||||
idempotencyKey,
|
||||
},
|
||||
});
|
||||
|
||||
const nextBalance = Math.max(0, wallet.balance - credits);
|
||||
|
||||
await tx.creditWallet.update({
|
||||
where: { id: wallet.id },
|
||||
data: { balance: nextBalance },
|
||||
});
|
||||
|
||||
await tx.creditTransaction.create({
|
||||
data: {
|
||||
walletId: wallet.id,
|
||||
amount: -credits,
|
||||
type: 'USAGE_DEBIT',
|
||||
referenceType: 'server',
|
||||
referenceId: server.id,
|
||||
idempotencyKey: `debit:${idempotencyKey}`,
|
||||
metadata: {
|
||||
durationSeconds,
|
||||
ramMb: server.ramMb,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.gameServer.update({
|
||||
where: { id: server.id },
|
||||
data: { lastMeteredAt: now },
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Job, Worker, type ConnectionOptions } from 'bullmq';
|
||||
import { validateConfig } from '@hexahost/config';
|
||||
import { prisma } from '@hexahost/database';
|
||||
|
||||
import { processIdleShutdownTick, processUsageMeteringTick } from './handlers/idle-billing';
|
||||
import { processNodeHealthTick } from './handlers/node-health';
|
||||
import { processNotificationJob } from './handlers/notifications';
|
||||
import { processAddonJob } from './handlers/server-addons';
|
||||
@@ -99,6 +100,12 @@ async function bootstrap(): Promise<void> {
|
||||
void processNodeHealthTick().catch((error: Error) => {
|
||||
logger.error({ err: error }, 'Node health tick failed');
|
||||
});
|
||||
void processIdleShutdownTick().catch((error: Error) => {
|
||||
logger.error({ err: error }, 'Idle shutdown tick failed');
|
||||
});
|
||||
void processUsageMeteringTick().catch((error: Error) => {
|
||||
logger.error({ err: error }, 'Usage metering tick failed');
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
const shutdown = async (signal: string): Promise<void> => {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,53 +2,54 @@
|
||||
|
||||
Last updated: 2026-06-26
|
||||
|
||||
## Current phase: Phase 6 — Multi-Node and queue ✅
|
||||
## Current phase: Phase 7 — Idle shutdown and free tier ✅
|
||||
|
||||
### Phase 6 completed
|
||||
### Phase 7 completed
|
||||
|
||||
| Area | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| `@hexahost/scheduler` | Done | Node scoring, RAM/slot capacity, least-loaded placement |
|
||||
| Prisma start queue + node capacity | Done | `ServerStartQueueEntry`, `maxRamMb`, `platformRamMb`, `QUEUED`/`UNKNOWN` |
|
||||
| `@hexahost/contracts` phase 6 | Done | `nodes.ts`, queue schemas, extended server status |
|
||||
| Worker scheduler integration | Done | Provisioning uses `selectNodeForAllocation` |
|
||||
| Start queue | Done | API enqueue + worker tick dispatch |
|
||||
| Node health tick | Done | Heartbeat timeout → `UNREACHABLE`, servers → `UNKNOWN` |
|
||||
| Drain / maintenance | Done | Admin `POST /admin/nodes/:id/drain` and `/maintenance` |
|
||||
| Allocation release | Done | Released on provision failure |
|
||||
| Dev seed | Done | Second node `local-dev-2` for multi-node tests |
|
||||
| `@hexahost/metering` | Done | RAM-GB-minute credits, Minecraft `list` parser |
|
||||
| Prisma billing models | Done | `CreditWallet`, `CreditTransaction`, `UsageRecord` |
|
||||
| Plan idle/quota fields | Done | Timeouts, countdown, `maxServersPerUser`, `queuePriority` |
|
||||
| Idle controller (worker) | Done | Player poll, countdown, auto-stop |
|
||||
| Usage metering (worker) | Done | 60s tick debits wallet, writes `UsageRecord` |
|
||||
| API billing | Done | `GET /account/wallet`, `GET /account/usage` |
|
||||
| API idle | Done | `GET /servers/:id/idle`, `POST .../idle/extend` |
|
||||
| Quotas & credits | Done | Server create/start guards |
|
||||
| Web | Done | Credit balance on dashboard, idle countdown on server card |
|
||||
|
||||
### Abnahmekriterium Phase 6
|
||||
### Abnahmekriterium Phase 7
|
||||
|
||||
- [x] Scheduler distributes servers across eligible nodes (least loaded RAM)
|
||||
- [x] Resource reservation via `GameServerAllocation` + node RAM budget
|
||||
- [x] Start queue when node lacks capacity
|
||||
- [x] Drain excludes node from new allocations
|
||||
- [x] Maintenance mode blocks scheduling
|
||||
- [x] Node heartbeat loss marks servers `UNKNOWN`
|
||||
- [ ] Manual E2E with two agents on different nodes
|
||||
- [x] Empty running server triggers idle countdown and auto-stop
|
||||
- [x] Player connection cancels countdown (via `list` poll)
|
||||
- [x] Usage debited to credit wallet with ledger + usage records
|
||||
- [x] Free plan grants credits on signup
|
||||
- [x] Server quota per plan enforced
|
||||
- [ ] Manual E2E: start server, wait idle, verify stop + usage
|
||||
|
||||
### Local test (two nodes)
|
||||
### Local test
|
||||
|
||||
1. `pnpm db:seed` — note both enrollment tokens
|
||||
2. Start agent 1 with `NODE_ID=...001` and agent 2 with `NODE_ID=...002`
|
||||
3. Create several servers and start them — verify different `nodeId` in API responses
|
||||
4. Saturate one node RAM (`maxRamMb` low) → next server lands on other node
|
||||
5. `POST /admin/nodes/:id/drain` — new provisions skip drained node
|
||||
1. `pnpm db:migrate && pnpm db:seed`
|
||||
2. Register/login → dashboard shows **5000 credits** (free plan)
|
||||
3. Create & start server → credits decrease while running
|
||||
4. Leave server empty → after ~10 min idle + 60s countdown → auto-stop
|
||||
5. `GET /account/usage` shows RAM-GB-minute records
|
||||
|
||||
### Known limitations
|
||||
### Free plan defaults (seed)
|
||||
|
||||
- Offline migration not implemented (manual re-provision only)
|
||||
- No admin UI for nodes yet (API only)
|
||||
- Queue priority uses plan metadata placeholder (`priority=0`)
|
||||
- CPU overcommit not configurable yet
|
||||
- `idleTimeoutMinutes`: 10
|
||||
- `idleCountdownSeconds`: 60
|
||||
- `monthlyCreditGrant`: 5000
|
||||
- `maxServersPerUser`: 3
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Multi-Node and queue ✅
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Software & add-ons ✅
|
||||
|
||||
Modrinth catalog, addon install/remove, SHA512 verify.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Worlds & backups ✅
|
||||
@@ -69,6 +70,6 @@ Modrinth catalog, addon install/remove, SHA512 verify.
|
||||
|
||||
## Phase 0 — Monorepo foundation ✅
|
||||
|
||||
### Next phase: Phase 7 — Idle shutdown and free tier
|
||||
### Next phase: Phase 8 — DNS and join-to-start
|
||||
|
||||
- Player detection, auto-stop, quotas, credits, usage metering
|
||||
- Port pools, DNS-SRV, edge gateway, start on connect
|
||||
|
||||
43
packages/contracts/src/billing.ts
Normal file
43
packages/contracts/src/billing.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const idleStatusResponseSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
playerCount: z.number().int().nullable(),
|
||||
idleStopAt: z.string().datetime().nullable(),
|
||||
secondsRemaining: z.number().int().nullable(),
|
||||
lastPlayerSeenAt: z.string().datetime().nullable(),
|
||||
});
|
||||
|
||||
export const creditTransactionSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
amount: z.number().int(),
|
||||
type: z.enum(['GRANT', 'USAGE_DEBIT', 'ADJUSTMENT', 'EXPIRE']),
|
||||
referenceType: z.string().nullable(),
|
||||
referenceId: z.string().nullable(),
|
||||
createdAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export const walletResponseSchema = z.object({
|
||||
balance: z.number().int(),
|
||||
periodStart: z.string().datetime(),
|
||||
transactions: z.array(creditTransactionSchema),
|
||||
});
|
||||
|
||||
export const usageRecordSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
serverId: z.string().uuid(),
|
||||
ramMb: z.number().int(),
|
||||
durationSeconds: z.number().int(),
|
||||
creditsCharged: z.number().int(),
|
||||
periodStart: z.string().datetime(),
|
||||
periodEnd: z.string().datetime(),
|
||||
createdAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export const usageListResponseSchema = z.object({
|
||||
records: z.array(usageRecordSchema),
|
||||
});
|
||||
|
||||
export type IdleStatusResponse = z.infer<typeof idleStatusResponseSchema>;
|
||||
export type WalletResponse = z.infer<typeof walletResponseSchema>;
|
||||
export type UsageListResponse = z.infer<typeof usageListResponseSchema>;
|
||||
@@ -194,3 +194,14 @@ export {
|
||||
type StartQueueEntry,
|
||||
type StartQueueResponse,
|
||||
} from './nodes';
|
||||
|
||||
export {
|
||||
idleStatusResponseSchema,
|
||||
creditTransactionSchema,
|
||||
walletResponseSchema,
|
||||
usageRecordSchema,
|
||||
usageListResponseSchema,
|
||||
type IdleStatusResponse,
|
||||
type WalletResponse,
|
||||
type UsageListResponse,
|
||||
} from './billing';
|
||||
|
||||
@@ -59,6 +59,8 @@ export const serverResponseSchema = z.object({
|
||||
dataPath: z.string().nullable(),
|
||||
activeWorldName: z.string(),
|
||||
ramMb: z.number().int(),
|
||||
idleShutdownEnabled: z.boolean(),
|
||||
idleStopAt: z.string().datetime().nullable(),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,79 @@
|
||||
-- Phase 7: Idle shutdown, credits, usage metering, plan quotas
|
||||
|
||||
CREATE TYPE "CreditTransactionType" AS ENUM ('GRANT', 'USAGE_DEBIT', 'ADJUSTMENT', 'EXPIRE');
|
||||
|
||||
ALTER TABLE "plans"
|
||||
ADD COLUMN IF NOT EXISTS "maxServersPerUser" INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS "idleTimeoutMinutes" INTEGER NOT NULL DEFAULT 15,
|
||||
ADD COLUMN IF NOT EXISTS "idleGraceAfterStartMinutes" INTEGER NOT NULL DEFAULT 3,
|
||||
ADD COLUMN IF NOT EXISTS "idleCountdownSeconds" INTEGER NOT NULL DEFAULT 120,
|
||||
ADD COLUMN IF NOT EXISTS "queuePriority" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS "monthlyCreditGrant" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE "game_servers"
|
||||
ADD COLUMN IF NOT EXISTS "idleShutdownEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN IF NOT EXISTS "lastPlayerSeenAt" TIMESTAMPTZ(3),
|
||||
ADD COLUMN IF NOT EXISTS "idleStopAt" TIMESTAMPTZ(3),
|
||||
ADD COLUMN IF NOT EXISTS "runningSince" TIMESTAMPTZ(3),
|
||||
ADD COLUMN IF NOT EXISTS "lastMeteredAt" TIMESTAMPTZ(3);
|
||||
|
||||
CREATE TABLE "credit_wallets" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"balance" INTEGER NOT NULL DEFAULT 0,
|
||||
"periodStart" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "credit_wallets_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "credit_wallets_userId_key" ON "credit_wallets"("userId");
|
||||
|
||||
ALTER TABLE "credit_wallets"
|
||||
ADD CONSTRAINT "credit_wallets_userId_fkey"
|
||||
FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
CREATE TABLE "credit_transactions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"walletId" TEXT NOT NULL,
|
||||
"amount" INTEGER NOT NULL,
|
||||
"type" "CreditTransactionType" NOT NULL,
|
||||
"referenceType" TEXT,
|
||||
"referenceId" TEXT,
|
||||
"idempotencyKey" TEXT NOT NULL,
|
||||
"metadata" JSONB,
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "credit_transactions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "credit_transactions_idempotencyKey_key" ON "credit_transactions"("idempotencyKey");
|
||||
CREATE INDEX "credit_transactions_walletId_createdAt_idx" ON "credit_transactions"("walletId", "createdAt");
|
||||
|
||||
ALTER TABLE "credit_transactions"
|
||||
ADD CONSTRAINT "credit_transactions_walletId_fkey"
|
||||
FOREIGN KEY ("walletId") REFERENCES "credit_wallets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
CREATE TABLE "usage_records" (
|
||||
"id" TEXT NOT NULL,
|
||||
"serverId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"ramMb" INTEGER NOT NULL,
|
||||
"durationSeconds" INTEGER NOT NULL,
|
||||
"creditsCharged" INTEGER NOT NULL,
|
||||
"periodStart" TIMESTAMPTZ(3) NOT NULL,
|
||||
"periodEnd" TIMESTAMPTZ(3) NOT NULL,
|
||||
"idempotencyKey" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "usage_records_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "usage_records_idempotencyKey_key" ON "usage_records"("idempotencyKey");
|
||||
CREATE INDEX "usage_records_serverId_periodStart_idx" ON "usage_records"("serverId", "periodStart");
|
||||
CREATE INDEX "usage_records_userId_periodStart_idx" ON "usage_records"("userId", "periodStart");
|
||||
|
||||
ALTER TABLE "usage_records"
|
||||
ADD CONSTRAINT "usage_records_serverId_fkey"
|
||||
FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -121,6 +121,13 @@ enum AddonInstallStatus {
|
||||
REMOVED
|
||||
}
|
||||
|
||||
enum CreditTransactionType {
|
||||
GRANT
|
||||
USAGE_DEBIT
|
||||
ADJUSTMENT
|
||||
EXPIRE
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
@@ -141,6 +148,7 @@ model User {
|
||||
emailVerificationTokens EmailVerificationToken[]
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
totpCredential TotpCredential?
|
||||
creditWallet CreditWallet?
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -278,6 +286,11 @@ model GameServer {
|
||||
dataPath String?
|
||||
ramMb Int @default(1024)
|
||||
activeWorldName String @default("world")
|
||||
idleShutdownEnabled Boolean @default(true)
|
||||
lastPlayerSeenAt DateTime? @db.Timestamptz(3)
|
||||
idleStopAt DateTime? @db.Timestamptz(3)
|
||||
runningSince DateTime? @db.Timestamptz(3)
|
||||
lastMeteredAt DateTime? @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
@@ -292,6 +305,7 @@ model GameServer {
|
||||
worldUploads WorldUpload[]
|
||||
installedAddons InstalledAddon[]
|
||||
startQueueEntry ServerStartQueueEntry?
|
||||
usageRecords UsageRecord[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([nodeId])
|
||||
@@ -406,6 +420,12 @@ model Plan {
|
||||
maxRamMb Int
|
||||
maxCpuCores Float
|
||||
maxStorageMb Int
|
||||
maxServersPerUser Int?
|
||||
idleTimeoutMinutes Int @default(15)
|
||||
idleGraceAfterStartMinutes Int @default(3)
|
||||
idleCountdownSeconds Int @default(120)
|
||||
queuePriority Int @default(0)
|
||||
monthlyCreditGrant Int @default(0)
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
@@ -475,6 +495,56 @@ model JobRecord {
|
||||
@@map("job_records")
|
||||
}
|
||||
|
||||
model CreditWallet {
|
||||
id String @id @default(uuid())
|
||||
userId String @unique
|
||||
balance Int @default(0)
|
||||
periodStart DateTime @default(now()) @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
transactions CreditTransaction[]
|
||||
|
||||
@@map("credit_wallets")
|
||||
}
|
||||
|
||||
model CreditTransaction {
|
||||
id String @id @default(uuid())
|
||||
walletId String
|
||||
amount Int
|
||||
type CreditTransactionType
|
||||
referenceType String?
|
||||
referenceId String?
|
||||
idempotencyKey String @unique
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
wallet CreditWallet @relation(fields: [walletId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([walletId, createdAt])
|
||||
@@map("credit_transactions")
|
||||
}
|
||||
|
||||
model UsageRecord {
|
||||
id String @id @default(uuid())
|
||||
serverId String
|
||||
userId String
|
||||
ramMb Int
|
||||
durationSeconds Int
|
||||
creditsCharged Int
|
||||
periodStart DateTime @db.Timestamptz(3)
|
||||
periodEnd DateTime @db.Timestamptz(3)
|
||||
idempotencyKey String @unique
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([serverId, periodStart])
|
||||
@@index([userId, periodStart])
|
||||
@@map("usage_records")
|
||||
}
|
||||
|
||||
model ServerBackup {
|
||||
id String @id @default(uuid())
|
||||
serverId String
|
||||
|
||||
@@ -29,9 +29,21 @@ async function main(): Promise<void> {
|
||||
maxRamMb: 2048,
|
||||
maxCpuCores: 1,
|
||||
maxStorageMb: 5120,
|
||||
maxServersPerUser: 3,
|
||||
idleTimeoutMinutes: 10,
|
||||
idleGraceAfterStartMinutes: 2,
|
||||
idleCountdownSeconds: 60,
|
||||
queuePriority: 0,
|
||||
monthlyCreditGrant: 5000,
|
||||
},
|
||||
update: {
|
||||
isActive: true,
|
||||
maxServersPerUser: 3,
|
||||
idleTimeoutMinutes: 10,
|
||||
idleGraceAfterStartMinutes: 2,
|
||||
idleCountdownSeconds: 60,
|
||||
queuePriority: 0,
|
||||
monthlyCreditGrant: 5000,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ export type {
|
||||
GameNode,
|
||||
GameNodeHeartbeat,
|
||||
ServerStartQueueEntry,
|
||||
CreditWallet,
|
||||
CreditTransaction,
|
||||
UsageRecord,
|
||||
Plan,
|
||||
FeatureFlag,
|
||||
SystemSetting,
|
||||
|
||||
File diff suppressed because one or more lines are too long
18
packages/metering/package.json
Normal file
18
packages/metering/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@hexahost/metering",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hexahost/typescript-config": "workspace:*",
|
||||
"@types/node": "^22.10.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.8"
|
||||
}
|
||||
}
|
||||
48
packages/metering/src/index.ts
Normal file
48
packages/metering/src/index.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/** Credits charged for RAM usage: 1 credit per GB-minute (rounded up). */
|
||||
export function calculateUsageCredits(ramMb: number, durationSeconds: number): number {
|
||||
if (ramMb <= 0 || durationSeconds <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const gbMinutes = (ramMb / 1024) * (durationSeconds / 60);
|
||||
return Math.max(1, Math.ceil(gbMinutes));
|
||||
}
|
||||
|
||||
export function parseMinecraftListOutput(output: string): number {
|
||||
const headerMatch = output.match(
|
||||
/There are (\d+) of a max(?:imum)? of \d+ players? online/i,
|
||||
);
|
||||
|
||||
if (headerMatch?.[1]) {
|
||||
return Number.parseInt(headerMatch[1], 10);
|
||||
}
|
||||
|
||||
const legacyMatch = output.match(/^(\d+) players? online/i);
|
||||
if (legacyMatch?.[1]) {
|
||||
return Number.parseInt(legacyMatch[1], 10);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export interface IdlePolicy {
|
||||
idleTimeoutMinutes: number;
|
||||
idleGraceAfterStartMinutes: number;
|
||||
idleCountdownSeconds: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_IDLE_POLICY: IdlePolicy = {
|
||||
idleTimeoutMinutes: 15,
|
||||
idleGraceAfterStartMinutes: 3,
|
||||
idleCountdownSeconds: 120,
|
||||
};
|
||||
|
||||
export function resolveIdlePolicy(plan?: Partial<IdlePolicy> | null): IdlePolicy {
|
||||
return {
|
||||
idleTimeoutMinutes: plan?.idleTimeoutMinutes ?? DEFAULT_IDLE_POLICY.idleTimeoutMinutes,
|
||||
idleGraceAfterStartMinutes:
|
||||
plan?.idleGraceAfterStartMinutes ?? DEFAULT_IDLE_POLICY.idleGraceAfterStartMinutes,
|
||||
idleCountdownSeconds:
|
||||
plan?.idleCountdownSeconds ?? DEFAULT_IDLE_POLICY.idleCountdownSeconds,
|
||||
};
|
||||
}
|
||||
23
packages/metering/src/metering.test.ts
Normal file
23
packages/metering/src/metering.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
calculateUsageCredits,
|
||||
parseMinecraftListOutput,
|
||||
} from './index';
|
||||
|
||||
describe('metering', () => {
|
||||
it('charges credits by RAM-GB-minutes', () => {
|
||||
expect(calculateUsageCredits(1024, 60)).toBe(1);
|
||||
expect(calculateUsageCredits(2048, 60)).toBe(2);
|
||||
expect(calculateUsageCredits(512, 30)).toBe(1);
|
||||
});
|
||||
|
||||
it('parses minecraft list command output', () => {
|
||||
expect(
|
||||
parseMinecraftListOutput('There are 2 of a max of 20 players online: Steve, Alex'),
|
||||
).toBe(2);
|
||||
expect(
|
||||
parseMinecraftListOutput('There are 0 of a maximum of 10 players online:'),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
9
packages/metering/tsconfig.json
Normal file
9
packages/metering/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@hexahost/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
21
pnpm-lock.yaml
generated
21
pnpm-lock.yaml
generated
@@ -41,6 +41,9 @@ importers:
|
||||
'@hexahost/database':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/database
|
||||
'@hexahost/metering':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/metering
|
||||
'@hexahost/scheduler':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/scheduler
|
||||
@@ -187,6 +190,9 @@ importers:
|
||||
'@hexahost/database':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/database
|
||||
'@hexahost/metering':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/metering
|
||||
'@hexahost/scheduler':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/scheduler
|
||||
@@ -321,6 +327,21 @@ importers:
|
||||
|
||||
packages/eslint-config: {}
|
||||
|
||||
packages/metering:
|
||||
devDependencies:
|
||||
'@hexahost/typescript-config':
|
||||
specifier: workspace:*
|
||||
version: link:../typescript-config
|
||||
'@types/node':
|
||||
specifier: ^22.10.2
|
||||
version: 22.20.0
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^3.0.8
|
||||
version: 3.2.6(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)
|
||||
|
||||
packages/scheduler:
|
||||
devDependencies:
|
||||
'@hexahost/typescript-config':
|
||||
|
||||
Reference in New Issue
Block a user