Files
Module-Rust/client/src/routes/public/ServerDetail.jsx
wtclaude cddba957d3
All checks were successful
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / frozen-manifest (pull_request) Successful in 49s
PR Checks / server-tests (pull_request) Successful in 7m49s
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
2026-09-25 13:29:59 -05:00

196 lines
8.3 KiB
JavaScript

// ── One server ────────────────────────────────────────────────────────────
//
// R8's page beneath the landing page, and the phase-4 criterion lives here: it
// renders the last thing this server said while every server is off. Nothing on
// it is a live call to a game host — every panel reads this module's own tables,
// filled by the ingest cursor — so a shard that has been down for a week renders
// a week-old killfeed and a leaderboard that is still correct, rather than an
// error page.
//
// ── Everything selectable is in the URL ───────────────────────────────────
//
// Tab, feed filter, wipe and leaderboard sort all live in search parameters.
// That costs a little ceremony here and buys the thing a community site is for:
// "look at last wipe's leaderboard on Main" is a LINK. State held in `useState`
// would make every one of those sentences unlinkable, lose the reader's place on
// a refresh, and make the browser's back button leave the page instead of
// undoing what they just clicked.
//
// `useSearchParams` comes from CORE's router (the shim in `src/shim/`), so it is
// the same live navigation context core's own pages use. A module with its own
// copy of react-router would get a `useParams` that returns nothing on a page
// that otherwise renders perfectly — see `core.js`'s identity check.
import { useSearchParams, useParams, Link } from 'react-router-dom'
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
import Clans from '../../components/Clans.jsx'
import Feed from '../../components/Feed.jsx'
import Leaderboard from '../../components/Leaderboard.jsx'
import MapView from '../../components/MapView.jsx'
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, nextWipe } from '../../lib/format.js'
import api from '../../api.js'
const TABS = [
{ id: 'feed', label: 'Feed' },
{ id: 'leaderboard', label: 'Leaderboard' },
{ id: 'online', label: 'Online' },
// Phase 14. The picture is public; what moves on it has an audience per layer.
{ id: 'map', label: 'Map' },
{ id: 'wipes', label: 'Wipes' },
// Phase 9. The list is public (D58); each clan's roster is on its own page.
{ id: 'clans', label: 'Clans' },
]
export default function ServerDetail() {
const { id } = useParams()
const [params, setParams] = useSearchParams()
const { data, loading, error } = useAsync(() => api.servers.get(id), [id])
const server = data ? data.server : null
const tab = TABS.some((t) => t.id === params.get('tab')) ? params.get('tab') : 'feed'
const filter = params.get('show') || 'all'
const sort = params.get('sort') || 'kills'
// `wipe` absent means all time; `wipe=current` means whatever wipe the server
// is on now, which is a moving target and therefore a word rather than an id —
// a link somebody shares stays about "now" rather than about the map that was
// current when they sent it.
const wipeParam = params.get('wipe')
const wipeId = !wipeParam || wipeParam === ALL_TIME ? null : wipeParam === 'current' ? (server && server.wipeId) || null : wipeParam
const set = (key, value) => {
const next = new URLSearchParams(params)
if (!value || value === 'all' || (key === 'tab' && value === 'feed')) next.delete(key)
else next.set(key, value)
// `replace` so that flipping between tabs does not fill the reader's history
// with one entry per click — back should leave the page they arrived on.
setParams(next, { replace: true })
}
if (loading) {
return (
<PublicLayout shell="mid">
<Loading />
</PublicLayout>
)
}
// A 404 from the detail route is the one answer the other four cannot give:
// an unknown id has no events, no leaderboard and nobody online, and each of
// those empty lists is a perfectly good answer to its own question. So this is
// where "there is no such server" is said.
//
// **A mistyped address is not a fault, and must not be dressed as one.** The
// first version of this page rendered core's `ErrorState` under the heading and
// the result read "No such server / Something went wrong" — which sends a
// reader who fat-fingered a URL looking for an outage. `ErrorState` is kept for
// the case it is for: a request that failed for a reason nobody can see.
if (error || !server) {
const missing = !error || error.status === 404
return (
<PublicLayout shell="mid">
<PageHeader
title={missing ? 'No such server' : 'That server could not be loaded'}
lead={
missing
? 'This address does not name a server this site follows.'
: 'The site could not read this server just now. It is worth trying again.'
}
/>
{!missing && <ErrorState error={error} />}
<p className="sans" style={{ marginTop: 20 }}>
<Link to="/rust">Back to the server list</Link>
</p>
</PublicLayout>
)
}
return (
<PublicLayout shell="mid">
<PageHeader
eyebrow="Rust"
title={server.name}
lead={describeWorld(server)}
/>
<div
className="sans"
style={{ display: 'flex', flexWrap: 'wrap', gap: 16, alignItems: 'baseline', marginBottom: 24 }}
>
<span style={{ color: server.online ? 'var(--mode-live, #5fb98a)' : 'var(--dim)' }}>
{server.online
? `${count(server.players)}${server.maxPlayers ? ` / ${count(server.maxPlayers)}` : ''} online`
: 'Offline'}
</span>
{/* `lastSeenAt` is when a frame arrived; `updatedAt` is when this site
last wrote the row, which a FAILED poll does too. Reading the second
as the first is what made an offline server claim it had reported just
now, every thirty seconds, for as long as it stayed down. */}
<span style={{ color: 'var(--dim)', fontSize: '0.8rem' }}>
{server.lastSeenAt ? `last reported ${ago(server.lastSeenAt)}` : 'has never reported'}
{server.stale && server.lastSeenAt ? ' — out of date, so it is shown as offline' : ''}
</span>
<span style={{ marginLeft: 'auto' }}>
<WipeSelect
serverId={server.id}
value={wipeParam}
currentWipeId={server.wipeId}
onChange={(value) => set('wipe', value === ALL_TIME ? null : value)}
/>
</span>
</div>
<Tabs tabs={TABS} active={tab} onSelect={(next) => set('tab', next)} label={`${server.name} sections`} />
{tab === 'feed' && (
<Feed serverId={server.id} wipeId={wipeId} filter={filter} onFilter={(value) => set('show', value)} />
)}
{tab === 'leaderboard' && (
<Leaderboard serverId={server.id} wipeId={wipeId} sort={sort} onSort={(value) => set('sort', value)} />
)}
{tab === 'online' && <Online serverId={server.id} online={server.online} />}
{tab === 'map' && <MapView serverId={server.id} online={server.online} />}
{tab === 'clans' && <Clans serverId={server.id} />}
{tab === 'wipes' && (
<Wipes
serverId={server.id}
currentWipeId={server.wipeId}
selected={wipeId}
// Picking a wipe here is a navigation as much as a filter: it is the
// question "what happened during that map", and the answer is the feed.
onSelect={(value) => {
const next = new URLSearchParams(params)
next.set('wipe', value)
next.delete('tab')
setParams(next, { replace: true })
}}
/>
)}
</PublicLayout>
)
}
/** The world line under the heading — the things a Rust player asks first. */
function describeWorld(server) {
const parts = [
server.level || null,
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.'
}