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
367 lines
16 KiB
JavaScript
367 lines
16 KiB
JavaScript
// ── 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',
|
||
}
|