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
129 lines
6.2 KiB
JavaScript
129 lines
6.2 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 { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
|
import Empty from '../../components/Empty.jsx'
|
|
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. */
|
|
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 && (
|
|
<Empty
|
|
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,
|
|
// 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(' · ')}
|
|
</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>
|
|
)
|
|
}
|