g1t/apps/sudo/app/components/incidents.tsx

101 lines3,700 bytesCodeBlame
1/**
2 * The Incidents pages' small parts: chips, a page's back link, the
3 * timers strip and the component impact picker. Forms only: sudo ships
4 * no JavaScript.
5 */
6import { ArrowLeft } from "lucide-react";
7import type { ReactNode } from "react";
8import { Link } from "react-router";
9
10import type { AdminIncident, ComponentImpact, IncidentSeverity, MaintenanceState } from "@g1t/contracts";
11
12import { Badge, Select } from "~/components/ui";
13import {
14 COMPONENT_IMPACTS,
15 IMPACT_TONE,
16 MAINTENANCE_TONE,
17 MAINTENANCE_WORD,
18 SEVERITY_TONE,
19 impactLabel,
20 phase,
21 severityLabel,
22} from "~/lib/incidents";
23
24export function SeverityBadge({ severity }: { severity: IncidentSeverity }) {
25 return <Badge tone={SEVERITY_TONE[severity]}>{severityLabel(severity)}</Badge>;
26}
27
28export function PhaseBadge({ incident }: { incident: Pick<AdminIncident, "visibility" | "status" | "postmortem_published_at"> }) {
29 const { label, tone } = phase(incident);
30 return <Badge tone={tone}>{label}</Badge>;
31}
32
33export function ImpactBadge({ impact }: { impact: ComponentImpact }) {
34 return <Badge tone={IMPACT_TONE[impact]}>{impactLabel(impact)}</Badge>;
35}
36
37export function MaintenanceBadge({ state }: { state: MaintenanceState }) {
38 return <Badge tone={MAINTENANCE_TONE[state]}>{MAINTENANCE_WORD[state]}</Badge>;
39}
40
41export function BackLink({ to, children }: { to: string; children: ReactNode }) {
42 return (
43 <Link to={to} className="inline-flex items-center gap-1.5 text-xs text-muted underline-offset-4 hover:text-fg hover:underline">
44 <ArrowLeft size={13} aria-hidden="true" />
45 {children}
46 </Link>
47 );
48}
49
50/** One timer: a label, a figure, and a line under it. */
51export function Timer({ label, value, hint, tone }: { label: string; value: ReactNode; hint?: ReactNode; tone?: "danger" | "warn" | "mint" }) {
52 const color = tone === "danger" ? "text-danger" : tone === "warn" ? "text-warn" : tone === "mint" ? "text-accent" : "text-fg";
53 return (
54 <div className="min-w-0 px-4 py-3">
55 <p className="text-xs text-muted">{label}</p>
56 <p className={`tabular mt-0.5 text-base font-semibold tracking-tight ${color}`}>{value}</p>
57 {hint && <p className="mt-0.5 truncate text-xs text-faint">{hint}</p>}
58 </div>
59 );
60}
61
62/**
63 * A row per part with a select of impacts. `blank` adds a first choice
64 * that leaves the part as it is (for updates); without it, every part
65 * starts operational (for declaring).
66 */
67export function ImpactPicker({
68 components,
69 current,
70 blank,
71}: {
72 components: { key: string; name: string }[];
73 current: Map<string, ComponentImpact>;
74 blank?: boolean;
75}) {
76 return (
77 <div className="divide-y divide-line rounded-md border border-line">
78 {components.map((c) => {
79 const now = current.get(c.key) ?? "operational";
80 return (
81 <label key={c.key} className="flex items-center gap-3 px-3 py-1.5">
82 <span className="min-w-0 grow text-sm">
83 <span className="block truncate">{c.name}</span>
84 {blank && now !== "operational" && <span className="block text-xs text-faint">now {impactLabel(now).toLowerCase()}</span>}
85 </span>
86 <span className="block w-40 shrink-0 sm:w-48">
87 <Select name={`impact.${c.key}`} defaultValue={blank ? "" : now} aria-label={`${c.name}: impact`} className="py-1 text-xs">
88 {blank && <option value="">No change</option>}
89 {COMPONENT_IMPACTS.map((i) => (
90 <option key={i.value} value={i.value}>
91 {i.label}
92 </option>
93 ))}
94 </Select>
95 </span>
96 </label>
97 );
98 })}
99 </div>
100 );
101}