page.tsx282 lines · main
| 1 | import { revalidatePath } from 'next/cache'; |
| 2 | |
| 3 | import { apiFetch, apiJson } from '../../../../../../lib/api'; |
| 4 | import { ConfirmButton } from './confirm-button'; |
| 5 | import { DiffPanel, type SnapshotDiff } from './diff-panel'; |
| 6 | import { RestorePreview } from './restore-preview'; |
| 7 | |
| 8 | interface Snapshot { |
| 9 | id: string; |
| 10 | name: string; |
| 11 | tableCount: number; |
| 12 | createdAt: string; |
| 13 | auto: boolean; |
| 14 | } |
| 15 | |
| 16 | interface SnapshotsResult { |
| 17 | snapshots: Snapshot[]; |
| 18 | } |
| 19 | |
| 20 | type AutoFrequency = 'daily' | 'twice_daily'; |
| 21 | |
| 22 | interface AutoSnapshotSettings { |
| 23 | enabled: boolean; |
| 24 | frequency: AutoFrequency; |
| 25 | retentionCount: number; |
| 26 | nextRunAt: string | null; |
| 27 | lastRunAt: string | null; |
| 28 | lastRunStatus: 'ok' | 'error' | 'skipped' | null; |
| 29 | lastRunError: string | null; |
| 30 | } |
| 31 | |
| 32 | export const dynamic = 'force-dynamic'; |
| 33 | |
| 34 | export default async function SnapshotsPage({ params }: { params: Promise<{ id: string }> }) { |
| 35 | const { id } = await params; |
| 36 | |
| 37 | const [{ snapshots }, auto] = await Promise.all([ |
| 38 | apiJson<SnapshotsResult>(`/v1/projects/${id}/studio/snapshots`), |
| 39 | apiJson<AutoSnapshotSettings>(`/v1/projects/${id}/studio/auto-snapshots`), |
| 40 | ]); |
| 41 | |
| 42 | async function save(formData: FormData) { |
| 43 | 'use server'; |
| 44 | const { id } = await params; |
| 45 | const name = String(formData.get('name') ?? '').trim() || 'snapshot'; |
| 46 | const res = await apiFetch(`/v1/projects/${id}/studio/snapshots`, { |
| 47 | method: 'POST', |
| 48 | headers: { 'content-type': 'application/json' }, |
| 49 | body: JSON.stringify({ name }), |
| 50 | }); |
| 51 | if (!res.ok) { |
| 52 | const body = await res.text().catch(() => ''); |
| 53 | throw new Error(body || `snapshot failed: ${res.status}`); |
| 54 | } |
| 55 | revalidatePath(`/dashboard/projects/${id}/snapshots`); |
| 56 | } |
| 57 | |
| 58 | async function restore(formData: FormData) { |
| 59 | 'use server'; |
| 60 | const { id } = await params; |
| 61 | const snapId = String(formData.get('snapId') ?? ''); |
| 62 | const res = await apiFetch(`/v1/projects/${id}/studio/snapshots/${snapId}/restore`, { |
| 63 | method: 'POST', |
| 64 | }); |
| 65 | if (!res.ok) { |
| 66 | const body = await res.text().catch(() => ''); |
| 67 | throw new Error(body || `restore failed: ${res.status}`); |
| 68 | } |
| 69 | revalidatePath(`/dashboard/projects/${id}/snapshots`); |
| 70 | } |
| 71 | |
| 72 | async function compare(snapId: string): Promise<SnapshotDiff> { |
| 73 | 'use server'; |
| 74 | const { id } = await params; |
| 75 | return apiJson<SnapshotDiff>(`/v1/projects/${id}/studio/snapshots/${snapId}/diff`); |
| 76 | } |
| 77 | |
| 78 | async function remove(formData: FormData) { |
| 79 | 'use server'; |
| 80 | const { id } = await params; |
| 81 | const snapId = String(formData.get('snapId') ?? ''); |
| 82 | const res = await apiFetch(`/v1/projects/${id}/studio/snapshots/${snapId}`, { method: 'DELETE' }); |
| 83 | if (!res.ok) { |
| 84 | const body = await res.text().catch(() => ''); |
| 85 | throw new Error(body || `delete failed: ${res.status}`); |
| 86 | } |
| 87 | revalidatePath(`/dashboard/projects/${id}/snapshots`); |
| 88 | } |
| 89 | |
| 90 | async function saveAuto(formData: FormData) { |
| 91 | 'use server'; |
| 92 | const { id } = await params; |
| 93 | // The toggle is a checkbox: present in the form data only when on. |
| 94 | const enabled = formData.get('enabled') === 'on'; |
| 95 | const frequency = String(formData.get('frequency') ?? 'daily'); |
| 96 | const retentionCount = Number(formData.get('retentionCount') ?? 7); |
| 97 | const res = await apiFetch(`/v1/projects/${id}/studio/auto-snapshots`, { |
| 98 | method: 'PUT', |
| 99 | headers: { 'content-type': 'application/json' }, |
| 100 | body: JSON.stringify({ enabled, frequency, retentionCount }), |
| 101 | }); |
| 102 | if (!res.ok) { |
| 103 | const body = await res.text().catch(() => ''); |
| 104 | throw new Error(body || `saving automatic backups failed: ${res.status}`); |
| 105 | } |
| 106 | revalidatePath(`/dashboard/projects/${id}/snapshots`); |
| 107 | } |
| 108 | |
| 109 | return ( |
| 110 | <div className="flex flex-col gap-6"> |
| 111 | <header> |
| 112 | <h2 className="font-mono text-sm text-[var(--color-text)]">snapshots</h2> |
| 113 | <p className="mt-1 font-mono text-xs text-[var(--color-text-muted)]"> |
| 114 | your undo button. save a snapshot of all your data before a big change — then restore it |
| 115 | in one click if anything goes wrong. experiment without fear. |
| 116 | </p> |
| 117 | </header> |
| 118 | |
| 119 | <section className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-5"> |
| 120 | <form action={save} className="flex flex-col gap-3 sm:flex-row sm:items-end"> |
| 121 | <label className="flex flex-1 flex-col gap-2"> |
| 122 | <span className="font-mono text-xs text-[var(--color-text-muted)]">snapshot name</span> |
| 123 | <input |
| 124 | name="name" |
| 125 | type="text" |
| 126 | maxLength={80} |
| 127 | placeholder="before importing customers" |
| 128 | className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 font-mono text-sm outline-none focus:border-[var(--color-primary)]" |
| 129 | /> |
| 130 | </label> |
| 131 | <button |
| 132 | type="submit" |
| 133 | className="rounded-md bg-[var(--color-primary)] px-4 py-2 font-mono text-sm font-medium text-[var(--color-text-inverse)] transition hover:bg-[var(--color-primary-hover)]" |
| 134 | > |
| 135 | save a snapshot |
| 136 | </button> |
| 137 | </form> |
| 138 | </section> |
| 139 | |
| 140 | {/* Automatic backups — set-and-forget save-points on a schedule. Plain |
| 141 | language: a person picks "once a day / twice a day" and "how many to |
| 142 | keep"; the worker does the rest. */} |
| 143 | <section className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-5"> |
| 144 | <div className="mb-3"> |
| 145 | <h3 className="font-mono text-sm text-[var(--color-text)]">automatic backups</h3> |
| 146 | <p className="mt-1 font-mono text-xs text-[var(--color-text-muted)]"> |
| 147 | let briven save a snapshot for you on a schedule, so you always have a recent |
| 148 | save-point — even if you forget. we keep the most recent ones and tidy up older |
| 149 | automatic snapshots for you. your hand-saved snapshots are never touched. |
| 150 | </p> |
| 151 | </div> |
| 152 | |
| 153 | <form action={saveAuto} className="flex flex-col gap-4"> |
| 154 | <label className="flex items-center gap-3"> |
| 155 | <input |
| 156 | type="checkbox" |
| 157 | name="enabled" |
| 158 | defaultChecked={auto.enabled} |
| 159 | className="h-4 w-4 accent-[var(--color-primary)]" |
| 160 | /> |
| 161 | <span className="font-mono text-sm text-[var(--color-text)]"> |
| 162 | turn automatic backups on |
| 163 | </span> |
| 164 | </label> |
| 165 | |
| 166 | <div className="flex flex-col gap-4 sm:flex-row sm:items-end"> |
| 167 | <label className="flex flex-col gap-2"> |
| 168 | <span className="font-mono text-xs text-[var(--color-text-muted)]">how often</span> |
| 169 | <select |
| 170 | name="frequency" |
| 171 | defaultValue={auto.frequency} |
| 172 | className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 font-mono text-sm outline-none focus:border-[var(--color-primary)]" |
| 173 | > |
| 174 | <option value="daily">once a day</option> |
| 175 | <option value="twice_daily">twice a day</option> |
| 176 | </select> |
| 177 | </label> |
| 178 | |
| 179 | <label className="flex flex-col gap-2"> |
| 180 | <span className="font-mono text-xs text-[var(--color-text-muted)]"> |
| 181 | how many to keep |
| 182 | </span> |
| 183 | <input |
| 184 | name="retentionCount" |
| 185 | type="number" |
| 186 | min={1} |
| 187 | max={90} |
| 188 | defaultValue={auto.retentionCount} |
| 189 | className="w-28 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 font-mono text-sm outline-none focus:border-[var(--color-primary)]" |
| 190 | /> |
| 191 | </label> |
| 192 | |
| 193 | <button |
| 194 | type="submit" |
| 195 | className="rounded-md bg-[var(--color-primary)] px-4 py-2 font-mono text-sm font-medium text-[var(--color-text-inverse)] transition hover:bg-[var(--color-primary-hover)]" |
| 196 | > |
| 197 | save settings |
| 198 | </button> |
| 199 | </div> |
| 200 | |
| 201 | <p className="font-mono text-[11px] text-[var(--color-text-subtle)]"> |
| 202 | {auto.enabled && auto.nextRunAt |
| 203 | ? `next automatic backup around ${new Date(auto.nextRunAt) |
| 204 | .toISOString() |
| 205 | .slice(0, 16) |
| 206 | .replace('T', ' ')} utc.` |
| 207 | : 'automatic backups are off — flip the switch above to turn them on.'} |
| 208 | {auto.lastRunAt |
| 209 | ? ` last automatic backup: ${new Date(auto.lastRunAt) |
| 210 | .toISOString() |
| 211 | .slice(0, 16) |
| 212 | .replace('T', ' ')} utc${auto.lastRunStatus === 'error' ? ' (failed — we will retry)' : ''}.` |
| 213 | : ''} |
| 214 | </p> |
| 215 | </form> |
| 216 | </section> |
| 217 | |
| 218 | <section className="flex flex-col gap-3"> |
| 219 | <h3 className="font-mono text-xs uppercase tracking-wider text-[var(--color-text-subtle)]"> |
| 220 | your snapshots ({snapshots.length}) |
| 221 | </h3> |
| 222 | {snapshots.length === 0 ? ( |
| 223 | <p className="rounded-md border border-dashed border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6 text-center font-mono text-sm text-[var(--color-text-muted)]"> |
| 224 | no snapshots yet — save one before your next big change. |
| 225 | </p> |
| 226 | ) : ( |
| 227 | <ul className="flex flex-col gap-2"> |
| 228 | {snapshots.map((s) => ( |
| 229 | <li |
| 230 | key={s.id} |
| 231 | className="flex flex-col gap-3 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] px-4 py-3" |
| 232 | > |
| 233 | <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> |
| 234 | <div className="min-w-0 flex-1"> |
| 235 | <p className="flex items-center gap-2 truncate font-mono text-sm text-[var(--color-text)]"> |
| 236 | <span className="truncate">{s.name}</span> |
| 237 | <span |
| 238 | className={`shrink-0 rounded-full px-2 py-0.5 font-mono text-[10px] uppercase tracking-wider ${ |
| 239 | s.auto |
| 240 | ? 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]' |
| 241 | : 'bg-[var(--color-border-subtle)] text-[var(--color-text-subtle)]' |
| 242 | }`} |
| 243 | > |
| 244 | {s.auto ? 'automatic' : 'manual'} |
| 245 | </span> |
| 246 | </p> |
| 247 | <p className="mt-0.5 font-mono text-xs text-[var(--color-text-muted)]"> |
| 248 | {s.tableCount} {s.tableCount === 1 ? 'table' : 'tables'} ·{' '} |
| 249 | {new Date(s.createdAt).toISOString().slice(0, 16).replace('T', ' ')} utc |
| 250 | </p> |
| 251 | </div> |
| 252 | <div className="flex items-center gap-2"> |
| 253 | <form action={remove}> |
| 254 | <input type="hidden" name="snapId" value={s.id} /> |
| 255 | <ConfirmButton |
| 256 | message="Delete this snapshot permanently?" |
| 257 | 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-error)] hover:text-[var(--color-error)]" |
| 258 | > |
| 259 | delete |
| 260 | </ConfirmButton> |
| 261 | </form> |
| 262 | </div> |
| 263 | </div> |
| 264 | {/* Restore-preview: shows what restoring WILL do (in plain words) |
| 265 | before the final confirm actually runs the restore action. */} |
| 266 | <form action={restore}> |
| 267 | <input type="hidden" name="snapId" value={s.id} /> |
| 268 | <RestorePreview snapshotName={s.name} loadDiff={compare.bind(null, s.id)} /> |
| 269 | </form> |
| 270 | <DiffPanel snapshotName={s.name} loadDiff={compare.bind(null, s.id)} /> |
| 271 | </li> |
| 272 | ))} |
| 273 | </ul> |
| 274 | )} |
| 275 | <p className="mt-1 font-mono text-[11px] text-[var(--color-text-subtle)]"> |
| 276 | click restore to preview exactly what will change before anything is overwritten. tip: |
| 277 | save a fresh snapshot first if you're unsure. |
| 278 | </p> |
| 279 | </section> |
| 280 | </div> |
| 281 | ); |
| 282 | } |