// ── 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 if (!data) return 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 (

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.

{servers.length === 0 && (

No servers are configured yet.

)} {servers.map((s) => (
{s.name} {s.id} {!s.enabled && disabled} {s.online ? `online · ${s.players}${s.maxPlayers ? `/${s.maxPlayers}` : ''}` : s.reachable ? 'sidecar up, game offline' : 'unreachable'}
{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)}` : ''}
{s.nextWipe ? `Next wipe ${nextWipe(s.nextWipe)}, from ${SOURCE[s.nextWipe.source] || s.nextWipe.source}.` : 'No wipe schedule set.'}
{confirming === s.id ? ( <> ) : ( )}
{notes[s.id] &&

{notes[s.id]}

}
))} {!form && (
)}
{form && ( setForm(null)} /> )}
) } function Field({ label, hint, children }) { return ( ) } 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.isNew ? 'Add a server' : `Edit ${form.name}`}

{form.isNew && ( )}
Wipe schedule {weekly && (
)} {form.wipeRule === 'biweekly' && ( )} {form.wipeOnce && } {oncePast && (

That date has passed, so it no longer changes anything. Saving will clear it.

)}
{ZONES.map((z) =>
{error && {error}}
) } 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', }