incident-row.tsx225 lines · main
1'use client';
2
3import { useRouter } from 'next/navigation';
4import { useState, useTransition } from 'react';
5
6import { StepUpPrompt } from '@/components/step-up-prompt';
7import { toValidDate } from '@/lib/utils';
8
9type Severity = 'critical' | 'major' | 'minor' | 'maintenance';
10
11interface Incident {
12 id: string;
13 startedAt: string;
14 resolvedAt: string | null;
15 severity: Severity;
16 services: readonly string[];
17 summary: string;
18 postmortem: string;
19 createdAt: string;
20}
21
22interface Props {
23 incident: Incident;
24 apiOrigin: string;
25}
26
27export function IncidentRow({ incident, apiOrigin }: Props) {
28 const router = useRouter();
29 const [busy, setBusy] = useState(false);
30 const [error, setError] = useState<string | null>(null);
31 const [pendingAction, setPendingAction] = useState<null | 'resolve' | 'save'>(null);
32 const [editing, setEditing] = useState(false);
33 const [summary, setSummary] = useState(incident.summary);
34 const [postmortem, setPostmortem] = useState(incident.postmortem);
35 const [, startTransition] = useTransition();
36
37 async function resolve() {
38 setBusy(true);
39 setError(null);
40 try {
41 const res = await fetch(`${apiOrigin}/v1/admin/incidents/${incident.id}/resolve`, {
42 method: 'POST',
43 credentials: 'include',
44 });
45 if (res.status === 403) {
46 const body = (await res.json().catch(() => null)) as { code?: string } | null;
47 if (body?.code === 'step_up_required') {
48 setPendingAction('resolve');
49 return;
50 }
51 }
52 if (!res.ok) throw new Error(`resolve failed: ${res.status}`);
53 startTransition(() => router.refresh());
54 } catch (err) {
55 setError(err instanceof Error ? err.message : 'resolve failed');
56 } finally {
57 setBusy(false);
58 }
59 }
60
61 async function save() {
62 setBusy(true);
63 setError(null);
64 try {
65 const res = await fetch(`${apiOrigin}/v1/admin/incidents/${incident.id}`, {
66 method: 'PATCH',
67 credentials: 'include',
68 headers: { 'content-type': 'application/json' },
69 body: JSON.stringify({ summary, postmortem }),
70 });
71 if (res.status === 403) {
72 const body = (await res.json().catch(() => null)) as { code?: string } | null;
73 if (body?.code === 'step_up_required') {
74 setPendingAction('save');
75 return;
76 }
77 }
78 if (!res.ok) {
79 const body = await res.text().catch(() => '');
80 throw new Error(body || `save failed: ${res.status}`);
81 }
82 setEditing(false);
83 startTransition(() => router.refresh());
84 } catch (err) {
85 setError(err instanceof Error ? err.message : 'save failed');
86 } finally {
87 setBusy(false);
88 }
89 }
90
91 const tone = severityTone(incident.severity);
92
93 return (
94 <li className="flex flex-col gap-4 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6">
95 <div className="flex flex-wrap items-baseline gap-2">
96 <span
97 className={`rounded-full border px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider ${tone}`}
98 >
99 {incident.severity}
100 </span>
101 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
102 {incident.services.join(', ')}
103 </span>
104 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
105 · started {formatTimestamp(incident.startedAt)}
106 {incident.resolvedAt ? ` · resolved ${formatTimestamp(incident.resolvedAt)}` : ' · ongoing'}
107 </span>
108 </div>
109
110 {editing ? (
111 <div className="flex flex-col gap-2">
112 <textarea
113 value={summary}
114 onChange={(e) => setSummary(e.target.value)}
115 maxLength={2000}
116 rows={3}
117 className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 font-mono text-sm text-[var(--color-text)] focus:border-[var(--color-primary)] focus:outline-none"
118 />
119 <textarea
120 value={postmortem}
121 onChange={(e) => setPostmortem(e.target.value)}
122 maxLength={20_000}
123 rows={6}
124 placeholder="postmortem (markdown). write once resolved."
125 className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 font-mono text-xs text-[var(--color-text-muted)] focus:border-[var(--color-primary)] focus:outline-none"
126 />
127 </div>
128 ) : (
129 <>
130 <p className="font-mono text-sm text-[var(--color-text)]">{incident.summary}</p>
131 {incident.postmortem ? (
132 <details className="font-mono text-xs text-[var(--color-text-muted)]">
133 <summary className="cursor-pointer">postmortem</summary>
134 <pre className="mt-1 whitespace-pre-wrap break-words">{incident.postmortem}</pre>
135 </details>
136 ) : null}
137 </>
138 )}
139
140 <div className="flex flex-wrap items-center gap-2">
141 {editing ? (
142 <>
143 <button
144 type="button"
145 onClick={save}
146 disabled={busy}
147 className="rounded-md bg-[var(--color-primary)] px-3 py-1.5 font-mono text-xs text-[var(--color-text-inverse)] hover:bg-[var(--color-primary-hover)] disabled:opacity-50"
148 >
149 {busy ? 'saving…' : 'save'}
150 </button>
151 <button
152 type="button"
153 onClick={() => {
154 setEditing(false);
155 setSummary(incident.summary);
156 setPostmortem(incident.postmortem);
157 }}
158 className="font-mono text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
159 >
160 cancel
161 </button>
162 </>
163 ) : (
164 <button
165 type="button"
166 onClick={() => setEditing(true)}
167 className="rounded-md border border-[var(--color-border-subtle)] px-3 py-1.5 font-mono text-xs text-[var(--color-text-muted)] hover:border-[var(--color-border-strong)] hover:text-[var(--color-text)]"
168 >
169 edit
170 </button>
171 )}
172 {!incident.resolvedAt ? (
173 <button
174 type="button"
175 onClick={resolve}
176 disabled={busy}
177 className="rounded-md border border-[var(--color-border-primary)] px-3 py-1.5 font-mono text-xs text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-text-inverse)] disabled:opacity-50"
178 >
179 mark resolved
180 </button>
181 ) : null}
182 </div>
183
184 {error ? (
185 <p className="font-mono text-[10px] text-[var(--color-error)]">{error}</p>
186 ) : null}
187
188 {pendingAction ? (
189 <StepUpPrompt
190 apiOrigin={apiOrigin}
191 reason={
192 pendingAction === 'resolve'
193 ? 'marking an incident resolved updates the public status page. confirm with your password.'
194 : 'editing an incident updates the public narrative. confirm with your password.'
195 }
196 onSuccess={async () => {
197 const action = pendingAction;
198 setPendingAction(null);
199 if (action === 'resolve') await resolve();
200 else if (action === 'save') await save();
201 }}
202 onCancel={() => setPendingAction(null)}
203 />
204 ) : null}
205 </li>
206 );
207}
208
209function severityTone(s: Severity): string {
210 switch (s) {
211 case 'critical':
212 return 'border-[var(--color-error)] text-[var(--color-error)]';
213 case 'major':
214 return 'border-[var(--color-warning)] text-[var(--color-warning)]';
215 case 'minor':
216 return 'border-[var(--color-border-subtle)] text-[var(--color-text-subtle)]';
217 case 'maintenance':
218 return 'border-[var(--color-border-primary)] bg-[var(--color-primary-subtle)] text-[var(--color-primary)]';
219 }
220}
221
222function formatTimestamp(iso: string): string {
223 const d = toValidDate(iso);
224 return d ? d.toISOString().replace('T', ' ').slice(0, 16) + ' utc' : '—';
225}