Files
Module-Rust/client/src/routes/admin/ModConfig.jsx
wtclaude 654a24585d
Some checks failed
PR Checks / server-tests (pull_request) Failing after 16s
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / frozen-manifest (pull_request) Failing after 37s
fix(rust): protocol 13 — a configuration save that settles after its reload (F9, D179)
The plugin now answers a save once the files are written, with pending and
a writeId, and reports the reload later as a config.outcome event. The save
is recorded as reloading with a settle_by of two plugin ceilings plus slack
on the database's clock; ingest settles the row by (server, writeId), only
while it is still reloading, so a replay moves nothing and a late outcome
still lands. A row past settle_by reads as lost.

GET /admin/rust/config/:serverId/writes/:writeId serves the poll; the page
polls it every two seconds, holds the Save button while it waits, and says
whether a rolled-back plugin came back on its old file. config.outcome is
a staff kind: it carries the server's log tail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 17:16:45 -05:00

642 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── Admin · Rust · Mod configuration ──────────────────────────────────────
//
// R18. An admin picks a server, a plugin and a file, changes something, and the
// plugin reloads. This module's second admin page, and the first that writes to
// somebody's filesystem.
//
// **What is on the screen is decided by what is dangerous about the action.**
// Four things are true here that are not true anywhere else in this module, and
// each of them is a piece of the page rather than a line in a doc:
//
// • a save can take a required plugin DOWN. So the reload target is a
// deliberate choice with the folder name as a guess, the result is reported
// as its own panel, and a rollback shows the server's own log line.
// • the form cannot express everything a config holds. A `null`, an empty
// array and anything past the depth limit are marked and sent to the raw
// tier rather than half-drawn.
// • three keys in the bridge's own config would cut the link carrying the
// edit, or split the server's history. They render read-only, with the
// reason (D38).
// • configs hold API keys and Discord webhooks. Those fields render masked
// with a reveal, which is about the shoulder rather than the wire: an admin
// can already read the file over SSH (D37), and the audit trail never
// records the values either way.
import { useCallback, useEffect, useState } from 'react'
import { ErrorState, Loading, useAsync } from '../../core.js'
import { ago } from '../../lib/format.js'
import api from '../../api.js'
function Card({ title, subtitle, children, actions }) {
return (
<section className="panel" style={{ padding: '16px 18px', marginBottom: 18 }}>
<header style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 12 }}>
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>
{title}
</h2>
{subtitle && (
<span className="sans dim" style={{ fontSize: '0.76rem' }}>
{subtitle}
</span>
)}
<span style={{ flex: 1 }} />
{actions}
</header>
{children}
</section>
)
}
function Warn({ children, tone = '#d08a2a' }) {
return (
<p className="sans" style={{ color: tone, fontSize: '0.78rem', margin: '6px 0 0' }}>
{children}
</p>
)
}
/** A value the form can edit: one row, typed by what the file already holds. */
function Field({ field, value, onChange, revealed, onReveal }) {
const indent = 12 * Math.max(0, field.depth - 1)
const label = (
<label
className="sans"
style={{
flex: '0 0 300px',
paddingLeft: indent,
color: field.locked ? 'var(--ink)' : 'var(--head)',
fontSize: '0.84rem',
wordBreak: 'break-word',
}}
title={field.path}
>
{field.key}
{field.locked && (
<span className="dim" style={{ fontSize: '0.72rem' }}>
{' '}
· read-only
</span>
)}
</label>
)
if (field.type === 'object' || field.type === 'array') {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 0 2px' }}>
<span
className="sans"
style={{ paddingLeft: indent, color: 'var(--head)', fontSize: '0.86rem', fontWeight: 500 }}
>
{field.key || '(the file)'}
</span>
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
{field.type === 'array' ? `${field.count} entries` : `${field.count} settings`}
{field.advanced && field.reason ? ` · ${field.reason}` : ''}
</span>
</div>
)
}
if (field.advanced) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
{label}
<span className="sans dim" style={{ fontSize: '0.78rem' }}>
{field.reason} — edit it in Raw JSON
</span>
</div>
)
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
{label}
{field.type === 'boolean' ? (
<input
type="checkbox"
checked={Boolean(value)}
disabled={field.locked}
onChange={(event) => onChange(field, event.target.checked)}
/>
) : (
<input
className="input"
style={{ flex: 1, minWidth: 0 }}
type={field.secret && !revealed ? 'password' : 'text'}
value={value === undefined || value === null ? '' : String(value)}
disabled={field.locked}
onChange={(event) => onChange(field, event.target.value)}
/>
)}
{field.secret && !field.locked && (
<button type="button" className="btn btn-ghost" onClick={() => onReveal(field.path)}>
{revealed ? 'Hide' : 'Show'}
</button>
)}
</div>
)
}
/** How often a save that is still reloading asks how it went. */
const POLL_MS = 2000
/**
* A settled write row as the report panel reads it.
*
* The row is this module's audit record, not the plugin's frame, so it carries
* no per-file `rewritten` — the file is re-read after either way, which is what
* the panel's note about rewriting was for.
*/
function reportFromWrite(write) {
return {
pending: write.outcome === 'reloading',
lost: write.outcome === 'lost',
reload: write.reloadTarget,
reloaded: Boolean(write.reloaded),
rolledBack: write.outcome === 'rolled-back',
restored: write.restored,
reason: write.detail,
log: write.log,
files: [],
}
}
/** The sentence at the top of the panel. */
function headline(report) {
if (report.pending) {
return `Saved. Reloading ${report.reload || 'the plugin'}… a first compile after a quiet spell can take a while.`
}
if (report.lost) {
return 'The server never said how the reload went — the bridge may have been reloaded, or the link dropped. Re-read the file to see what is on disk.'
}
if (report.rolledBack) {
return report.restored === false
? 'The plugin did not come back, so the old file was put back — and it did not come back on the old file either. It is down: check the server console.'
: 'The plugin did not come back, so the old file was put back automatically.'
}
return report.reloaded ? 'Saved, and the plugin reloaded.' : `Saved. ${report.reason || 'Nothing was reloaded.'}`
}
/** What the game said happened. The rollback case is the one worth reading. */
function Report({ report }) {
if (!report) return null
const tone = report.rolledBack || report.lost ? '#e05a5a' : 'var(--ink)'
return (
<div style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 10, marginTop: 10 }}>
<p className="sans" style={{ color: tone, fontSize: '0.84rem', margin: 0 }}>
{headline(report)}
</p>
{report.rolledBack && report.reason && (
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '4px 0 0' }}>
{report.reason}
</p>
)}
{report.log && (
<pre
className="sans"
style={{
background: 'var(--line-soft)',
padding: 10,
marginTop: 8,
fontSize: '0.74rem',
maxHeight: 200,
overflow: 'auto',
whiteSpace: 'pre-wrap',
}}
>
{report.log}
</pre>
)}
{report.files.some((f) => f.rewritten) && (
<Warn>
The plugin rewrote the file as it loaded — both frameworks add any settings a config is
missing and save it back, so what is on disk now is not byte-for-byte what was sent.
</Warn>
)}
</div>
)
}
export default function ModConfig() {
const [serverId, setServerId] = useState('')
const [path, setPath] = useState('')
const [tier, setTier] = useState('form')
const [edits, setEdits] = useState({})
const [raw, setRaw] = useState('')
const [reload, setReload] = useState('')
const [revealed, setRevealed] = useState({})
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [report, setReport] = useState(null)
const [fileNonce, setFileNonce] = useState(0)
// The write a save left reloading, polled until it settles (protocol 13, D179).
const [watching, setWatching] = useState(null)
const { data: servers, error: serverError } = useAsync(() => api.admin.listServers(), [])
// The tree is asked for per server and never cached across one: what is on a
// host's disk has no stale answer worth showing, and a plugin loaded a minute
// ago has to be able to appear.
const { data: tree, error: treeError } = useAsync(
() => (serverId ? api.adminConfig.files(serverId) : Promise.resolve(null)),
[serverId],
)
const { data: file, error: fileError } = useAsync(
() => (serverId && path ? api.adminConfig.file(serverId, path) : Promise.resolve(null)),
[serverId, path, fileNonce],
)
const reset = useCallback(() => {
setEdits({})
setRevealed({})
setError('')
}, [])
// A freshly opened file starts from what the host holds: the raw editor's text
// and the reload target's guess both come from the answer rather than from
// whatever the previous file left behind.
//
// **The guess is only taken when the dropdown actually offers it.** A `<select>`
// whose value matches no `<option>` displays the first one, so a guess of
// `RunicGateway` — which is deliberately not offered, because the bridge cannot
// reload itself — put "nothing — just write the file" on the screen while the
// request carried `reload: RunicGateway`, and every save of our own config was
// refused for a reason the page had just said did not apply.
useEffect(() => {
if (!file) return
setRaw(file.text)
const offered = (tree ? tree.loaded : []).some(
(p) => p.name === file.plugin && p.name !== (tree && tree.self),
)
setReload(offered ? file.plugin : '')
reset()
}, [file, tree, reset])
useEffect(() => {
setPath('')
setReport(null)
setWatching(null)
}, [serverId])
// A save that reloads a plugin comes back before the reload has finished.
// Ask how it went every couple of seconds until the row leaves `reloading` —
// the server calls it `lost` once twice the plugin's ceiling has passed, so
// this always ends — then re-read the file, because a reload rewrites it and a
// rollback puts the old one back.
useEffect(() => {
if (!watching) return undefined
let stopped = false
let handle = null
const poll = async () => {
try {
const { write } = await api.adminConfig.write(watching.serverId, watching.id)
if (stopped) return
setReport(reportFromWrite(write))
if (write.outcome !== 'reloading') {
setWatching(null)
setFileNonce((n) => n + 1)
return
}
} catch {
// One failed poll is a blip, not an answer; the next one asks again.
if (stopped) return
}
handle = setTimeout(poll, POLL_MS)
}
handle = setTimeout(poll, POLL_MS)
return () => {
stopped = true
clearTimeout(handle)
}
}, [watching])
if (serverError) return <ErrorState error={serverError} />
if (!servers) return <Loading />
const rows = servers.servers || servers || []
const change = (field, value) => setEdits((current) => ({ ...current, [field.path]: { field, value } }))
const save = async () => {
setBusy(true)
setError('')
setReport(null)
try {
const body =
tier === 'form'
? {
path,
version: file.version,
...(reload ? { reload } : {}),
// A number goes up as the TEXT that was typed. `2.50` stays
// `2.50` and `1.0` stays `1.0`; turning either into a JavaScript
// number here is precisely the bug the server half exists to
// avoid, and it would be reintroduced in the browser.
edits: Object.values(edits).map(({ field, value }) =>
field.type === 'number'
? { pointer: field.pointer, raw: String(value) }
: { pointer: field.pointer, value },
),
}
: { path, version: file.version, ...(reload ? { reload } : {}), text: raw }
const answer = await api.adminConfig.save(serverId, body)
if (answer.pending && answer.write && answer.write.id) {
setReport({ ...(answer.report || {}), pending: true, reload: reload || null, files: [] })
setWatching({ serverId, id: answer.write.id })
} else {
setReport(answer.report || null)
}
if (!answer.changed) setError('Nothing changed, so nothing was written.')
// Re-read either way: a successful reload usually rewrites the file with
// the defaults it was missing, and a rollback means what is on disk is no
// longer what is on the screen.
setFileNonce((n) => n + 1)
} catch (err) {
setError(err.message || 'That save did not work.')
} finally {
setBusy(false)
}
}
const pending = Object.keys(edits).length
return (
<div style={{ maxWidth: 980 }}>
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
These are the configuration files on the game host itself, read live through the bridge. A
save backs the file up, writes it, reloads the plugin you name, and <strong>puts the old
file back automatically</strong> if the plugin does not come back. The game’s data
directory — kit cooldowns, zone definitions, the permission store — is not settings and is
never listed here.
</p>
<Card title="Server" subtitle={`${rows.length} configured`}>
<select className="input" value={serverId} onChange={(event) => setServerId(event.target.value)}>
<option value="">Choose a server…</option>
{rows.map((row) => (
<option key={row.id} value={row.id}>
{row.name || row.id}
</option>
))}
</select>
{tree && tree.root && (
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
{tree.root}
{tree.truncated ? ' · the walk stopped at its limit, so this is not the whole tree' : ''}
</p>
)}
</Card>
{serverId && treeError && <ErrorState error={treeError} />}
{serverId && !treeError && !tree && <Loading />}
{tree && (
<Card title="Files" subtitle="grouped by the plugin each one probably belongs to">
{tree.plugins.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>
This server reports no configuration files.
</p>
)}
{tree.plugins.map((group) => (
<div key={group.plugin} style={{ padding: '8px 0', borderTop: '1px solid var(--line-soft)' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
<strong className="sans" style={{ fontSize: '0.88rem', fontWeight: 500 }}>
{group.title || group.plugin}
</strong>
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
{group.loaded ? `loaded · ${group.version}` : 'not loaded'}
{group.isBridge ? ' · this bridge' : ''}
</span>
</div>
{group.files.map((entry) => (
<div
key={entry.path}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '4px 0 4px 12px' }}
>
<button
type="button"
className={entry.path === path ? 'btn btn-primary' : 'btn btn-ghost'}
disabled={!entry.editable}
onClick={() => {
setPath(entry.path)
setReport(null)
setTier('form')
}}
>
{entry.path}
</button>
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
{Math.round(entry.bytes / 102.4) / 10} KB
{entry.modified ? ` · changed ${ago(entry.modified)}` : ''}
{entry.reason ? ` · ${entry.reason}` : ''}
</span>
</div>
))}
{!group.loaded && (
<Warn>
Nothing on this server is loaded under that name, so a save here is written and
not reloaded. It applies the next time the plugin loads.
</Warn>
)}
</div>
))}
</Card>
)}
{path && fileError && <ErrorState error={fileError} />}
{path && !fileError && !file && <Loading />}
{file && (
<Card
title={file.path}
subtitle={tier === 'form' ? `${pending} unsaved` : 'raw JSON'}
actions={
<>
<button
type="button"
className={tier === 'form' ? 'btn btn-primary' : 'btn btn-ghost'}
onClick={() => setTier('form')}
>
Settings
</button>
<button
type="button"
className={tier === 'raw' ? 'btn btn-primary' : 'btn btn-ghost'}
onClick={() => setTier('raw')}
>
Raw JSON
</button>
</>
}
>
{file.parseError && (
<Warn tone="#e05a5a">
This file is not valid JSON on the server ({file.parseError}), so there is nothing to
draw a form from. Raw JSON is the tier that can fix it.
</Warn>
)}
{file.isBridge && (
<Warn>
This is the bridge’s own configuration. Its address, port and server id are read-only
here — changing any of them from the website would cut the link carrying the change,
or strand every row this site holds for this server. They are editable on the host
itself. This plugin also cannot be reloaded from here.
</Warn>
)}
{tier === 'form' && file.fields && (
<div style={{ marginTop: 6 }}>
{file.fields
.filter((field) => field.path !== '')
.map((field) => (
<Field
key={field.path}
field={field}
value={
edits[field.path]
? edits[field.path].value
: field.type === 'number'
? field.raw
: field.value
}
onChange={change}
revealed={Boolean(revealed[field.path])}
onReveal={(p) => setRevealed((current) => ({ ...current, [p]: !current[p] }))}
/>
))}
</div>
)}
{tier === 'raw' && (
<textarea
className="input"
spellCheck={false}
value={raw}
onChange={(event) => setRaw(event.target.value)}
style={{ width: '100%', minHeight: 360, fontFamily: 'monospace', fontSize: '0.8rem' }}
/>
)}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
marginTop: 12,
borderTop: '1px solid var(--line-soft)',
paddingTop: 12,
}}
>
<label className="sans dim" style={{ fontSize: '0.78rem' }}>
Reload
</label>
{/* A guess, and it says so. The folder a config sits in is convention
rather than contract, so reloading it silently is how the wrong
plugin gets reloaded, reports success, and the edited one never
re-reads anything. */}
<select className="input" value={reload} onChange={(event) => setReload(event.target.value)}>
<option value="">nothing — just write the file</option>
{(tree ? tree.loaded : [])
.filter((p) => p.name !== tree.self)
.map((p) => (
<option key={p.name} value={p.name}>
{p.name}
</option>
))}
</select>
<span style={{ flex: 1 }} />
<button
type="button"
className="btn btn-primary"
disabled={busy || Boolean(watching) || (tier === 'form' && pending === 0) || (tier === 'raw' && raw === file.text)}
onClick={save}
>
{busy ? 'Saving…' : watching ? 'Reloading…' : 'Save and reload'}
</button>
</div>
{/* Beside the button, not at the top of the page. A save is made at the
bottom of a long form, and a refusal rendered above the fold is a
click that visibly did nothing. */}
{error && (
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.82rem', margin: '8px 0 0' }}>
{error}
</p>
)}
<Report report={report} />
</Card>
)}
{serverId && <History serverId={serverId} nonce={fileNonce} />}
</div>
)
}
/** Who changed what, including the saves that were refused or undone. */
function History({ serverId, nonce }) {
const { data } = useAsync(() => api.adminConfig.writes(serverId), [serverId, nonce])
if (!data || !data.writes || data.writes.length === 0) return null
return (
<Card title="Recent changes" subtitle="every save, including the ones that did not land">
{data.writes.map((row) => (
<div
key={row.id}
className="sans"
style={{ padding: '8px 0', borderTop: '1px solid var(--line-soft)', fontSize: '0.82rem' }}
>
<div style={{ display: 'flex', gap: 8, alignItems: 'baseline' }}>
<strong style={{ fontWeight: 500 }}>{row.path}</strong>
<span
className="sans"
style={{ fontSize: '0.74rem', color: row.outcome === 'applied' ? 'var(--ink)' : '#d08a2a' }}
>
{row.outcome}
{row.reloaded ? ' · reloaded' : ''}
</span>
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
{ago(row.createdAt)}
{row.tier === 'raw' ? ' · raw' : ''}
</span>
</div>
{(row.changes || []).map((change, index) => (
<div key={`${row.id}-${index}`} className="dim" style={{ fontSize: '0.74rem' }}>
{change.path}
{change.from !== null && change.to !== null ? `: ${change.from} → ${change.to}` : ''}
</div>
))}
{row.detail && (
<div className="dim" style={{ fontSize: '0.74rem' }}>
{row.detail}
</div>
)}
</div>
))}
</Card>
)
}