Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/web/src/app/status/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <StatusPage status={await loadStatus()} />;
}
111 changes: 111 additions & 0 deletions apps/web/src/components/status/status-page.tsx
Original file line number Diff line number Diff line change
@@ -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<Overall, string> = {
operational: "All systems operational",
degraded: "Some parts are down",
outage: "AnyPlay is down",
unreachable: "We can’t reach the AnyPlay cloud",
};

const PARTS: Record<PartId, { name: string; what: string }> = {
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<PartState, string> = { up: "Operational", down: "Down", unknown: "Not monitored" };

const DOT: Record<PartState, string> = {
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 (
<div className="night min-h-dvh bg-bg text-ink">
<a
href="#main"
className="fixed top-3 left-3 z-50 -translate-y-20 rounded-pill bg-[#f6f5f1] px-4 py-2 text-small font-semibold text-[#161513] transition-transform duration-[var(--duration-quick)] focus-visible:translate-y-0"
>
Skip to content
</a>
<header className="mx-auto flex h-20 w-full max-w-[1160px] items-center justify-between px-[var(--gutter)]">
<Link href="/" aria-label="AnyPlay, home" className="-mx-2 rounded-pill px-2 py-1">
<Wordmark />
</Link>
<Link
href="/"
className="inline-flex min-h-9 [@media(pointer:coarse)]:min-h-11 items-center rounded-pill px-3 text-small text-ink-2 transition-[color] duration-[var(--duration-quick)] [@media(hover:hover)]:hover:text-ink"
>
Back to the start
</Link>
</header>

<main id="main" tabIndex={-1} className="outline-none">
<div className="mx-auto w-full max-w-[1160px] px-[var(--gutter)] pt-8 pb-24 lg:pt-14">
<div className="max-w-[36rem]">
<h1 className="display text-[clamp(2rem,1.6rem+1.4vw,2.6rem)] leading-[1.05] text-balance">Status</h1>
<p className="mt-6 flex items-center gap-3 text-lead font-semibold">
<span aria-hidden className={`size-3 shrink-0 rounded-full ${overallDot(status.overall)}`} />
{HEADLINE[status.overall]}
</p>
<p className="mt-3 text-small text-ink-3">
Checked at <time dateTime={new Date(status.checked).toISOString()}>{utc(status.checked)}</time>. The cloud
checks itself every ten seconds. Reload for the latest.
</p>

<section aria-labelledby="parts" className="mt-10">
<h2 id="parts" className="sr-only">
Parts of the service
</h2>
<ul className="overflow-hidden rounded-surface bg-[#141312] shadow-[inset_0_0_0_1px_rgb(255_255_255/0.07),inset_0_1px_0_rgb(255_255_255/0.05)]">
{PART_IDS.map((id) => (
<li
key={id}
className="grid grid-cols-[minmax(0,1fr)_auto] items-baseline gap-x-6 border-t border-line px-6 py-4 first:border-t-0"
>
<div>
<p className="text-body font-semibold">{PARTS[id].name}</p>
<p className="text-small text-ink-3 text-pretty">{PARTS[id].what}</p>
</div>
<p className="flex items-center gap-2 text-small text-ink-2">
<span aria-hidden className={`size-2 shrink-0 rounded-full ${DOT[status.parts[id]]}`} />
{STATE_WORD[status.parts[id]]}
</p>
</li>
))}
</ul>
</section>

<p className="mt-10 text-body text-ink-2 text-pretty">
Something not working while everything here says operational? Tell us on the community Discord or open an
issue on GitHub.
</p>
</div>
</div>
</main>
<LegalLinks />
</div>
);
}
72 changes: 72 additions & 0 deletions apps/web/src/lib/status.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) => ({
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));
});
});
71 changes: 71 additions & 0 deletions apps/web/src/lib/status.ts
Original file line number Diff line number Diff line change
@@ -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<PartId, PartState>;
};

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<Status, "overall"> & { overall: Exclude<Overall, "unreachable"> };

/** 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<string, unknown>;
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<PartId, PartState> = {
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<string, unknown>;
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<string, string | undefined> = process.env,
fetcher: typeof fetch = fetch,
now: () => number = Date.now,
): Promise<Status> {
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 } };
}
Loading