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>
141 lines
4.8 KiB
TypeScript
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,
|
|
}
|
|
})
|