Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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