From 1904b109ae04e4b5da11201e15dd1d39ac4116a5 Mon Sep 17 00:00:00 2001 From: AlxcBTW <187763517+AlxcBTW@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:36:27 +0100 Subject: [PATCH] Add the public /status page, read from the cloud's GET /v1/status --- apps/web/src/app/status/page.tsx | 21 ++++ .../web/src/components/status/status-page.tsx | 111 ++++++++++++++++++ apps/web/src/lib/status.test.ts | 72 ++++++++++++ apps/web/src/lib/status.ts | 71 +++++++++++ 4 files changed, 275 insertions(+) create mode 100644 apps/web/src/app/status/page.tsx create mode 100644 apps/web/src/components/status/status-page.tsx create mode 100644 apps/web/src/lib/status.test.ts create mode 100644 apps/web/src/lib/status.ts diff --git a/apps/web/src/app/status/page.tsx b/apps/web/src/app/status/page.tsx new file mode 100644 index 0000000..7789a60 --- /dev/null +++ b/apps/web/src/app/status/page.tsx @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import type { Metadata } from "next"; +import { connection } from "next/server"; +import { StatusPage } from "@/components/status/status-page"; +import { loadStatus } from "@/lib/status"; + +const description = "Whether AnyPlay’s cloud, relay and website are working right now."; + +export const metadata: Metadata = { + title: "Status", + description, + alternates: { canonical: "/status" }, + openGraph: { type: "website", siteName: "AnyPlay", title: "Status · AnyPlay", description, url: "/status" }, +}; + +export default async function Page() { + // Per request: the status is only worth anything when it is current, and Next's inline scripts + // carry the nonce of the script policy (src/proxy.ts). + await connection(); + return ; +} diff --git a/apps/web/src/components/status/status-page.tsx b/apps/web/src/components/status/status-page.tsx new file mode 100644 index 0000000..90543b1 --- /dev/null +++ b/apps/web/src/components/status/status-page.tsx @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import Link from "next/link"; +import { Wordmark } from "../landing/header"; +import { LegalLinks } from "../legal/links"; +import { PART_IDS, type Overall, type PartId, type PartState, type Status } from "@/lib/status"; + +/** + * The public status page: one sentence about the whole service, then one row per part. It is a plain + * server-rendered page on the night palette with no motion. The word in each row carries the state; + * the dot beside it only repeats it. + */ + +const HEADLINE: Record = { + operational: "All systems operational", + degraded: "Some parts are down", + outage: "AnyPlay is down", + unreachable: "We can’t reach the AnyPlay cloud", +}; + +const PARTS: Record = { + api: { name: "Cloud", what: "Sign-in, friends, parties and messages" }, + database: { name: "Database", what: "Where accounts and friend lists are kept" }, + signalling: { name: "Live updates", what: "Presence, invitations and connecting players" }, + relay: { name: "Relay", what: "Carries game traffic when players can’t connect directly" }, + web: { name: "Website", what: "This site and the account pages" }, +}; + +const STATE_WORD: Record = { up: "Operational", down: "Down", unknown: "Not monitored" }; + +const DOT: Record = { + up: "bg-[#5fd38d]", + down: "bg-[#ff6b5e]", + unknown: "bg-ink-3", +}; + +function overallDot(overall: Overall): string { + return overall === "operational" ? DOT.up : overall === "degraded" ? "bg-[#f2b84b]" : DOT.down; +} + +export function utc(ms: number): string { + return new Date(ms).toISOString().slice(11, 16) + " UTC"; +} + +export function StatusPage({ status }: { status: Status }) { + return ( +
+ + Skip to content + +
+ + + + + Back to the start + +
+ +
+
+
+

Status

+

+ + {HEADLINE[status.overall]} +

+

+ Checked at . The cloud + checks itself every ten seconds. Reload for the latest. +

+ +
+

+ Parts of the service +

+
    + {PART_IDS.map((id) => ( +
  • +
    +

    {PARTS[id].name}

    +

    {PARTS[id].what}

    +
    +

    + + {STATE_WORD[status.parts[id]]} +

    +
  • + ))} +
+
+ +

+ Something not working while everything here says operational? Tell us on the community Discord or open an + issue on GitHub. +

+
+
+
+ +
+ ); +} diff --git a/apps/web/src/lib/status.test.ts b/apps/web/src/lib/status.test.ts new file mode 100644 index 0000000..09f2fde --- /dev/null +++ b/apps/web/src/lib/status.test.ts @@ -0,0 +1,72 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { StatusPage, utc } from "@/components/status/status-page"; +import { loadStatus, parseStatus } from "./status"; + +const body = (over: Record = {}) => ({ + status: "operational", + checked: 1_800_000_000_000, + components: [ + { id: "api", status: "up" }, + { id: "database", status: "up" }, + { id: "signalling", status: "up" }, + { id: "relay", status: "up" }, + { id: "web", status: "unknown" }, + ], + ...over, +}); +const answer = (json: unknown, ok = true) => (async () => ({ ok, json: async () => json }) as Response) as typeof fetch; + +describe("parseStatus", () => { + it("reads the answer of the cloud", () => { + expect(parseStatus(body())?.parts.relay).toBe("up"); + }); + it("refuses a wrong shape", () => { + expect(parseStatus(null)).toBeNull(); + expect(parseStatus(body({ status: "fine" }))).toBeNull(); + expect(parseStatus(body({ checked: "now" }))).toBeNull(); + expect(parseStatus(body({ components: [{ id: "api", status: "great" }] }))).toBeNull(); + }); + it("ignores a part it does not know", () => { + const parsed = parseStatus(body({ components: [{ id: "toaster", status: "down" }] })); + expect(parsed?.parts.api).toBe("unknown"); + }); +}); + +describe("loadStatus", () => { + it("asks CLOUD_URL and calls the website up when it is this page", async () => { + let asked = ""; + const fetcher = (async (url: string) => { + asked = url; + return { ok: true, json: async () => body() } as Response; + }) as unknown as typeof fetch; + const status = await loadStatus({ CLOUD_URL: "http://cloud.internal:8080/" }, fetcher); + expect(asked).toBe("http://cloud.internal:8080/v1/status"); + expect(status.overall).toBe("operational"); + expect(status.parts.web).toBe("up"); + }); + it("says the cloud is unreachable when it does not answer", async () => { + const failing = (async () => { + throw new Error("refused"); + }) as typeof fetch; + const status = await loadStatus({}, failing, () => 5); + expect(status).toMatchObject({ overall: "unreachable", checked: 5, parts: { api: "down", web: "up" } }); + }); + it("treats an error status or garbage as no answer", async () => { + expect((await loadStatus({}, answer({}, false))).overall).toBe("unreachable"); + expect((await loadStatus({}, answer({ hello: 1 }))).overall).toBe("unreachable"); + }); +}); + +describe("StatusPage", () => { + it("names every part with a word, and prints the time", async () => { + const status = await loadStatus({}, answer(body({ components: [{ id: "relay", status: "down" }], status: "degraded" }))); + const html = renderToStaticMarkup(createElement(StatusPage, { status })); + expect(html).toContain("Some parts are down"); + for (const name of ["Cloud", "Database", "Live updates", "Relay", "Website"]) expect(html).toContain(name); + expect(html).toContain("Down"); + expect(html).toContain("Not monitored"); + expect(html).toContain(utc(1_800_000_000_000)); + }); +}); diff --git a/apps/web/src/lib/status.ts b/apps/web/src/lib/status.ts new file mode 100644 index 0000000..3ff8b50 --- /dev/null +++ b/apps/web/src/lib/status.ts @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// The public status of the cloud: what GET /v1/status answers, checked, and read for the status page. +// The page reads it on the server, so the browser never needs to reach the cloud's internal address. + +export type PartId = "api" | "database" | "signalling" | "relay" | "web"; +export type PartState = "up" | "down" | "unknown"; +export type Overall = "operational" | "degraded" | "outage" | "unreachable"; + +export type Status = { + overall: Overall; + /** Unix milliseconds of the cloud's last check, or of our attempt when it did not answer. */ + checked: number; + parts: Record; +}; + +export const PART_IDS: readonly PartId[] = ["api", "database", "signalling", "relay", "web"]; + +const STATES: readonly string[] = ["up", "down", "unknown"]; +const OVERALL: readonly string[] = ["operational", "degraded", "outage"]; + +type Parsed = Omit & { overall: Exclude }; + +/** Accepts the cloud's answer only in its exact shape; anything else counts as no answer. */ +export function parseStatus(body: unknown): Parsed | null { + if (typeof body !== "object" || body === null) return null; + const { status, checked, components } = body as Record; + if (typeof status !== "string" || !OVERALL.includes(status)) return null; + if (typeof checked !== "number" || !Number.isFinite(checked)) return null; + if (!Array.isArray(components)) return null; + const parts: Record = { + api: "unknown", + database: "unknown", + signalling: "unknown", + relay: "unknown", + web: "unknown", + }; + for (const c of components) { + if (typeof c !== "object" || c === null) return null; + const { id, status: state } = c as Record; + if (typeof id !== "string" || typeof state !== "string" || !STATES.includes(state)) return null; + if ((PART_IDS as readonly string[]).includes(id)) parts[id as PartId] = state as PartState; + } + return { overall: status as Parsed["overall"], checked, parts }; +} + +/** + * Asks the cloud. When it does not answer in time, the API is down and nothing else is known; the + * website part is up either way, because this page is being served by it. + */ +export async function loadStatus( + env: Record = process.env, + fetcher: typeof fetch = fetch, + now: () => number = Date.now, +): Promise { + const base = (env.CLOUD_URL?.trim() || "http://cloud:8080").replace(/\/+$/, ""); + let parsed: Parsed | null = null; + try { + const response = await fetcher(`${base}/v1/status`, { cache: "no-store", signal: AbortSignal.timeout(5000) }); + if (response.ok) parsed = parseStatus(await response.json()); + } catch { + parsed = null; + } + if (!parsed) { + return { + overall: "unreachable", + checked: now(), + parts: { api: "down", database: "unknown", signalling: "unknown", relay: "unknown", web: "up" }, + }; + } + return { ...parsed, parts: { ...parsed.parts, web: parsed.parts.web === "unknown" ? "up" : parsed.parts.web } }; +}