Files
corrosion-admin-panel/frontend/src/stores/instances.ts
Vantz Stockwell f60e6abd33
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 44s
CI / integration (push) Successful in 21s
feat(server): config file editor — read/edit/save a host config file per instance
The Server page's config-honesty note now leads somewhere real: a
Configuration file panel that loads a config file from the instance
(prefilled with the game's primaryConfigFile hint — server.cfg,
ServerSettings.ini, GameXishu.json), edits it in a mono textarea, and
saves it straight to the host through the jailed agent file bridge.
Not-found is handled gracefully (empty editor to create). Works across
games; gameProfiles gains primaryConfigFile per game.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:07:59 -04:00

141 lines
4.8 KiB
TypeScript

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 and apply the agent's
* reply state OPTIMISTICALLY. The reply is authoritative for the action just
* taken; the fleet DB only catches up on the next heartbeat (~10s), so an
* immediate refetch would read a stale state and clobber the result.
* 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 })
applyReplyState(id, res)
return res
} finally {
acting.value = null
}
}
/** Update an instance's state/uptime from a lifecycle/status reply. */
function applyReplyState(id: string, res: Record<string, unknown>): void {
if ((res as { status?: string }).status !== 'success') return
const stateObj = (res as { state?: { state?: string } }).state
const newState = stateObj?.state
const inst = instances.value.find((i) => i.id === id)
if (inst && typeof newState === 'string') {
inst.state = newState
const up = (res as { uptime_seconds?: number }).uptime_seconds
inst.uptime_seconds = typeof up === 'number' ? up : newState === 'running' ? inst.uptime_seconds : 0
}
}
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 })
}
/** Read a config/text file from the current instance (jailed to its root). */
async function readFile(path: string): Promise<string> {
const id = currentId.value
if (!id) throw new Error('No instance selected')
const res = await api.get<{ content?: string }>(
`/instances/${id}/file?path=${encodeURIComponent(path)}`,
)
return res?.content ?? ''
}
/** Write a config/text file to the current instance. */
async function writeFile(path: string, content: string): Promise<void> {
const id = currentId.value
if (!id) throw new Error('No instance selected')
await api.put(`/instances/${id}/file`, { path, content })
}
return {
instances,
currentId,
current,
loading,
acting,
error,
fetchInstances,
select,
lifecycle,
rcon,
readFile,
writeFile,
}
})