database-controls.tsx372 lines · main
| 1 | 'use client'; |
| 2 | |
| 3 | import { useCallback, useEffect, useState } from 'react'; |
| 4 | |
| 5 | import { StepUpPrompt } from '@/components/step-up-prompt'; |
| 6 | |
| 7 | interface DbHealth { |
| 8 | reachable: boolean; |
| 9 | latencyMs: number | null; |
| 10 | tableCount: number | null; |
| 11 | headCommit: string | null; |
| 12 | error: string | null; |
| 13 | } |
| 14 | |
| 15 | interface Snapshot { |
| 16 | id: string; |
| 17 | name: string; |
| 18 | tableCount: number; |
| 19 | createdAt: string; |
| 20 | auto: boolean; |
| 21 | commitHash: string; |
| 22 | } |
| 23 | |
| 24 | interface 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 | */ |
| 37 | export 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 | } |