Server status slot: states and data contract #51

Closed
opened 2026-07-28 08:10:36 +00:00 by archvillainette · 1 comment
Owner

Part of #47

Question

What does the server status slot show, and what data does it need?

Today sow-nodebb-plugin-pages/templates/home.tpl hardcodes two servers (Live, Testing), a dead dot, the string "Status unknown", a direct-connect address play.westgate.pw, and a Connect Guide link. Nothing is behind it. The servers are going live, so the slot needs to be designed for real data — and to degrade honestly until the feed exists.

Resolve:

  • What the slot shows when a server is up: name, player count, cap, uptime, last-updated? Which of those are load-bearing for a player deciding whether to log in, and which are noise.
  • What it shows when a server is down, when it is restarting, and when the feed itself is unreachable — three different states that are easy to collapse into one wrong one. "Status unknown" forever is the current failure.
  • Whether the direct-connect address belongs in this slot or elsewhere, and whether it should be copyable.
  • Whether Live and Testing are peers in the design, or Testing is subordinate.
  • Where the slot lives besides home: topbar, footer, both, neither.
  • Its data contract: exact field names, types, units, refresh interval, and staleness threshold, written precisely enough that whoever builds the feed does not have to guess.

Then file the feed request as an issue in sow-nodebb, carrying that contract, and link it here. Building the feed is out of scope for this map; specifying what it must return is not.

Part of #47 ## Question What does the server status slot show, and what data does it need? Today `sow-nodebb-plugin-pages/templates/home.tpl` hardcodes two servers (Live, Testing), a dead dot, the string "Status unknown", a direct-connect address `play.westgate.pw`, and a Connect Guide link. Nothing is behind it. The servers are going live, so the slot needs to be designed for real data — and to degrade honestly until the feed exists. Resolve: - What the slot shows when a server is up: name, player count, cap, uptime, last-updated? Which of those are load-bearing for a player deciding whether to log in, and which are noise. - What it shows when a server is down, when it is restarting, and when the feed itself is unreachable — three different states that are easy to collapse into one wrong one. "Status unknown" forever is the current failure. - Whether the direct-connect address belongs in this slot or elsewhere, and whether it should be copyable. - Whether Live and Testing are peers in the design, or Testing is subordinate. - Where the slot lives besides home: topbar, footer, both, neither. - Its data contract: exact field names, types, units, refresh interval, and staleness threshold, written precisely enough that whoever builds the feed does not have to guess. Then file the feed request as an issue in `sow-nodebb`, carrying that contract, and link it here. Building the feed is out of scope for this map; specifying what it must return is not.
archvillainette added the wayfinder:grilling label 2026-07-28 08:10:36 +00:00
archvillainette added a new dependency 2026-07-28 08:10:54 +00:00
archvillainette self-assigned this 2026-07-31 13:46:24 +00:00
Author
Owner

Resolution

The slot answers exactly one question: "can I log in right now?" Everything that does not serve that decision is cut.

What it shows

Two peer cards, Live and Testing, side by side — equal weight, no subordination.

Each card:

  • a status dot (kept — the at-a-glance signal; the sentence carries the detail)
  • server name
  • X players online — the raw number, always. Honesty beats vanity: 2 players online on a Tuesday morning is what it says. No thresholds, no "a few", no 14/60 cap (a fraction reads emptier than a count).
  • uptime as hh:mm:ss, in a slot the failure states reuse
  • the server's own direct-connect address, copy-on-click. One shared address was wrong the moment Live and Testing became peers.
  • in-game time, as a line the feed supplies pre-formatted

One Connect Guide link for the whole strip, not per card.

Explicitly cut as noise: player cap, last-updated (surfaced only when stale), any dashboard/telemetry chrome beyond the dot — per #49's No Machines, Only Fittings.

The three bad states

Collapsing these into one grey "Status unknown" is the current failure, and the worst of the three lies: server up, page says unknown, player leaves.

State Copy Dot Address
up X players online + uptime lit shown, copyable
down Offline dark shown, copyable
restarting Restarting (replaces uptime — the card never grows) amber, pulsing shown, copyable
stale / unreachable Status unavailable dark hollow shown, copyable

The address stays visible and copyable in every state, including the two where we do not know: the server may be fine and the player should be free to try anyway.

stale is decided by the browser, not the feed — a feed that is down cannot tell you it is down. If generated_at is older than the stale threshold, the page stops believing the document, whatever it says.

Where the slot lives

Home only. A topbar status pip is a real idea but not this ticket's call — it is permanent chrome on every forum and wiki page, so it belongs to the shell tickets (#54 ornament set, #56 width and density) once those exist. Footer is worthless for a "right now" decision.

Transport: why not Prometheus directly

The truth already exists in Prometheus — sow-codebase's PlayerCountMetricsService exports player count and the online player list, plus readiness and main-thread metrics (sow-codebase/docs/server-overview.md). But the browser must never talk to Prometheus:

  • It is not reachable. Two stacks, one per host (sow-platform/docs/runbooks/observability.md): game metrics live on netcup-game, NodeBB runs on ovh-main (hosts/ovh-main/configuration.nix). Exporter and Prometheus ports bind to the br-sow bridge only — "unreachable from the internet on either host" (modules/firewall.nix). A Prometheus never sees the other host.
  • It should not be. No auth, the whole metric surface exposed, PromQL as a public API, and one page reload per visitor hitting the monitoring stack.

So Prometheus is the source of truth; a small JSON document is the transport. The JSON stops being hand-written and becomes a projection of the metrics.

Publisher: the game host (netcup-game). It renders the document from its own local Prometheus and ships it to a public URL. Rejected alternative: NodeBB on ovh-main pulling from netcup-game, which needs a cross-host hole the observability design deliberately does not have. Publishing from the game host also means the status survives NodeBB being down, and staleness is self-evident from the timestamp.

The data contract

Served as JSON over HTTPS, Cache-Control: no-store, CORS readable by the forum origin.

{
  "version": 1,
  "generated_at": "2026-07-31T15:56:48Z",
  "servers": [
    {
      "id": "live",
      "name": "Live",
      "state": "up",
      "players": 14,
      "address": "play.westgate.pw",
      "started_at": "2026-07-30T21:02:11Z",
      "game_time": "08:00 on day 4 of Tarsakh, 190 AR"
    },
    {
      "id": "testing",
      "name": "Testing",
      "state": "restarting",
      "players": 0,
      "address": "test.westgate.pw",
      "started_at": null,
      "game_time": null
    }
  ]
}
Field Type Units / rules
version integer contract version. 1. Bumped only on a breaking change; consumers ignore unknown fields.
generated_at string RFC 3339 UTC, when the document was written. Required. The staleness clock.
servers array render order is array order. Cards are peers.
servers[].id string stable machine key (live, testing). Never shown.
servers[].name string display name.
servers[].state enum up | down | restarting. No other value; an unknown value is treated as down.
servers[].players integer count of players connected. 0 when not up. Never null, never negative.
servers[].address string direct-connect address, host or host:port, exactly as a player must type it.
servers[].started_at string | null RFC 3339 UTC of process start. null unless state is up.
servers[].game_time string | null pre-formatted in-game date/time. The server owns the wording; the page renders it verbatim. null if unavailable.

Uptime is derived, never transmitted. The feed sends started_at; the browser subtracts it from its own clock. Nothing polls per second to make a clock tick — a per-second display, if wanted, is a local timer over a value already held.

  • Publish interval: 30s — matches the Prometheus scrape already running on netcup-game.
  • Browser poll: 60s while the tab is visible, paused when hidden (visibilitychange), refetched immediately on becoming visible again.
  • Stale threshold: 90s — 3× publish interval. Survives one missed publish, catches a dead publisher fast. Past it, every card renders Status unavailable regardless of its state.
  • Any fetch failure, non-2xx, or unparseable body is treated as stale, never as down.

Out of scope, deliberately

Player names are not in v1. The contract carries counts only. The home page stays simple: count per server plus a link out to a real player list. A /playerlist page — portraits, hide flags, the Astrolabe-style view — is a separate effort spanning sow-nodebb-plugin-pages, sow-codebase, and platform metrics, and it is where the visibility/opt-out decision has to be made before any name is ever published. Arelith is inspiration, not a thing to copy.

Follow-up issues

Building the feed is out of scope for this map; specifying what it must return was not.

  • sow-platform#127 — publish the status document from netcup-game, per this contract.
  • sow-nodebb-plugin-pages#13 — rebuild the home status strip against this contract, replacing the hardcoded markup and the sow-status.json stub.
  • sow-codebase#243 — expose the online player list with a per-player hide flag, including the opt-in/opt-out call.
  • sow-nodebb-plugin-pages#14 — the /playerlist page (future, blocked on the two above).
## Resolution The slot answers exactly one question: **"can I log in right now?"** Everything that does not serve that decision is cut. ### What it shows Two **peer** cards, Live and Testing, side by side — equal weight, no subordination. Each card: - a status **dot** (kept — the at-a-glance signal; the sentence carries the detail) - server name - **`X players online`** — the raw number, always. Honesty beats vanity: `2 players online` on a Tuesday morning is what it says. No thresholds, no "a few", no `14/60` cap (a fraction reads emptier than a count). - **uptime** as `hh:mm:ss`, in a slot the failure states reuse - the server's **own direct-connect address**, copy-on-click. One shared address was wrong the moment Live and Testing became peers. - **in-game time**, as a line the feed supplies pre-formatted One **Connect Guide** link for the whole strip, not per card. Explicitly cut as noise: player cap, last-updated (surfaced only when stale), any dashboard/telemetry chrome beyond the dot — per #49's *No Machines, Only Fittings*. ### The three bad states Collapsing these into one grey "Status unknown" is the current failure, and the worst of the three lies: server up, page says unknown, player leaves. | State | Copy | Dot | Address | | --- | --- | --- | --- | | **up** | `X players online` + uptime | lit | shown, copyable | | **down** | `Offline` | dark | shown, copyable | | **restarting** | `Restarting` (replaces uptime — the card never grows) | amber, pulsing | shown, copyable | | **stale / unreachable** | `Status unavailable` | dark hollow | shown, copyable | The address stays visible and copyable in **every** state, including the two where we do not know: the server may be fine and the player should be free to try anyway. `stale` is decided **by the browser**, not the feed — a feed that is down cannot tell you it is down. If `generated_at` is older than the stale threshold, the page stops believing the document, whatever it says. ### Where the slot lives **Home only.** A topbar status pip is a real idea but not this ticket's call — it is permanent chrome on every forum and wiki page, so it belongs to the shell tickets (#54 ornament set, #56 width and density) once those exist. Footer is worthless for a "right now" decision. ### Transport: why not Prometheus directly The truth already exists in Prometheus — `sow-codebase`'s `PlayerCountMetricsService` exports player count and the online player list, plus readiness and main-thread metrics (`sow-codebase/docs/server-overview.md`). But the browser must never talk to Prometheus: - **It is not reachable.** Two stacks, one per host (`sow-platform/docs/runbooks/observability.md`): game metrics live on `netcup-game`, NodeBB runs on `ovh-main` (`hosts/ovh-main/configuration.nix`). Exporter and Prometheus ports bind to the `br-sow` bridge only — "unreachable from the internet on either host" (`modules/firewall.nix`). A Prometheus never sees the other host. - **It should not be.** No auth, the whole metric surface exposed, PromQL as a public API, and one page reload per visitor hitting the monitoring stack. So **Prometheus is the source of truth; a small JSON document is the transport.** The JSON stops being hand-written and becomes a projection of the metrics. **Publisher: the game host (`netcup-game`).** It renders the document from its own local Prometheus and ships it to a public URL. Rejected alternative: NodeBB on `ovh-main` pulling from `netcup-game`, which needs a cross-host hole the observability design deliberately does not have. Publishing from the game host also means the status survives NodeBB being down, and staleness is self-evident from the timestamp. ### The data contract Served as JSON over HTTPS, `Cache-Control: no-store`, CORS readable by the forum origin. ```json { "version": 1, "generated_at": "2026-07-31T15:56:48Z", "servers": [ { "id": "live", "name": "Live", "state": "up", "players": 14, "address": "play.westgate.pw", "started_at": "2026-07-30T21:02:11Z", "game_time": "08:00 on day 4 of Tarsakh, 190 AR" }, { "id": "testing", "name": "Testing", "state": "restarting", "players": 0, "address": "test.westgate.pw", "started_at": null, "game_time": null } ] } ``` | Field | Type | Units / rules | | --- | --- | --- | | `version` | integer | contract version. `1`. Bumped only on a breaking change; consumers ignore unknown fields. | | `generated_at` | string | RFC 3339 UTC, when the document was written. **Required.** The staleness clock. | | `servers` | array | render order is array order. Cards are peers. | | `servers[].id` | string | stable machine key (`live`, `testing`). Never shown. | | `servers[].name` | string | display name. | | `servers[].state` | enum | `up` \| `down` \| `restarting`. No other value; an unknown value is treated as `down`. | | `servers[].players` | integer | count of players connected. `0` when not `up`. Never null, never negative. | | `servers[].address` | string | direct-connect address, host or `host:port`, exactly as a player must type it. | | `servers[].started_at` | string \| null | RFC 3339 UTC of process start. `null` unless `state` is `up`. | | `servers[].game_time` | string \| null | pre-formatted in-game date/time. The server owns the wording; the page renders it verbatim. `null` if unavailable. | **Uptime is derived, never transmitted.** The feed sends `started_at`; the browser subtracts it from its own clock. Nothing polls per second to make a clock tick — a per-second display, if wanted, is a local timer over a value already held. - **Publish interval:** 30s — matches the Prometheus scrape already running on `netcup-game`. - **Browser poll:** 60s while the tab is visible, **paused when hidden** (`visibilitychange`), refetched immediately on becoming visible again. - **Stale threshold:** 90s — 3× publish interval. Survives one missed publish, catches a dead publisher fast. Past it, every card renders `Status unavailable` regardless of its `state`. - Any fetch failure, non-2xx, or unparseable body is treated as stale, never as `down`. ### Out of scope, deliberately **Player names are not in v1.** The contract carries counts only. The home page stays simple: count per server plus a link out to a real player list. A `/playerlist` page — portraits, hide flags, the Astrolabe-style view — is a separate effort spanning `sow-nodebb-plugin-pages`, `sow-codebase`, and platform metrics, and it is where the visibility/opt-out decision has to be made *before* any name is ever published. Arelith is inspiration, not a thing to copy. ### Follow-up issues Building the feed is out of scope for this map; specifying what it must return was not. - [sow-platform#127](https://git.westgate.pw/ShadowsOverWestgate/sow-platform/issues/127) — publish the status document from `netcup-game`, per this contract. - [sow-nodebb-plugin-pages#13](https://git.westgate.pw/ShadowsOverWestgate/sow-nodebb-plugin-pages/issues/13) — rebuild the home status strip against this contract, replacing the hardcoded markup and the `sow-status.json` stub. - [sow-codebase#243](https://git.westgate.pw/ShadowsOverWestgate/sow-codebase/issues/243) — expose the online player list with a per-player hide flag, including the opt-in/opt-out call. - [sow-nodebb-plugin-pages#14](https://git.westgate.pw/ShadowsOverWestgate/sow-nodebb-plugin-pages/issues/14) — the `/playerlist` page (future, blocked on the two above).
Sign in to join this conversation.