feat(fleet): remove host — DELETE /api/fleet/hosts/:id + Fleet card action
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 36s
CI / integration (push) Successful in 21s

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>
This commit is contained in:
Vantz Stockwell
2026-06-11 18:21:04 -04:00
parent 009ceb86ad
commit 06e832fca1
5 changed files with 98 additions and 4 deletions

View File

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

View File

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

View File

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

View File

@@ -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<void> {
await api.del(`/fleet/hosts/${hostId}`)
await fetchFleet()
}
return {
hosts,
summary,
loading,
error,
fetchFleet,
removeHost,
}
})

View File

@@ -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<string | null>(null)
const removingHostId = ref<string | null>(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 {
<span class="fleet-host__meta-item" v-if="host.os || host.arch">
<Icon name="cpu" :size="12" />{{ [host.os, host.arch].filter(Boolean).join(' / ') }}
</span>
<!-- Remove host offline only; a live agent re-registers -->
<template v-if="confirmHostId === host.id">
<span class="fleet-host__confirm">Remove host &amp; its instances?</span>
<Button
variant="danger-soft"
size="sm"
:loading="removingHostId === host.id"
@click="removeHost(host)"
>Remove</Button>
<Button variant="ghost" size="sm" :disabled="removingHostId === host.id" @click="confirmHostId = null">Cancel</Button>
</template>
<Button
v-else-if="host.status !== 'connected'"
variant="ghost"
size="sm"
icon="trash-2"
@click="confirmHostId = host.id"
>Remove</Button>
</div>
</div>