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>
This commit is contained in:
@@ -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] },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user