// ── 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 (

{title}

{subtitle && ( {subtitle} )} {actions}
{children}
) } function Warn({ children, tone = '#d08a2a' }) { return (

{children}

) } /** 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 = ( ) if (field.type === 'object' || field.type === 'array') { return (
{field.key || '(the file)'} {field.type === 'array' ? `${field.count} entries` : `${field.count} settings`} {field.advanced && field.reason ? ` · ${field.reason}` : ''}
) } if (field.advanced) { return (
{label} {field.reason} — edit it in Raw JSON
) } return (
{label} {field.type === 'boolean' ? ( onChange(field, event.target.checked)} /> ) : ( onChange(field, event.target.value)} /> )} {field.secret && !field.locked && ( )}
) } /** 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 (

{headline(report)}

{report.rolledBack && report.reason && (

{report.reason}

)} {report.log && (
          {report.log}
        
)} {report.files.some((f) => f.rewritten) && ( 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. )}
) } 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 ` setServerId(event.target.value)}> {rows.map((row) => ( ))} {tree && tree.root && (

{tree.root} {tree.truncated ? ' · the walk stopped at its limit, so this is not the whole tree' : ''}

)} {serverId && treeError && } {serverId && !treeError && !tree && } {tree && ( {tree.plugins.length === 0 && (

This server reports no configuration files.

)} {tree.plugins.map((group) => (
{group.title || group.plugin} {group.loaded ? `loaded · ${group.version}` : 'not loaded'} {group.isBridge ? ' · this bridge' : ''}
{group.files.map((entry) => (
{Math.round(entry.bytes / 102.4) / 10} KB {entry.modified ? ` · changed ${ago(entry.modified)}` : ''} {entry.reason ? ` · ${entry.reason}` : ''}
))} {!group.loaded && ( 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. )}
))}
)} {path && fileError && } {path && !fileError && !file && } {file && ( } > {file.parseError && ( 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. )} {file.isBridge && ( 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. )} {tier === 'form' && file.fields && (
{file.fields .filter((field) => field.path !== '') .map((field) => ( setRevealed((current) => ({ ...current, [p]: !current[p] }))} /> ))}
)} {tier === 'raw' && (