Self-service host removal. DELETE /api/fleet/hosts/:id (server.manage, tenant-guarded): refuses while the host is 'connected' (409 — a live agent re-registers on its next heartbeat, stop it first), deletes the host's game_instances explicitly (FK is SET NULL, would otherwise orphan them; instance_stats cascade), and clears the legacy server_connections row if it was the license's last host. Fleet view: offline host cards get a Remove button with inline confirm + toast. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
171 lines
5.6 KiB
TypeScript
171 lines
5.6 KiB
TypeScript
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<AgentHost>,
|
|
@InjectRepository(GameInstance)
|
|
private readonly instanceRepo: Repository<GameInstance>,
|
|
@InjectRepository(ServerConnection)
|
|
private readonly connectionRepo: Repository<ServerConnection>,
|
|
) {}
|
|
|
|
/**
|
|
* 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<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,
|
|
},
|
|
};
|
|
}
|
|
}
|