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

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 || '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)}` : ''}
{s.nextWipe ? `Next wipe ${nextWipe(s.nextWipe)}, from ${SOURCE[s.nextWipe.source] || s.nextWipe.source}.` : 'No wipe schedule set.'}
{titlesSummary(s.titles, s.titlePush, categories)}
{runicNpcNote(s.runicNpc) && (
{runicNpcNote(s.runicNpc)}
)} {zoneHelperNote(s.zoneHelper) && (
{zoneHelperNote(s.zoneHelper)}
)}
{confirming === s.id ? ( <> ) : ( )}
{notes[s.id] &&

{notes[s.id]}

}
))} {!form && (
)}
{form && ( setForm(null)} /> )} {titling && ( { setTitling(null); setReloads((n) => n + 1) }} onCancel={() => setTitling(null)} /> )} setReloads((n) => n + 1)} />
) } 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.

)}
World events r.id === form.creditRule) || CREDIT_RULES[0]).hint} It names them in the feed and counts toward Rotorbane and Tankbreaker, from now on.`} >
{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', }