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. +

+ +
+ {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', +} diff --git a/client/src/routes/public/ServerDetail.jsx b/client/src/routes/public/ServerDetail.jsx index 3653b00..469dff6 100644 --- a/client/src/routes/public/ServerDetail.jsx +++ b/client/src/routes/public/ServerDetail.jsx @@ -31,7 +31,7 @@ import Online from '../../components/Online.jsx' import Tabs from '../../components/Tabs.jsx' import WipeSelect, { ALL_TIME } from '../../components/WipeSelect.jsx' import Wipes from '../../components/Wipes.jsx' -import { ago, count, day } from '../../lib/format.js' +import { ago, count, day, nextWipe } from '../../lib/format.js' import api from '../../api.js' const TABS = [ @@ -188,6 +188,7 @@ function describeWorld(server) { server.worldSize ? `size ${count(server.worldSize)}` : null, server.seed ? `seed ${server.seed}` : null, server.wipedAt ? `wiped ${day(server.wipedAt)}` : null, + nextWipe(server.nextWipe) ? `next wipe ${nextWipe(server.nextWipe)}` : null, ].filter(Boolean) return parts.length > 0 ? parts.join(' · ') : 'This server has not described itself yet.' diff --git a/client/src/routes/public/Servers.jsx b/client/src/routes/public/Servers.jsx index bac1f41..377a100 100644 --- a/client/src/routes/public/Servers.jsx +++ b/client/src/routes/public/Servers.jsx @@ -24,7 +24,7 @@ import { Link } from 'react-router-dom' import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js' import Empty from '../../components/Empty.jsx' -import { ago, count, day } from '../../lib/format.js' +import { ago, count, day, nextWipe } from '../../lib/format.js' import api from '../../api.js' /** The "last reported" line, which has three cases and not one. */ @@ -93,6 +93,9 @@ export default function Servers() { server.level || null, server.worldSize ? `size ${count(server.worldSize)}` : null, server.wipedAt ? `wiped ${day(server.wipedAt)}` : null, + // Phase 16 (D131): absent from an older module, and null for a + // server with no schedule — either way the part is left out. + nextWipe(server.nextWipe) ? `next wipe ${nextWipe(server.nextWipe)}` : null, ] .filter(Boolean) .join(' · ')} diff --git a/client/test/format.test.js b/client/test/format.test.js index 052c6c4..aaf4417 100644 --- a/client/test/format.test.js +++ b/client/test/format.test.js @@ -11,7 +11,7 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { ago, clock, count, day, duration, prefab, shortId } from '../src/lib/format.js' +import { ago, clock, count, day, duration, nextWipe, prefab, shortId } from '../src/lib/format.js' const NOW = Date.parse('2026-09-16T12:00:00Z') @@ -94,3 +94,16 @@ test("a feed row from another day carries its date, not just a time", () => { const earlyToday = Date.parse('2026-09-16T00:20:00') assert.ok(clock(lateLastNight, earlyToday).length > time(lateLastNight).length) }) + +test('the next wipe: the reader’s own clock, how far away, and whether it moved', () => { + const now = Date.parse('2026-09-25T12:00:00Z') + const forced = nextWipe({ at: '2026-10-01T18:00:00.000Z', source: 'forced' }, now) + assert.match(forced, /\(in 6 days\)$/) + assert.ok(!forced.includes('rescheduled')) + + assert.match(nextWipe({ at: '2026-09-27T17:00:00.000Z', source: 'once' }, now), /\(in 2 days\) — rescheduled$/) + + // No schedule is no line, not "unknown": the page leaves the part out. + assert.equal(nextWipe(null, now), null) + assert.equal(nextWipe({ at: 'soon', source: 'rule' }, now), null) +})