Phase7
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 9s
CI / Go — node-agent tests (push) Failing after 8s
CI / Go — edge-gateway build (push) Successful in 14s

This commit is contained in:
smueller
2026-06-26 13:20:55 +02:00
parent 49a98ee31d
commit ab21f53cdd
86 changed files with 1207 additions and 63 deletions

View File

@@ -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",

View File

@@ -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,

View File

@@ -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,

View File

@@ -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({

View 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);
}
}

View 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 {}

View 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;
}
}

View File

@@ -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(),

View 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 {}

View 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);
}
}

View File

@@ -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);
}
}

View File

@@ -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),

View File

@@ -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(),
};

View 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'));
});
});

View File

@@ -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",

View File

@@ -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",

View File

@@ -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>

View File

@@ -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}

View File

@@ -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",

View 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`);
}

View File

@@ -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 {

View File

@@ -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",

View 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 },
});
});
}

View File

@@ -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