diff --git a/README.md b/README.md
index a12cfe5..e3789c5 100644
--- a/README.md
+++ b/README.md
@@ -43,11 +43,13 @@ rows here; the website core never learns there is more than one.
| Public | `GET …/servers/:id/clans` — the server's clans, best score first (public: names nobody) |
| Public | `GET /api/v1/public/rust/clans/:externalId` — one clan, and its roster inside the roster audience |
| Player | `GET /api/v1/player/rust/servers` — the server list, on the authenticated tier |
-| Admin | `GET/PUT/DELETE /api/v1/admin/rust/servers` and `POST …/:id/test` |
+| Admin | `GET/PUT/DELETE /api/v1/admin/rust/servers` and `POST …/:id/test` — the `PUT` carries the wipe schedule |
| Admin | `GET/PUT /api/v1/admin/rust/visibility` — who may see who is online, fleet-wide and per server |
| Pages | `/rust` — the server list, and the module's landing page |
| Pages | `/rust/servers/:id` — one server: feed, leaderboard, who is on, wipes, clans |
| Pages | `/rust/clans/:externalId` — one clan, with core's Team notify, activity and forum in three module slots |
+| Pages | `/admin/rust/servers` — add, edit, test and remove servers, and set each one's wipe schedule |
+| Discord | `/status`, `/wipe`, `/top`, `/online`, `/clan` — read-only, answered from this module's tables |
| Teams | The deployment's Team provider: a first-party Rust clan is a Team |
| Slot | `site.footer.status` — a live server/player count in core's footer |
@@ -73,8 +75,18 @@ at most 100 clans per server, and a server at that ceiling answers core partiall
removes a Team on its word. Core holds one Team provider per site, which is one reason **a site runs
one module**: core's installer refuses a second.
-The rest of the module — notifications,
-events, the live map, Discord commands — arrives phase by phase. **Nothing is registered before it
+**The next wipe is the operator's to state** (Admin → Rust servers): a rule — the monthly forced
+wipe only, weekly or every other week, each in the server's own time zone and always including the
+forced wipe (first Thursday, 19:00 UK time) — plus an optional one-off date that replaces the next
+computed wipe. It is computed on every read and never stored, so it cannot go stale after a wipe.
+The server list, the server page, the Android app and `/wipe` all show it.
+
+**The Discord commands answer from the tables, never from a game server**, inside core's three-second
+budget. Every refusal is private. **An answer narrower than public goes to the caller alone:** a
+moderator's `/online` in a public channel shows the names to the moderator, never to the channel, and
+the same for a clan roster. Everything else is posted where it was asked.
+
+The rest of the module arrives phase by phase. **Nothing is registered before it
has something behind it:** a declared trigger nothing emits and a declared slot nothing fills are
both surfaces an operator can configure and then wait on, which is worse than an absent one.
@@ -181,8 +193,8 @@ directory; a symlink answers no and the module is skipped in complete silence.
Either way, the module appears when the process restarts: the volume is read at require time.
-Then, in Admin → Rust, add a server: its name, the sidecar's base URL, and the token the sidecar
-printed on first start (`rust-link-sidecar --print-config`). **The token is write-only** — it is
+Then, in Admin → Rust servers, add a server: its name, the sidecar's base URL, and the token the
+sidecar printed on first start (`rust-link-sidecar --print-config`). **The token is write-only** — it is
stored encrypted through core's own secret box and never returned to any client; the panel reports
only whether one is set.
diff --git a/client/src/entry.jsx b/client/src/entry.jsx
index e2dc353..fd5b373 100644
--- a/client/src/entry.jsx
+++ b/client/src/entry.jsx
@@ -25,9 +25,10 @@ import Account from './routes/player/Account.jsx'
import Permissions from './routes/admin/Permissions.jsx'
import ModConfig from './routes/admin/ModConfig.jsx'
import Visibility from './routes/admin/Visibility.jsx'
+import ServerSettings from './routes/admin/ServerSettings.jsx'
import UserRustSections from './routes/admin/UserRustSections.jsx'
import FooterStatus from './components/FooterStatus.jsx'
-import { IconEye, IconKey, IconLink, IconSliders } from './icons.jsx'
+import { IconEye, IconKey, IconLink, IconServer, IconSliders } from './icons.jsx'
// The module id, exactly as `module.json` spells it. Core keys the registry by it
// and prefixes every route path with it.
@@ -104,6 +105,10 @@ registry.registerRoutes(ID, {
// nothing names who is online by default; this is where an operator widens
// it on purpose, fleet-wide or per server.
{ path: 'visibility', element: },
+ // The servers themselves (phase 16, D133): the page that was missing. Until
+ // it, a server row was written only through the API, and D130's wipe
+ // schedule needed somewhere to be typed.
+ { path: 'servers', element: },
],
})
@@ -153,6 +158,7 @@ registry.registerNav(ID, {
{ label: 'Rust permissions', to: '/admin/rust', icon: IconKey },
{ label: 'Rust mod config', to: '/admin/rust/config', icon: IconSliders },
{ label: 'Rust visibility', to: '/admin/rust/visibility', icon: IconEye },
+ { label: 'Rust servers', to: '/admin/rust/servers', icon: IconServer },
],
})
diff --git a/client/src/icons.jsx b/client/src/icons.jsx
index f8e90ae..e5ef5b0 100644
--- a/client/src/icons.jsx
+++ b/client/src/icons.jsx
@@ -95,4 +95,19 @@ export const IconEye = () => (
)
-export default { IconLink, IconKey, IconSliders, IconEye }
+/**
+ * Two stacked units — the admin sidebar's row for the servers themselves.
+ *
+ * The one row that is about the machines rather than what happens on them: the
+ * sidecar each one answers through, and when each one wipes.
+ */
+export const IconServer = () => (
+
+
+
+
+
+
+)
+
+export default { IconLink, IconKey, IconSliders, IconEye, IconServer }
diff --git a/client/src/lib/format.js b/client/src/lib/format.js
index 920da42..9140226 100644
--- a/client/src/lib/format.js
+++ b/client/src/lib/format.js
@@ -125,6 +125,29 @@ export function shortId(steamId) {
return id.length > 10 ? `…${id.slice(-6)}` : id
}
+/**
+ * The next wipe (phase 16, D130), as `Thu, Oct 1, 7:00 PM (in 6 days)` in the
+ * VIEWER's locale and clock — or `null` when the server has no schedule.
+ *
+ * The server computes the instant from the operator's rule in the operator's
+ * zone; the page only says it the reader's way. A `once` source is an operator
+ * moving a wipe, and says so, because "the wipe is not when it usually is" is
+ * the thing a regular needs to notice.
+ */
+export function nextWipe(value, now = Date.now()) {
+ if (!value || !value.at) return null
+ const at = toMillis(value.at)
+ if (at === null) return null
+ const when = new Date(at).toLocaleString(undefined, {
+ weekday: 'short',
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ })
+ return `${when} (${ago(at, now)})${value.source === 'once' ? ' — rescheduled' : ''}`
+}
+
function toMillis(value) {
if (value === null || value === undefined || value === '') return null
if (typeof value === 'number') return Number.isFinite(value) ? value : null
@@ -133,4 +156,4 @@ function toMillis(value) {
return Number.isNaN(parsed) ? null : parsed
}
-export default { ago, clock, day, duration, count, prefab, shortId }
+export default { ago, clock, day, duration, count, prefab, shortId, nextWipe }
diff --git a/client/src/routes/admin/ServerSettings.jsx b/client/src/routes/admin/ServerSettings.jsx
new file mode 100644
index 0000000..8c9e823
--- /dev/null
+++ b/client/src/routes/admin/ServerSettings.jsx
@@ -0,0 +1,366 @@
+// ── 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.
+