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

101 lines3,700 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1/**
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}