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
111 lines
3.8 KiB
JavaScript
111 lines
3.8 KiB
JavaScript
// ── Public · Rust — the handlers ──────────────────────────────────────────
|
|
//
|
|
// Thin on purpose: read the request, call a model, answer. Everything worth
|
|
// testing is in the model, which needs no express and no database to test.
|
|
//
|
|
// **A handler must not throw past express.** Core mounts this router inside its
|
|
// own tier router, so an unhandled rejection here reaches core's error handler
|
|
// and answers 500 — survivable, but it means an operator sees core blamed for a
|
|
// fault in this module. Catch, log through `core.logger` (so the line carries the
|
|
// module id), and answer something honest.
|
|
|
|
const core = require('../../core')
|
|
|
|
const events = require('../../model/events/events.model')
|
|
const servers = require('../../model/servers/servers.model')
|
|
|
|
const log = core.logger('public')
|
|
|
|
async function listServers(req, res) {
|
|
try {
|
|
res.json({ servers: await servers.listPublic() })
|
|
} catch (err) {
|
|
log.error('failed to read the server list', { error: err.message })
|
|
res.status(500).json({ error: 'Failed to read the server list' })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One server, or a 404.
|
|
*
|
|
* **The 404 is the feature.** Everything else under `/servers/:id` answers an
|
|
* empty list for a server that does not exist — an unknown id has no events, no
|
|
* leaderboard and nobody online, and each of those is a perfectly good answer to
|
|
* the question it was asked. Only this route can tell the page that the server
|
|
* itself is not there, which is what stops `/rust/servers/typo` rendering as a
|
|
* quiet server with nothing to say.
|
|
*/
|
|
async function getServer(req, res) {
|
|
try {
|
|
const server = await servers.getPublic(req.params.id)
|
|
if (!server) {
|
|
res.status(404).json({ error: 'No such server' })
|
|
return
|
|
}
|
|
res.json({ server })
|
|
} catch (err) {
|
|
log.error('failed to read a server', { server: req.params.id, error: err.message })
|
|
res.status(500).json({ error: 'Failed to read the server' })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The killfeed, and everything else public that happened on one server.
|
|
*
|
|
* **`admin` is not passed, and that is the whole security posture of this
|
|
* handler.** `events.recent` takes the viewer explicitly and defaults to the
|
|
* public allowlist, so the way to leak an IP address from here is to add an
|
|
* argument rather than to forget one.
|
|
*/
|
|
async function listEvents(req, res) {
|
|
try {
|
|
res.json({
|
|
events: await events.recent({
|
|
serverId: req.params.id,
|
|
kind: req.query.kind,
|
|
wipeId: req.query.wipe || null,
|
|
limit: req.query.limit,
|
|
}),
|
|
})
|
|
} catch (err) {
|
|
log.error('failed to read events', { server: req.params.id, error: err.message })
|
|
res.status(500).json({ error: 'Failed to read events' })
|
|
}
|
|
}
|
|
|
|
async function listLeaderboard(req, res) {
|
|
try {
|
|
res.json({
|
|
leaderboard: await events.leaderboard({
|
|
serverId: req.params.id,
|
|
wipeId: req.query.wipe || null,
|
|
sort: req.query.sort,
|
|
limit: req.query.limit,
|
|
}),
|
|
})
|
|
} catch (err) {
|
|
log.error('failed to read the leaderboard', { server: req.params.id, error: err.message })
|
|
res.status(500).json({ error: 'Failed to read the leaderboard' })
|
|
}
|
|
}
|
|
|
|
async function listWipes(req, res) {
|
|
try {
|
|
res.json({ wipes: await events.wipes(req.params.id) })
|
|
} catch (err) {
|
|
log.error('failed to read wipes', { server: req.params.id, error: err.message })
|
|
res.status(500).json({ error: 'Failed to read wipes' })
|
|
}
|
|
}
|
|
|
|
async function listOnline(req, res) {
|
|
try {
|
|
res.json({ players: await events.online(req.params.id) })
|
|
} catch (err) {
|
|
log.error('failed to read presence', { server: req.params.id, error: err.message })
|
|
res.status(500).json({ error: 'Failed to read who is online' })
|
|
}
|
|
}
|
|
|
|
module.exports = { listServers, getServer, listEvents, listLeaderboard, listWipes, listOnline }
|