feat(fleet): Phase B — fleet overview UI + GET /api/fleet read endpoint

Tenant-scoped fleet read: GET /api/fleet returns agent_hosts (host
metrics) each with their game_instances, plus a summary
(host/instance/online counts). FleetView lists host cards (status, CPU/
mem/disk/uptime/last-heartbeat) with their instances (game, state badge,
uptime); honest empty state -> Server page when no hosts. New 'Fleet'
sidebar nav item across all four game profiles, /fleet route. Store
follows the no-throw-on-fetch pattern (error state, never bricks). The
marketing hero made real from the live fleet tables.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-06-11 12:32:55 -04:00
parent 4a4ae7a5d4
commit 7a07d600e7
8 changed files with 734 additions and 3 deletions

View File

@@ -45,6 +45,7 @@ import { BetterChatModule } from './modules/betterchat/betterchat.module';
import { TimedExecuteModule } from './modules/timedexecute/timedexecute.module';
import { RaidableBasesModule } from './modules/raidablebases/raidablebases.module';
import { EarlyAccessModule } from './modules/early-access/early-access.module';
import { FleetModule } from './modules/fleet/fleet.module';
// Shared Services
import { NatsService } from './services/nats.service';
@@ -133,6 +134,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
TimedExecuteModule,
RaidableBasesModule,
EarlyAccessModule,
FleetModule,
],
providers: [
// Global guards (order matters: auth first, then license, then permissions)

View File

@@ -0,0 +1,19 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { FleetService } from './fleet.service';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
@ApiTags('fleet')
@ApiBearerAuth()
@Controller('fleet')
export class FleetController {
constructor(private readonly fleetService: FleetService) {}
@Get()
@RequirePermission('server.view')
@ApiOperation({ summary: 'Get fleet overview — hosts and game instances for this license' })
async getFleet(@CurrentTenant() licenseId: string) {
return this.fleetService.getFleet(licenseId);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FleetController } from './fleet.controller';
import { FleetService } from './fleet.service';
import { AgentHost } from '../../entities/agent-host.entity';
import { GameInstance } from '../../entities/game-instance.entity';
@Module({
imports: [TypeOrmModule.forFeature([AgentHost, GameInstance])],
controllers: [FleetController],
providers: [FleetService],
exports: [FleetService],
})
export class FleetModule {}

View File

@@ -0,0 +1,134 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AgentHost } from '../../entities/agent-host.entity';
import { GameInstance } from '../../entities/game-instance.entity';
export interface FleetInstanceDto {
id: string;
agent_instance_id: string;
game: string;
label: string | null;
state: string;
uptime_seconds: number;
last_seen_at: string | null;
}
export interface FleetHostDto {
id: string;
hostname: string;
status: string;
agent_version: string | null;
os: string | null;
arch: string | null;
cpu_percent: number | null;
cpu_cores: number | null;
mem_total_mb: number | null;
mem_used_mb: number | null;
uptime_seconds: number | null;
disks: AgentHost['disks'];
last_heartbeat_at: string | null;
instances: FleetInstanceDto[];
}
export interface FleetSummaryDto {
host_count: number;
instance_count: number;
online_host_count: number;
}
export interface FleetResponseDto {
hosts: FleetHostDto[];
summary: FleetSummaryDto;
}
@Injectable()
export class FleetService {
constructor(
@InjectRepository(AgentHost)
private readonly hostRepo: Repository<AgentHost>,
@InjectRepository(GameInstance)
private readonly instanceRepo: Repository<GameInstance>,
) {}
async getFleet(licenseId: string): Promise<FleetResponseDto> {
const [hosts, instances] = await Promise.all([
this.hostRepo.find({
where: { license_id: licenseId },
order: { hostname: 'ASC' },
}),
this.instanceRepo.find({
where: { license_id: licenseId },
order: { game: 'ASC', label: 'ASC' },
}),
]);
// Group instances by host_id. Bigint columns come back as strings from pg — coerce.
const instancesByHost = new Map<string | null, FleetInstanceDto[]>();
for (const inst of instances) {
const key = inst.host_id ?? null;
if (!instancesByHost.has(key)) {
instancesByHost.set(key, []);
}
instancesByHost.get(key)!.push({
id: inst.id,
agent_instance_id: inst.agent_instance_id,
game: inst.game,
label: inst.label,
state: inst.state,
uptime_seconds: Number(inst.uptime_seconds),
last_seen_at: inst.last_seen_at ? inst.last_seen_at.toISOString() : null,
});
}
const hostDtos: FleetHostDto[] = hosts.map((h) => ({
id: h.id,
hostname: h.hostname,
status: h.status,
agent_version: h.agent_version,
os: h.os,
arch: h.arch,
cpu_percent: h.cpu_percent !== null && h.cpu_percent !== undefined ? Number(h.cpu_percent) : null,
cpu_cores: h.cpu_cores !== null && h.cpu_cores !== undefined ? Number(h.cpu_cores) : null,
mem_total_mb: h.mem_total_mb !== null && h.mem_total_mb !== undefined ? Number(h.mem_total_mb) : null,
mem_used_mb: h.mem_used_mb !== null && h.mem_used_mb !== undefined ? Number(h.mem_used_mb) : null,
uptime_seconds: h.uptime_seconds !== null && h.uptime_seconds !== undefined ? Number(h.uptime_seconds) : null,
disks: h.disks,
last_heartbeat_at: h.last_heartbeat_at ? h.last_heartbeat_at.toISOString() : null,
instances: instancesByHost.get(h.id) ?? [],
}));
// Append synthetic "unassigned" bucket only if orphaned instances exist
const unassigned = instancesByHost.get(null) ?? [];
if (unassigned.length > 0) {
hostDtos.push({
id: '__unassigned__',
hostname: 'Unassigned',
status: 'offline',
agent_version: null,
os: null,
arch: null,
cpu_percent: null,
cpu_cores: null,
mem_total_mb: null,
mem_used_mb: null,
uptime_seconds: null,
disks: null,
last_heartbeat_at: null,
instances: unassigned,
});
}
const online_host_count = hosts.filter((h) => h.status === 'connected').length;
const instance_count = instances.length;
return {
hosts: hostDtos,
summary: {
host_count: hosts.length,
instance_count,
online_host_count,
},
};
}
}