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}` };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user