diff --git a/backend-nest/src/modules/fleet/fleet.controller.ts b/backend-nest/src/modules/fleet/fleet.controller.ts index 784491d..33bca19 100644 --- a/backend-nest/src/modules/fleet/fleet.controller.ts +++ b/backend-nest/src/modules/fleet/fleet.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, Delete, Param } from '@nestjs/common'; import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; import { FleetService } from './fleet.service'; import { CurrentTenant } from '../../common/decorators/current-tenant.decorator'; @@ -16,4 +16,11 @@ export class FleetController { async getFleet(@CurrentTenant() licenseId: string) { return this.fleetService.getFleet(licenseId); } + + @Delete('hosts/:id') + @RequirePermission('server.manage') + @ApiOperation({ summary: 'Remove a host and its instances (host must be offline)' }) + async deleteHost(@CurrentTenant() licenseId: string, @Param('id') id: string) { + return this.fleetService.deleteHost(licenseId, id); + } } diff --git a/backend-nest/src/modules/fleet/fleet.module.ts b/backend-nest/src/modules/fleet/fleet.module.ts index 24e690b..77c34d3 100644 --- a/backend-nest/src/modules/fleet/fleet.module.ts +++ b/backend-nest/src/modules/fleet/fleet.module.ts @@ -4,9 +4,10 @@ import { FleetController } from './fleet.controller'; import { FleetService } from './fleet.service'; import { AgentHost } from '../../entities/agent-host.entity'; import { GameInstance } from '../../entities/game-instance.entity'; +import { ServerConnection } from '../../entities/server-connection.entity'; @Module({ - imports: [TypeOrmModule.forFeature([AgentHost, GameInstance])], + imports: [TypeOrmModule.forFeature([AgentHost, GameInstance, ServerConnection])], controllers: [FleetController], providers: [FleetService], exports: [FleetService], diff --git a/backend-nest/src/modules/fleet/fleet.service.ts b/backend-nest/src/modules/fleet/fleet.service.ts index 913b9a2..de606e2 100644 --- a/backend-nest/src/modules/fleet/fleet.service.ts +++ b/backend-nest/src/modules/fleet/fleet.service.ts @@ -1,8 +1,9 @@ -import { Injectable } from '@nestjs/common'; +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; @@ -49,8 +50,43 @@ export class FleetService { 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({ diff --git a/frontend/src/stores/fleet.ts b/frontend/src/stores/fleet.ts index 68b29e4..3acc2f0 100644 --- a/frontend/src/stores/fleet.ts +++ b/frontend/src/stores/fleet.ts @@ -77,11 +77,22 @@ export const useFleetStore = defineStore('fleet', () => { } } + /** + * Remove a host and its instances. Throws on failure (e.g. 409 when the host + * is still online) so the caller can surface the message; refetches on + * success. + */ + async function removeHost(hostId: string): Promise { + await api.del(`/fleet/hosts/${hostId}`) + await fetchFleet() + } + return { hosts, summary, loading, error, fetchFleet, + removeHost, } }) diff --git a/frontend/src/views/admin/FleetView.vue b/frontend/src/views/admin/FleetView.vue index 05f0383..5296896 100644 --- a/frontend/src/views/admin/FleetView.vue +++ b/frontend/src/views/admin/FleetView.vue @@ -12,9 +12,10 @@ * * No fabricated data. All nulls render as '—' via safeFixed/safeDate. */ -import { onMounted, computed } from 'vue' +import { onMounted, computed, ref } from 'vue' import { useRouter } from 'vue-router' import { useFleetStore } from '@/stores/fleet' +import { useToastStore } from '@/stores/toast' import type { FleetHost } from '@/stores/fleet' import { safeFixed, safeDate } from '@/utils/formatters' import Panel from '@/components/ds/data/Panel.vue' @@ -30,6 +31,7 @@ import Icon from '@/components/ds/core/Icon.vue' // --------------------------------------------------------------------------- const fleet = useFleetStore() const router = useRouter() +const toast = useToastStore() onMounted(() => { fleet.fetchFleet() @@ -40,6 +42,25 @@ onMounted(() => { // --------------------------------------------------------------------------- const hasHosts = computed(() => fleet.hosts.length > 0) +// --------------------------------------------------------------------------- +// Remove host (offline only — a live agent re-registers) +// --------------------------------------------------------------------------- +const confirmHostId = ref(null) +const removingHostId = ref(null) + +async function removeHost(host: FleetHost) { + removingHostId.value = host.id + try { + await fleet.removeHost(host.id) + toast.success(`Removed ${host.hostname}`) + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Failed to remove host') + } finally { + removingHostId.value = null + confirmHostId.value = null + } +} + /** Map host status → Badge tone */ function hostTone(status: string): 'online' | 'offline' | 'warn' { if (status === 'connected') return 'online' @@ -184,6 +205,24 @@ function relativeHeartbeat(iso: string | null): string { {{ [host.os, host.arch].filter(Boolean).join(' / ') }} + + +