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