feat: public status page
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}` };
|
||||
}
|
||||
Reference in New Issue
Block a user