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">
|
<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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user