Files
Module-Rust/client/src/routes/admin/ServerSettings.jsx
wtclaude cddba957d3
All checks were successful
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / frozen-manifest (pull_request) Successful in 49s
PR Checks / server-tests (pull_request) Successful in 7m49s
feat(rust): Admin → Rust servers page and the next wipe on the web (phase 16)
The module had no page for its own server rows: they were written only
through PUT /admin/rust/servers/:id, and the README described an
"Admin → Rust" server form that did not exist. D133 (org lead) builds it:
add, edit, test and delete a server, with the D130 wipe schedule in the
same form. The token stays write-only and an edit sends the stored
protocol back rather than re-stamping the row.

The next wipe shows on the server list and in the server page's header,
in the reader's own clock, marked "rescheduled" for a one-off date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 13:29:59 -05:00

367 lines
16 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.
import { useState } from 'react'
import { ErrorState, Loading, useAsync } from '../../core.js'
import api from '../../api.js'
import { ago, nextWipe } from '../../lib/format.js'
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.' },
]
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. */
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: '',
}
}
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,
}
}
/** 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.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)
if (loadError) return <ErrorState error={loadError} />
if (!data) return <Loading />
const servers = data.servers || []
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 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} · {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 style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
<button type="button" className="btn" onClick={() => { setError(''); setForm(formFrom(s)) }}>Edit</button>
<button type="button" className="btn" onClick={() => test(s)}>Test the sidecar</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)} />
)}
</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>
<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',
}