feat: public status page

This commit is contained in:
2026-08-24 14:53:42 +00:00
parent 3abbdc41d6
commit f4f41e400b
7 changed files with 446 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
"use client";
import { useEffect, useState } from "react";
import type { StatusSnapshot } from "@/lib/api";
import ComponentRow from "@/components/status/ComponentRow";
import IncidentCard from "@/components/status/IncidentCard";
const OVERALL_COPY: Record<string, string> = {
up: "All systems operational",
degraded: "Partially degraded service",
maintenance: "Under maintenance",
down: "Service disruption",
};
// Every state carries a word as well as a colour: the page must be readable
// without relying on hue. The banner also carries a distinct glyph per state
// (below), matching the approved mockup's shape requirement.
const OVERALL_TONE: Record<string, string> = {
up: "bg-success/10 text-success border-success/30",
degraded: "bg-warning/10 text-warning border-warning/30",
maintenance: "bg-accent/10 text-accent border-accent/30",
down: "bg-danger/10 text-danger border-danger/30",
};
function OverallGlyph({ state }: { state: string }) {
// A distinct shape per state, not just a distinct colour: a filled
// check for up, a wrench-like circle for maintenance, and an
// exclamation mark (upright for degraded, in a filled ring for down)
// otherwise.
if (state === "up") {
return (
<svg className="h-4 w-4 shrink-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
<circle cx="8" cy="8" r="6.4" />
<path d="M5.2 8.2l2 2 3.6-4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
if (state === "maintenance") {
return (
<svg className="h-4 w-4 shrink-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
<circle cx="8" cy="8" r="6.4" />
<path d="M6 6l4 4M10 6l-4 4" strokeLinecap="round" />
</svg>
);
}
return (
<svg className="h-4 w-4 shrink-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
<circle cx="8" cy="8" r="6.4" />
<path d="M8 4.8v3.6M8 11.1h.01" strokeLinecap="round" />
</svg>
);
}
export default function StatusPageView({
pageId,
initial,
}: {
pageId: string;
initial: StatusSnapshot;
}) {
const [snap, setSnap] = useState(initial);
useEffect(() => {
const id = setInterval(async () => {
try {
const res = await fetch(`/public/status/${encodeURIComponent(pageId)}`, {
cache: "no-store",
});
if (res.ok) setSnap(await res.json());
} catch {
// A failed refresh leaves the last good snapshot on screen.
// A status page that blanks itself when the network hiccups is
// worse than one showing data 60 seconds old.
}
}, 60_000);
return () => clearInterval(id);
}, [pageId]);
if (!snap.available) {
return (
<main className="mx-auto max-w-3xl px-6 py-24 text-center">
<h1 className="text-2xl font-semibold text-text-primary">{snap.title || "Status"}</h1>
<p className="mt-4 text-text-secondary">
{snap.reason === "licence_inactive"
? "This status page is temporarily unavailable."
: "Status pages are not enabled on this instance."}
</p>
</main>
);
}
return (
<main className="mx-auto max-w-3xl px-6 py-12">
<header className="mb-8 flex items-center gap-4">
{snap.logo_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={snap.logo_url} alt="" className="h-10 w-auto" />
) : null}
<div>
<h1 className="text-2xl font-semibold text-text-primary">{snap.title}</h1>
{snap.description ? (
<p className="mt-1 text-sm text-text-secondary">{snap.description}</p>
) : null}
</div>
</header>
<div
className={`mb-6 flex items-center gap-3 rounded-lg border px-4 py-3 text-sm font-medium ${
OVERALL_TONE[snap.overall] ?? OVERALL_TONE.degraded
}`}
>
<OverallGlyph state={snap.overall} />
{OVERALL_COPY[snap.overall] ?? "Status unknown"}
</div>
{snap.banner ? (
<div className="mb-8 rounded-lg border border-accent/30 bg-accent/10 px-4 py-3 text-sm text-text-primary">
{snap.banner.text}
</div>
) : null}
{snap.active_incidents.length > 0 ? (
<section className="mb-8">
<h2 className="mb-3 font-mono text-xs uppercase tracking-wider text-text-secondary">
Active
</h2>
<div className="space-y-3">
{snap.active_incidents.map((i) => (
<IncidentCard key={i.id} incident={i} />
))}
</div>
</section>
) : null}
{snap.upcoming_maintenance.length > 0 ? (
<section className="mb-8">
<h2 className="mb-3 font-mono text-xs uppercase tracking-wider text-text-secondary">
Scheduled maintenance
</h2>
<div className="space-y-3">
{snap.upcoming_maintenance.map((i) => (
<IncidentCard key={i.id} incident={i} />
))}
</div>
</section>
) : null}
{snap.sections.map((section) => (
<section key={section.name} className="mb-8">
<h2 className="mb-3 font-mono text-xs uppercase tracking-wider text-text-secondary">
{section.name}
</h2>
{section.components.length > 0 ? (
<div className="divide-y divide-border rounded-lg border border-border bg-surface">
{section.components.map((c) => (
<ComponentRow key={c.name} component={c} />
))}
</div>
) : (
<div className="rounded-lg border border-border bg-surface px-4 py-4 text-sm text-text-secondary">
No components in this section.
</div>
)}
</section>
))}
{snap.history.length > 0 ? (
<section className="mb-8">
<h2 className="mb-3 font-mono text-xs uppercase tracking-wider text-text-secondary">
Past incidents
</h2>
<div className="space-y-3">
{snap.history.map((i) => (
<IncidentCard key={i.id} incident={i} />
))}
</div>
</section>
) : null}
<footer className="mt-12 text-center text-xs text-text-secondary">
Updated {new Date(snap.generated_at).toLocaleString()} · refreshes every 60 seconds
</footer>
</main>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { notFound } from "next/navigation";
import StatusPageView from "./StatusPageView";
import type { StatusSnapshot } from "@/lib/api";
// Deliberately outside the (app) route group: no sidebar, no session fetch, no
// auth redirect. This page is served to the public.
export const dynamic = "force-dynamic";
async function fetchSnapshot(host: string, pageId: string): Promise<StatusSnapshot | null> {
const base = process.env.API_URL ?? process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
// The instance is resolved server-side from the Host header, so it has to
// be forwarded explicitly — the server-to-server fetch does not carry it.
const res = await fetch(`${base}/public/status/${encodeURIComponent(pageId)}`, {
headers: { Host: host },
cache: "no-store",
});
if (!res.ok) return null;
return res.json();
}
export default async function PublicStatusPage({
params,
}: {
params: Promise<{ pageId: string }>;
}) {
const { pageId } = await params;
const { headers } = await import("next/headers");
const h = await headers();
const host = h.get("x-forwarded-host") ?? h.get("host") ?? "";
const snapshot = await fetchSnapshot(host, pageId);
if (!snapshot) notFound();
return <StatusPageView pageId={pageId} initial={snapshot} />;
}
export async function generateMetadata({ params }: { params: Promise<{ pageId: string }> }) {
const { pageId } = await params;
return { title: `Status — ${pageId}` };
}