feat(rust): mod configuration from the site, and an editor that will not rewrite a float
R18's two tiers: a form generated from a config file's own values, and raw JSON for what a form cannot express. Admin → Rust mod config, one live round trip per action, nothing cached between a browser and a game host's disk. `configEdit.js` is the part that could not be done naively. JavaScript cannot tell `1` from `1.0`, and both mod frameworks deserialize a config into typed C# classes — so a read-modify-write silently rewrites every whole-numbered float as an integer on fields nobody touched, and a plugin that then throws at load does not come back. It never parses, mutates and re-serialises: it records the SOURCE SPAN of every value and splices literals into them, so an untouched `1.0` is still `1.0` and a number an admin types travels as text the whole way (D35/D36). The bridge's own config is editable with `Host`, `Port` and `ServerId` locked, in the form and in the raw tier, because either would cut the link carrying the edit or strand every row this site holds (D38). Credentials render masked with a reveal; the raw tier shows them (D37) and the audit trail never does. `rust_config_writes` records every save including the refused and the rolled back — an operator asking why a setting is not what they set needs to see that somebody tried. Three defects a browser walk found that 179 green tests did not: * every save of the bridge's own config was refused while the page said the opposite — a `<select>` whose value matches no `<option>` shows the first one, so the reload guess `RunicGateway` was on the wire and "nothing" was on the screen; * `btn ghost` is not a class this platform defines (`.btn-ghost` is), so every secondary button in this module has rendered as a primary one since phase 7 — here it made the open file and the active tier indistinguishable; * a save's refusal rendered at the top of a long form, far from the button. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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: <ServerDetail /> },
|
||||
],
|
||||
player: [{ path: '', element: <Account /> }],
|
||||
admin: [{ path: '', element: <Permissions /> }],
|
||||
admin: [
|
||||
{ path: '', element: <Permissions /> },
|
||||
// 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: <ModConfig /> },
|
||||
],
|
||||
})
|
||||
|
||||
// ── 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 ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -62,4 +62,25 @@ export const IconKey = () => (
|
||||
</Icon>
|
||||
)
|
||||
|
||||
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 = () => (
|
||||
<Icon>
|
||||
<path d="M4 6h10" />
|
||||
<path d="M18 6h2" />
|
||||
<circle cx="16" cy="6" r="2" />
|
||||
<path d="M4 12h4" />
|
||||
<path d="M12 12h8" />
|
||||
<circle cx="10" cy="12" r="2" />
|
||||
<path d="M4 18h10" />
|
||||
<path d="M18 18h2" />
|
||||
<circle cx="16" cy="18" r="2" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export default { IconLink, IconKey, IconSliders }
|
||||
|
||||
554
client/src/routes/admin/ModConfig.jsx
Normal file
554
client/src/routes/admin/ModConfig.jsx
Normal file
@@ -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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 10, marginTop: 10 }}>
|
||||
<p className="sans" style={{ color: tone, fontSize: '0.84rem', margin: 0 }}>
|
||||
{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.'}`}
|
||||
</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)
|
||||
|
||||
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)
|
||||
}, [serverId])
|
||||
|
||||
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)
|
||||
|
||||
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 || (tier === 'form' && pending === 0) || (tier === 'raw' && raw === file.text)}
|
||||
onClick={save}
|
||||
>
|
||||
{busy ? 'Saving…' : '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>
|
||||
)
|
||||
}
|
||||
@@ -114,7 +114,7 @@ function ServerState({ row, onSync, busy }) {
|
||||
{row.lastOkAt ? `last pushed ${ago(row.lastOkAt)}` : 'never pushed'}
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button type="button" className="btn ghost" onClick={() => onSync(row.serverId)} disabled={busy}>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => onSync(row.serverId)} disabled={busy}>
|
||||
{busy ? 'Syncing…' : 'Sync now'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -158,10 +158,10 @@ function DriftRow({ row, onAdopt, onRevoke, busy }) {
|
||||
{row.serverId} · seen {ago(row.firstSeen)}
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" className="btn ghost" onClick={() => onAdopt(row)} disabled={busy}>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => onAdopt(row)} disabled={busy}>
|
||||
Adopt
|
||||
</button>
|
||||
<button type="button" className="btn ghost" onClick={() => onRevoke(row)} disabled={busy}>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => onRevoke(row)} disabled={busy}>
|
||||
Revoke
|
||||
</button>
|
||||
</Row>
|
||||
@@ -224,7 +224,7 @@ function GroupCard({ group, catalogue, servers, pending, onChanged, setError })
|
||||
actions={
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
className="btn btn-ghost"
|
||||
disabled={busy}
|
||||
onClick={() => act(() => api.adminPermissions.deleteGroup(group.name))}
|
||||
>
|
||||
@@ -248,7 +248,7 @@ function GroupCard({ group, catalogue, servers, pending, onChanged, setError })
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
className="btn btn-ghost"
|
||||
disabled={busy}
|
||||
onClick={() => save(group.permissions.filter((p) => p !== perm))}
|
||||
>
|
||||
@@ -317,7 +317,7 @@ function GroupCard({ group, catalogue, servers, pending, onChanged, setError })
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
className="btn btn-ghost"
|
||||
disabled={busy}
|
||||
onClick={() => act(() => api.adminPermissions.removeMember(group.name, m.userId))}
|
||||
>
|
||||
@@ -415,7 +415,7 @@ export default function Permissions() {
|
||||
title="Servers"
|
||||
subtitle={`${servers.length} configured`}
|
||||
actions={
|
||||
<button type="button" className="btn ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.sync())}>
|
||||
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.sync())}>
|
||||
Sync all
|
||||
</button>
|
||||
}
|
||||
@@ -490,7 +490,7 @@ export default function Permissions() {
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
className="btn btn-ghost"
|
||||
disabled={busy}
|
||||
onClick={() => act(() => api.adminPermissions.revoke(row.id))}
|
||||
>
|
||||
|
||||
@@ -83,7 +83,7 @@ function LinkPanel({ userId, link, onRemoved }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="btn ghost" onClick={unlink} disabled={busy} style={{ flex: 'none' }}>
|
||||
<button type="button" className="btn btn-ghost" onClick={unlink} disabled={busy} style={{ flex: 'none' }}>
|
||||
{busy ? 'Unlinking…' : 'Unlink'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -180,7 +180,7 @@ function PermissionsPanel({ userId, data, onChanged }) {
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
className="btn btn-ghost"
|
||||
disabled={busy}
|
||||
onClick={() => act(() => api.adminUserPermissions.revoke(userId, row.id))}
|
||||
style={{ flex: 'none' }}
|
||||
|
||||
@@ -122,7 +122,7 @@ function LinkRow({ link, onRemoved }) {
|
||||
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '6px 0 0' }}>{error}</p>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="btn ghost" onClick={remove} disabled={busy} style={{ flex: 'none' }}>
|
||||
<button type="button" className="btn btn-ghost" onClick={remove} disabled={busy} style={{ flex: 'none' }}>
|
||||
{busy ? 'Unlinking…' : 'Unlink'}
|
||||
</button>
|
||||
</li>
|
||||
|
||||
Reference in New Issue
Block a user