Files
Module-Rust/client/src/routes/admin/ServerSettings.jsx
wtclaude 7a0d0842ba
All checks were successful
PR Checks / client-build (pull_request) Successful in 54s
PR Checks / server-tests (pull_request) Successful in 1m0s
PR Checks / frozen-manifest (pull_request) Successful in 1m19s
feat(demo): the public demo's module-side locks and seed hook
website/DEMO_MODE.md §3.2, §3.4, §3.5, §7.3 and §7.5 (step 2 of §10).
Nothing changes off the demo; everything below sits behind ctx.demoMode.

- demoLocks in module.json: PUT and DELETE /admin/rust/servers/:id.
- The console-command deny list (DM11): quit, restart, oxide.unload,
  oxide.reload, rcon.*, fps.limit and server.tickrate, by first word,
  with global. stripped and o. read as oxide. Refused at save through
  core's demoRefuses (MODULE_API 1.19.0, DM18) and again at run, with
  retry: false, before the rig is asked. Teardown commands too.
- The config editor (DM14): the bridge's files are left out of the
  list and refused for reading, writing and in the write history, by
  name (canonicalised) and by the catalogue's self. Other plugins'
  credential fields are redacted in the text and the form and made
  read-only; raw-JSON saves are refused; a file that does not parse is
  withheld. The admin server list leaves out the sidecar URL.
- onDemoSeed (DM15): upserts the rig's server row with an encrypted
  token and demo-player's stand-in Steam link.
- The release's install manifest carries protocol, read from
  PROTOCOL_VERSION, for the wipe's bundle match (DM12).

coreApi is ^1.19.0; ci/core-ref.json pins website#224's head.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-26 03:40:30 -05:00

455 lines
21 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 · Servers (phase 16, D133) ───────────────────────────────
//
// The page the module did not have. Until phase 16 a server row was written only
// through `PUT /admin/rust/servers/:id` — D106 recorded it, and the README said
// "in Admin → Rust, add a server" about a page that did not exist. D130's wipe
// schedule needed somewhere to be typed, and the org lead chose to build the
// missing page rather than hang the schedule on the visibility page (D133).
//
// One form for adding and editing, because they are one `PUT`. Three rules it
// keeps from the API:
//
// 1. **The token is write-only.** The field is blank on every edit, a blank
// save leaves the stored one alone, and the row says whether one is stored.
// 2. **The protocol a row was configured against is kept.** The `PUT` defaults
// an omitted protocol to this build's, so an edit sends the stored value back
// rather than silently re-stamping the row.
// 3. **The wipe schedule is the operator's words, not the forecast.** The form
// shows what was stored; the row shows what it computes to, from the same
// answer the public pages read.
//
// Test and Delete act at once rather than on Save — they are questions put to a
// sidecar and a removal, not settings.
//
// Phase 17 added a server's chat titles and the announcement voice, in
// `ChatTitles.jsx`, each saved on its own.
import { useState } from 'react'
import { ErrorState, Loading, useAsync } from '../../core.js'
import api from '../../api.js'
import { ago, nextWipe } from '../../lib/format.js'
import { CategoryTitles, TitlesForm, VoiceCard, integrationsLine, titlesSummary } from './ChatTitles.jsx'
const RULES = [
{ id: 'none', label: 'No schedule', hint: 'Nothing is forecast, not even the monthly forced wipe.' },
{ id: 'forced', label: 'Forced wipe only', hint: 'The first Thursday of each month, 19:00 UK time — Facepunch forces it on every server.' },
{ id: 'weekly', label: 'Weekly', hint: 'Every week on the day and time below, and the forced wipe.' },
{ id: 'biweekly', label: 'Every other week', hint: 'Every second week, on the weeks the date below falls in, and the forced wipe.' },
]
// Who a downed patrol helicopter, Chinook or Bradley credits (protocol 14, D372).
// The same rule writes the feed line and the Rotorbane / Tankbreaker counts; the
// server's own list, mirrored here so the form needs no round trip.
const CREDIT_RULES = [
{ id: 'blow', label: 'Killing blow', hint: 'The player whose hit destroyed it — for the patrol helicopter, the crippling hit, else the last hit. The default.' },
{ id: 'last', label: 'Last hit', hint: 'The last player to hurt it.' },
{ id: 'most', label: 'Most damage', hint: 'The player who dealt it the most damage.' },
{ id: 'all', label: 'Everyone who hurt it', hint: 'Every player who damaged it, one each.' },
]
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
const SOURCE = { forced: 'the forced wipe', rule: 'its own schedule', once: 'the one-off date' }
/** The browser's own zone — the likeliest answer for an operator typing a time. */
const localZone = () => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
} catch {
return 'UTC'
}
}
/** Every zone the browser knows, for the datalist; an older browser gets none and a free field. */
const ZONES = (() => {
try {
return typeof Intl.supportedValuesOf === 'function' ? Intl.supportedValuesOf('timeZone') : []
} catch {
return []
}
})()
/** An ISO instant as the value a `datetime-local` input takes, in the browser's clock. */
/**
* Why the server's RunicNPC will not do (runicnpc stage 9, D310), or null when it
* does. RunicNPC is required, so a server without it is shown as incomplete.
*/
export function runicNpcNote(npc) {
if (!npc || npc.ready) return null
return `Incomplete: ${npc.absence || 'RunicNPC is not loaded on it'}. RunicNPC is required — without it every Place NPCs step is refused, Rust’s own scientists included. Runic Gateway’s installer and egg put it in place.`
}
/**
* What the missing ZoneManager helper costs this server (PLAN_FIXES F12, D182), or
* null when there is nothing to say — the helper patched, or a plugin that
* reported no ZoneManager state at all. Scoring is never what is lost: the bridge
* falls back to the zone's shape. ZoneManager's own flags are.
*/
export function zoneHelperNote(helper) {
if (!helper || helper.state === 'patched' || helper.state === 'no-zonemanager') return null
const cost =
'Event zones still score everybody inside, but ZoneManager’s own flags and messages miss a player who was already standing in a zone when it opened or came back after a restart.'
if (helper.state === 'missing') {
return `The ZoneManager helper (RunicGatewayZones.cs) is not installed. ${cost} Reinstall or update the plugin to put it back.`
}
return `The ZoneManager helper could not patch this ZoneManager${helper.reason ? ` (${helper.reason})` : ''}. ${cost}`
}
function toLocalInput(iso) {
if (!iso) return ''
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function blankForm() {
return {
isNew: true,
id: '',
name: '',
sidecarBaseUrl: '',
sidecarToken: '',
enabled: true,
sortOrder: 0,
protocol: undefined,
wipeRule: 'none',
wipeDay: 4,
wipeTime: '19:00',
wipeTz: localZone(),
wipeAnchor: '',
wipeOnce: '',
creditRule: 'blow',
}
}
function formFrom(server) {
const s = server.schedule || { rule: 'none' }
return {
isNew: false,
id: server.id,
name: server.name,
sidecarBaseUrl: server.sidecarBaseUrl || '',
sidecarToken: '',
enabled: server.enabled,
sortOrder: server.sortOrder,
protocol: server.protocol,
wipeRule: s.rule || 'none',
wipeDay: s.day == null ? 4 : s.day,
wipeTime: s.time || '19:00',
wipeTz: s.tz || localZone(),
wipeAnchor: s.anchor || '',
wipeOnce: toLocalInput(s.onceAt),
storedOnceAt: s.onceAt || null,
creditRule: server.creditRule || 'blow',
}
}
/** The PUT body. The token only when one was typed; the schedule always. */
function bodyFrom(form) {
const weekly = form.wipeRule === 'weekly' || form.wipeRule === 'biweekly'
const onceUnchanged = form.storedOnceAt && form.wipeOnce === toLocalInput(form.storedOnceAt)
const body = {
name: form.name.trim(),
sidecarBaseUrl: form.sidecarBaseUrl.trim(),
enabled: form.enabled,
sortOrder: Number(form.sortOrder) || 0,
wipeRule: form.wipeRule,
wipeDay: weekly ? Number(form.wipeDay) : null,
wipeTime: weekly ? form.wipeTime : null,
wipeTz: weekly ? form.wipeTz.trim() : null,
wipeAnchor: form.wipeRule === 'biweekly' ? form.wipeAnchor : null,
// A past one-off date that was not touched is dropped rather than sent back:
// the server refuses a past date on save (it says nothing about the next
// wipe), and an operator editing the name must not be stopped by it.
wipeOnceAt: !form.wipeOnce || (onceUnchanged && Date.parse(form.storedOnceAt) <= Date.now())
? null
: new Date(form.wipeOnce).toISOString(),
}
if (form.creditRule) body.creditRule = form.creditRule
if (form.sidecarToken) body.sidecarToken = form.sidecarToken
if (form.protocol !== undefined) body.protocol = form.protocol
return body
}
export default function ServerSettings() {
const [reloads, setReloads] = useState(0)
const { data, error: loadError } = useAsync(() => api.admin.listServers(), [reloads])
const [form, setForm] = useState(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [notes, setNotes] = useState({})
const [confirming, setConfirming] = useState(null)
const [titling, setTitling] = useState(null)
if (loadError) return <ErrorState error={loadError} />
if (!data) return <Loading />
const servers = data.servers || []
const categories = data.titleCategories || []
const set = (key) => (e) => {
const value = e && e.target ? (e.target.type === 'checkbox' ? e.target.checked : e.target.value) : e
setForm((f) => ({ ...f, [key]: value }))
}
const save = async (e) => {
e.preventDefault()
setBusy(true)
setError('')
try {
await api.admin.saveServer(form.id.trim(), bodyFrom(form))
setForm(null)
setReloads((n) => n + 1)
} catch (err) {
setError(err.message || 'That did not save.')
} finally {
setBusy(false)
}
}
const test = async (server) => {
setNotes((n) => ({ ...n, [server.id]: 'Asking the sidecar…' }))
try {
const r = await api.admin.testServer(server.id)
const sidecar = r.sidecar || {}
const text = r.ok
? `The sidecar answered${sidecar.protocol ? ` (protocol ${sidecar.protocol})` : ''}${sidecar.plugin_connected === false ? ', but the game’s plugin is not connected to it' : ''}.`
: `The sidecar did not answer: ${r.status}.`
setNotes((n) => ({ ...n, [server.id]: text }))
} catch (err) {
setNotes((n) => ({ ...n, [server.id]: err.message || 'The test did not run.' }))
}
}
const mods = async (server) => {
setNotes((n) => ({ ...n, [server.id]: 'Asking the game…' }))
try {
const line = integrationsLine(await api.admin.integrations(server.id))
setNotes((n) => ({ ...n, [server.id]: line }))
} catch (err) {
setNotes((n) => ({ ...n, [server.id]: err.message || 'The game could not be asked.' }))
}
}
const remove = async (server) => {
setConfirming(null)
try {
await api.admin.deleteServer(server.id)
setReloads((n) => n + 1)
} catch (err) {
setNotes((n) => ({ ...n, [server.id]: err.message || 'That did not delete.' }))
}
}
return (
<div style={{ maxWidth: 900 }}>
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
Each Rust server this site follows, the sidecar it answers through, and when it wipes. A server’s sidecar is
installed on its own game host; this page tells the site where to find it.
</p>
<section className="panel" style={{ padding: '16px 18px', marginBottom: 18 }}>
{servers.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>No servers are configured yet.</p>
)}
{servers.map((s) => (
<div key={s.id} className="sans" style={{ borderTop: '1px solid var(--line-soft)', padding: '10px 0', fontSize: '0.84rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', gap: 10 }}>
<strong style={{ color: 'var(--head)' }}>{s.name}</strong>
<code className="dim" style={{ fontSize: '0.74rem' }}>{s.id}</code>
{!s.enabled && <span className="dim" style={{ fontSize: '0.74rem' }}>disabled</span>}
<span style={{ marginLeft: 'auto', color: s.online ? 'var(--mode-live, #5fb98a)' : 'var(--dim)' }}>
{s.online ? `online · ${s.players}${s.maxPlayers ? `/${s.maxPlayers}` : ''}` : s.reachable ? 'sidecar up, game offline' : 'unreachable'}
</span>
</div>
<div className="dim" style={{ fontSize: '0.76rem', marginTop: 4 }}>
{s.sidecarBaseUrl || 'sidecar address hidden'} · {s.hasToken ? 'token stored' : 'no token'} · protocol {s.protocol}
{s.sidecarProtocol != null && s.sidecarProtocol !== s.protocol ? ` (sidecar speaks ${s.sidecarProtocol})` : ''}
{s.lastSeenAt ? ` · last seen ${ago(s.lastSeenAt)}` : ''}
</div>
<div className="dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
{s.nextWipe
? `Next wipe ${nextWipe(s.nextWipe)}, from ${SOURCE[s.nextWipe.source] || s.nextWipe.source}.`
: 'No wipe schedule set.'}
</div>
<div className="dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>{titlesSummary(s.titles, s.titlePush, categories)}</div>
{runicNpcNote(s.runicNpc) && (
<div style={{ fontSize: '0.76rem', marginTop: 2, color: 'var(--warn, #d9c184)' }}>{runicNpcNote(s.runicNpc)}</div>
)}
{zoneHelperNote(s.zoneHelper) && (
<div style={{ fontSize: '0.76rem', marginTop: 2, color: 'var(--warn, #d9c184)' }}>{zoneHelperNote(s.zoneHelper)}</div>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
<button type="button" className="btn" onClick={() => { setError(''); setTitling(null); setForm(formFrom(s)) }}>Edit</button>
<button type="button" className="btn" onClick={() => test(s)}>Test the sidecar</button>
<button type="button" className="btn" onClick={() => { setForm(null); setTitling(s) }}>Chat titles</button>
<button type="button" className="btn" onClick={() => mods(s)}>Optional mods</button>
{confirming === s.id ? (
<>
<button type="button" className="btn" onClick={() => remove(s)}>Delete {s.name} and everything recorded about it</button>
<button type="button" className="btn" onClick={() => setConfirming(null)}>Keep it</button>
</>
) : (
<button type="button" className="btn" onClick={() => setConfirming(s.id)}>Delete</button>
)}
</div>
{notes[s.id] && <p className="dim" style={{ fontSize: '0.76rem', margin: '6px 0 0' }}>{notes[s.id]}</p>}
</div>
))}
{!form && (
<div style={{ marginTop: 12 }}>
<button type="button" className="btn" onClick={() => { setError(''); setForm(blankForm()) }}>Add a server</button>
</div>
)}
</section>
{form && (
<ServerForm form={form} set={set} busy={busy} error={error} onSave={save} onCancel={() => setForm(null)} />
)}
{titling && (
<TitlesForm
key={titling.id}
server={titling}
categories={categories}
onSaved={() => { setTitling(null); setReloads((n) => n + 1) }}
onCancel={() => setTitling(null)}
/>
)}
<CategoryTitles categories={categories} onSaved={() => setReloads((n) => n + 1)} />
<VoiceCard />
</div>
)
}
function Field({ label, hint, children }) {
return (
<label className="sans" style={{ display: 'grid', gap: 4, fontSize: '0.84rem' }}>
<span style={{ color: 'var(--head)' }}>{label}</span>
{children}
{hint && <span className="dim" style={{ fontSize: '0.74rem' }}>{hint}</span>}
</label>
)
}
function ServerForm({ form, set, busy, error, onSave, onCancel }) {
const weekly = form.wipeRule === 'weekly' || form.wipeRule === 'biweekly'
const rule = RULES.find((r) => r.id === form.wipeRule) || RULES[0]
const oncePast = form.storedOnceAt && form.wipeOnce === toLocalInput(form.storedOnceAt) && Date.parse(form.storedOnceAt) <= Date.now()
return (
<form onSubmit={onSave} className="panel" style={{ padding: '16px 18px', display: 'grid', gap: 14 }}>
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>
{form.isNew ? 'Add a server' : `Edit ${form.name}`}
</h2>
{form.isNew && (
<Field label="Id" hint="Lowercase letters, digits and hyphens. It is in every link to this server and cannot be changed later.">
<input value={form.id} onChange={set('id')} required pattern="[a-z0-9][a-z0-9-]{0,63}" style={inputStyle} />
</Field>
)}
<Field label="Name">
<input value={form.name} onChange={set('name')} required maxLength={120} style={inputStyle} />
</Field>
<Field label="Sidecar address" hint="The base URL of the sidecar on the game host, e.g. http://10.0.0.5:8090.">
<input value={form.sidecarBaseUrl} onChange={set('sidecarBaseUrl')} required style={inputStyle} />
</Field>
<Field
label="Sidecar token"
hint={form.isNew ? 'From the sidecar’s own config. Needed to add a server.' : 'Leave blank to keep the stored token. It is never shown again once saved.'}
>
<input
type="password"
autoComplete="new-password"
value={form.sidecarToken}
onChange={set('sidecarToken')}
required={form.isNew}
maxLength={512}
style={inputStyle}
/>
</Field>
<div style={{ display: 'flex', gap: 18, flexWrap: 'wrap' }}>
<label className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '0.84rem' }}>
<input type="checkbox" checked={form.enabled} onChange={set('enabled')} /> Enabled
</label>
<Field label="Order">
<input type="number" value={form.sortOrder} onChange={set('sortOrder')} min={-1000} max={1000} style={{ ...inputStyle, width: 90 }} />
</Field>
</div>
<fieldset style={{ border: '1px solid var(--line-soft)', borderRadius: 8, padding: '12px 14px', display: 'grid', gap: 12 }}>
<legend className="sans" style={{ color: 'var(--head)', fontSize: '0.86rem', padding: '0 6px' }}>Wipe schedule</legend>
<Field label="Rule" hint={rule.hint}>
<select value={form.wipeRule} onChange={set('wipeRule')} style={inputStyle}>
{RULES.map((r) => <option key={r.id} value={r.id}>{r.label}</option>)}
</select>
</Field>
{weekly && (
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<Field label="Day">
<select value={form.wipeDay} onChange={set('wipeDay')} style={inputStyle}>
{DAYS.map((d, i) => <option key={d} value={i}>{d}</option>)}
</select>
</Field>
<Field label="Time">
<input type="time" value={form.wipeTime} onChange={set('wipeTime')} required style={inputStyle} />
</Field>
<Field label="Time zone" hint="The zone the time is in. It follows that zone’s summer time.">
<input value={form.wipeTz} onChange={set('wipeTz')} list="rust-zones" required style={inputStyle} />
</Field>
</div>
)}
{form.wipeRule === 'biweekly' && (
<Field label="One wipe on this schedule" hint={`A ${DAYS[form.wipeDay]} the server wiped or will wipe on. It says which weeks are wipe weeks.`}>
<input type="date" value={form.wipeAnchor} onChange={set('wipeAnchor')} required style={inputStyle} />
</Field>
)}
<Field
label="One-off wipe (optional)"
hint="A delayed or extra wipe, in your own clock. While it is in the future it is the next wipe, and anything scheduled before it is skipped."
>
<span style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="datetime-local" value={form.wipeOnce} onChange={set('wipeOnce')} style={inputStyle} />
{form.wipeOnce && <button type="button" className="btn" onClick={() => set('wipeOnce')('')}>Clear</button>}
</span>
</Field>
{oncePast && (
<p className="sans dim" style={{ fontSize: '0.76rem', margin: 0 }}>
That date has passed, so it no longer changes anything. Saving will clear it.
</p>
)}
</fieldset>
<fieldset style={{ border: '1px solid var(--line-soft)', borderRadius: 8, padding: '12px 14px', display: 'grid', gap: 12 }}>
<legend className="sans" style={{ color: 'var(--head)', fontSize: '0.86rem', padding: '0 6px' }}>World events</legend>
<Field
label="Who downs a helicopter or a Bradley"
hint={`${(CREDIT_RULES.find((r) => r.id === form.creditRule) || CREDIT_RULES[0]).hint} It names them in the feed and counts toward Rotorbane and Tankbreaker, from now on.`}
>
<select value={form.creditRule} onChange={set('creditRule')} style={inputStyle}>
{CREDIT_RULES.map((r) => <option key={r.id} value={r.id}>{r.label}</option>)}
</select>
</Field>
</fieldset>
<datalist id="rust-zones">{ZONES.map((z) => <option key={z} value={z} />)}</datalist>
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button type="submit" className="btn" disabled={busy}>{busy ? 'Saving…' : 'Save'}</button>
<button type="button" className="btn" onClick={onCancel} disabled={busy}>Cancel</button>
{error && <span style={{ color: '#d08a2a', fontSize: '0.8rem' }}>{error}</span>}
</div>
</form>
)
}
const inputStyle = {
background: 'var(--panel-flat, transparent)',
color: 'var(--text)',
border: '1px solid var(--line)',
borderRadius: 'var(--radius-input, 6px)',
padding: '5px 8px',
fontSize: '0.84rem',
}