diff --git a/ci/bundle.json b/ci/bundle.json index 6ecfb44..b0b374d 100644 --- a/ci/bundle.json +++ b/ci/bundle.json @@ -29,6 +29,7 @@ "server": [ "boot.js", "catalogue.js", + "configEdit.js", "core.js", "db", "index.js", diff --git a/client/src/api.js b/client/src/api.js index 5947fe7..5ec6d44 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -147,6 +147,34 @@ export const adminPermissions = { req('/admin/rust/permissions/sync', { method: 'POST', body: serverId ? { serverId } : {} }), } +// ── admin · mod configuration (R18) ─────────────────────────────────────── +// +// Every call here is a LIVE round trip to a game host, which makes this the only +// section of this file where a call can be slow, or fail because a server is +// off. Nothing is cached anywhere between the browser and the host's disk: a +// cached config is an edit an operator made over SSH that this website then +// silently overwrote. +// +// `save` carries a `version` the host issued with the file. Send a stale one and +// the answer is a 409 with the current file attached, rather than an overwrite +// of whatever somebody else changed in the meantime. +export const adminConfig = { + files: (serverId) => req(`/admin/rust/config/${encodeURIComponent(serverId)}/files`), + + file: (serverId, path) => + req(`/admin/rust/config/${encodeURIComponent(serverId)}/file${query({ path })}`), + + // Two tiers, one route. `edits` is the generated form — pointers and literals, + // type-preserving — and `text` is the raw document. A number travels as TEXT + // in both: `1.0` parsed into a JavaScript number and sent back as `1` is the + // whole failure this feature was designed around. + save: (serverId, body) => + req(`/admin/rust/config/${encodeURIComponent(serverId)}/file`, { method: 'POST', body }), + + writes: (serverId, limit = null) => + req(`/admin/rust/config/${encodeURIComponent(serverId)}/writes${query({ limit })}`), +} + // ── the admin.users.detail extension slot ───────────────────────────────── // // The client half of R13's first slot. Core hands the component a `userId` and @@ -189,6 +217,7 @@ export default { playerLinks, admin, adminPermissions, + adminConfig, adminUserLinks, adminUserPermissions, BASE, diff --git a/client/src/entry.jsx b/client/src/entry.jsx index 2a1c57e..81dd701 100644 --- a/client/src/entry.jsx +++ b/client/src/entry.jsx @@ -22,9 +22,10 @@ import Servers from './routes/public/Servers.jsx' import ServerDetail from './routes/public/ServerDetail.jsx' import Account from './routes/player/Account.jsx' import Permissions from './routes/admin/Permissions.jsx' +import ModConfig from './routes/admin/ModConfig.jsx' import UserRustSections from './routes/admin/UserRustSections.jsx' import FooterStatus from './components/FooterStatus.jsx' -import { IconKey, IconLink } from './icons.jsx' +import { IconKey, IconLink, IconSliders } from './icons.jsx' // The module id, exactly as `module.json` spells it. Core keys the registry by it // and prefixes every route path with it. @@ -82,7 +83,18 @@ registry.registerRoutes(ID, { { path: 'servers/:id', element: }, ], player: [{ path: '', element: }], - admin: [{ path: '', element: }], + admin: [ + { path: '', element: }, + // Phase 7b (R18). A second admin page rather than a tab on the first: the + // permission mirror decides who may do what inside the game, and this edits + // the game host's own files. They are neighbours, not halves of one screen, + // and the nav says so with two rows. + // + // A static segment under the module's namespace, so it lands at + // `/admin/rust/config` and core's admin gate applies to it exactly as it + // does to the page above. + { path: 'config', element: }, + ], }) // ── Nav ─────────────────────────────────────────────────────────────────── @@ -127,7 +139,10 @@ registry.registerNav(ID, { // every sidebar row, and the one without is the only text in a column of glyphs. registry.registerNav(ID, { area: 'admin', - items: [{ label: 'Rust permissions', to: '/admin/rust', icon: IconKey }], + items: [ + { label: 'Rust permissions', to: '/admin/rust', icon: IconKey }, + { label: 'Rust mod config', to: '/admin/rust/config', icon: IconSliders }, + ], }) // ── Extension slots ─────────────────────────────────────────────────────── diff --git a/client/src/icons.jsx b/client/src/icons.jsx index 4019cd2..f506bca 100644 --- a/client/src/icons.jsx +++ b/client/src/icons.jsx @@ -62,4 +62,25 @@ export const IconKey = () => ( ) -export default { IconLink, IconKey } +/** + * Sliders — the admin sidebar's row for the mod-configuration editor. + * + * Not a gear: core's account row is a gear, and two gears in one sidebar say + * "settings" twice without saying whose. Sliders read as values being tuned, + * which is exactly what that page does to somebody else's game host. + */ +export const IconSliders = () => ( + + + + + + + + + + + +) + +export default { IconLink, IconKey, IconSliders } diff --git a/client/src/routes/admin/ModConfig.jsx b/client/src/routes/admin/ModConfig.jsx new file mode 100644 index 0000000..a30af7f --- /dev/null +++ b/client/src/routes/admin/ModConfig.jsx @@ -0,0 +1,554 @@ +// ── 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 && ( + + )} +
+ ) +} + +/** What the game said happened. The rollback case is the one worth reading. */ +function Report({ report }) { + if (!report) return null + + const tone = report.rolledBack ? '#e05a5a' : 'var(--ink)' + + return ( +
+

+ {report.rolledBack + ? 'The plugin did not come back, so the old file was put back automatically.' + : report.reloaded + ? 'Saved, and the plugin reloaded.' + : `Saved. ${report.reason || 'Nothing was reloaded.'}`} +

+ {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) + + 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' && ( +