feat(server): instance-centric controls — real per-instance state + lifecycle
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:
106
frontend/src/stores/instances.ts
Normal file
106
frontend/src/stores/instances.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user