feat(rust): Admin → Rust servers page and the next wipe on the web (phase 16)
The module had no page for its own server rows: they were written only through PUT /admin/rust/servers/:id, and the README described an "Admin → Rust" server form that did not exist. D133 (org lead) builds it: add, edit, test and delete a server, with the D130 wipe schedule in the same form. The token stays write-only and an edit sends the stored protocol back rather than re-stamping the row. The next wipe shows on the server list and in the server page's header, in the reader's own clock, marked "rescheduled" for a one-off date. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
22
README.md
22
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.
|
||||
|
||||
|
||||
@@ -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: <Visibility /> },
|
||||
// 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: <ServerSettings /> },
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -95,4 +95,19 @@ export const IconEye = () => (
|
||||
</Icon>
|
||||
)
|
||||
|
||||
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 = () => (
|
||||
<Icon>
|
||||
<rect x="3" y="4" width="18" height="7" rx="1.5" />
|
||||
<rect x="3" y="13" width="18" height="7" rx="1.5" />
|
||||
<path d="M7 7.5h.01" />
|
||||
<path d="M7 16.5h.01" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export default { IconLink, IconKey, IconSliders, IconEye, IconServer }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
366
client/src/routes/admin/ServerSettings.jsx
Normal file
366
client/src/routes/admin/ServerSettings.jsx
Normal file
@@ -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 <ErrorState error={loadError} />
|
||||
if (!data) return <Loading />
|
||||
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 (
|
||||
<div style={{ maxWidth: 900 }}>
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<section className="panel" style={{ padding: '16px 18px', marginBottom: 18 }}>
|
||||
{servers.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>No servers are configured yet.</p>
|
||||
)}
|
||||
{servers.map((s) => (
|
||||
<div key={s.id} className="sans" style={{ borderTop: '1px solid var(--line-soft)', padding: '10px 0', fontSize: '0.84rem' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', gap: 10 }}>
|
||||
<strong style={{ color: 'var(--head)' }}>{s.name}</strong>
|
||||
<code className="dim" style={{ fontSize: '0.74rem' }}>{s.id}</code>
|
||||
{!s.enabled && <span className="dim" style={{ fontSize: '0.74rem' }}>disabled</span>}
|
||||
<span style={{ marginLeft: 'auto', color: s.online ? 'var(--mode-live, #5fb98a)' : 'var(--dim)' }}>
|
||||
{s.online ? `online · ${s.players}${s.maxPlayers ? `/${s.maxPlayers}` : ''}` : s.reachable ? 'sidecar up, game offline' : 'unreachable'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="dim" style={{ fontSize: '0.76rem', marginTop: 4 }}>
|
||||
{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)}` : ''}
|
||||
</div>
|
||||
<div className="dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||
{s.nextWipe
|
||||
? `Next wipe ${nextWipe(s.nextWipe)}, from ${SOURCE[s.nextWipe.source] || s.nextWipe.source}.`
|
||||
: 'No wipe schedule set.'}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
|
||||
<button type="button" className="btn" onClick={() => { setError(''); setForm(formFrom(s)) }}>Edit</button>
|
||||
<button type="button" className="btn" onClick={() => test(s)}>Test the sidecar</button>
|
||||
{confirming === s.id ? (
|
||||
<>
|
||||
<button type="button" className="btn" onClick={() => remove(s)}>Delete {s.name} and everything recorded about it</button>
|
||||
<button type="button" className="btn" onClick={() => setConfirming(null)}>Keep it</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" className="btn" onClick={() => setConfirming(s.id)}>Delete</button>
|
||||
)}
|
||||
</div>
|
||||
{notes[s.id] && <p className="dim" style={{ fontSize: '0.76rem', margin: '6px 0 0' }}>{notes[s.id]}</p>}
|
||||
</div>
|
||||
))}
|
||||
{!form && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<button type="button" className="btn" onClick={() => { setError(''); setForm(blankForm()) }}>Add a server</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{form && (
|
||||
<ServerForm form={form} set={set} busy={busy} error={error} onSave={save} onCancel={() => setForm(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }) {
|
||||
return (
|
||||
<label className="sans" style={{ display: 'grid', gap: 4, fontSize: '0.84rem' }}>
|
||||
<span style={{ color: 'var(--head)' }}>{label}</span>
|
||||
{children}
|
||||
{hint && <span className="dim" style={{ fontSize: '0.74rem' }}>{hint}</span>}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
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 onSubmit={onSave} className="panel" style={{ padding: '16px 18px', display: 'grid', gap: 14 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>
|
||||
{form.isNew ? 'Add a server' : `Edit ${form.name}`}
|
||||
</h2>
|
||||
|
||||
{form.isNew && (
|
||||
<Field label="Id" hint="Lowercase letters, digits and hyphens. It is in every link to this server and cannot be changed later.">
|
||||
<input value={form.id} onChange={set('id')} required pattern="[a-z0-9][a-z0-9-]{0,63}" style={inputStyle} />
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Name">
|
||||
<input value={form.name} onChange={set('name')} required maxLength={120} style={inputStyle} />
|
||||
</Field>
|
||||
<Field label="Sidecar address" hint="The base URL of the sidecar on the game host, e.g. http://10.0.0.5:8090.">
|
||||
<input value={form.sidecarBaseUrl} onChange={set('sidecarBaseUrl')} required style={inputStyle} />
|
||||
</Field>
|
||||
<Field
|
||||
label="Sidecar token"
|
||||
hint={form.isNew ? 'From the sidecar’s own config. Needed to add a server.' : 'Leave blank to keep the stored token. It is never shown again once saved.'}
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={form.sidecarToken}
|
||||
onChange={set('sidecarToken')}
|
||||
required={form.isNew}
|
||||
maxLength={512}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
<div style={{ display: 'flex', gap: 18, flexWrap: 'wrap' }}>
|
||||
<label className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '0.84rem' }}>
|
||||
<input type="checkbox" checked={form.enabled} onChange={set('enabled')} /> Enabled
|
||||
</label>
|
||||
<Field label="Order">
|
||||
<input type="number" value={form.sortOrder} onChange={set('sortOrder')} min={-1000} max={1000} style={{ ...inputStyle, width: 90 }} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<fieldset style={{ border: '1px solid var(--line-soft)', borderRadius: 8, padding: '12px 14px', display: 'grid', gap: 12 }}>
|
||||
<legend className="sans" style={{ color: 'var(--head)', fontSize: '0.86rem', padding: '0 6px' }}>Wipe schedule</legend>
|
||||
<Field label="Rule" hint={rule.hint}>
|
||||
<select value={form.wipeRule} onChange={set('wipeRule')} style={inputStyle}>
|
||||
{RULES.map((r) => <option key={r.id} value={r.id}>{r.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
{weekly && (
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<Field label="Day">
|
||||
<select value={form.wipeDay} onChange={set('wipeDay')} style={inputStyle}>
|
||||
{DAYS.map((d, i) => <option key={d} value={i}>{d}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Time">
|
||||
<input type="time" value={form.wipeTime} onChange={set('wipeTime')} required style={inputStyle} />
|
||||
</Field>
|
||||
<Field label="Time zone" hint="The zone the time is in. It follows that zone’s summer time.">
|
||||
<input value={form.wipeTz} onChange={set('wipeTz')} list="rust-zones" required style={inputStyle} />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
{form.wipeRule === 'biweekly' && (
|
||||
<Field label="One wipe on this schedule" hint={`A ${DAYS[form.wipeDay]} the server wiped or will wipe on. It says which weeks are wipe weeks.`}>
|
||||
<input type="date" value={form.wipeAnchor} onChange={set('wipeAnchor')} required style={inputStyle} />
|
||||
</Field>
|
||||
)}
|
||||
<Field
|
||||
label="One-off wipe (optional)"
|
||||
hint="A delayed or extra wipe, in your own clock. While it is in the future it is the next wipe, and anything scheduled before it is skipped."
|
||||
>
|
||||
<span style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input type="datetime-local" value={form.wipeOnce} onChange={set('wipeOnce')} style={inputStyle} />
|
||||
{form.wipeOnce && <button type="button" className="btn" onClick={() => set('wipeOnce')('')}>Clear</button>}
|
||||
</span>
|
||||
</Field>
|
||||
{oncePast && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: 0 }}>
|
||||
That date has passed, so it no longer changes anything. Saving will clear it.
|
||||
</p>
|
||||
)}
|
||||
</fieldset>
|
||||
<datalist id="rust-zones">{ZONES.map((z) => <option key={z} value={z} />)}</datalist>
|
||||
|
||||
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<button type="submit" className="btn" disabled={busy}>{busy ? 'Saving…' : 'Save'}</button>
|
||||
<button type="button" className="btn" onClick={onCancel} disabled={busy}>Cancel</button>
|
||||
{error && <span style={{ color: '#d08a2a', fontSize: '0.8rem' }}>{error}</span>}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
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',
|
||||
}
|
||||
@@ -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.'
|
||||
|
||||
@@ -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(' · ')}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user