feat(server): instance-centric controls — real per-instance state + lifecycle
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 55s
CI / integration (push) Successful in 22s

The Server page now manages the selected GAME INSTANCE, not the legacy
host connection. New instances store flattens the fleet and drives the
command bridge. New 'Game instance' panel: real state badge
(running/stopped/crashed/configured), uptime, host, and an instance
selector when >1. Start/Stop/Restart/Refresh wired to POST
/api/instances/:id/lifecycle — gated on the actual instance state (not
host connectivity), with telemetry-only instances flagged. Works across
all four games (state + lifecycle are game-agnostic).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-06-11 18:37:53 -04:00
parent 06e832fca1
commit c0b20f2f78
2 changed files with 262 additions and 31 deletions

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useServerStore } from '@/stores/server'
import { useInstancesStore } from '@/stores/instances'
import { useToastStore } from '@/stores/toast'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
@@ -19,9 +20,38 @@ import Switch from '@/components/ds/forms/Switch.vue'
import Tabs from '@/components/ds/navigation/Tabs.vue'
const server = useServerStore()
const instancesStore = useInstancesStore()
const toast = useToastStore()
const { activeGame } = useThemeGame()
// ---- Current game instance (the thing this page actually manages) ----
const currentInstance = computed(() => instancesStore.current)
const instanceState = computed(() => currentInstance.value?.state ?? null)
const instanceRunning = computed(() => instanceState.value === 'running')
const instanceManaged = computed(() =>
!!instanceState.value && !['unmanaged', 'configured', 'missing_root'].includes(instanceState.value),
)
const instanceStateTone = computed<'online' | 'offline' | 'warn'>(() => {
const s = instanceState.value
if (s === 'running') return 'online'
if (s === 'crashed') return 'warn'
return 'offline'
})
const instanceStateLabel = computed(() => {
const s = instanceState.value
if (!s) return 'No instance'
return s.charAt(0).toUpperCase() + s.slice(1).replace('_', ' ')
})
function fmtUptime(secs: number | undefined): string {
if (!secs || secs <= 0) return '—'
const d = Math.floor(secs / 86400)
const h = Math.floor((secs % 86400) / 3600)
const m = Math.floor((secs % 3600) / 60)
if (d > 0) return `${d}d ${h}h`
if (h > 0) return `${h}h ${m}m`
return `${m}m`
}
// Profile follows the GameSwitcher. 'all' defaults to rust (neutral house skin).
const profile = computed(() => {
const game = activeGame.value === 'all' ? 'rust' : activeGame.value
@@ -296,15 +326,32 @@ async function saveConfig() {
}
async function serverAction(action: 'start' | 'stop' | 'restart') {
if (!currentInstance.value) {
toast.error('No game instance to control — connect the host agent first')
return
}
actionLoading.value = action
try {
if (action === 'start') await server.startServer()
else if (action === 'stop') await server.stopServer()
else await server.restartServer()
await server.fetchServer()
toast.success(`Server ${action} command sent`)
const res = await instancesStore.lifecycle(action)
if ((res as { status?: string }).status === 'error') {
toast.error(String((res as { message?: string }).message ?? `Failed to ${action}`))
} else {
toast.success(`${currentInstance.value?.agent_instance_id ?? 'Instance'}: ${action} ok`)
}
} catch (e) {
toast.error(e instanceof Error ? e.message : `Failed to ${action} server`)
} finally {
actionLoading.value = null
}
}
async function refreshInstanceStatus() {
if (!currentInstance.value) return
actionLoading.value = 'status'
try {
await instancesStore.lifecycle('status')
} catch {
toast.error(`Failed to ${action} server`)
/* status best-effort */
} finally {
actionLoading.value = null
}
@@ -354,6 +401,9 @@ const connStatusTone = computed<'online' | 'offline' | 'warn'>(() => {
onMounted(async () => {
await server.fetchServer()
loadFormFromConfig()
// Load the fleet's instances; prefer one matching the active game.
const game = activeGame.value === 'all' ? undefined : activeGame.value
await instancesStore.fetchInstances(game)
// Fetch agent credentials for the TOML config block (leave null on error — honest fallback)
try {
@@ -426,31 +476,93 @@ onMounted(async () => {
</div>
</Panel>
<!-- Controls -->
<Panel title="Controls">
<div class="sv__controls">
<Button
variant="outline"
icon="play"
:loading="actionLoading === 'start'"
:disabled="server.connection?.connection_status === 'connected' || actionLoading !== null"
@click="serverAction('start')"
>Start server</Button>
<Button
variant="danger-soft"
icon="power"
:loading="actionLoading === 'stop'"
:disabled="server.connection?.connection_status !== 'connected' || actionLoading !== null"
@click="serverAction('stop')"
>Stop server</Button>
<Button
variant="secondary"
icon="refresh-cw"
:loading="actionLoading === 'restart'"
:disabled="server.connection?.connection_status !== 'connected' || actionLoading !== null"
@click="serverAction('restart')"
>Restart server</Button>
</div>
<!-- Game instance real per-instance state + lifecycle -->
<Panel title="Game instance">
<template #actions>
<Badge :tone="instanceStateTone" :dot="true" :pulse="instanceRunning">{{ instanceStateLabel }}</Badge>
</template>
<!-- No instance yet -->
<EmptyState
v-if="!currentInstance"
icon="server"
title="No game instance connected"
:description="'Install the host agent and add a ' + profile.label + ' instance to its config to manage it here.'"
/>
<template v-else>
<!-- Instance selector when more than one -->
<div v-if="instancesStore.instances.length > 1" class="sv__instance-pick sv__mb">
<span class="sv__field-label">Instance</span>
<select
class="sv__select"
:value="instancesStore.currentId ?? ''"
@change="instancesStore.select(($event.target as HTMLSelectElement).value)"
>
<option v-for="i in instancesStore.instances" :key="i.id" :value="i.id">
{{ i.label || i.agent_instance_id }} ({{ i.game }}) · {{ i.host_hostname }}
</option>
</select>
</div>
<!-- Instance facts -->
<div class="sv__grid4 sv__mb">
<div class="sv__field">
<div class="sv__field-label">Instance</div>
<div class="sv__field-val sv__field-val--mono">{{ currentInstance.agent_instance_id }}</div>
</div>
<div class="sv__field">
<div class="sv__field-label">State</div>
<div class="sv__field-val sv__field-val--inline">
<StatusDot :tone="instanceStateTone" :pulse="instanceRunning" />
<span>{{ instanceStateLabel }}</span>
</div>
</div>
<div class="sv__field">
<div class="sv__field-label">Uptime</div>
<div class="sv__field-val sv__field-val--mono">{{ fmtUptime(currentInstance.uptime_seconds) }}</div>
</div>
<div class="sv__field">
<div class="sv__field-label">Host</div>
<div class="sv__field-val sv__field-val--mono">{{ currentInstance.host_hostname }}</div>
</div>
</div>
<!-- Lifecycle controls gated on real instance state -->
<div class="sv__controls">
<Button
variant="outline"
icon="play"
:loading="actionLoading === 'start'"
:disabled="instanceRunning || !instanceManaged || actionLoading !== null"
@click="serverAction('start')"
>Start</Button>
<Button
variant="danger-soft"
icon="power"
:loading="actionLoading === 'stop'"
:disabled="!instanceRunning || actionLoading !== null"
@click="serverAction('stop')"
>Stop</Button>
<Button
variant="secondary"
icon="refresh-cw"
:loading="actionLoading === 'restart'"
:disabled="!instanceManaged || actionLoading !== null"
@click="serverAction('restart')"
>Restart</Button>
<Button
variant="ghost"
icon="refresh-cw"
:loading="actionLoading === 'status'"
:disabled="actionLoading !== null"
@click="refreshInstanceStatus"
>Refresh</Button>
</div>
<Alert v-if="!instanceManaged" tone="info" class="sv__mt-sm">
This instance is telemetry-only add an <code>executable</code> to its agent config to enable start/stop.
</Alert>
</template>
</Panel>
<!-- Host agent -->
@@ -1013,6 +1125,19 @@ onMounted(async () => {
/* Controls */
.sv__controls { display: flex; flex-wrap: wrap; gap: 10px; }
.sv__mt-sm { margin-top: 12px; }
.sv__instance-pick { display: flex; align-items: center; gap: 12px; }
.sv__select {
background: var(--surface-base);
color: var(--text-primary);
border: none;
box-shadow: var(--ring-subtle);
border-radius: var(--radius-md);
padding: 8px 12px;
font-size: var(--text-sm);
font-family: var(--font-mono);
min-width: 280px;
}
/* Section head (label inside panel body) */
.sv__section-head {