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
97 lines
4.8 KiB
JavaScript
97 lines
4.8 KiB
JavaScript
// ── This module's own API bindings ────────────────────────────────────────
|
|
//
|
|
// Core hands out the request PRIMITIVE and nothing above it (MODULE_API.md
|
|
// §3.5): same-origin `/api/v1`, cookies included, JSON in and out, and an
|
|
// `ApiError` thrown on any non-2xx. The paths are this module's, because the
|
|
// routes at the other end are — `server/router/**` in this repo serves them.
|
|
//
|
|
// **Do not build your own fetch wrapper.** The primitive is what carries the
|
|
// session cookie, the CSRF handling and the error shape core's `ErrorState`
|
|
// knows how to render. A module that calls `fetch` directly gets none of that
|
|
// and finds out one page at a time.
|
|
//
|
|
// Keeping the bindings in one file, ordered the way the routers are, is
|
|
// convention rather than contract — but the two halves of every call live in
|
|
// different directories and nothing checks them against each other, so anything
|
|
// that makes a mismatch easy to see is worth doing.
|
|
|
|
import rg from './core.js'
|
|
|
|
const { request: req, BASE } = rg.api
|
|
|
|
// ── public ────────────────────────────────────────────────────────────────
|
|
// Token-free, same-origin reads. Paths are relative to `/api/v1`, so this hits
|
|
// `/api/v1/public/rust/servers` — the route `server/router/public/rust.router.js`
|
|
// registers under the `/rust` prefix `module.json` declares.
|
|
export const servers = {
|
|
list: () => req('/public/rust/servers'),
|
|
|
|
// One server, and the only route under `/servers/:id` that can answer "no such
|
|
// server": the four below answer an empty list for an id nobody ever
|
|
// configured, because an unknown server genuinely has no events.
|
|
get: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}`),
|
|
|
|
// `kind` is a comma-separated list and `wipe` a wipe id; both are optional and
|
|
// both are built here rather than in a page, so the query string this module
|
|
// sends exists in one file.
|
|
events: (id, { kinds = null, wipe = null, limit = null } = {}) =>
|
|
req(`/public/rust/servers/${encodeURIComponent(id)}/events${query({
|
|
kind: kinds && kinds.length ? kinds.join(',') : null,
|
|
wipe,
|
|
limit,
|
|
})}`),
|
|
|
|
leaderboard: (id, { wipe = null, sort = null, limit = null } = {}) =>
|
|
req(`/public/rust/servers/${encodeURIComponent(id)}/leaderboard${query({ wipe, sort, limit })}`),
|
|
|
|
wipes: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/wipes`),
|
|
|
|
online: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/online`),
|
|
}
|
|
|
|
/**
|
|
* A query string from the parameters that have a value, or `''`.
|
|
*
|
|
* **An absent parameter must be absent, not empty.** `?wipe=` is not the same
|
|
* question as no `wipe` at all — the first asks for a wipe whose id is the empty
|
|
* string — and a page that sends one because a `<select>` is on "All time" gets
|
|
* an empty leaderboard and no error.
|
|
*/
|
|
function query(params) {
|
|
const search = new URLSearchParams()
|
|
for (const [key, value] of Object.entries(params)) {
|
|
if (value !== null && value !== undefined && value !== '') search.set(key, String(value))
|
|
}
|
|
const string = search.toString()
|
|
return string ? `?${string}` : ''
|
|
}
|
|
|
|
// ── player ────────────────────────────────────────────────────────────────
|
|
// The same list, on the authenticated tier. It exists so that per-player detail
|
|
// can be added at an address clients are already calling; today the two answers
|
|
// are identical and the server delegates to one model so they cannot drift.
|
|
export const playerServers = {
|
|
list: () => req('/player/rust/servers'),
|
|
}
|
|
|
|
// ── admin ─────────────────────────────────────────────────────────────────
|
|
// **`sidecarToken` goes up and never comes back.** The list answers `hasToken`,
|
|
// and a save that omits the field leaves the stored credential alone — so an
|
|
// admin form must send it only when the operator typed one, rather than sending
|
|
// its own empty field on every save.
|
|
export const admin = {
|
|
listServers: () => req('/admin/rust/servers'),
|
|
saveServer: (id, body) =>
|
|
req(`/admin/rust/servers/${encodeURIComponent(id)}`, { method: 'PUT', body }),
|
|
deleteServer: (id) =>
|
|
req(`/admin/rust/servers/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
|
testServer: (id) =>
|
|
req(`/admin/rust/servers/${encodeURIComponent(id)}/test`, { method: 'POST' }),
|
|
}
|
|
|
|
// Exported for the rare caller that needs the base itself — an `<img src>`, a
|
|
// download link, an EventSource. Reach for `request` first.
|
|
export { BASE, query }
|
|
|
|
export default { servers, playerServers, admin, BASE }
|