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