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

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