The org lead's rule, settled 2026-09-22: who is online is always the
narrowest audience - staff - unless an operator deliberately widens it,
and a count is fine where a list of names is not.
The public site broke that in three places since phase 4. The Online
tab named every player, the feed carried joins, respawns, deaths, chat
and tallies, and the leaderboard's lastSeen - refreshed every minute by
a gather tally - said who was on as plainly as either. All three now
sit behind one setting:
* PRESENCE_KINDS, a subset of the public allowlist, gated per request.
Below the audience the feed keeps the server's own story (wipe, start,
shutdown) and says presenceHidden rather than looking quiet.
* the Online route answers { players: [], hidden, count, audience } -
same shape, so an older client renders empty rather than breaking.
* rungs staff / signed_in / public, fleet-wide default in a new
rust_settings table with an optional per-server override on
rust_servers; an unknown stored word narrows to staff.
* the viewer's standing is RE-READ from the users row (ctx.users.getById),
not taken from the token, so a demotion or a ban applies on the next
request. Walked: a moderator demoted mid-session lost the roll call on
the same cookie.
* per-viewer answers are Cache-Control: private, no-store.
* GET/PUT /admin/rust/visibility (requireRole admin) and an admin page,
Rust visibility; every save is one activity-log row.
The browser walk also found every empty state in this module rendering
as a blank box. Core's EmptyState renders children only; this module
passed title/message (the shape the Integration Kit template teaches)
and React dropped both without a word. Fixed module-side with a small
Empty wrapper - nothing core or module-uo renders changes - and a client
test that refuses a titled EmptyState or a PageHeader subtitle.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
126 lines
6.0 KiB
JavaScript
126 lines
6.0 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 } 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,
|
|
]
|
|
.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>
|
|
)
|
|
}
|