// ── 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
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.
No servers are configured yet.
)} {servers.map((s) => ({s.id}
{!s.enabled && disabled}
{s.online ? `online · ${s.players}${s.maxPlayers ? `/${s.maxPlayers}` : ''}` : s.reachable ? 'sidecar up, game offline' : 'unreachable'}
{notes[s.id]}
}