// ── 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 ( {loading && } {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 && ( )} {servers.length > 0 && (
{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. {server.name} {[ server.level || null, server.worldSize ? `size ${count(server.worldSize)}` : null, server.wipedAt ? `wiped ${day(server.wipedAt)}` : null, ] .filter(Boolean) .join(' · ')} {/* `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)} {server.online ? `${count(server.players)}${server.maxPlayers ? ` / ${count(server.maxPlayers)}` : ''} online` : 'Offline'} ))}
)}
) }