From f4f41e400b373569408037accb8833665cbf8154 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 24 Aug 2026 14:53:42 +0000 Subject: [PATCH] feat: public status page --- web/app/status/[pageId]/StatusPageView.tsx | 185 +++++++++++++++++++++ web/app/status/[pageId]/page.tsx | 40 +++++ web/components/status/ComponentRow.tsx | 48 ++++++ web/components/status/HistoryBar.tsx | 31 ++++ web/components/status/IncidentCard.tsx | 67 ++++++++ web/lib/api.ts | 69 ++++++++ web/next.config.ts | 6 + 7 files changed, 446 insertions(+) create mode 100644 web/app/status/[pageId]/StatusPageView.tsx create mode 100644 web/app/status/[pageId]/page.tsx create mode 100644 web/components/status/ComponentRow.tsx create mode 100644 web/components/status/HistoryBar.tsx create mode 100644 web/components/status/IncidentCard.tsx diff --git a/web/app/status/[pageId]/StatusPageView.tsx b/web/app/status/[pageId]/StatusPageView.tsx new file mode 100644 index 0000000..25cf12f --- /dev/null +++ b/web/app/status/[pageId]/StatusPageView.tsx @@ -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 = { + 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 = { + 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 ( + + ); + } + if (state === "maintenance") { + return ( + + ); + } + return ( + + ); +} + +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 ( +
+

{snap.title || "Status"}

+

+ {snap.reason === "licence_inactive" + ? "This status page is temporarily unavailable." + : "Status pages are not enabled on this instance."} +

+
+ ); + } + + return ( +
+
+ {snap.logo_url ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : null} +
+

{snap.title}

+ {snap.description ? ( +

{snap.description}

+ ) : null} +
+
+ +
+ + {OVERALL_COPY[snap.overall] ?? "Status unknown"} +
+ + {snap.banner ? ( +
+ {snap.banner.text} +
+ ) : null} + + {snap.active_incidents.length > 0 ? ( +
+

+ Active +

+
+ {snap.active_incidents.map((i) => ( + + ))} +
+
+ ) : null} + + {snap.upcoming_maintenance.length > 0 ? ( +
+

+ Scheduled maintenance +

+
+ {snap.upcoming_maintenance.map((i) => ( + + ))} +
+
+ ) : null} + + {snap.sections.map((section) => ( +
+

+ {section.name} +

+ {section.components.length > 0 ? ( +
+ {section.components.map((c) => ( + + ))} +
+ ) : ( +
+ No components in this section. +
+ )} +
+ ))} + + {snap.history.length > 0 ? ( +
+

+ Past incidents +

+
+ {snap.history.map((i) => ( + + ))} +
+
+ ) : null} + +
+ Updated {new Date(snap.generated_at).toLocaleString()} · refreshes every 60 seconds +
+
+ ); +} diff --git a/web/app/status/[pageId]/page.tsx b/web/app/status/[pageId]/page.tsx new file mode 100644 index 0000000..9149875 --- /dev/null +++ b/web/app/status/[pageId]/page.tsx @@ -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 { + 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 ; +} + +export async function generateMetadata({ params }: { params: Promise<{ pageId: string }> }) { + const { pageId } = await params; + return { title: `Status — ${pageId}` }; +} diff --git a/web/components/status/ComponentRow.tsx b/web/components/status/ComponentRow.tsx new file mode 100644 index 0000000..0b0c15b --- /dev/null +++ b/web/components/status/ComponentRow.tsx @@ -0,0 +1,48 @@ +import type { PublicComponent } from "@/lib/api"; +import HistoryBar from "./HistoryBar"; + +const LABEL: Record = { + 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 = { + up: "bg-success", + down: "bg-danger", + maintenance: "bg-accent", + pending: "bg-warning", + no_data: "bg-border", +}; + +export default function ComponentRow({ component }: { component: PublicComponent }) { + return ( +
+
+ {component.name} + {/* Every state carries a word next to the dot: colour alone + never carries the meaning on this page. */} + + + {LABEL[component.status] ?? "Unknown"} + +
+
+ +
+
+ 90 days ago + {component.uptime_90d.toFixed(2)}% uptime + Today +
+
+ ); +} diff --git a/web/components/status/HistoryBar.tsx b/web/components/status/HistoryBar.tsx new file mode 100644 index 0000000..9a85e34 --- /dev/null +++ b/web/components/status/HistoryBar.tsx @@ -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 = { + up: "bg-success", + down: "bg-danger", + maintenance: "bg-accent", + no_data: "bg-border", +}; + +export default function HistoryBar({ days }: { days: PublicDay[] }) { + return ( + + ); +} diff --git a/web/components/status/IncidentCard.tsx b/web/components/status/IncidentCard.tsx new file mode 100644 index 0000000..8f9e0f9 --- /dev/null +++ b/web/components/status/IncidentCard.tsx @@ -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 = { + 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 ( + + {status.replace("_", " ")} + + ); +} + +export default function IncidentCard({ incident }: { incident: PublicIncident }) { + return ( +
+
+
+

{incident.title}

+

+ {new Date(incident.started_at).toLocaleString()} + {incident.resolved_at + ? ` — resolved ${new Date(incident.resolved_at).toLocaleString()}` + : ""} +

+
+ +
+ {incident.affected && incident.affected.length > 0 ? ( +

+ Affects {incident.affected.join(", ")} +

+ ) : null} + {incident.updates && incident.updates.length > 0 ? ( +
    + {incident.updates + .slice() + .reverse() + .map((u, i) => ( +
  1. + + {u.status.replace("_", " ")} + + + {new Date(u.at).toLocaleString()} + +

    {u.body}

    +
  2. + ))} +
+ ) : null} +
+ ); +} diff --git a/web/lib/api.ts b/web/lib/api.ts index 568e19d..45162e0 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -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; +} diff --git a/web/next.config.ts b/web/next.config.ts index 30a10ba..3b3ed52 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -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*`, + }, ]; }, };