import { Injectable, NotFoundException, ConflictException } 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'; import { ServerConnection } from '../../entities/server-connection.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, @InjectRepository(GameInstance) private readonly instanceRepo: Repository, @InjectRepository(ServerConnection) private readonly connectionRepo: Repository, ) {} /** * Remove a host and its game instances from the fleet. * * Refuses while the host is `connected` — a live agent re-registers on its * next heartbeat, so the operator must stop the agent first. Deletes the * host's instances explicitly (the FK is SET NULL, which would otherwise * orphan them); instance_stats cascade. If this was the license's last host, * the legacy single-server connection row is cleared too so the old * Dashboard doesn't show a stale server. */ async deleteHost( licenseId: string, hostId: string, ): Promise<{ deleted: true; instances_removed: number }> { const host = await this.hostRepo.findOne({ where: { id: hostId, license_id: licenseId } }); if (!host) throw new NotFoundException('Host not found'); if (host.status === 'connected') { throw new ConflictException( 'Host is online — stop the agent first, or it will re-register on its next heartbeat', ); } const del = await this.instanceRepo.delete({ license_id: licenseId, host_id: hostId }); await this.hostRepo.delete({ id: hostId, license_id: licenseId }); const remaining = await this.hostRepo.count({ where: { license_id: licenseId } }); if (remaining === 0) { await this.connectionRepo.delete({ license_id: licenseId }); } return { deleted: true, instances_removed: del.affected ?? 0 }; } async getFleet(licenseId: string): Promise { 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(); 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, }, }; } }