page.tsx282 lines · main
1import { revalidatePath } from 'next/cache';
2
3import { apiFetch, apiJson } from '../../../../../../lib/api';
4import { ConfirmButton } from './confirm-button';
5import { DiffPanel, type SnapshotDiff } from './diff-panel';
6import { RestorePreview } from './restore-preview';
7
8interface Snapshot {
9 id: string;
10 name: string;
11 tableCount: number;
12 createdAt: string;
13 auto: boolean;
14}
15
16interface SnapshotsResult {
17 snapshots: Snapshot[];
18}
19
20type AutoFrequency = 'daily' | 'twice_daily';
21
22interface 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
32export const dynamic = 'force-dynamic';
33
34export 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&apos;re unsure.
278 </p>
279 </section>
280 </div>
281 );
282}