database-controls.tsx372 lines · main
1'use client';
2
3import { useCallback, useEffect, useState } from 'react';
4
5import { StepUpPrompt } from '@/components/step-up-prompt';
6
7interface DbHealth {
8 reachable: boolean;
9 latencyMs: number | null;
10 tableCount: number | null;
11 headCommit: string | null;
12 error: string | null;
13}
14
15interface Snapshot {
16 id: string;
17 name: string;
18 tableCount: number;
19 createdAt: string;
20 auto: boolean;
21 commitHash: string;
22}
23
24interface Props {
25 project: { id: string; name: string; slug: string };
26 apiOrigin: string;
27}
28
29/**
30 * Database controls for the admin project-detail page. Mirrors
31 * project-actions.tsx: same fetch-with-credentials pattern, same 403
32 * step_up_required inline re-auth, same honest inline error text. Loads
33 * health + snapshots on mount, then offers three actions: restart
34 * connections (safe), recover to a snapshot (typed RECOVER confirm), and
35 * reprovision (typed project-name confirm — destroys everything).
36 */
37export function DatabaseControls({ project, apiOrigin }: Props) {
38 const [health, setHealth] = useState<DbHealth | null>(null);
39 const [snapshots, setSnapshots] = useState<Snapshot[] | null>(null);
40 const [loading, setLoading] = useState(true);
41 const [busy, setBusy] = useState(false);
42 const [error, setError] = useState<string | null>(null);
43 const [notice, setNotice] = useState<string | null>(null);
44 const [pending, setPending] = useState<(() => Promise<void>) | null>(null);
45 const [showRecover, setShowRecover] = useState(false);
46 const [recoverTarget, setRecoverTarget] = useState<string | null>(null);
47 const [recoverWord, setRecoverWord] = useState('');
48 const [showReprovision, setShowReprovision] = useState(false);
49 const [confirmName, setConfirmName] = useState('');
50
51 const refresh = useCallback(async () => {
52 try {
53 const [healthRes, snapsRes] = await Promise.all([
54 fetch(`${apiOrigin}/v1/admin/projects/${project.id}/database/health`, {
55 credentials: 'include',
56 }),
57 fetch(`${apiOrigin}/v1/admin/projects/${project.id}/database/snapshots`, {
58 credentials: 'include',
59 }),
60 ]);
61 if (!healthRes.ok) throw new Error(`health check failed: ${healthRes.status}`);
62 const healthBody = (await healthRes.json()) as { health: DbHealth };
63 setHealth(healthBody.health);
64 if (snapsRes.ok) {
65 const snapsBody = (await snapsRes.json()) as { snapshots: Snapshot[] };
66 setSnapshots(snapsBody.snapshots);
67 } else {
68 setSnapshots(null);
69 }
70 } catch (err) {
71 setError(err instanceof Error ? err.message : 'request failed');
72 } finally {
73 setLoading(false);
74 }
75 }, [apiOrigin, project.id]);
76
77 useEffect(() => {
78 void refresh();
79 }, [refresh]);
80
81 // Runs a mutation, surfacing step-up re-auth inline (like project-actions).
82 // `retry` is stashed so the StepUpPrompt onSuccess can replay it.
83 async function call(
84 path: string,
85 body: Record<string, unknown>,
86 retry: () => Promise<void>,
87 ): Promise<Record<string, unknown> | null> {
88 setBusy(true);
89 setError(null);
90 setNotice(null);
91 try {
92 const res = await fetch(`${apiOrigin}${path}`, {
93 method: 'POST',
94 credentials: 'include',
95 headers: { 'content-type': 'application/json' },
96 body: JSON.stringify(body),
97 });
98 if (res.status === 403) {
99 const parsed = (await res.json().catch(() => null)) as { code?: string } | null;
100 if (parsed?.code === 'step_up_required') {
101 setPending(() => retry);
102 return null;
103 }
104 }
105 if (!res.ok) {
106 const text = await res.text().catch(() => '');
107 throw new Error(text || `request failed: ${res.status}`);
108 }
109 return (await res.json()) as Record<string, unknown>;
110 } catch (err) {
111 setError(err instanceof Error ? err.message : 'request failed');
112 return null;
113 } finally {
114 setBusy(false);
115 }
116 }
117
118 async function restart() {
119 const run = async () => {
120 const result = await call(
121 `/v1/admin/projects/${project.id}/database/restart`,
122 {},
123 run,
124 );
125 if (result) {
126 setNotice('connections restarted — fresh pool on the next query.');
127 await refresh();
128 }
129 };
130 await run();
131 }
132
133 async function recover(snapshotId: string) {
134 const run = async () => {
135 const result = await call(
136 `/v1/admin/projects/${project.id}/database/recover`,
137 { snapshotId },
138 run,
139 );
140 if (result) {
141 setNotice(
142 `recovered to ${snapshotId} — a safety snapshot (${String(
143 result.preRecoverySnapshotId,
144 )}) was taken first, ${String(result.tablesAfterRecover)} tables now.`,
145 );
146 setShowRecover(false);
147 setRecoverTarget(null);
148 setRecoverWord('');
149 await refresh();
150 }
151 };
152 await run();
153 }
154
155 async function reprovision() {
156 const run = async () => {
157 const result = await call(
158 `/v1/admin/projects/${project.id}/database/reprovision`,
159 { confirmName },
160 run,
161 );
162 if (result) {
163 setNotice('database reprovisioned — fresh and empty.');
164 setShowReprovision(false);
165 setConfirmName('');
166 await refresh();
167 }
168 };
169 await run();
170 }
171
172 return (
173 <div className="flex flex-col gap-4 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6 font-mono text-xs">
174 {/* ── status line ────────────────────────────────────────────── */}
175 {loading ? (
176 <span className="text-[var(--color-text-muted)]">checking database health…</span>
177 ) : health ? (
178 <div className="flex flex-wrap items-center gap-3">
179 {health.reachable ? (
180 <span className="rounded-full bg-green-400/20 px-2 py-0.5 text-[10px] uppercase tracking-wider text-[var(--color-success)]">
181 healthy
182 </span>
183 ) : (
184 <span className="rounded-full bg-red-400/20 px-2 py-0.5 text-[10px] uppercase tracking-wider text-red-400">
185 unreachable
186 </span>
187 )}
188 {health.latencyMs != null ? (
189 <span className="text-[var(--color-text-muted)]">{health.latencyMs}ms</span>
190 ) : null}
191 {health.tableCount != null ? (
192 <span className="text-[var(--color-text-muted)]">
193 {health.tableCount} table{health.tableCount === 1 ? '' : 's'}
194 </span>
195 ) : null}
196 {health.headCommit ? (
197 <span className="text-[var(--color-text-subtle)]">
198 head {health.headCommit.slice(0, 8)}
199 </span>
200 ) : null}
201 {health.error ? <span className="text-[var(--color-error)]">{health.error}</span> : null}
202 </div>
203 ) : (
204 <span className="text-[var(--color-text-muted)]">health unavailable.</span>
205 )}
206
207 {/* ── actions ────────────────────────────────────────────────── */}
208 <div className="flex flex-wrap items-center gap-2">
209 <button
210 type="button"
211 disabled={busy || loading}
212 onClick={() => void restart()}
213 className="rounded-md border border-[var(--color-border)] px-2 py-1 text-[var(--color-primary)] disabled:opacity-50"
214 >
215 restart connections
216 </button>
217 <button
218 type="button"
219 disabled={busy || loading}
220 onClick={() => {
221 setShowRecover((v) => !v);
222 setShowReprovision(false);
223 }}
224 className="rounded-md border border-[var(--color-border)] px-2 py-1 text-[var(--color-text)] disabled:opacity-50"
225 >
226 recover…
227 </button>
228 <button
229 type="button"
230 disabled={busy || loading}
231 onClick={() => {
232 setShowReprovision((v) => !v);
233 setShowRecover(false);
234 }}
235 className="rounded-md border border-red-400/40 px-2 py-1 text-red-400 disabled:opacity-50"
236 >
237 reprovision…
238 </button>
239 </div>
240 <p className="text-[10px] text-[var(--color-text-subtle)]">
241 restart is safe — it only drops the connection pool, never data.
242 </p>
243
244 {/* ── recover panel ──────────────────────────────────────────── */}
245 {showRecover ? (
246 <div className="flex flex-col gap-2 rounded-md border border-[var(--color-border-subtle)] p-3">
247 <span className="text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
248 snapshots
249 </span>
250 {snapshots === null ? (
251 <span className="text-[var(--color-text-muted)]">snapshots unavailable.</span>
252 ) : snapshots.length === 0 ? (
253 <span className="text-[var(--color-text-muted)]">no snapshots yet.</span>
254 ) : (
255 <ul className="flex flex-col gap-2">
256 {snapshots.map((s) => (
257 <li key={s.id} className="flex flex-wrap items-center gap-3">
258 <span className="text-[var(--color-text)]">{s.name}</span>
259 <span className="text-[var(--color-text-muted)]">
260 {new Date(s.createdAt).toLocaleString()}
261 </span>
262 <span className="text-[var(--color-text-muted)]">
263 {s.tableCount} table{s.tableCount === 1 ? '' : 's'}
264 </span>
265 {recoverTarget === s.id ? (
266 <span className="flex items-center gap-2">
267 <input
268 value={recoverWord}
269 disabled={busy}
270 onChange={(e) => setRecoverWord(e.target.value)}
271 placeholder="type RECOVER"
272 className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 text-[var(--color-text)] disabled:opacity-50"
273 />
274 <button
275 type="button"
276 disabled={busy || recoverWord !== 'RECOVER'}
277 onClick={() => void recover(s.id)}
278 className="rounded-md border border-[var(--color-border)] px-2 py-1 text-[var(--color-primary)] disabled:opacity-50"
279 >
280 confirm recover
281 </button>
282 <button
283 type="button"
284 disabled={busy}
285 onClick={() => {
286 setRecoverTarget(null);
287 setRecoverWord('');
288 }}
289 className="rounded-md border border-[var(--color-border)] px-2 py-1 text-[var(--color-text-muted)] disabled:opacity-50"
290 >
291 cancel
292 </button>
293 </span>
294 ) : (
295 <button
296 type="button"
297 disabled={busy}
298 onClick={() => {
299 setRecoverTarget(s.id);
300 setRecoverWord('');
301 }}
302 className="rounded-md border border-[var(--color-border)] px-2 py-1 text-[var(--color-text)] disabled:opacity-50"
303 >
304 recover to this
305 </button>
306 )}
307 </li>
308 ))}
309 </ul>
310 )}
311 <p className="text-[10px] text-[var(--color-text-subtle)]">
312 a fresh safety snapshot is taken automatically before every recover, so a recover can
313 itself be undone.
314 </p>
315 </div>
316 ) : null}
317
318 {/* ── reprovision panel ──────────────────────────────────────── */}
319 {showReprovision ? (
320 <div className="flex flex-col gap-2 rounded-md border border-red-400/40 p-3">
321 <p className="text-red-400">
322 this wipes the database completely — every table, every row, and every snapshot is
323 destroyed permanently. there is no undo. a fresh empty database takes its place.
324 </p>
325 <div className="flex flex-wrap items-center gap-2">
326 <input
327 value={confirmName}
328 disabled={busy}
329 onChange={(e) => setConfirmName(e.target.value)}
330 placeholder="type the project name to confirm"
331 className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 text-[var(--color-text)] disabled:opacity-50"
332 />
333 <button
334 type="button"
335 disabled={busy || confirmName.length === 0}
336 onClick={() => void reprovision()}
337 className="rounded-md border border-red-400/40 px-2 py-1 text-red-400 disabled:opacity-50"
338 >
339 reprovision database
340 </button>
341 <button
342 type="button"
343 disabled={busy}
344 onClick={() => {
345 setShowReprovision(false);
346 setConfirmName('');
347 }}
348 className="rounded-md border border-[var(--color-border)] px-2 py-1 text-[var(--color-text-muted)] disabled:opacity-50"
349 >
350 cancel
351 </button>
352 </div>
353 </div>
354 ) : null}
355
356 {notice ? <span className="text-[10px] text-[var(--color-success)]">{notice}</span> : null}
357 {error ? <span className="text-[10px] text-[var(--color-error)]">{error}</span> : null}
358 {pending ? (
359 <StepUpPrompt
360 apiOrigin={apiOrigin}
361 reason="database controls require fresh step-up auth."
362 onSuccess={async () => {
363 const retry = pending;
364 setPending(null);
365 await retry();
366 }}
367 onCancel={() => setPending(null)}
368 />
369 ) : null}
370 </div>
371 );
372}