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
|
## 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.
|
Siehe [docs/IMPLEMENTATION_STATUS.md](docs/IMPLEMENTATION_STATUS.md) für Details.
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"@hexahost/config": "workspace:*",
|
"@hexahost/config": "workspace:*",
|
||||||
"@hexahost/contracts": "workspace:*",
|
"@hexahost/contracts": "workspace:*",
|
||||||
"@hexahost/database": "workspace:*",
|
"@hexahost/database": "workspace:*",
|
||||||
|
"@hexahost/metering": "workspace:*",
|
||||||
"@hexahost/scheduler": "workspace:*",
|
"@hexahost/scheduler": "workspace:*",
|
||||||
"@hexahost/storage": "workspace:*",
|
"@hexahost/storage": "workspace:*",
|
||||||
"@nestjs/common": "^11.0.7",
|
"@nestjs/common": "^11.0.7",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { RequestIdMiddleware } from './common/middleware/request-id.middleware';
|
|||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { BillingModule } from './billing/billing.module';
|
||||||
import { CatalogModule } from './catalog/catalog.module';
|
import { CatalogModule } from './catalog/catalog.module';
|
||||||
import { AdminModule } from './admin/admin.module';
|
import { AdminModule } from './admin/admin.module';
|
||||||
import { NodesModule } from './nodes/nodes.module';
|
import { NodesModule } from './nodes/nodes.module';
|
||||||
@@ -42,6 +43,7 @@ import { LoggerModule } from 'nestjs-pino';
|
|||||||
HealthModule,
|
HealthModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
CatalogModule,
|
CatalogModule,
|
||||||
|
BillingModule,
|
||||||
ServersModule,
|
ServersModule,
|
||||||
NodesModule,
|
NodesModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Redis from 'ioredis';
|
|||||||
import { getConfig } from '@hexahost/config';
|
import { getConfig } from '@hexahost/config';
|
||||||
|
|
||||||
import { AppConfigModule } from '../config/app-config.module';
|
import { AppConfigModule } from '../config/app-config.module';
|
||||||
|
import { BillingModule } from '../billing/billing.module';
|
||||||
|
|
||||||
import { AuditService } from './audit.service';
|
import { AuditService } from './audit.service';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
@@ -18,7 +19,7 @@ import { MeController } from './me.controller';
|
|||||||
import { SessionService } from './session.service';
|
import { SessionService } from './session.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AppConfigModule],
|
imports: [AppConfigModule, BillingModule],
|
||||||
controllers: [AuthController, MeController],
|
controllers: [AuthController, MeController],
|
||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
|
|
||||||
import { AuditService } from './audit.service';
|
import { AuditService } from './audit.service';
|
||||||
import { SessionService } from './session.service';
|
import { SessionService } from './session.service';
|
||||||
|
import { BillingService } from '../billing/billing.service';
|
||||||
|
|
||||||
const LOCKOUT_ATTEMPTS = 5;
|
const LOCKOUT_ATTEMPTS = 5;
|
||||||
const LOCKOUT_DURATION_MS = 15 * 60 * 1000;
|
const LOCKOUT_DURATION_MS = 15 * 60 * 1000;
|
||||||
@@ -74,6 +75,7 @@ export class AuthService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly sessionService: SessionService,
|
private readonly sessionService: SessionService,
|
||||||
private readonly auditService: AuditService,
|
private readonly auditService: AuditService,
|
||||||
|
private readonly billingService: BillingService,
|
||||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||||
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||||
@Inject(NOTIFICATIONS_QUEUE) private readonly notificationsQueue: Queue,
|
@Inject(NOTIFICATIONS_QUEUE) private readonly notificationsQueue: Queue,
|
||||||
@@ -123,6 +125,8 @@ export class AuthService {
|
|||||||
return created;
|
return created;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.billingService.ensureWalletForUser(user.id);
|
||||||
|
|
||||||
await this.createEmailVerificationToken(user.id, user.email);
|
await this.createEmailVerificationToken(user.id, user.email);
|
||||||
|
|
||||||
await this.auditService.record({
|
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,
|
actorId: string,
|
||||||
correlationId: string,
|
correlationId: string,
|
||||||
): Promise<{ server: GameServer; queuePosition: number }> {
|
): 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 expiresAt = new Date(Date.now() + START_QUEUE_EXPIRY_MS);
|
||||||
|
const priority = plan?.queuePriority ?? 0;
|
||||||
|
|
||||||
const updated = await this.prisma.$transaction(async (tx) => {
|
const updated = await this.prisma.$transaction(async (tx) => {
|
||||||
await tx.serverStartQueueEntry.upsert({
|
await tx.serverStartQueueEntry.upsert({
|
||||||
@@ -39,11 +50,13 @@ export class SchedulingService {
|
|||||||
serverId: server.id,
|
serverId: server.id,
|
||||||
nodeId: server.nodeId!,
|
nodeId: server.nodeId!,
|
||||||
status: 'QUEUED',
|
status: 'QUEUED',
|
||||||
|
priority,
|
||||||
correlationId,
|
correlationId,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
status: 'QUEUED',
|
status: 'QUEUED',
|
||||||
|
priority,
|
||||||
correlationId,
|
correlationId,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
requestedAt: new Date(),
|
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 { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
|
||||||
|
|
||||||
import { ServersService } from './servers.service';
|
import { ServersService } from './servers.service';
|
||||||
|
import { IdleService } from './idle/idle.service';
|
||||||
|
|
||||||
@ApiTags('servers')
|
@ApiTags('servers')
|
||||||
@Controller('servers')
|
@Controller('servers')
|
||||||
@UseGuards(SessionGuard)
|
@UseGuards(SessionGuard)
|
||||||
export class ServersController {
|
export class ServersController {
|
||||||
constructor(private readonly serversService: ServersService) {}
|
constructor(
|
||||||
|
private readonly serversService: ServersService,
|
||||||
|
private readonly idleService: IdleService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@@ -104,4 +108,23 @@ export class ServersController {
|
|||||||
) {
|
) {
|
||||||
return this.serversService.cancelQueuedStart(user.id, id);
|
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 { NodeBridgeModule } from '../nodes/node-bridge.module';
|
||||||
import { AppConfigModule } from '../config/app-config.module';
|
import { AppConfigModule } from '../config/app-config.module';
|
||||||
|
|
||||||
|
import { BillingModule } from '../billing/billing.module';
|
||||||
import { SchedulingModule } from '../scheduling/scheduling.module';
|
import { SchedulingModule } from '../scheduling/scheduling.module';
|
||||||
|
|
||||||
import { ConsoleModule } from './console/console.module';
|
import { ConsoleModule } from './console/console.module';
|
||||||
import { AddonsModule } from './addons/addons.module';
|
import { AddonsModule } from './addons/addons.module';
|
||||||
import { BackupsModule } from './backups/backups.module';
|
import { BackupsModule } from './backups/backups.module';
|
||||||
import { FilesModule } from './files/files.module';
|
import { FilesModule } from './files/files.module';
|
||||||
|
import { IdleModule } from './idle/idle.module';
|
||||||
import { WorldsModule } from './worlds/worlds.module';
|
import { WorldsModule } from './worlds/worlds.module';
|
||||||
import { PlayersModule } from './players/players.module';
|
import { PlayersModule } from './players/players.module';
|
||||||
import { PropertiesModule } from './properties/properties.module';
|
import { PropertiesModule } from './properties/properties.module';
|
||||||
@@ -33,6 +35,8 @@ import { PlansService } from './plans.service';
|
|||||||
AppConfigModule,
|
AppConfigModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
SchedulingModule,
|
SchedulingModule,
|
||||||
|
BillingModule,
|
||||||
|
IdleModule,
|
||||||
forwardRef(() => ConsoleModule),
|
forwardRef(() => ConsoleModule),
|
||||||
forwardRef(() => AddonsModule),
|
forwardRef(() => AddonsModule),
|
||||||
forwardRef(() => BackupsModule),
|
forwardRef(() => BackupsModule),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type { GameServer } from '@hexahost/database';
|
|||||||
type GameServerStatus = GameServer['status'];
|
type GameServerStatus = GameServer['status'];
|
||||||
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { BillingService } from '../billing/billing.service';
|
||||||
import { SchedulingService } from '../scheduling/scheduling.service';
|
import { SchedulingService } from '../scheduling/scheduling.service';
|
||||||
|
|
||||||
import { findOwnedServer } from './shared/server-ownership.util';
|
import { findOwnedServer } from './shared/server-ownership.util';
|
||||||
@@ -36,6 +37,7 @@ export class ServersService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly serverState: ServerStateService,
|
private readonly serverState: ServerStateService,
|
||||||
private readonly scheduling: SchedulingService,
|
private readonly scheduling: SchedulingService,
|
||||||
|
private readonly billing: BillingService,
|
||||||
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||||
@Inject(SERVER_LIFECYCLE_QUEUE)
|
@Inject(SERVER_LIFECYCLE_QUEUE)
|
||||||
private readonly lifecycleQueue: Queue,
|
private readonly lifecycleQueue: Queue,
|
||||||
@@ -47,6 +49,8 @@ export class ServersService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
input: CreateServerRequest,
|
input: CreateServerRequest,
|
||||||
): Promise<ServerResponse> {
|
): Promise<ServerResponse> {
|
||||||
|
await this.billing.assertCanCreateServer(userId, input.planId);
|
||||||
|
|
||||||
const server = await this.prisma.gameServer.create({
|
const server = await this.prisma.gameServer.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
@@ -110,6 +114,8 @@ export class ServersService {
|
|||||||
const correlationId = randomUUID();
|
const correlationId = randomUUID();
|
||||||
|
|
||||||
if (targetStatus === 'STARTING') {
|
if (targetStatus === 'STARTING') {
|
||||||
|
await this.billing.assertCanStartServer(userId);
|
||||||
|
|
||||||
const canStart = await this.scheduling.canStartServer(server);
|
const canStart = await this.scheduling.canStartServer(server);
|
||||||
if (!canStart) {
|
if (!canStart) {
|
||||||
const queued = await this.scheduling.enqueueStart(
|
const queued = await this.scheduling.enqueueStart(
|
||||||
@@ -288,6 +294,21 @@ export class ServersService {
|
|||||||
data: {
|
data: {
|
||||||
status: toStatus,
|
status: toStatus,
|
||||||
version: { increment: 1 },
|
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,
|
dataPath: server.dataPath,
|
||||||
activeWorldName: server.activeWorldName,
|
activeWorldName: server.activeWorldName,
|
||||||
ramMb: server.ramMb,
|
ramMb: server.ramMb,
|
||||||
|
idleShutdownEnabled: server.idleShutdownEnabled,
|
||||||
|
idleStopAt: server.idleStopAt?.toISOString() ?? null,
|
||||||
createdAt: server.createdAt.toISOString(),
|
createdAt: server.createdAt.toISOString(),
|
||||||
updatedAt: server.updatedAt.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",
|
"allocatedRam": "Zugewiesener RAM",
|
||||||
"activitySubtitle": "Übersicht",
|
"activitySubtitle": "Übersicht",
|
||||||
"totalServers": "Server gesamt",
|
"totalServers": "Server gesamt",
|
||||||
|
"credits": "Credits",
|
||||||
|
"creditsSubtitle": "Laufzeit-Guthaben",
|
||||||
|
"creditsRemaining": "Verbleibende Credits",
|
||||||
"viewAllServers": "Alle Server anzeigen",
|
"viewAllServers": "Alle Server anzeigen",
|
||||||
"serversSummary": "Server in Ihrem Konto"
|
"serversSummary": "Server in Ihrem Konto"
|
||||||
},
|
},
|
||||||
@@ -154,6 +157,7 @@
|
|||||||
"ram": "RAM",
|
"ram": "RAM",
|
||||||
"start": "Starten",
|
"start": "Starten",
|
||||||
"stop": "Stoppen",
|
"stop": "Stoppen",
|
||||||
|
"idleCountdown": "Auto-Stop in {seconds}s (keine Spieler)",
|
||||||
"details": "Details",
|
"details": "Details",
|
||||||
"detailSubtitle": "Serverdetails und Lifecycle-Steuerung",
|
"detailSubtitle": "Serverdetails und Lifecycle-Steuerung",
|
||||||
"backToList": "Zurück zur Serverliste",
|
"backToList": "Zurück zur Serverliste",
|
||||||
|
|||||||
@@ -108,6 +108,9 @@
|
|||||||
"allocatedRam": "Allocated RAM",
|
"allocatedRam": "Allocated RAM",
|
||||||
"activitySubtitle": "Overview",
|
"activitySubtitle": "Overview",
|
||||||
"totalServers": "Total servers",
|
"totalServers": "Total servers",
|
||||||
|
"credits": "Credits",
|
||||||
|
"creditsSubtitle": "Runtime balance",
|
||||||
|
"creditsRemaining": "Credits remaining",
|
||||||
"viewAllServers": "View all servers",
|
"viewAllServers": "View all servers",
|
||||||
"serversSummary": "Servers in your account"
|
"serversSummary": "Servers in your account"
|
||||||
},
|
},
|
||||||
@@ -154,6 +157,7 @@
|
|||||||
"ram": "RAM",
|
"ram": "RAM",
|
||||||
"start": "Start",
|
"start": "Start",
|
||||||
"stop": "Stop",
|
"stop": "Stop",
|
||||||
|
"idleCountdown": "Auto-stop in {seconds}s (no players)",
|
||||||
"details": "Details",
|
"details": "Details",
|
||||||
"detailSubtitle": "Server details and lifecycle controls",
|
"detailSubtitle": "Server details and lifecycle controls",
|
||||||
"backToList": "Back to servers",
|
"backToList": "Back to servers",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useTranslations } from "next-intl";
|
|||||||
import { Link, useRouter } from "@/i18n/navigation";
|
import { Link, useRouter } from "@/i18n/navigation";
|
||||||
import { EmptyState } from "@/components/dashboard/empty-state";
|
import { EmptyState } from "@/components/dashboard/empty-state";
|
||||||
import { DashboardSkeleton } from "@/components/dashboard/skeleton";
|
import { DashboardSkeleton } from "@/components/dashboard/skeleton";
|
||||||
|
import { getWallet } from "@/lib/api/billing";
|
||||||
import { listServers } from "@/lib/api/servers";
|
import { listServers } from "@/lib/api/servers";
|
||||||
|
|
||||||
export function DashboardContent() {
|
export function DashboardContent() {
|
||||||
@@ -15,6 +16,10 @@ export function DashboardContent() {
|
|||||||
queryKey: ["servers"],
|
queryKey: ["servers"],
|
||||||
queryFn: listServers,
|
queryFn: listServers,
|
||||||
});
|
});
|
||||||
|
const { data: wallet } = useQuery({
|
||||||
|
queryKey: ["wallet"],
|
||||||
|
queryFn: getWallet,
|
||||||
|
});
|
||||||
|
|
||||||
const servers = data?.servers ?? [];
|
const servers = data?.servers ?? [];
|
||||||
const activeCount = servers.filter((server) =>
|
const activeCount = servers.filter((server) =>
|
||||||
@@ -63,14 +68,14 @@ export function DashboardContent() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t("activity")}</CardTitle>
|
<CardTitle>{t("credits")}</CardTitle>
|
||||||
<CardDescription>{t("activitySubtitle")}</CardDescription>
|
<CardDescription>{t("creditsSubtitle")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="font-mono text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
|
<p className="font-mono text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
|
||||||
{servers.length}
|
{wallet?.balance ?? "—"}
|
||||||
</p>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
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 { useTranslations } from "next-intl";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link } from "@/i18n/navigation";
|
import { Link } from "@/i18n/navigation";
|
||||||
import { ApiError } from "@/lib/api/auth";
|
import { ApiError } from "@/lib/api/auth";
|
||||||
|
import { getServerIdleStatus } from "@/lib/api/billing";
|
||||||
import {
|
import {
|
||||||
type Server,
|
type Server,
|
||||||
startServer,
|
startServer,
|
||||||
@@ -23,6 +24,13 @@ export function ServerCard({ server, showDetailsLink = true }: ServerCardProps)
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
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({
|
const startMutation = useMutation({
|
||||||
mutationFn: () => startServer(server.id),
|
mutationFn: () => startServer(server.id),
|
||||||
onSuccess: () => {
|
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 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 =
|
const address =
|
||||||
server.hostPort !== null ? `localhost:${server.hostPort}` : t("addressPending");
|
server.hostPort !== null ? `localhost:${server.hostPort}` : t("addressPending");
|
||||||
|
|
||||||
@@ -76,6 +86,12 @@ export function ServerCard({ server, showDetailsLink = true }: ServerCardProps)
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</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 ? (
|
{actionError ? (
|
||||||
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
|
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
|
||||||
{actionError}
|
{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",
|
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",
|
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",
|
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",
|
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",
|
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",
|
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",
|
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",
|
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",
|
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"
|
| "PROVISIONING"
|
||||||
| "INSTALLING"
|
| "INSTALLING"
|
||||||
| "STOPPED"
|
| "STOPPED"
|
||||||
|
| "QUEUED"
|
||||||
| "STARTING"
|
| "STARTING"
|
||||||
| "RUNNING"
|
| "RUNNING"
|
||||||
| "STOPPING"
|
| "STOPPING"
|
||||||
|
| "UNKNOWN"
|
||||||
| "ERROR"
|
| "ERROR"
|
||||||
| "DELETING"
|
| "DELETING"
|
||||||
| "DELETED";
|
| "DELETED";
|
||||||
@@ -38,6 +40,8 @@ export interface Server {
|
|||||||
containerId: string | null;
|
containerId: string | null;
|
||||||
dataPath: string | null;
|
dataPath: string | null;
|
||||||
ramMb: number;
|
ramMb: number;
|
||||||
|
idleShutdownEnabled: boolean;
|
||||||
|
idleStopAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -49,6 +53,7 @@ export interface ServerListResponse {
|
|||||||
export interface ServerActionResponse {
|
export interface ServerActionResponse {
|
||||||
server: Server;
|
server: Server;
|
||||||
jobId?: string;
|
jobId?: string;
|
||||||
|
queuePosition?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateServerPayload {
|
export interface CreateServerPayload {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@hexahost/catalog": "workspace:*",
|
"@hexahost/catalog": "workspace:*",
|
||||||
"@hexahost/config": "workspace:*",
|
"@hexahost/config": "workspace:*",
|
||||||
"@hexahost/database": "workspace:*",
|
"@hexahost/database": "workspace:*",
|
||||||
|
"@hexahost/metering": "workspace:*",
|
||||||
"@hexahost/scheduler": "workspace:*",
|
"@hexahost/scheduler": "workspace:*",
|
||||||
"@hexahost/storage": "workspace:*",
|
"@hexahost/storage": "workspace:*",
|
||||||
"bullmq": "^5.34.10",
|
"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 { validateConfig } from '@hexahost/config';
|
||||||
import { prisma } from '@hexahost/database';
|
import { prisma } from '@hexahost/database';
|
||||||
|
|
||||||
|
import { processIdleShutdownTick, processUsageMeteringTick } from './handlers/idle-billing';
|
||||||
import { processNodeHealthTick } from './handlers/node-health';
|
import { processNodeHealthTick } from './handlers/node-health';
|
||||||
import { processNotificationJob } from './handlers/notifications';
|
import { processNotificationJob } from './handlers/notifications';
|
||||||
import { processAddonJob } from './handlers/server-addons';
|
import { processAddonJob } from './handlers/server-addons';
|
||||||
@@ -99,6 +100,12 @@ async function bootstrap(): Promise<void> {
|
|||||||
void processNodeHealthTick().catch((error: Error) => {
|
void processNodeHealthTick().catch((error: Error) => {
|
||||||
logger.error({ err: error }, 'Node health tick failed');
|
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);
|
}, 60_000);
|
||||||
|
|
||||||
const shutdown = async (signal: string): Promise<void> => {
|
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
|
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 |
|
| Area | Status | Notes |
|
||||||
|------|--------|-------|
|
|------|--------|-------|
|
||||||
| `@hexahost/scheduler` | Done | Node scoring, RAM/slot capacity, least-loaded placement |
|
| `@hexahost/metering` | Done | RAM-GB-minute credits, Minecraft `list` parser |
|
||||||
| Prisma start queue + node capacity | Done | `ServerStartQueueEntry`, `maxRamMb`, `platformRamMb`, `QUEUED`/`UNKNOWN` |
|
| Prisma billing models | Done | `CreditWallet`, `CreditTransaction`, `UsageRecord` |
|
||||||
| `@hexahost/contracts` phase 6 | Done | `nodes.ts`, queue schemas, extended server status |
|
| Plan idle/quota fields | Done | Timeouts, countdown, `maxServersPerUser`, `queuePriority` |
|
||||||
| Worker scheduler integration | Done | Provisioning uses `selectNodeForAllocation` |
|
| Idle controller (worker) | Done | Player poll, countdown, auto-stop |
|
||||||
| Start queue | Done | API enqueue + worker tick dispatch |
|
| Usage metering (worker) | Done | 60s tick debits wallet, writes `UsageRecord` |
|
||||||
| Node health tick | Done | Heartbeat timeout → `UNREACHABLE`, servers → `UNKNOWN` |
|
| API billing | Done | `GET /account/wallet`, `GET /account/usage` |
|
||||||
| Drain / maintenance | Done | Admin `POST /admin/nodes/:id/drain` and `/maintenance` |
|
| API idle | Done | `GET /servers/:id/idle`, `POST .../idle/extend` |
|
||||||
| Allocation release | Done | Released on provision failure |
|
| Quotas & credits | Done | Server create/start guards |
|
||||||
| Dev seed | Done | Second node `local-dev-2` for multi-node tests |
|
| 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] Empty running server triggers idle countdown and auto-stop
|
||||||
- [x] Resource reservation via `GameServerAllocation` + node RAM budget
|
- [x] Player connection cancels countdown (via `list` poll)
|
||||||
- [x] Start queue when node lacks capacity
|
- [x] Usage debited to credit wallet with ledger + usage records
|
||||||
- [x] Drain excludes node from new allocations
|
- [x] Free plan grants credits on signup
|
||||||
- [x] Maintenance mode blocks scheduling
|
- [x] Server quota per plan enforced
|
||||||
- [x] Node heartbeat loss marks servers `UNKNOWN`
|
- [ ] Manual E2E: start server, wait idle, verify stop + usage
|
||||||
- [ ] Manual E2E with two agents on different nodes
|
|
||||||
|
|
||||||
### Local test (two nodes)
|
### Local test
|
||||||
|
|
||||||
1. `pnpm db:seed` — note both enrollment tokens
|
1. `pnpm db:migrate && pnpm db:seed`
|
||||||
2. Start agent 1 with `NODE_ID=...001` and agent 2 with `NODE_ID=...002`
|
2. Register/login → dashboard shows **5000 credits** (free plan)
|
||||||
3. Create several servers and start them — verify different `nodeId` in API responses
|
3. Create & start server → credits decrease while running
|
||||||
4. Saturate one node RAM (`maxRamMb` low) → next server lands on other node
|
4. Leave server empty → after ~10 min idle + 60s countdown → auto-stop
|
||||||
5. `POST /admin/nodes/:id/drain` — new provisions skip drained node
|
5. `GET /account/usage` shows RAM-GB-minute records
|
||||||
|
|
||||||
### Known limitations
|
### Free plan defaults (seed)
|
||||||
|
|
||||||
- Offline migration not implemented (manual re-provision only)
|
- `idleTimeoutMinutes`: 10
|
||||||
- No admin UI for nodes yet (API only)
|
- `idleCountdownSeconds`: 60
|
||||||
- Queue priority uses plan metadata placeholder (`priority=0`)
|
- `monthlyCreditGrant`: 5000
|
||||||
- CPU overcommit not configurable yet
|
- `maxServersPerUser`: 3
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6 — Multi-Node and queue ✅
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 5 — Software & add-ons ✅
|
## Phase 5 — Software & add-ons ✅
|
||||||
|
|
||||||
Modrinth catalog, addon install/remove, SHA512 verify.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 4 — Worlds & backups ✅
|
## Phase 4 — Worlds & backups ✅
|
||||||
@@ -69,6 +70,6 @@ Modrinth catalog, addon install/remove, SHA512 verify.
|
|||||||
|
|
||||||
## Phase 0 — Monorepo foundation ✅
|
## 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 StartQueueEntry,
|
||||||
type StartQueueResponse,
|
type StartQueueResponse,
|
||||||
} from './nodes';
|
} 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(),
|
dataPath: z.string().nullable(),
|
||||||
activeWorldName: z.string(),
|
activeWorldName: z.string(),
|
||||||
ramMb: z.number().int(),
|
ramMb: z.number().int(),
|
||||||
|
idleShutdownEnabled: z.boolean(),
|
||||||
|
idleStopAt: z.string().datetime().nullable(),
|
||||||
createdAt: z.string().datetime(),
|
createdAt: z.string().datetime(),
|
||||||
updatedAt: 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
|
REMOVED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum CreditTransactionType {
|
||||||
|
GRANT
|
||||||
|
USAGE_DEBIT
|
||||||
|
ADJUSTMENT
|
||||||
|
EXPIRE
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
@@ -141,6 +148,7 @@ model User {
|
|||||||
emailVerificationTokens EmailVerificationToken[]
|
emailVerificationTokens EmailVerificationToken[]
|
||||||
passwordResetTokens PasswordResetToken[]
|
passwordResetTokens PasswordResetToken[]
|
||||||
totpCredential TotpCredential?
|
totpCredential TotpCredential?
|
||||||
|
creditWallet CreditWallet?
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -278,6 +286,11 @@ model GameServer {
|
|||||||
dataPath String?
|
dataPath String?
|
||||||
ramMb Int @default(1024)
|
ramMb Int @default(1024)
|
||||||
activeWorldName String @default("world")
|
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)
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||||
|
|
||||||
@@ -292,6 +305,7 @@ model GameServer {
|
|||||||
worldUploads WorldUpload[]
|
worldUploads WorldUpload[]
|
||||||
installedAddons InstalledAddon[]
|
installedAddons InstalledAddon[]
|
||||||
startQueueEntry ServerStartQueueEntry?
|
startQueueEntry ServerStartQueueEntry?
|
||||||
|
usageRecords UsageRecord[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([nodeId])
|
@@index([nodeId])
|
||||||
@@ -406,6 +420,12 @@ model Plan {
|
|||||||
maxRamMb Int
|
maxRamMb Int
|
||||||
maxCpuCores Float
|
maxCpuCores Float
|
||||||
maxStorageMb Int
|
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?
|
metadata Json?
|
||||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||||
@@ -475,6 +495,56 @@ model JobRecord {
|
|||||||
@@map("job_records")
|
@@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 {
|
model ServerBackup {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
serverId String
|
serverId String
|
||||||
|
|||||||
@@ -29,9 +29,21 @@ async function main(): Promise<void> {
|
|||||||
maxRamMb: 2048,
|
maxRamMb: 2048,
|
||||||
maxCpuCores: 1,
|
maxCpuCores: 1,
|
||||||
maxStorageMb: 5120,
|
maxStorageMb: 5120,
|
||||||
|
maxServersPerUser: 3,
|
||||||
|
idleTimeoutMinutes: 10,
|
||||||
|
idleGraceAfterStartMinutes: 2,
|
||||||
|
idleCountdownSeconds: 60,
|
||||||
|
queuePriority: 0,
|
||||||
|
monthlyCreditGrant: 5000,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
maxServersPerUser: 3,
|
||||||
|
idleTimeoutMinutes: 10,
|
||||||
|
idleGraceAfterStartMinutes: 2,
|
||||||
|
idleCountdownSeconds: 60,
|
||||||
|
queuePriority: 0,
|
||||||
|
monthlyCreditGrant: 5000,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export type {
|
|||||||
GameNode,
|
GameNode,
|
||||||
GameNodeHeartbeat,
|
GameNodeHeartbeat,
|
||||||
ServerStartQueueEntry,
|
ServerStartQueueEntry,
|
||||||
|
CreditWallet,
|
||||||
|
CreditTransaction,
|
||||||
|
UsageRecord,
|
||||||
Plan,
|
Plan,
|
||||||
FeatureFlag,
|
FeatureFlag,
|
||||||
SystemSetting,
|
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':
|
'@hexahost/database':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/database
|
version: link:../../packages/database
|
||||||
|
'@hexahost/metering':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/metering
|
||||||
'@hexahost/scheduler':
|
'@hexahost/scheduler':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/scheduler
|
version: link:../../packages/scheduler
|
||||||
@@ -187,6 +190,9 @@ importers:
|
|||||||
'@hexahost/database':
|
'@hexahost/database':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/database
|
version: link:../../packages/database
|
||||||
|
'@hexahost/metering':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/metering
|
||||||
'@hexahost/scheduler':
|
'@hexahost/scheduler':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/scheduler
|
version: link:../../packages/scheduler
|
||||||
@@ -321,6 +327,21 @@ importers:
|
|||||||
|
|
||||||
packages/eslint-config: {}
|
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:
|
packages/scheduler:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@hexahost/typescript-config':
|
'@hexahost/typescript-config':
|
||||||
|
|||||||
Reference in New Issue
Block a user