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
+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>
);
}