feat(server): config file editor — read/edit/save a host config file per instance
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

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>
This commit is contained in:
Vantz Stockwell
2026-06-11 19:07:59 -04:00
parent 877fadcb6c
commit f60e6abd33
3 changed files with 129 additions and 0 deletions

View File

@@ -102,6 +102,12 @@ export interface GameProfile {
terminology: GameTerminology terminology: GameTerminology
/** Notable game-specific mechanics that affect server administration. */ /** Notable game-specific mechanics that affect server administration. */
special?: string[] special?: string[]
/**
* Primary editable config file, relative to the instance root — prefilled in
* the Server-page config editor as a hint (operator can change it). null if
* the game has no single primary config file.
*/
primaryConfigFile?: string | null
/** /**
* Stat field labels shown on server cards and the dashboard. * Stat field labels shown on server cards and the dashboard.
* First entry is always Players; subsequent entries are game-specific. * First entry is always Players; subsequent entries are game-specific.
@@ -185,6 +191,7 @@ export const GAME_PROFILES: Record<GameId, GameProfile> = {
group: 'Team', group: 'Team',
}, },
statFields: ['Players', 'uMod', 'Wipe'], statFields: ['Players', 'uMod', 'Wipe'],
primaryConfigFile: 'server/cfg/server.cfg',
nav: RUST_NAV, nav: RUST_NAV,
}, },
@@ -207,6 +214,7 @@ export const GAME_PROFILES: Record<GameId, GameProfile> = {
}, },
special: ['Clans', 'Thralls', 'Avatars', 'Purge', 'PvP windows'], special: ['Clans', 'Thralls', 'Avatars', 'Purge', 'PvP windows'],
statFields: ['Players', 'Clans', 'Purge'], statFields: ['Players', 'Clans', 'Purge'],
primaryConfigFile: 'ConanSandbox/Saved/Config/LinuxServer/ServerSettings.ini',
nav: [ nav: [
{ label: '', items: [NAV_DASHBOARD] }, { label: '', items: [NAV_DASHBOARD] },
{ {
@@ -252,6 +260,7 @@ export const GAME_PROFILES: Record<GameId, GameProfile> = {
}, },
special: ['Cluster', 'Tribes'], special: ['Cluster', 'Tribes'],
statFields: ['Players', 'Tribe', 'Mask'], statFields: ['Players', 'Tribe', 'Mask'],
primaryConfigFile: 'WS/Saved/GameplaySettings/GameXishu.json',
nav: [ nav: [
{ label: '', items: [NAV_DASHBOARD] }, { label: '', items: [NAV_DASHBOARD] },
{ {
@@ -294,6 +303,7 @@ export const GAME_PROFILES: Record<GameId, GameProfile> = {
}, },
special: ['Sietches', 'Deep Desert', 'Bases', 'Landsraad'], special: ['Sietches', 'Deep Desert', 'Bases', 'Landsraad'],
statFields: ['Players', 'Sietches', 'Control'], statFields: ['Players', 'Sietches', 'Control'],
primaryConfigFile: null,
nav: [ nav: [
{ label: '', items: [NAV_DASHBOARD] }, { label: '', items: [NAV_DASHBOARD] },
{ {

View File

@@ -106,6 +106,23 @@ export const useInstancesStore = defineStore('instances', () => {
return api.post<Record<string, unknown>>(`/instances/${id}/rcon`, { command }) 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 { return {
instances, instances,
currentId, currentId,
@@ -117,5 +134,7 @@ export const useInstancesStore = defineStore('instances', () => {
select, select,
lifecycle, lifecycle,
rcon, rcon,
readFile,
writeFile,
} }
}) })

View File

@@ -357,6 +357,51 @@ async function refreshInstanceStatus() {
} }
} }
// ---- Config file editor (reads/writes via the jailed agent file manager) ----
const cfgPath = ref('')
const cfgContent = ref('')
const cfgLoaded = ref(false)
const cfgLoading = ref(false)
const cfgSaving = ref(false)
const cfgError = ref<string | null>(null)
// A reasonable default config-file hint per game (operator can change it).
const cfgHint = computed(() => profile.value.primaryConfigFile ?? '')
async function loadConfigFile() {
const path = (cfgPath.value || cfgHint.value).trim()
if (!path || !currentInstance.value) return
cfgPath.value = path
cfgLoading.value = true
cfgError.value = null
try {
cfgContent.value = await instancesStore.readFile(path)
cfgLoaded.value = true
} catch (e) {
// Not-found is fine — present an empty editor to create it.
cfgContent.value = ''
cfgLoaded.value = true
cfgError.value = e instanceof Error ? e.message : 'File not found — saving will create it'
} finally {
cfgLoading.value = false
}
}
async function saveConfigFile() {
const path = cfgPath.value.trim()
if (!path || !currentInstance.value) return
cfgSaving.value = true
try {
await instancesStore.writeFile(path, cfgContent.value)
cfgError.value = null
toast.success(`Saved ${path}`)
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to save file')
} finally {
cfgSaving.value = false
}
}
async function toggleAutomation(field: 'crash_recovery_enabled' | 'auto_update_on_force_wipe' | 'force_wipe_eligible') { async function toggleAutomation(field: 'crash_recovery_enabled' | 'auto_update_on_force_wipe' | 'force_wipe_eligible') {
if (!server.config) return if (!server.config) return
const newValue = !server.config[field] const newValue = !server.config[field]
@@ -1040,6 +1085,44 @@ onMounted(async () => {
</form> </form>
</Panel> </Panel>
<!-- Config file editor — reads/writes via the jailed agent file manager -->
<Panel v-if="currentInstance" title="Configuration file" subtitle="Edit a config file directly on the host (jailed to the instance)">
<div class="sv__cfg-row sv__mb-sm">
<Input
v-model="cfgPath"
:placeholder="cfgHint || 'path/relative/to/instance/root'"
class="sv__cfg-path"
:mono="true"
/>
<Button
variant="secondary"
icon="folder-open"
:loading="cfgLoading"
:disabled="(!cfgPath && !cfgHint) || cfgLoading"
@click="loadConfigFile"
>Load</Button>
<Button
v-if="cfgLoaded"
icon="check"
:loading="cfgSaving"
:disabled="!cfgPath || cfgSaving"
@click="saveConfigFile"
>Save</Button>
</div>
<Alert v-if="cfgError" tone="info" class="sv__mb-sm">{{ cfgError }}</Alert>
<textarea
v-if="cfgLoaded"
v-model="cfgContent"
class="sv__cfg-editor"
spellcheck="false"
rows="16"
></textarea>
<p v-else class="sv__cfg-hint">
Load <code>{{ cfgHint || 'a config file' }}</code> to view and edit it. Changes are written
straight to the host through the agent — jailed to this instance's directory.
</p>
</Panel>
<!-- Automation --> <!-- Automation -->
<Panel title="Automation"> <Panel title="Automation">
<div class="sv__toggles"> <div class="sv__toggles">
@@ -1127,6 +1210,23 @@ onMounted(async () => {
.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__mt-sm { margin-top: 12px; }
.sv__instance-pick { display: flex; align-items: center; gap: 12px; } .sv__instance-pick { display: flex; align-items: center; gap: 12px; }
.sv__cfg-row { display: flex; gap: 10px; align-items: center; }
.sv__cfg-path { flex: 1; }
.sv__cfg-editor {
width: 100%;
min-height: 320px;
background: var(--surface-base);
color: var(--text-primary);
border: none;
box-shadow: var(--ring-subtle);
border-radius: var(--radius-md);
padding: 14px 16px;
font-family: var(--font-mono);
font-size: var(--text-xs);
line-height: 1.6;
resize: vertical;
}
.sv__cfg-hint { margin: 0; font-size: var(--text-sm); color: var(--text-tertiary); line-height: 1.55; }
.sv__select { .sv__select {
background: var(--surface-base); background: var(--surface-base);
color: var(--text-primary); color: var(--text-primary);