Phase 4. `/rust` is the server list and the module's landing page (D12);
`/rust/servers/:id` is one server with four tabs — feed, leaderboard, who is
on, wipes (D13). Everything selectable lives in the URL, so any view of the
page is a link. The feed and the presence list poll every twenty seconds while
the tab is visible and not at all when it is not (D14); the leaderboard and the
wipe list load once. `site.footer.status` is filled with a live server and
player count (D15).
Nothing on these pages calls a game server. Every field comes from this
module's own tables, which is what the phase criterion is about: the site
renders the last thing each server said while every server is off.
Walking that criterion in a browser against a live rig found four defects, two
of them already shipped in phase 3:
* An unreachable refresh called `putState` — the whole-row write — with two
fields, so a host that rebooted lost its hostname, map, size, seed and wipe
id. The list then read "Offline" with nothing beside it, which is not "here
is what we know" but "we have never heard of it". `markUnreachable` now
moves three columns and mentions no others.
* "Last reported" read `updated_at`, which a FAILED poll writes too — so an
offline server claimed it had reported just now, every thirty seconds, for
as long as it stayed down. `last_seen_at` is the new column, moved only by a
frame that arrived.
* Feed rows showed a bare time of day, so three events from six weeks ago all
read as this afternoon once the feed was filtered to a past wipe.
* `/rust/servers/typo` rendered core's ErrorState under its own heading and
read "No such server / Something went wrong", sending a reader who mistyped
a URL looking for an outage.
Also: a detail route (`GET …/servers/:id`), because it is the only route under
that path that can say a server does not exist — the other four answer an empty
list for an id nobody configured, and each of those is a good answer to its own
question.
`useAsync` cannot poll: it blanks its data on every dependency change, so a
twenty-second refresh built on it would clear the killfeed and re-fill it four
times a minute. `hooks/usePolled.js` is the module's own, invisible when it
succeeds and keeping the rows when it fails.
The client test fake was *nearly* core — it prefixed routes without stripping
the trailing separator, so the first module to register an index route failed
the nav check for a link that works in a browser. It now copies core's line
character for character.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
125 lines
5.9 KiB
JavaScript
125 lines
5.9 KiB
JavaScript
// ── The server list, and the module's landing page ────────────────────────
|
|
//
|
|
// R8: the list is what `/rust` renders, and `/rust/servers/:id` hangs beneath
|
|
// it. The route is registered with an empty path in `entry.jsx` — core turns
|
|
// that into the module's own namespace root — so this page's address is the one
|
|
// an operator links to when they mean "our Rust servers".
|
|
//
|
|
// An ordinary React component. Nothing about being inside a module changes how
|
|
// you write one; the only differences are where React comes from (core, via the
|
|
// aliases in `vite.config.js`, so the import below looks completely normal and is
|
|
// not) and where the chrome comes from (`../../core.js`, the shared UI kit).
|
|
//
|
|
// **Render `PublicLayout` yourself, and pass a `shell`.** Core wraps public
|
|
// routes in its maintenance gate and nothing else, so a page that omits the
|
|
// layout renders bare; without a `shell` it renders full-bleed with the footer
|
|
// riding up underneath it. Name a width, never a class — the classes are core's
|
|
// (MODULE_API.md §3.3).
|
|
//
|
|
// **This page never calls a game server.** Every field it renders comes from
|
|
// this module's own tables, written by the ingest cursor, which is what lets it
|
|
// render "offline, last seen an hour ago" instead of an error page when a shard
|
|
// is down. The site's availability does not depend on the game's.
|
|
|
|
import { Link } from 'react-router-dom'
|
|
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
|
import { ago, count, day } from '../../lib/format.js'
|
|
import api from '../../api.js'
|
|
|
|
/** The "last reported" line, which has three cases and not one. */
|
|
function reported(server) {
|
|
if (!server.lastSeenAt) return 'This server has never reported.'
|
|
if (server.stale) return `Last reported ${ago(server.lastSeenAt)} — out of date, so it is shown as offline.`
|
|
return `Last reported ${ago(server.lastSeenAt)}.`
|
|
}
|
|
|
|
export default function Servers() {
|
|
// `useAsync` is core's fetch/loading/error hook, and the components below are
|
|
// its states. Using them rather than rolling your own is what makes a module
|
|
// page indistinguishable from a core one while it loads and while it fails.
|
|
//
|
|
// It loads once, deliberately. The DETAIL page polls, because that is where
|
|
// somebody watching a server sits; a list is a place people pass through.
|
|
const { data, loading, error } = useAsync(() => api.servers.list(), [])
|
|
const servers = data ? data.servers : []
|
|
|
|
return (
|
|
<PublicLayout shell="mid">
|
|
<PageHeader
|
|
// `lead`, not `subtitle`. PageHeader takes `eyebrow`, `title`, `lead` and
|
|
// `center`, and an unknown prop on a React component is silently dropped
|
|
// — so a page written with `subtitle` renders its title and nothing else,
|
|
// on a site where every core page has a line under its heading.
|
|
title="Servers"
|
|
lead="Every Rust server this community runs, as each one last reported itself"
|
|
/>
|
|
|
|
{loading && <Loading />}
|
|
{error && <ErrorState error={error} />}
|
|
|
|
{/* An operator who has configured no servers is not an error and not an
|
|
empty game — it is an install that is not finished. Saying so beats a
|
|
blank page that looks like a failure. */}
|
|
{data && servers.length === 0 && (
|
|
<EmptyState
|
|
title="No servers yet"
|
|
message="An administrator adds a Rust server, and its sidecar, from the admin panel."
|
|
/>
|
|
)}
|
|
|
|
{servers.length > 0 && (
|
|
<div style={{ display: 'grid', gap: 12 }}>
|
|
{servers.map((server) => (
|
|
// The whole row is the link. A server's name being the only clickable
|
|
// part is the thing people miss on a list of cards, and `a.card`
|
|
// already carries core's own hover treatment.
|
|
<Link
|
|
key={server.id}
|
|
to={`/rust/servers/${encodeURIComponent(server.id)}`}
|
|
className="card"
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'baseline',
|
|
gap: '1rem',
|
|
padding: '16px 20px',
|
|
}}
|
|
>
|
|
<span>
|
|
<strong style={{ color: 'var(--ink)' }}>{server.name}</strong>
|
|
<span className="sans" style={{ display: 'block', color: 'var(--dim)', fontSize: '0.78rem', marginTop: 4 }}>
|
|
{[
|
|
server.level || null,
|
|
server.worldSize ? `size ${count(server.worldSize)}` : null,
|
|
server.wipedAt ? `wiped ${day(server.wipedAt)}` : null,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' · ')}
|
|
</span>
|
|
<span className="sans" style={{ display: 'block', color: 'var(--dim)', fontSize: '0.74rem', marginTop: 2 }}>
|
|
{/* `lastSeenAt`, never `updatedAt`. The second is when THIS
|
|
site last wrote the row — which a failed poll does too — so
|
|
a page reading it told a reader that a server down for three
|
|
days had reported just now. And `stale` is a first-class
|
|
part of the answer rather than something inferred from a
|
|
timestamp: the server decides what counts as stale, because
|
|
the server knows how often a sidecar is supposed to check in. */}
|
|
{reported(server)}
|
|
</span>
|
|
</span>
|
|
<span
|
|
className="sans"
|
|
style={{ whiteSpace: 'nowrap', color: server.online ? 'var(--mode-live, #5fb98a)' : 'var(--dim)' }}
|
|
>
|
|
{server.online
|
|
? `${count(server.players)}${server.maxPlayers ? ` / ${count(server.maxPlayers)}` : ''} online`
|
|
: 'Offline'}
|
|
</span>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</PublicLayout>
|
|
)
|
|
}
|