feat: the first pages, and what a browser walk found behind them
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
This commit is contained in:
@@ -1,43 +1,45 @@
|
||||
// ── The server list ───────────────────────────────────────────────────────
|
||||
// ── 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
|
||||
// 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.** Core wraps public routes in its maintenance
|
||||
// gate and nothing else, so a page that omits the layout renders bare — no
|
||||
// header, no footer, no site chrome — which looks like a bug and is the contract
|
||||
// (§3.3). Admin and player routes are the other way round: core wraps those.
|
||||
// **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).
|
||||
//
|
||||
// **And pass a `shell`.** The layout is the chrome; `shell` is the body — the
|
||||
// centred column, the vertical padding, and the thing that holds the footer at
|
||||
// the bottom of the viewport. Widths are 'narrow', 'mid' and 'wide'; name a
|
||||
// width, never a class, because the classes belong to core's stylesheet.
|
||||
//
|
||||
// This is the phase-1 version of the landing page R8 calls for. It lists servers
|
||||
// and links nowhere yet — `/rust/servers/:id` is the next phase's work — so it is
|
||||
// deliberately a table and not a design.
|
||||
// **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'
|
||||
|
||||
// A relative time that does not need a date library. `Intl.RelativeTimeFormat`
|
||||
// is in every browser core supports, and one fewer dependency in the chunk is
|
||||
// one fewer thing an operator ships.
|
||||
const RELATIVE = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||
|
||||
function ago(iso) {
|
||||
if (!iso) return 'never'
|
||||
const seconds = Math.round((new Date(iso).getTime() - Date.now()) / 1000)
|
||||
const [unit, size] = Math.abs(seconds) < 3600 ? ['minute', 60] : ['hour', 3600]
|
||||
return RELATIVE.format(Math.round(seconds / size), unit)
|
||||
/** 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 : []
|
||||
|
||||
@@ -66,37 +68,54 @@ export default function Servers() {
|
||||
)}
|
||||
|
||||
{servers.length > 0 && (
|
||||
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{servers.map((server) => (
|
||||
<div
|
||||
// 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: '0.75rem 0',
|
||||
borderBottom: '1px solid rgba(128,128,128,0.25)',
|
||||
padding: '16px 20px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>{server.name}</strong>
|
||||
{server.level ? <span style={{ opacity: 0.7 }}> · {server.level}</span> : null}
|
||||
<div style={{ opacity: 0.7, fontSize: '0.9em' }}>
|
||||
{/* `stale` is a first-class part of the answer rather than
|
||||
something the page infers from a timestamp. The server
|
||||
decides what counts as stale, because the server is what
|
||||
knows how often a sidecar is supposed to check in. */}
|
||||
Last reported {ago(server.updatedAt)}
|
||||
{server.stale ? ' — out of date, so it is shown as offline.' : '.'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ whiteSpace: 'nowrap' }}>
|
||||
<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
|
||||
? `${server.players}${server.maxPlayers ? ` / ${server.maxPlayers}` : ''} online`
|
||||
? `${count(server.players)}${server.maxPlayers ? ` / ${count(server.maxPlayers)}` : ''} online`
|
||||
: 'Offline'}
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user