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

@@ -0,0 +1,106 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useApi } from '@/composables/useApi'
import type { FleetData, FleetInstance } from '@/stores/fleet'
/** A game instance enriched with its host context, flattened from the fleet. */
export interface ManagedInstance extends FleetInstance {
host_id: string
host_hostname: string
host_status: string
}
type LifecycleAction = 'start' | 'stop' | 'restart' | 'status' | 'steam_update'
/**
* Instance management — the Server page operates on a selected game instance
* (not the legacy single-server connection). Reads the fleet to enumerate
* instances and drives the per-instance command bridge
* (POST /api/instances/:id/lifecycle | /rcon).
*/
export const useInstancesStore = defineStore('instances', () => {
const instances = ref<ManagedInstance[]>([])
const currentId = ref<string | null>(null)
const loading = ref(false)
const acting = ref<LifecycleAction | null>(null)
const error = ref<string | null>(null)
const api = useApi()
const current = computed<ManagedInstance | null>(
() => instances.value.find((i) => i.id === currentId.value) ?? null,
)
/** Fetch the fleet and flatten its instances. Optionally prefer a game. */
async function fetchInstances(preferGame?: string): Promise<void> {
loading.value = true
error.value = null
try {
const data = await api.get<FleetData>('/fleet')
const flat: ManagedInstance[] = []
for (const host of data.hosts) {
for (const inst of host.instances) {
flat.push({
...inst,
host_id: host.id,
host_hostname: host.hostname,
host_status: host.status,
})
}
}
instances.value = flat
// Keep the current selection if it still exists; else prefer the active
// game, else the first instance.
if (!flat.some((i) => i.id === currentId.value)) {
const preferred = preferGame ? flat.find((i) => i.game === preferGame) : undefined
currentId.value = (preferred ?? flat[0])?.id ?? null
}
} catch (e) {
console.error('Failed to fetch instances:', e)
error.value = e instanceof Error ? e.message : 'Failed to load instances'
} finally {
loading.value = false
}
}
function select(id: string): void {
currentId.value = id
}
/**
* Send a lifecycle command to the current instance. Returns the agent's
* reply (which carries the new state); refetches so the list reflects it.
* Throws on failure so the view can toast.
*/
async function lifecycle(action: LifecycleAction): Promise<Record<string, unknown>> {
const id = currentId.value
if (!id) throw new Error('No instance selected')
acting.value = action
try {
const res = await api.post<Record<string, unknown>>(`/instances/${id}/lifecycle`, { action })
await fetchInstances()
return res
} finally {
acting.value = null
}
}
async function rcon(command: string): Promise<Record<string, unknown>> {
const id = currentId.value
if (!id) throw new Error('No instance selected')
return api.post<Record<string, unknown>>(`/instances/${id}/rcon`, { command })
}
return {
instances,
currentId,
current,
loading,
acting,
error,
fetchInstances,
select,
lifecycle,
rcon,
}
})

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">
<!-- 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="server.connection?.connection_status === 'connected' || actionLoading !== null"
:disabled="instanceRunning || !instanceManaged || actionLoading !== null"
@click="serverAction('start')"
>Start server</Button>
>Start</Button>
<Button
variant="danger-soft"
icon="power"
:loading="actionLoading === 'stop'"
:disabled="server.connection?.connection_status !== 'connected' || actionLoading !== null"
:disabled="!instanceRunning || actionLoading !== null"
@click="serverAction('stop')"
>Stop server</Button>
>Stop</Button>
<Button
variant="secondary"
icon="refresh-cw"
:loading="actionLoading === 'restart'"
:disabled="server.connection?.connection_status !== 'connected' || actionLoading !== null"
:disabled="!instanceManaged || actionLoading !== null"
@click="serverAction('restart')"
>Restart server</Button>
>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 {