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}` };
}
+48
View File
@@ -0,0 +1,48 @@
import type { PublicComponent } from "@/lib/api";
import HistoryBar from "./HistoryBar";
const LABEL: Record<string, string> = {
up: "Operational",
down: "Down",
maintenance: "Maintenance",
pending: "Pending",
no_data: "Unknown",
};
// A component under maintenance is drawn as maintenance, never as down — but
// its uptime figure (below) is left untouched. The window changes how a
// component is drawn, never what the numbers say; see
// applyMaintenanceRepaint in statussnapshot.go.
const DOT: Record<string, string> = {
up: "bg-success",
down: "bg-danger",
maintenance: "bg-accent",
pending: "bg-warning",
no_data: "bg-border",
};
export default function ComponentRow({ component }: { component: PublicComponent }) {
return (
<div className="px-4 py-4">
<div className="mb-2 flex items-center justify-between gap-4">
<span className="font-medium text-text-primary">{component.name}</span>
{/* Every state carries a word next to the dot: colour alone
never carries the meaning on this page. */}
<span className="flex items-center gap-2 text-sm text-text-secondary">
<span
className={`h-2 w-2 rounded-full ${DOT[component.status] ?? DOT.no_data}`}
/>
{LABEL[component.status] ?? "Unknown"}
</span>
</div>
<div className="overflow-x-auto">
<HistoryBar days={component.days} />
</div>
<div className="mt-1 flex justify-between text-xs text-text-secondary">
<span>90 days ago</span>
<span>{component.uptime_90d.toFixed(2)}% uptime</span>
<span>Today</span>
</div>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import type { PublicDay } from "@/lib/api";
// The no-data tail (a component created less recently than 90 days ago) reads
// as grey rather than as uptime — see uptimeFromDays in
// server/internal/services/statussnapshot.go, which skips these days rather
// than counting them as zero. Painting them the same as "up" here would undo
// that on the one screen a reader actually looks at.
const TONE: Record<string, string> = {
up: "bg-success",
down: "bg-danger",
maintenance: "bg-accent",
no_data: "bg-border",
};
export default function HistoryBar({ days }: { days: PublicDay[] }) {
return (
<div className="flex gap-[2px]" aria-hidden="true">
{days.map((d) => (
<span
key={d.date}
title={
d.state === "no_data"
? `${d.date}: no data`
: `${d.date}: ${d.uptime.toFixed(2)}% up`
}
className={`h-6 w-[3px] rounded-full ${TONE[d.state] ?? TONE.no_data}`}
/>
))}
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
import type { PublicIncident } from "@/lib/api";
// Mirrors the mockup's .pill--inv/--mon/--res/--sch: colour plus the status
// word itself (rendered as the pill's text), never colour alone.
const PILL_TONE: Record<string, string> = {
investigating: "text-danger border-danger/35 bg-danger/10",
identified: "text-danger border-danger/35 bg-danger/10",
monitoring: "text-warning border-warning/35 bg-warning/10",
resolved: "text-success border-success/35 bg-success/10",
scheduled: "text-accent border-accent/35 bg-accent/10",
in_progress: "text-accent border-accent/35 bg-accent/10",
completed: "text-success border-success/35 bg-success/10",
};
function StatusPill({ status }: { status: string }) {
return (
<span
className={`shrink-0 whitespace-nowrap rounded-full border px-2.5 py-1 font-mono text-[0.62rem] uppercase tracking-wider ${
PILL_TONE[status] ?? "text-text-secondary border-border bg-surface-2"
}`}
>
{status.replace("_", " ")}
</span>
);
}
export default function IncidentCard({ incident }: { incident: PublicIncident }) {
return (
<article className="rounded-lg border border-border bg-surface px-4 py-3">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="font-medium text-text-primary">{incident.title}</h3>
<p className="mt-1 text-xs text-text-secondary">
{new Date(incident.started_at).toLocaleString()}
{incident.resolved_at
? ` — resolved ${new Date(incident.resolved_at).toLocaleString()}`
: ""}
</p>
</div>
<StatusPill status={incident.status} />
</div>
{incident.affected && incident.affected.length > 0 ? (
<p className="mt-2 text-xs text-text-secondary">
Affects {incident.affected.join(", ")}
</p>
) : null}
{incident.updates && incident.updates.length > 0 ? (
<ol className="mt-3 space-y-2 border-l border-border pl-3">
{incident.updates
.slice()
.reverse()
.map((u, i) => (
<li key={i} className="text-sm">
<span className="font-mono text-xs uppercase tracking-wider text-text-secondary">
{u.status.replace("_", " ")}
</span>
<span className="ml-2 text-xs text-text-secondary">
{new Date(u.at).toLocaleString()}
</span>
<p className="mt-1 text-text-primary">{u.body}</p>
</li>
))}
</ol>
) : null}
</article>
);
}
+69
View File
@@ -1247,3 +1247,72 @@ export interface Skip {
due: string;
at: string;
}
// ---------------------------------------------------------------------------
// Public status page types
//
// These mirror services.StatusSnapshot and friends in
// server/internal/services/statussnapshot.go field for field — that Go file
// is the contract. They back the anonymous /status/[pageId] page, which is
// deliberately outside the (app) route group and never calls `request()`
// (no session, no auth). Task 10 adds the authoring types and api client
// methods alongside these; it must not redeclare this block.
// ---------------------------------------------------------------------------
export interface PublicDay {
date: string;
state: "up" | "down" | "maintenance" | "no_data";
uptime: number;
}
export interface PublicComponent {
name: string;
status: "up" | "down" | "maintenance" | "pending" | "no_data";
uptime_90d: number;
days: PublicDay[];
}
export interface PublicSection {
name: string;
components: PublicComponent[];
}
export interface PublicIncidentUpdate {
at: string;
status: string;
body: string;
}
export interface PublicIncident {
id: string;
kind: string;
title: string;
impact?: string;
status: string;
affected?: string[];
started_at: string;
resolved_at?: string;
scheduled_start?: string;
scheduled_end?: string;
updates?: PublicIncidentUpdate[];
}
export interface PublicBanner {
level: string;
text: string;
}
export interface StatusSnapshot {
available: boolean;
reason?: string;
title: string;
description?: string;
logo_url?: string;
banner?: PublicBanner;
overall: string;
sections: PublicSection[];
active_incidents: PublicIncident[];
upcoming_maintenance: PublicIncident[];
history: PublicIncident[];
generated_at: string;
}
+6
View File
@@ -48,6 +48,12 @@ const nextConfig: NextConfig = {
source: "/update.ps1",
destination: `${apiUrl}/update.ps1`,
},
{
// The public status page refreshes itself in the browser, so
// the public prefix has to be proxied the same way /api is.
source: "/public/:path*",
destination: `${apiUrl}/public/:path*`,
},
];
},
};