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"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useServerStore } from '@/stores/server' import { useServerStore } from '@/stores/server'
import { useInstancesStore } from '@/stores/instances'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import { useThemeGame } from '@/composables/useThemeGame' import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles' 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' import Tabs from '@/components/ds/navigation/Tabs.vue'
const server = useServerStore() const server = useServerStore()
const instancesStore = useInstancesStore()
const toast = useToastStore() const toast = useToastStore()
const { activeGame } = useThemeGame() 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). // Profile follows the GameSwitcher. 'all' defaults to rust (neutral house skin).
const profile = computed(() => { const profile = computed(() => {
const game = activeGame.value === 'all' ? 'rust' : activeGame.value const game = activeGame.value === 'all' ? 'rust' : activeGame.value
@@ -296,15 +326,32 @@ async function saveConfig() {
} }
async function serverAction(action: 'start' | 'stop' | 'restart') { 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 actionLoading.value = action
try { try {
if (action === 'start') await server.startServer() const res = await instancesStore.lifecycle(action)
else if (action === 'stop') await server.stopServer() if ((res as { status?: string }).status === 'error') {
else await server.restartServer() toast.error(String((res as { message?: string }).message ?? `Failed to ${action}`))
await server.fetchServer() } else {
toast.success(`Server ${action} command sent`) 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 { } catch {
toast.error(`Failed to ${action} server`) /* status best-effort */
} finally { } finally {
actionLoading.value = null actionLoading.value = null
} }
@@ -354,6 +401,9 @@ const connStatusTone = computed<'online' | 'offline' | 'warn'>(() => {
onMounted(async () => { onMounted(async () => {
await server.fetchServer() await server.fetchServer()
loadFormFromConfig() 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) // Fetch agent credentials for the TOML config block (leave null on error — honest fallback)
try { try {
@@ -426,31 +476,93 @@ onMounted(async () => {
</div> </div>
</Panel> </Panel>
<!-- Controls --> <!-- Game instance real per-instance state + lifecycle -->
<Panel title="Controls"> <Panel title="Game instance">
<div class="sv__controls"> <template #actions>
<Button <Badge :tone="instanceStateTone" :dot="true" :pulse="instanceRunning">{{ instanceStateLabel }}</Badge>
variant="outline" </template>
icon="play"
:loading="actionLoading === 'start'" <!-- No instance yet -->
:disabled="server.connection?.connection_status === 'connected' || actionLoading !== null" <EmptyState
@click="serverAction('start')" v-if="!currentInstance"
>Start server</Button> icon="server"
<Button title="No game instance connected"
variant="danger-soft" :description="'Install the host agent and add a ' + profile.label + ' instance to its config to manage it here.'"
icon="power" />
:loading="actionLoading === 'stop'"
:disabled="server.connection?.connection_status !== 'connected' || actionLoading !== null" <template v-else>
@click="serverAction('stop')" <!-- Instance selector when more than one -->
>Stop server</Button> <div v-if="instancesStore.instances.length > 1" class="sv__instance-pick sv__mb">
<Button <span class="sv__field-label">Instance</span>
variant="secondary" <select
icon="refresh-cw" class="sv__select"
:loading="actionLoading === 'restart'" :value="instancesStore.currentId ?? ''"
:disabled="server.connection?.connection_status !== 'connected' || actionLoading !== null" @change="instancesStore.select(($event.target as HTMLSelectElement).value)"
@click="serverAction('restart')" >
>Restart server</Button> <option v-for="i in instancesStore.instances" :key="i.id" :value="i.id">
</div> {{ 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> </Panel>
<!-- Host agent --> <!-- Host agent -->
@@ -1013,6 +1125,19 @@ onMounted(async () => {
/* Controls */ /* Controls */
.sv__controls { display: flex; flex-wrap: wrap; gap: 10px; } .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) */ /* Section head (label inside panel body) */
.sv__section-head { .sv__section-head {