@morphika/andami 0.2.9 → 0.2.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -3
- package/admin/backups.ts +7 -0
- package/app/admin/backups/page.tsx +1 -0
- package/app/admin/layout.tsx +327 -274
- package/app/api/admin/backups/export/route.ts +76 -0
- package/app/api/admin/backups/prepare-export/route.ts +56 -0
- package/app/api/admin/backups/restore/route.ts +131 -0
- package/app/api/admin/backups/restore-data/route.ts +424 -0
- package/app/api/admin/backups/status/route.ts +35 -0
- package/app/api/custom-sections/[id]/route.ts +9 -5
- package/components/admin/backups/BackupsPage.tsx +1581 -0
- package/components/builder/CustomSectionInstanceCard.tsx +7 -3
- package/components/builder/ReadOnlyFrame.tsx +4 -2
- package/components/builder/settings-panel/CustomSectionSettings.tsx +5 -3
- package/lib/backup/export.ts +377 -0
- package/lib/backup/manifest.ts +121 -0
- package/lib/backup/r2-helpers.ts +294 -0
- package/lib/backup/restore.ts +266 -0
- package/lib/backup/sanity-ops.ts +194 -0
- package/lib/builder/serializer/normalizers.ts +1 -0
- package/lib/builder/store-canvas.ts +4 -0
- package/lib/builder/store.ts +1 -0
- package/lib/builder/types.ts +4 -0
- package/lib/security.ts +30 -0
- package/lib/security.ts.new +27 -0
- package/lib/storage/types.ts +33 -1
- package/lib/version.ts +7 -4
- package/package.json +17 -1
|
@@ -0,0 +1,1581 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
4
|
+
import { csrfHeaders } from "../../../lib/csrf-client";
|
|
5
|
+
import { formatDate, formatBytes } from "../../../lib/format-utils";
|
|
6
|
+
import { getSiteConfig } from "../../../lib/config";
|
|
7
|
+
import { getMimeType } from "../../../lib/storage/types";
|
|
8
|
+
import type { BackupManifest, BackupStatus } from "../../../lib/backup/manifest";
|
|
9
|
+
|
|
10
|
+
// ============================================
|
|
11
|
+
// Types
|
|
12
|
+
// ============================================
|
|
13
|
+
|
|
14
|
+
/** Shape returned by GET /api/admin/backups/prepare-export (V2) */
|
|
15
|
+
interface ExportData {
|
|
16
|
+
manifest: BackupManifest;
|
|
17
|
+
documents: {
|
|
18
|
+
pages: Record<string, unknown>[];
|
|
19
|
+
siteSettings: Record<string, unknown> | null;
|
|
20
|
+
siteStyles: Record<string, unknown> | null;
|
|
21
|
+
assetRegistry: Record<string, unknown> | null;
|
|
22
|
+
customSections: Record<string, unknown>[];
|
|
23
|
+
};
|
|
24
|
+
assets: {
|
|
25
|
+
r2_connected: boolean;
|
|
26
|
+
public_url: string | null;
|
|
27
|
+
files: { key: string; size: number }[];
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A presigned upload URL pre-generated by the server for a single asset key. */
|
|
32
|
+
interface PresignedUploadUrl {
|
|
33
|
+
key: string;
|
|
34
|
+
uploadUrl: string;
|
|
35
|
+
contentType: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Shape returned by POST /api/admin/backups/restore-data (V2) */
|
|
39
|
+
interface RestoreDataResult {
|
|
40
|
+
success: boolean;
|
|
41
|
+
sanity: {
|
|
42
|
+
wiped: Record<string, number>;
|
|
43
|
+
restored: Record<string, number>;
|
|
44
|
+
};
|
|
45
|
+
r2_wiped: number | null;
|
|
46
|
+
/**
|
|
47
|
+
* Presigned PUT URLs (one per asset key) — the browser uploads directly
|
|
48
|
+
* to R2 without invoking Vercel per file. Undefined when R2 isn't
|
|
49
|
+
* connected or the request didn't include any assets.
|
|
50
|
+
*/
|
|
51
|
+
upload_urls?: PresignedUploadUrl[];
|
|
52
|
+
/** TTL of the presigned URLs, in seconds. */
|
|
53
|
+
upload_ttl_seconds?: number;
|
|
54
|
+
duration_ms: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ============================================
|
|
58
|
+
// SVG Icons
|
|
59
|
+
// ============================================
|
|
60
|
+
|
|
61
|
+
function BackupIcon({ size = 20 }: { size?: number }) {
|
|
62
|
+
return (
|
|
63
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="#3b82f6" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
64
|
+
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
|
|
65
|
+
<path d="M12 12v9" />
|
|
66
|
+
<path d="m16 16-4-4-4 4" />
|
|
67
|
+
</svg>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function RestoreIcon({ size = 20 }: { size?: number }) {
|
|
72
|
+
return (
|
|
73
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
74
|
+
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
|
|
75
|
+
<path d="M12 12v9" />
|
|
76
|
+
<path d="m8 17 4 4 4-4" />
|
|
77
|
+
</svg>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function SpinnerIcon({ size = 16 }: { size?: number }) {
|
|
82
|
+
return (
|
|
83
|
+
<svg className="animate-spin" width={size} height={size} viewBox="0 0 24 24" fill="none">
|
|
84
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
85
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
86
|
+
</svg>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function CheckIcon({ size = 16 }: { size?: number }) {
|
|
91
|
+
return (
|
|
92
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
93
|
+
<polyline points="20 6 9 17 4 12" />
|
|
94
|
+
</svg>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function AlertIcon({ size = 16 }: { size?: number }) {
|
|
99
|
+
return (
|
|
100
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
101
|
+
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
|
102
|
+
<line x1="12" y1="9" x2="12" y2="13" />
|
|
103
|
+
<line x1="12" y1="17" x2="12.01" y2="17" />
|
|
104
|
+
</svg>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function UploadIcon({ size = 32 }: { size?: number }) {
|
|
109
|
+
return (
|
|
110
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
111
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
112
|
+
<polyline points="17 8 12 3 7 8" />
|
|
113
|
+
<line x1="12" y1="3" x2="12" y2="15" />
|
|
114
|
+
</svg>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ============================================
|
|
119
|
+
// Helpers
|
|
120
|
+
// ============================================
|
|
121
|
+
|
|
122
|
+
/** Total document count from a BackupStatus */
|
|
123
|
+
function totalDocs(status: BackupStatus): number {
|
|
124
|
+
const c = status.document_counts;
|
|
125
|
+
return (
|
|
126
|
+
c.pages +
|
|
127
|
+
c.custom_sections +
|
|
128
|
+
(c.site_settings ? 1 : 0) +
|
|
129
|
+
(c.site_styles ? 1 : 0) +
|
|
130
|
+
(c.asset_registry ? 1 : 0)
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Total document count from a BackupManifest */
|
|
135
|
+
function totalManifestDocs(m: BackupManifest): number {
|
|
136
|
+
const d = m.documents;
|
|
137
|
+
return d.pages + d.custom_sections + d.site_settings + d.site_styles + d.asset_registry;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ============================================
|
|
141
|
+
// Concurrency pool — download/upload assets in parallel
|
|
142
|
+
// ============================================
|
|
143
|
+
|
|
144
|
+
interface PoolProgress {
|
|
145
|
+
completed: number;
|
|
146
|
+
total: number;
|
|
147
|
+
bytesTransferred: number;
|
|
148
|
+
/**
|
|
149
|
+
* L-8: total expected size in bytes across all files (when known).
|
|
150
|
+
* The upload flow sets this from the ZIP entry `uncompressedSize` stats
|
|
151
|
+
* so the progress bar can be weighted by bytes instead of file count —
|
|
152
|
+
* which is much smoother when a handful of large files dominate the
|
|
153
|
+
* upload (e.g. a 200 MB video alongside 50 tiny thumbnails).
|
|
154
|
+
*
|
|
155
|
+
* `0` means byte totals aren't available and the UI should fall back to
|
|
156
|
+
* file-count progress. The download flow always populates this because
|
|
157
|
+
* prepare-export returns sizes alongside keys.
|
|
158
|
+
*/
|
|
159
|
+
totalBytes: number;
|
|
160
|
+
failed: string[];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Download assets from R2 CDN into a JSZip instance with a concurrency pool.
|
|
165
|
+
* Each file is fetched directly from the R2 public URL — no Vercel traffic.
|
|
166
|
+
*/
|
|
167
|
+
async function downloadAssetsToZip(
|
|
168
|
+
files: { key: string; size: number }[],
|
|
169
|
+
publicUrl: string,
|
|
170
|
+
zip: import("jszip"),
|
|
171
|
+
onProgress: (p: PoolProgress) => void,
|
|
172
|
+
abortSignal: AbortSignal,
|
|
173
|
+
concurrency = 6
|
|
174
|
+
): Promise<PoolProgress> {
|
|
175
|
+
const progress: PoolProgress = {
|
|
176
|
+
completed: 0,
|
|
177
|
+
total: files.length,
|
|
178
|
+
bytesTransferred: 0,
|
|
179
|
+
// L-8: use the sizes from prepare-export so the bar reflects byte
|
|
180
|
+
// progress, not file count. Falls back gracefully to 0 when sizes
|
|
181
|
+
// aren't available (the caller then shows a count-based bar).
|
|
182
|
+
totalBytes: files.reduce((s, f) => s + (f.size || 0), 0),
|
|
183
|
+
failed: [],
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const queue = [...files];
|
|
187
|
+
|
|
188
|
+
async function worker() {
|
|
189
|
+
while (queue.length > 0) {
|
|
190
|
+
if (abortSignal.aborted) return;
|
|
191
|
+
|
|
192
|
+
const file = queue.shift()!;
|
|
193
|
+
try {
|
|
194
|
+
const res = await fetch(`${publicUrl.replace(/\/$/, "")}/${file.key}`, {
|
|
195
|
+
signal: abortSignal,
|
|
196
|
+
});
|
|
197
|
+
if (res.ok) {
|
|
198
|
+
const blob = await res.blob();
|
|
199
|
+
zip.file(`assets/${file.key}`, blob);
|
|
200
|
+
progress.bytesTransferred += blob.size;
|
|
201
|
+
} else {
|
|
202
|
+
progress.failed.push(file.key);
|
|
203
|
+
}
|
|
204
|
+
} catch (err) {
|
|
205
|
+
if (abortSignal.aborted) return;
|
|
206
|
+
progress.failed.push(file.key);
|
|
207
|
+
}
|
|
208
|
+
progress.completed++;
|
|
209
|
+
onProgress({ ...progress });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, files.length) }, () => worker()));
|
|
214
|
+
return progress;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Upload assets to R2 using presigned PUT URLs that were pre-generated
|
|
219
|
+
* server-side (see `/api/admin/backups/restore-data`). This keeps Vercel
|
|
220
|
+
* out of the per-asset hot path — the restore endpoint signs every URL
|
|
221
|
+
* in one invocation, then the browser PUTs directly to R2.
|
|
222
|
+
*
|
|
223
|
+
* Each failed upload is retried up to `maxRetries` times with exponential
|
|
224
|
+
* backoff. Transient R2 or network errors no longer silently "eat" files.
|
|
225
|
+
*/
|
|
226
|
+
async function uploadAssetsFromZip(
|
|
227
|
+
zip: import("jszip"),
|
|
228
|
+
presigned: PresignedUploadUrl[],
|
|
229
|
+
onProgress: (p: PoolProgress) => void,
|
|
230
|
+
abortSignal: AbortSignal,
|
|
231
|
+
concurrency = 6,
|
|
232
|
+
maxRetries = 3
|
|
233
|
+
): Promise<PoolProgress> {
|
|
234
|
+
// Map by key for O(1) lookup
|
|
235
|
+
const urlByKey = new Map<string, PresignedUploadUrl>();
|
|
236
|
+
for (const p of presigned) urlByKey.set(p.key, p);
|
|
237
|
+
|
|
238
|
+
// Collect all asset entries from the ZIP. We also read each entry's
|
|
239
|
+
// uncompressed size from JSZip's internal `_data` stats so L-8 can
|
|
240
|
+
// weight the progress bar by bytes rather than file count. JSZip does
|
|
241
|
+
// not expose a typed accessor for this, so we cast carefully.
|
|
242
|
+
const assetEntries: { key: string; zipPath: string; size: number }[] = [];
|
|
243
|
+
zip.forEach((relativePath: string, zipEntry) => {
|
|
244
|
+
if (relativePath.startsWith("assets/") && !relativePath.endsWith("/")) {
|
|
245
|
+
const key = relativePath.replace(/^assets\//, "");
|
|
246
|
+
const size =
|
|
247
|
+
(zipEntry as unknown as { _data?: { uncompressedSize?: number } })._data
|
|
248
|
+
?.uncompressedSize ?? 0;
|
|
249
|
+
assetEntries.push({ key, zipPath: relativePath, size });
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const progress: PoolProgress = {
|
|
254
|
+
completed: 0,
|
|
255
|
+
total: assetEntries.length,
|
|
256
|
+
bytesTransferred: 0,
|
|
257
|
+
// L-8: sum of uncompressed sizes — may be 0 if JSZip couldn't read
|
|
258
|
+
// the stats (extremely old ZIPs). In that case `ProgressBar` falls
|
|
259
|
+
// back to file-count mode.
|
|
260
|
+
totalBytes: assetEntries.reduce((s, e) => s + (e.size || 0), 0),
|
|
261
|
+
failed: [],
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
if (assetEntries.length === 0) return progress;
|
|
265
|
+
|
|
266
|
+
const queue = [...assetEntries];
|
|
267
|
+
|
|
268
|
+
async function uploadOne(
|
|
269
|
+
entry: { key: string; zipPath: string }
|
|
270
|
+
): Promise<{ ok: true; bytes: number } | { ok: false }> {
|
|
271
|
+
const zipFile = zip.file(entry.zipPath);
|
|
272
|
+
if (!zipFile) return { ok: false };
|
|
273
|
+
|
|
274
|
+
const blob = await zipFile.async("blob");
|
|
275
|
+
const presignedEntry = urlByKey.get(entry.key);
|
|
276
|
+
if (!presignedEntry) return { ok: false };
|
|
277
|
+
|
|
278
|
+
// Content-Type MUST match what the server signed with, otherwise R2
|
|
279
|
+
// rejects with 403. We trust the server's contentType and fall back
|
|
280
|
+
// to a local lookup if the field is missing.
|
|
281
|
+
const contentType =
|
|
282
|
+
presignedEntry.contentType || getMimeType(entry.key);
|
|
283
|
+
|
|
284
|
+
const putRes = await fetch(presignedEntry.uploadUrl, {
|
|
285
|
+
method: "PUT",
|
|
286
|
+
headers: { "Content-Type": contentType },
|
|
287
|
+
body: blob,
|
|
288
|
+
signal: abortSignal,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
if (putRes.ok) {
|
|
292
|
+
return { ok: true, bytes: blob.size };
|
|
293
|
+
}
|
|
294
|
+
return { ok: false };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function uploadWithRetry(
|
|
298
|
+
entry: { key: string; zipPath: string }
|
|
299
|
+
): Promise<{ ok: boolean; bytes: number }> {
|
|
300
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
301
|
+
if (abortSignal.aborted) return { ok: false, bytes: 0 };
|
|
302
|
+
try {
|
|
303
|
+
const result = await uploadOne(entry);
|
|
304
|
+
if (result.ok) return { ok: true, bytes: result.bytes };
|
|
305
|
+
} catch (err) {
|
|
306
|
+
if (abortSignal.aborted) return { ok: false, bytes: 0 };
|
|
307
|
+
// Fallthrough to retry below.
|
|
308
|
+
}
|
|
309
|
+
if (attempt < maxRetries) {
|
|
310
|
+
// Exponential backoff with jitter: 400ms, 900ms, 2000ms (approx)
|
|
311
|
+
const delayMs = 400 * Math.pow(2, attempt) + Math.random() * 200;
|
|
312
|
+
await new Promise<void>((resolve) => {
|
|
313
|
+
const timer = setTimeout(() => resolve(), delayMs);
|
|
314
|
+
// If aborted during backoff, clear the timer and bail out.
|
|
315
|
+
const onAbort = () => {
|
|
316
|
+
clearTimeout(timer);
|
|
317
|
+
resolve();
|
|
318
|
+
};
|
|
319
|
+
if (abortSignal.aborted) {
|
|
320
|
+
onAbort();
|
|
321
|
+
} else {
|
|
322
|
+
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return { ok: false, bytes: 0 };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function worker() {
|
|
331
|
+
while (queue.length > 0) {
|
|
332
|
+
if (abortSignal.aborted) return;
|
|
333
|
+
|
|
334
|
+
const entry = queue.shift()!;
|
|
335
|
+
try {
|
|
336
|
+
const result = await uploadWithRetry(entry);
|
|
337
|
+
if (result.ok) {
|
|
338
|
+
progress.bytesTransferred += result.bytes;
|
|
339
|
+
} else {
|
|
340
|
+
progress.failed.push(entry.key);
|
|
341
|
+
}
|
|
342
|
+
} catch (err) {
|
|
343
|
+
if (abortSignal.aborted) return;
|
|
344
|
+
progress.failed.push(entry.key);
|
|
345
|
+
}
|
|
346
|
+
progress.completed++;
|
|
347
|
+
onProgress({ ...progress });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, assetEntries.length) }, () => worker()));
|
|
352
|
+
return progress;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ============================================
|
|
356
|
+
// Progress Bar Component
|
|
357
|
+
// ============================================
|
|
358
|
+
|
|
359
|
+
function ProgressBar({
|
|
360
|
+
phase,
|
|
361
|
+
progress,
|
|
362
|
+
totalFiles,
|
|
363
|
+
bytesTransferred,
|
|
364
|
+
totalBytes,
|
|
365
|
+
failedCount,
|
|
366
|
+
}: {
|
|
367
|
+
phase: string;
|
|
368
|
+
/**
|
|
369
|
+
* Fallback count-based progress in the 0–100 range. Used when
|
|
370
|
+
* `totalBytes` is 0 (e.g. very old ZIPs where JSZip can't read the
|
|
371
|
+
* uncompressed size). When `totalBytes > 0` the bar uses bytes instead
|
|
372
|
+
* and `progress` is ignored — this keeps the ProgressBar contract
|
|
373
|
+
* backward-compatible with older callers.
|
|
374
|
+
*/
|
|
375
|
+
progress: number;
|
|
376
|
+
totalFiles?: number;
|
|
377
|
+
bytesTransferred?: number;
|
|
378
|
+
/**
|
|
379
|
+
* L-8: total expected bytes across all files. When > 0, the progress
|
|
380
|
+
* bar is weighted by bytes transferred — much smoother when a handful
|
|
381
|
+
* of large files dominate the transfer.
|
|
382
|
+
*/
|
|
383
|
+
totalBytes?: number;
|
|
384
|
+
failedCount?: number;
|
|
385
|
+
}) {
|
|
386
|
+
// L-8: prefer byte-weighted progress when we have total bytes; fall
|
|
387
|
+
// back to the count-based value otherwise.
|
|
388
|
+
const useBytes = totalBytes !== undefined && totalBytes > 0 && bytesTransferred !== undefined;
|
|
389
|
+
const displayProgress = useBytes
|
|
390
|
+
? Math.min(100, (bytesTransferred! / totalBytes!) * 100)
|
|
391
|
+
: progress;
|
|
392
|
+
return (
|
|
393
|
+
<div className="space-y-1.5">
|
|
394
|
+
<div className="flex items-center justify-between text-xs">
|
|
395
|
+
<span className="text-neutral-600 font-medium">{phase}</span>
|
|
396
|
+
{totalFiles !== undefined && totalFiles > 0 && (
|
|
397
|
+
<span className="text-neutral-400">
|
|
398
|
+
{Math.round(displayProgress)}%
|
|
399
|
+
{bytesTransferred !== undefined && bytesTransferred > 0 && ` · ${formatBytes(bytesTransferred)}`}
|
|
400
|
+
{useBytes && ` / ${formatBytes(totalBytes!)}`}
|
|
401
|
+
</span>
|
|
402
|
+
)}
|
|
403
|
+
</div>
|
|
404
|
+
<div className="w-full bg-neutral-100 rounded-full h-2 overflow-hidden">
|
|
405
|
+
<div
|
|
406
|
+
className="bg-blue-500 h-full rounded-full transition-all duration-300 ease-out"
|
|
407
|
+
style={{ width: `${Math.min(100, Math.max(0, displayProgress))}%` }}
|
|
408
|
+
/>
|
|
409
|
+
</div>
|
|
410
|
+
{failedCount !== undefined && failedCount > 0 && (
|
|
411
|
+
<p className="text-[11px] text-amber-600">
|
|
412
|
+
{failedCount} file{failedCount !== 1 ? "s" : ""} failed
|
|
413
|
+
</p>
|
|
414
|
+
)}
|
|
415
|
+
</div>
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ============================================
|
|
420
|
+
// Create Backup Section (V2 — client-side)
|
|
421
|
+
// ============================================
|
|
422
|
+
|
|
423
|
+
type ExportPhase = "idle" | "fetching" | "downloading" | "zipping" | "done" | "error";
|
|
424
|
+
|
|
425
|
+
function CreateBackupSection({
|
|
426
|
+
status,
|
|
427
|
+
statusLoading,
|
|
428
|
+
statusError,
|
|
429
|
+
}: {
|
|
430
|
+
status: BackupStatus | null;
|
|
431
|
+
statusLoading: boolean;
|
|
432
|
+
statusError: string | null;
|
|
433
|
+
}) {
|
|
434
|
+
const [phase, setPhase] = useState<ExportPhase>("idle");
|
|
435
|
+
const [exportError, setExportError] = useState<string | null>(null);
|
|
436
|
+
const [downloadProgress, setDownloadProgress] = useState<PoolProgress | null>(null);
|
|
437
|
+
const [zipProgress, setZipProgress] = useState(0);
|
|
438
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
439
|
+
|
|
440
|
+
const handleCreateBackup = async () => {
|
|
441
|
+
setPhase("fetching");
|
|
442
|
+
setExportError(null);
|
|
443
|
+
setDownloadProgress(null);
|
|
444
|
+
setZipProgress(0);
|
|
445
|
+
abortRef.current = new AbortController();
|
|
446
|
+
|
|
447
|
+
try {
|
|
448
|
+
// ── Phase 1: Fetch export data (JSON) from Vercel ──
|
|
449
|
+
const res = await fetch("/api/admin/backups/prepare-export", {
|
|
450
|
+
signal: abortRef.current.signal,
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
if (!res.ok) {
|
|
454
|
+
const data = await res.json().catch(() => ({ error: "Export failed" }));
|
|
455
|
+
throw new Error(data.error || `Export failed (${res.status})`);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const exportData: ExportData = await res.json();
|
|
459
|
+
|
|
460
|
+
// ── Phase 2: Build ZIP in browser ──
|
|
461
|
+
const JSZip = (await import("jszip")).default;
|
|
462
|
+
const zip = new JSZip();
|
|
463
|
+
|
|
464
|
+
// Add manifest
|
|
465
|
+
zip.file("manifest.json", JSON.stringify(exportData.manifest, null, 2));
|
|
466
|
+
|
|
467
|
+
// Add Sanity documents
|
|
468
|
+
zip.file("sanity/pages.json", JSON.stringify(exportData.documents.pages, null, 2));
|
|
469
|
+
|
|
470
|
+
if (exportData.documents.siteSettings) {
|
|
471
|
+
zip.file("sanity/settings.json", JSON.stringify(exportData.documents.siteSettings, null, 2));
|
|
472
|
+
}
|
|
473
|
+
if (exportData.documents.siteStyles) {
|
|
474
|
+
zip.file("sanity/styles.json", JSON.stringify(exportData.documents.siteStyles, null, 2));
|
|
475
|
+
}
|
|
476
|
+
if (exportData.documents.assetRegistry) {
|
|
477
|
+
zip.file("sanity/asset-registry.json", JSON.stringify(exportData.documents.assetRegistry, null, 2));
|
|
478
|
+
}
|
|
479
|
+
if (exportData.documents.customSections.length > 0) {
|
|
480
|
+
zip.file("sanity/custom-sections.json", JSON.stringify(exportData.documents.customSections, null, 2));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ── Phase 3: Download assets directly from R2 ──
|
|
484
|
+
let assetResult: PoolProgress | null = null;
|
|
485
|
+
|
|
486
|
+
if (
|
|
487
|
+
exportData.assets.r2_connected &&
|
|
488
|
+
exportData.assets.public_url &&
|
|
489
|
+
exportData.assets.files.length > 0
|
|
490
|
+
) {
|
|
491
|
+
// L-3: ZIP bomb / runaway-export guard.
|
|
492
|
+
//
|
|
493
|
+
// The file list comes from our own Sanity-backed asset registry so it
|
|
494
|
+
// isn't adversarial by default, but an unusually large R2 bucket (or
|
|
495
|
+
// a registry row pointing at a surprisingly large object) can still
|
|
496
|
+
// make the browser OOM while it builds the ZIP in memory. We apply:
|
|
497
|
+
//
|
|
498
|
+
// • A soft warning (console.warn) above 500 MB — existing behaviour,
|
|
499
|
+
// kept so developers can spot runaway exports in dev tools.
|
|
500
|
+
// • A hard abort above HARD_MAX_EXPORT_BYTES (2 GB). At that scale
|
|
501
|
+
// JSZip's single-blob output is almost guaranteed to fail in
|
|
502
|
+
// practice across Chrome/Safari/Firefox, and continuing would
|
|
503
|
+
// just burn CPU before erroring with an opaque RangeError.
|
|
504
|
+
const HARD_MAX_EXPORT_BYTES = 2 * 1024 * 1024 * 1024; // 2 GB
|
|
505
|
+
const totalSize = exportData.assets.files.reduce((s, f) => s + f.size, 0);
|
|
506
|
+
if (totalSize > HARD_MAX_EXPORT_BYTES) {
|
|
507
|
+
throw new Error(
|
|
508
|
+
`Backup too large for client-side export: ${formatBytes(totalSize)} exceeds the ${formatBytes(
|
|
509
|
+
HARD_MAX_EXPORT_BYTES
|
|
510
|
+
)} safety cap. Please contact support for a server-side migration path.`
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
if (totalSize > 500 * 1024 * 1024) {
|
|
514
|
+
console.warn(
|
|
515
|
+
`[Backup] Large backup: ${formatBytes(totalSize)}. Browser memory may be affected.`
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
setPhase("downloading");
|
|
520
|
+
assetResult = await downloadAssetsToZip(
|
|
521
|
+
exportData.assets.files,
|
|
522
|
+
exportData.assets.public_url,
|
|
523
|
+
zip,
|
|
524
|
+
(p) => setDownloadProgress({ ...p }),
|
|
525
|
+
abortRef.current.signal
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
if (abortRef.current.signal.aborted) return;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ── Phase 4: Generate final ZIP blob ──
|
|
532
|
+
setPhase("zipping");
|
|
533
|
+
const blob = await zip.generateAsync(
|
|
534
|
+
{ type: "blob", compression: "DEFLATE", compressionOptions: { level: 5 } },
|
|
535
|
+
(meta) => setZipProgress(meta.percent)
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
// ── Phase 5: Trigger browser download ──
|
|
539
|
+
const date = new Date().toISOString().split("T")[0];
|
|
540
|
+
const filename = `andami-backup-${date}.zip`;
|
|
541
|
+
|
|
542
|
+
const url = URL.createObjectURL(blob);
|
|
543
|
+
const a = document.createElement("a");
|
|
544
|
+
a.href = url;
|
|
545
|
+
a.download = filename;
|
|
546
|
+
document.body.appendChild(a);
|
|
547
|
+
a.click();
|
|
548
|
+
document.body.removeChild(a);
|
|
549
|
+
URL.revokeObjectURL(url);
|
|
550
|
+
|
|
551
|
+
setPhase("done");
|
|
552
|
+
setDownloadProgress(assetResult);
|
|
553
|
+
setTimeout(() => setPhase("idle"), 8000);
|
|
554
|
+
} catch (err) {
|
|
555
|
+
if (abortRef.current?.signal.aborted) {
|
|
556
|
+
setPhase("idle");
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
setExportError(err instanceof Error ? err.message : "Export failed");
|
|
560
|
+
setPhase("error");
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
const handleCancel = () => {
|
|
565
|
+
abortRef.current?.abort();
|
|
566
|
+
setPhase("idle");
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
const isWorking = phase === "fetching" || phase === "downloading" || phase === "zipping";
|
|
570
|
+
|
|
571
|
+
return (
|
|
572
|
+
<div className="border rounded-xl bg-white border-blue-200/60 ring-1 ring-blue-100/40">
|
|
573
|
+
{/* Header */}
|
|
574
|
+
<div className="flex items-center gap-3 px-4 py-3 border-b border-neutral-100">
|
|
575
|
+
<div className="w-8 h-8 rounded-lg bg-blue-50 flex items-center justify-center shrink-0">
|
|
576
|
+
<BackupIcon size={16} />
|
|
577
|
+
</div>
|
|
578
|
+
<h2 className="text-sm font-semibold text-neutral-900">Create Backup</h2>
|
|
579
|
+
</div>
|
|
580
|
+
|
|
581
|
+
{/* Content */}
|
|
582
|
+
<div className="px-4 py-3 space-y-3">
|
|
583
|
+
{/* Status loading */}
|
|
584
|
+
{statusLoading && (
|
|
585
|
+
<div className="flex items-center gap-2 text-xs text-neutral-400">
|
|
586
|
+
<SpinnerIcon size={14} />
|
|
587
|
+
Loading site status...
|
|
588
|
+
</div>
|
|
589
|
+
)}
|
|
590
|
+
|
|
591
|
+
{/* Status error */}
|
|
592
|
+
{statusError && (
|
|
593
|
+
<div className="p-2.5 rounded-lg bg-red-50 border border-red-200">
|
|
594
|
+
<p className="text-xs text-red-700">{statusError}</p>
|
|
595
|
+
</div>
|
|
596
|
+
)}
|
|
597
|
+
|
|
598
|
+
{/* Status card */}
|
|
599
|
+
{status && !statusLoading && (
|
|
600
|
+
<div className="space-y-3">
|
|
601
|
+
{/* R2 warning */}
|
|
602
|
+
{!status.r2_connected && (
|
|
603
|
+
<div className="flex items-start gap-2 p-2.5 rounded-lg bg-amber-50 border border-amber-200">
|
|
604
|
+
<span className="text-amber-600 mt-0.5 shrink-0"><AlertIcon size={14} /></span>
|
|
605
|
+
<p className="text-xs text-amber-700">
|
|
606
|
+
R2 storage is not connected. The backup will contain Sanity data only (no assets).
|
|
607
|
+
</p>
|
|
608
|
+
</div>
|
|
609
|
+
)}
|
|
610
|
+
|
|
611
|
+
{/* Stats grid */}
|
|
612
|
+
<div className="grid grid-cols-2 gap-2">
|
|
613
|
+
<div className="p-2.5 rounded-lg bg-neutral-50 border border-neutral-100">
|
|
614
|
+
<p className="text-[10px] uppercase tracking-wider text-neutral-400 font-medium mb-0.5">Documents</p>
|
|
615
|
+
<p className="text-sm font-semibold text-neutral-900">{totalDocs(status)}</p>
|
|
616
|
+
<p className="text-[11px] text-neutral-500 mt-0.5">
|
|
617
|
+
{status.document_counts.pages} pages · {status.document_counts.custom_sections} sections
|
|
618
|
+
</p>
|
|
619
|
+
</div>
|
|
620
|
+
<div className="p-2.5 rounded-lg bg-neutral-50 border border-neutral-100">
|
|
621
|
+
<p className="text-[10px] uppercase tracking-wider text-neutral-400 font-medium mb-0.5">Assets</p>
|
|
622
|
+
<p className="text-sm font-semibold text-neutral-900">
|
|
623
|
+
{status.r2_connected ? status.asset_count.toLocaleString() : "—"}
|
|
624
|
+
</p>
|
|
625
|
+
<p className="text-[11px] text-neutral-500 mt-0.5">
|
|
626
|
+
{status.r2_connected ? formatBytes(status.asset_size_bytes) : "R2 not connected"}
|
|
627
|
+
</p>
|
|
628
|
+
</div>
|
|
629
|
+
</div>
|
|
630
|
+
|
|
631
|
+
{/* Export error */}
|
|
632
|
+
{phase === "error" && exportError && (
|
|
633
|
+
<div className="p-2.5 rounded-lg bg-red-50 border border-red-200">
|
|
634
|
+
<p className="text-xs text-red-700">{exportError}</p>
|
|
635
|
+
</div>
|
|
636
|
+
)}
|
|
637
|
+
|
|
638
|
+
{/* Progress indicators */}
|
|
639
|
+
{phase === "fetching" && (
|
|
640
|
+
<ProgressBar phase="Fetching site data..." progress={15} />
|
|
641
|
+
)}
|
|
642
|
+
|
|
643
|
+
{phase === "downloading" && downloadProgress && (
|
|
644
|
+
<ProgressBar
|
|
645
|
+
phase={`Downloading assets... ${downloadProgress.completed} / ${downloadProgress.total} files`}
|
|
646
|
+
progress={downloadProgress.total > 0
|
|
647
|
+
? (downloadProgress.completed / downloadProgress.total) * 100
|
|
648
|
+
: 0
|
|
649
|
+
}
|
|
650
|
+
totalFiles={downloadProgress.total}
|
|
651
|
+
bytesTransferred={downloadProgress.bytesTransferred}
|
|
652
|
+
totalBytes={downloadProgress.totalBytes}
|
|
653
|
+
failedCount={downloadProgress.failed.length}
|
|
654
|
+
/>
|
|
655
|
+
)}
|
|
656
|
+
|
|
657
|
+
{phase === "zipping" && (
|
|
658
|
+
<ProgressBar phase="Building ZIP archive..." progress={zipProgress} />
|
|
659
|
+
)}
|
|
660
|
+
|
|
661
|
+
{/* Done message */}
|
|
662
|
+
{phase === "done" && (
|
|
663
|
+
<div className="flex items-start gap-2 p-2.5 rounded-lg bg-green-50 border border-green-200">
|
|
664
|
+
<span className="text-green-600 mt-0.5 shrink-0"><CheckIcon size={14} /></span>
|
|
665
|
+
<div>
|
|
666
|
+
<p className="text-xs text-green-700 font-medium">Backup downloaded successfully</p>
|
|
667
|
+
{downloadProgress && downloadProgress.failed.length > 0 && (
|
|
668
|
+
<p className="text-[11px] text-amber-600 mt-0.5">
|
|
669
|
+
{downloadProgress.completed - downloadProgress.failed.length} of {downloadProgress.total} assets included.{" "}
|
|
670
|
+
{downloadProgress.failed.length} failed to download.
|
|
671
|
+
</p>
|
|
672
|
+
)}
|
|
673
|
+
</div>
|
|
674
|
+
</div>
|
|
675
|
+
)}
|
|
676
|
+
|
|
677
|
+
{/* Action buttons */}
|
|
678
|
+
<div className="flex items-center gap-2">
|
|
679
|
+
{!isWorking && phase !== "done" && (
|
|
680
|
+
<button
|
|
681
|
+
onClick={handleCreateBackup}
|
|
682
|
+
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 text-white text-sm font-medium px-4 py-2 hover:bg-blue-700 transition-colors"
|
|
683
|
+
>
|
|
684
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
685
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
686
|
+
<polyline points="7 10 12 15 17 10" />
|
|
687
|
+
<line x1="12" y1="15" x2="12" y2="3" />
|
|
688
|
+
</svg>
|
|
689
|
+
Create Backup
|
|
690
|
+
</button>
|
|
691
|
+
)}
|
|
692
|
+
|
|
693
|
+
{isWorking && (
|
|
694
|
+
<button
|
|
695
|
+
onClick={handleCancel}
|
|
696
|
+
className="inline-flex items-center gap-2 rounded-lg border border-neutral-200 bg-white text-neutral-600 text-sm font-medium px-4 py-2 hover:bg-neutral-50 transition-colors"
|
|
697
|
+
>
|
|
698
|
+
Cancel
|
|
699
|
+
</button>
|
|
700
|
+
)}
|
|
701
|
+
</div>
|
|
702
|
+
</div>
|
|
703
|
+
)}
|
|
704
|
+
</div>
|
|
705
|
+
</div>
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// ============================================
|
|
710
|
+
// Restore Section (V2 — client-side assets)
|
|
711
|
+
// ============================================
|
|
712
|
+
|
|
713
|
+
type RestorePhase =
|
|
714
|
+
| "idle"
|
|
715
|
+
| "preview"
|
|
716
|
+
| "confirming"
|
|
717
|
+
| "restoring_docs"
|
|
718
|
+
| "uploading_assets"
|
|
719
|
+
| "done"
|
|
720
|
+
| "error";
|
|
721
|
+
|
|
722
|
+
function RestoreSection({
|
|
723
|
+
status,
|
|
724
|
+
onRestoreSuccess,
|
|
725
|
+
}: {
|
|
726
|
+
status: BackupStatus | null;
|
|
727
|
+
/**
|
|
728
|
+
* Invoked after a restore finishes successfully (documents restored,
|
|
729
|
+
* asset upload phase complete or unneeded). Used by the parent to
|
|
730
|
+
* refetch `/api/admin/backups/status` so the "Create Backup" card
|
|
731
|
+
* immediately reflects the new doc/asset counts — otherwise the
|
|
732
|
+
* admin sees stale pre-restore numbers and panics (M-7).
|
|
733
|
+
*/
|
|
734
|
+
onRestoreSuccess?: () => void;
|
|
735
|
+
}) {
|
|
736
|
+
const [phase, setPhase] = useState<RestorePhase>("idle");
|
|
737
|
+
const [file, setFile] = useState<File | null>(null);
|
|
738
|
+
const [zipInstance, setZipInstance] = useState<import("jszip") | null>(null);
|
|
739
|
+
const [manifest, setManifest] = useState<BackupManifest | null>(null);
|
|
740
|
+
const [parseError, setParseError] = useState<string | null>(null);
|
|
741
|
+
const [confirmText, setConfirmText] = useState("");
|
|
742
|
+
const [restoreError, setRestoreError] = useState<string | null>(null);
|
|
743
|
+
const [dragOver, setDragOver] = useState(false);
|
|
744
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
745
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
746
|
+
|
|
747
|
+
// Result state
|
|
748
|
+
const [docResult, setDocResult] = useState<RestoreDataResult | null>(null);
|
|
749
|
+
const [uploadProgress, setUploadProgress] = useState<PoolProgress | null>(null);
|
|
750
|
+
const [totalDuration, setTotalDuration] = useState(0);
|
|
751
|
+
|
|
752
|
+
// Count assets in the ZIP
|
|
753
|
+
const [assetCount, setAssetCount] = useState(0);
|
|
754
|
+
|
|
755
|
+
// ── Upload-phase tracking for M-8 (TTL warning) ──
|
|
756
|
+
// We record when the upload phase started and the TTL the server granted
|
|
757
|
+
// to every presigned URL. If the upload hasn't finished by the time we're
|
|
758
|
+
// past ~80% of the TTL budget, we surface a warning so the admin can
|
|
759
|
+
// decide whether to abort and re-generate fresh URLs.
|
|
760
|
+
const [uploadStartedAt, setUploadStartedAt] = useState<number | null>(null);
|
|
761
|
+
const [uploadTtlSeconds, setUploadTtlSeconds] = useState<number | null>(null);
|
|
762
|
+
const [uploadNearExpiry, setUploadNearExpiry] = useState(false);
|
|
763
|
+
|
|
764
|
+
// ── L-12: user-tunable upload concurrency ──
|
|
765
|
+
//
|
|
766
|
+
// Default is 4 parallel workers — a reasonable sweet spot on home fiber
|
|
767
|
+
// that avoids saturating the admin's connection while keeping the
|
|
768
|
+
// restore under typical TTL budgets. Admins on faster pipes can push
|
|
769
|
+
// this up to 8, slower connections can drop to 2. The value persists
|
|
770
|
+
// in localStorage so the choice survives between restores. Any value
|
|
771
|
+
// outside the 2..8 range is normalized on read.
|
|
772
|
+
const CONCURRENCY_STORAGE_KEY = "andami.backup.restore.uploadConcurrency";
|
|
773
|
+
const DEFAULT_CONCURRENCY = 4;
|
|
774
|
+
const clampConcurrency = (n: number) =>
|
|
775
|
+
Math.max(2, Math.min(8, Math.round(n)));
|
|
776
|
+
const [uploadConcurrency, setUploadConcurrency] = useState<number>(
|
|
777
|
+
DEFAULT_CONCURRENCY
|
|
778
|
+
);
|
|
779
|
+
|
|
780
|
+
// Hydrate the persisted concurrency once after mount (avoids SSR / hydration
|
|
781
|
+
// mismatch — localStorage is window-only).
|
|
782
|
+
useEffect(() => {
|
|
783
|
+
try {
|
|
784
|
+
const raw =
|
|
785
|
+
typeof window !== "undefined" &&
|
|
786
|
+
window.localStorage.getItem(CONCURRENCY_STORAGE_KEY);
|
|
787
|
+
if (raw) {
|
|
788
|
+
const parsed = Number.parseInt(raw, 10);
|
|
789
|
+
if (Number.isFinite(parsed)) {
|
|
790
|
+
setUploadConcurrency(clampConcurrency(parsed));
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
} catch {
|
|
794
|
+
// localStorage can throw in private-mode Safari, etc. Safe to ignore —
|
|
795
|
+
// we just fall back to the default.
|
|
796
|
+
}
|
|
797
|
+
}, []);
|
|
798
|
+
|
|
799
|
+
const persistConcurrency = useCallback((n: number) => {
|
|
800
|
+
const clamped = clampConcurrency(n);
|
|
801
|
+
setUploadConcurrency(clamped);
|
|
802
|
+
try {
|
|
803
|
+
if (typeof window !== "undefined") {
|
|
804
|
+
window.localStorage.setItem(CONCURRENCY_STORAGE_KEY, String(clamped));
|
|
805
|
+
}
|
|
806
|
+
} catch {
|
|
807
|
+
// See comment above — non-fatal.
|
|
808
|
+
}
|
|
809
|
+
}, []);
|
|
810
|
+
|
|
811
|
+
const siteName = getSiteConfig().name;
|
|
812
|
+
|
|
813
|
+
// ── Warn the user if they try to close the tab mid-restore ──
|
|
814
|
+
// Closing during `restoring_docs` leaves Sanity mid-wipe. Closing during
|
|
815
|
+
// `uploading_assets` leaves R2 partially populated. Both produce a broken
|
|
816
|
+
// site with no "resume" path. The beforeunload prompt gives the admin a
|
|
817
|
+
// last chance to stay on the page.
|
|
818
|
+
//
|
|
819
|
+
// M-6 explicitly covers both phases because the upload phase is where
|
|
820
|
+
// most of the clock time lives — it's the phase where an admin is most
|
|
821
|
+
// likely to get impatient and close the tab.
|
|
822
|
+
useEffect(() => {
|
|
823
|
+
const isDestructive =
|
|
824
|
+
phase === "restoring_docs" || phase === "uploading_assets";
|
|
825
|
+
if (!isDestructive) return;
|
|
826
|
+
const message =
|
|
827
|
+
phase === "restoring_docs"
|
|
828
|
+
? "Restore in progress — closing this page will leave the site in a broken state."
|
|
829
|
+
: "Asset upload in progress — closing this page will leave the site with broken images.";
|
|
830
|
+
const handler = (e: BeforeUnloadEvent) => {
|
|
831
|
+
e.preventDefault();
|
|
832
|
+
// Modern browsers show their own generic text, but some still fall
|
|
833
|
+
// back to `returnValue` — so we populate it with the phase-specific
|
|
834
|
+
// message for the handful of environments that still display it.
|
|
835
|
+
e.returnValue = message;
|
|
836
|
+
return message;
|
|
837
|
+
};
|
|
838
|
+
window.addEventListener("beforeunload", handler);
|
|
839
|
+
return () => window.removeEventListener("beforeunload", handler);
|
|
840
|
+
}, [phase]);
|
|
841
|
+
|
|
842
|
+
// ── M-8: Poll TTL budget during upload phase ──
|
|
843
|
+
// Every 5s while uploading, check whether we've consumed >80% of the TTL
|
|
844
|
+
// granted by the server. If so, set `uploadNearExpiry` so the UI shows
|
|
845
|
+
// a banner suggesting the admin either waits patiently or cancels to
|
|
846
|
+
// retry with a fresh batch of URLs.
|
|
847
|
+
useEffect(() => {
|
|
848
|
+
if (phase !== "uploading_assets" || !uploadStartedAt || !uploadTtlSeconds) {
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
const check = () => {
|
|
852
|
+
const elapsedSec = (Date.now() - uploadStartedAt) / 1000;
|
|
853
|
+
if (elapsedSec > uploadTtlSeconds * 0.8) {
|
|
854
|
+
setUploadNearExpiry(true);
|
|
855
|
+
}
|
|
856
|
+
};
|
|
857
|
+
check();
|
|
858
|
+
const id = setInterval(check, 5000);
|
|
859
|
+
return () => clearInterval(id);
|
|
860
|
+
}, [phase, uploadStartedAt, uploadTtlSeconds]);
|
|
861
|
+
|
|
862
|
+
// ── Parse ZIP and extract manifest ──
|
|
863
|
+
const handleFile = useCallback(async (f: File) => {
|
|
864
|
+
setFile(f);
|
|
865
|
+
setParseError(null);
|
|
866
|
+
setManifest(null);
|
|
867
|
+
setZipInstance(null);
|
|
868
|
+
setDocResult(null);
|
|
869
|
+
setUploadProgress(null);
|
|
870
|
+
setRestoreError(null);
|
|
871
|
+
setConfirmText("");
|
|
872
|
+
setAssetCount(0);
|
|
873
|
+
|
|
874
|
+
if (!f.name.endsWith(".zip")) {
|
|
875
|
+
setParseError("Please select a .zip backup file.");
|
|
876
|
+
setPhase("idle");
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
try {
|
|
881
|
+
const JSZip = (await import("jszip")).default;
|
|
882
|
+
const zip = await JSZip.loadAsync(f);
|
|
883
|
+
const manifestEntry = zip.file("manifest.json");
|
|
884
|
+
|
|
885
|
+
if (!manifestEntry) {
|
|
886
|
+
setParseError("Invalid backup: manifest.json not found in the ZIP archive.");
|
|
887
|
+
setPhase("idle");
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const manifestText = await manifestEntry.async("string");
|
|
892
|
+
const parsed = JSON.parse(manifestText);
|
|
893
|
+
|
|
894
|
+
if (parsed.version !== 1) {
|
|
895
|
+
setParseError(`Unsupported backup version: ${parsed.version}. Expected version 1.`);
|
|
896
|
+
setPhase("idle");
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
if (!parsed.documents || !parsed.assets) {
|
|
901
|
+
setParseError("Invalid backup: manifest is missing required sections.");
|
|
902
|
+
setPhase("idle");
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// Count asset files in the ZIP
|
|
907
|
+
let assets = 0;
|
|
908
|
+
zip.forEach((path: string) => {
|
|
909
|
+
if (path.startsWith("assets/") && !path.endsWith("/")) assets++;
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
setZipInstance(zip);
|
|
913
|
+
setManifest(parsed as BackupManifest);
|
|
914
|
+
setAssetCount(assets);
|
|
915
|
+
setPhase("preview");
|
|
916
|
+
} catch (err) {
|
|
917
|
+
setParseError(
|
|
918
|
+
err instanceof Error
|
|
919
|
+
? `Failed to read backup: ${err.message}`
|
|
920
|
+
: "Failed to read the backup file."
|
|
921
|
+
);
|
|
922
|
+
setPhase("idle");
|
|
923
|
+
}
|
|
924
|
+
}, []);
|
|
925
|
+
|
|
926
|
+
// ── Drag & Drop ──
|
|
927
|
+
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
928
|
+
e.preventDefault();
|
|
929
|
+
setDragOver(false);
|
|
930
|
+
const f = e.dataTransfer.files?.[0];
|
|
931
|
+
if (f) handleFile(f);
|
|
932
|
+
}, [handleFile]);
|
|
933
|
+
|
|
934
|
+
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
935
|
+
e.preventDefault();
|
|
936
|
+
setDragOver(true);
|
|
937
|
+
}, []);
|
|
938
|
+
|
|
939
|
+
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
940
|
+
e.preventDefault();
|
|
941
|
+
setDragOver(false);
|
|
942
|
+
}, []);
|
|
943
|
+
|
|
944
|
+
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
945
|
+
const f = e.target.files?.[0];
|
|
946
|
+
if (f) handleFile(f);
|
|
947
|
+
}, [handleFile]);
|
|
948
|
+
|
|
949
|
+
// ── V2 Restore ──
|
|
950
|
+
const handleRestore = async () => {
|
|
951
|
+
if (!zipInstance || !manifest) return;
|
|
952
|
+
const startTime = Date.now();
|
|
953
|
+
setPhase("restoring_docs");
|
|
954
|
+
setRestoreError(null);
|
|
955
|
+
setDocResult(null);
|
|
956
|
+
setUploadProgress(null);
|
|
957
|
+
setUploadStartedAt(null);
|
|
958
|
+
setUploadTtlSeconds(null);
|
|
959
|
+
setUploadNearExpiry(false);
|
|
960
|
+
abortRef.current = new AbortController();
|
|
961
|
+
|
|
962
|
+
try {
|
|
963
|
+
// ── Phase 1: Extract Sanity documents from ZIP ──
|
|
964
|
+
const extractJson = async (path: string): Promise<unknown | null> => {
|
|
965
|
+
const entry = zipInstance.file(path);
|
|
966
|
+
if (!entry) return null;
|
|
967
|
+
const text = await entry.async("string");
|
|
968
|
+
return JSON.parse(text);
|
|
969
|
+
};
|
|
970
|
+
|
|
971
|
+
const documents = {
|
|
972
|
+
pages: (await extractJson("sanity/pages.json")) ?? [],
|
|
973
|
+
siteSettings: await extractJson("sanity/settings.json"),
|
|
974
|
+
siteStyles: await extractJson("sanity/styles.json"),
|
|
975
|
+
assetRegistry: await extractJson("sanity/asset-registry.json"),
|
|
976
|
+
customSections: (await extractJson("sanity/custom-sections.json")) ?? [],
|
|
977
|
+
};
|
|
978
|
+
|
|
979
|
+
// Collect the asset keys present in the ZIP. The server uses this
|
|
980
|
+
// list to pre-sign upload URLs in a single invocation, which keeps
|
|
981
|
+
// Vercel invocations flat regardless of how many assets we restore
|
|
982
|
+
// (no more N calls to /api/admin/r2/upload-url, no 60 req/min cap).
|
|
983
|
+
const assetEntries: { key: string; size: number }[] = [];
|
|
984
|
+
zipInstance.forEach((relativePath, zipEntry) => {
|
|
985
|
+
if (relativePath.startsWith("assets/") && !relativePath.endsWith("/") && !zipEntry.dir) {
|
|
986
|
+
const key = relativePath.replace(/^assets\//, "");
|
|
987
|
+
// Size is available on the zip entry's uncompressed stats.
|
|
988
|
+
const rawSize =
|
|
989
|
+
(zipEntry as unknown as { _data?: { uncompressedSize?: number } })._data?.uncompressedSize ?? 0;
|
|
990
|
+
assetEntries.push({ key, size: rawSize });
|
|
991
|
+
}
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
// Determine if we need to wipe R2 (only if there are assets to upload)
|
|
995
|
+
const hasAssets = assetEntries.length > 0;
|
|
996
|
+
const r2Connected = status?.r2_connected ?? false;
|
|
997
|
+
const shouldWipeR2 = hasAssets && r2Connected;
|
|
998
|
+
|
|
999
|
+
// ── Phase 2: Send documents to Vercel (JSON only) ──
|
|
1000
|
+
// M-8: request a generous TTL when there are many assets. The server
|
|
1001
|
+
// clamps to a safe range (5 min..1 h) so this is a hint, not a demand.
|
|
1002
|
+
// Rough heuristic: 30 min baseline + 30s per 10 assets, capped to 1 h.
|
|
1003
|
+
const requestedTtlSec = Math.min(
|
|
1004
|
+
3600,
|
|
1005
|
+
Math.max(1800, 1800 + Math.ceil(assetEntries.length / 10) * 30)
|
|
1006
|
+
);
|
|
1007
|
+
|
|
1008
|
+
const res = await fetch("/api/admin/backups/restore-data", {
|
|
1009
|
+
method: "POST",
|
|
1010
|
+
headers: {
|
|
1011
|
+
...csrfHeaders(),
|
|
1012
|
+
"Content-Type": "application/json",
|
|
1013
|
+
},
|
|
1014
|
+
body: JSON.stringify({
|
|
1015
|
+
manifest,
|
|
1016
|
+
documents,
|
|
1017
|
+
wipe_r2: shouldWipeR2,
|
|
1018
|
+
assets: r2Connected ? assetEntries : [],
|
|
1019
|
+
upload_ttl_seconds: requestedTtlSec,
|
|
1020
|
+
}),
|
|
1021
|
+
signal: abortRef.current.signal,
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
const data = await res.json();
|
|
1025
|
+
|
|
1026
|
+
if (!res.ok) {
|
|
1027
|
+
throw new Error(data.error || `Restore failed (${res.status})`);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const result = data as RestoreDataResult;
|
|
1031
|
+
setDocResult(result);
|
|
1032
|
+
|
|
1033
|
+
// ── Phase 3: Upload assets directly to R2 via presigned URLs ──
|
|
1034
|
+
if (hasAssets && r2Connected) {
|
|
1035
|
+
setPhase("uploading_assets");
|
|
1036
|
+
// Record the upload deadline so the TTL-warning effect can fire.
|
|
1037
|
+
setUploadStartedAt(Date.now());
|
|
1038
|
+
setUploadTtlSeconds(result.upload_ttl_seconds ?? null);
|
|
1039
|
+
|
|
1040
|
+
const uploads = result.upload_urls ?? [];
|
|
1041
|
+
if (uploads.length === 0) {
|
|
1042
|
+
// Server couldn't pre-sign (R2 config missing or signing failed).
|
|
1043
|
+
// Mark all assets as failed rather than silently skipping them.
|
|
1044
|
+
setUploadProgress({
|
|
1045
|
+
completed: assetEntries.length,
|
|
1046
|
+
total: assetEntries.length,
|
|
1047
|
+
bytesTransferred: 0,
|
|
1048
|
+
totalBytes: assetEntries.reduce((s, a) => s + (a.size || 0), 0),
|
|
1049
|
+
failed: assetEntries.map((a) => a.key),
|
|
1050
|
+
});
|
|
1051
|
+
} else {
|
|
1052
|
+
const uploadResult = await uploadAssetsFromZip(
|
|
1053
|
+
zipInstance,
|
|
1054
|
+
uploads,
|
|
1055
|
+
(p) => setUploadProgress({ ...p }),
|
|
1056
|
+
abortRef.current.signal,
|
|
1057
|
+
// L-12: user-configurable concurrency (persisted in localStorage)
|
|
1058
|
+
uploadConcurrency
|
|
1059
|
+
);
|
|
1060
|
+
|
|
1061
|
+
if (abortRef.current.signal.aborted) return;
|
|
1062
|
+
setUploadProgress(uploadResult);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
setTotalDuration(Date.now() - startTime);
|
|
1067
|
+
setPhase("done");
|
|
1068
|
+
// M-7: refresh the "Create Backup" status card so its doc/asset
|
|
1069
|
+
// counts reflect the freshly-restored state. Fire-and-forget — we
|
|
1070
|
+
// don't block the "done" UI on this.
|
|
1071
|
+
onRestoreSuccess?.();
|
|
1072
|
+
} catch (err) {
|
|
1073
|
+
if (abortRef.current?.signal.aborted) {
|
|
1074
|
+
setPhase("idle");
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
setRestoreError(err instanceof Error ? err.message : "Restore failed");
|
|
1078
|
+
setPhase("error");
|
|
1079
|
+
}
|
|
1080
|
+
};
|
|
1081
|
+
|
|
1082
|
+
const handleCancel = () => {
|
|
1083
|
+
abortRef.current?.abort();
|
|
1084
|
+
setPhase("idle");
|
|
1085
|
+
};
|
|
1086
|
+
|
|
1087
|
+
// ── Reset ──
|
|
1088
|
+
const handleReset = () => {
|
|
1089
|
+
setPhase("idle");
|
|
1090
|
+
setFile(null);
|
|
1091
|
+
setZipInstance(null);
|
|
1092
|
+
setManifest(null);
|
|
1093
|
+
setParseError(null);
|
|
1094
|
+
setConfirmText("");
|
|
1095
|
+
setDocResult(null);
|
|
1096
|
+
setUploadProgress(null);
|
|
1097
|
+
setRestoreError(null);
|
|
1098
|
+
setAssetCount(0);
|
|
1099
|
+
setTotalDuration(0);
|
|
1100
|
+
setUploadStartedAt(null);
|
|
1101
|
+
setUploadTtlSeconds(null);
|
|
1102
|
+
setUploadNearExpiry(false);
|
|
1103
|
+
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
1104
|
+
};
|
|
1105
|
+
|
|
1106
|
+
// M-1: exact match only. GitHub-style "type the literal site name".
|
|
1107
|
+
// No trim, no lowercasing — if the admin can't get the casing right,
|
|
1108
|
+
// they shouldn't be firing a destructive, irreversible operation.
|
|
1109
|
+
// If `siteName` ever resolves to an empty string (misconfigured instance)
|
|
1110
|
+
// the match would trivially pass on an empty textbox, so we also require
|
|
1111
|
+
// a non-empty confirmText. A sensible fallback ("CONFIRM") is used when
|
|
1112
|
+
// the site name is missing so the admin still has to type something.
|
|
1113
|
+
const hasSiteName = typeof siteName === "string" && siteName.length > 0;
|
|
1114
|
+
const confirmTarget = hasSiteName ? siteName : "CONFIRM";
|
|
1115
|
+
const confirmationMatch =
|
|
1116
|
+
confirmText.length > 0 && confirmText === confirmTarget;
|
|
1117
|
+
|
|
1118
|
+
return (
|
|
1119
|
+
<div className="border rounded-xl bg-white border-red-200/60 ring-1 ring-red-100/40">
|
|
1120
|
+
{/* Header */}
|
|
1121
|
+
<div className="flex items-center gap-3 px-4 py-3 border-b border-neutral-100">
|
|
1122
|
+
<div className="w-8 h-8 rounded-lg bg-red-50 flex items-center justify-center shrink-0">
|
|
1123
|
+
<RestoreIcon size={16} />
|
|
1124
|
+
</div>
|
|
1125
|
+
<div className="flex items-center gap-2 min-w-0 flex-1">
|
|
1126
|
+
<h2 className="text-sm font-semibold text-neutral-900">Restore from Backup</h2>
|
|
1127
|
+
<span className="inline-flex items-center text-[9px] font-semibold uppercase tracking-wider text-red-600 bg-red-50 px-1.5 py-0.5 rounded-full">
|
|
1128
|
+
Danger
|
|
1129
|
+
</span>
|
|
1130
|
+
</div>
|
|
1131
|
+
</div>
|
|
1132
|
+
|
|
1133
|
+
<div className="px-4 py-3 space-y-3">
|
|
1134
|
+
{/* R2 not connected warning */}
|
|
1135
|
+
{status && !status.r2_connected && (
|
|
1136
|
+
<div className="flex items-start gap-2 p-2.5 rounded-lg bg-amber-50 border border-amber-200">
|
|
1137
|
+
<span className="text-amber-600 mt-0.5 shrink-0"><AlertIcon size={14} /></span>
|
|
1138
|
+
<p className="text-xs text-amber-700">
|
|
1139
|
+
R2 storage is not connected. Only Sanity documents will be restored — assets in the backup will be skipped.
|
|
1140
|
+
</p>
|
|
1141
|
+
</div>
|
|
1142
|
+
)}
|
|
1143
|
+
|
|
1144
|
+
{/* ── Phase: idle — Drop zone ── */}
|
|
1145
|
+
{(phase === "idle") && (
|
|
1146
|
+
<>
|
|
1147
|
+
<div
|
|
1148
|
+
onDrop={handleDrop}
|
|
1149
|
+
onDragOver={handleDragOver}
|
|
1150
|
+
onDragLeave={handleDragLeave}
|
|
1151
|
+
onClick={() => fileInputRef.current?.click()}
|
|
1152
|
+
className={`
|
|
1153
|
+
flex flex-col items-center justify-center gap-2 p-6 rounded-lg border-2 border-dashed cursor-pointer
|
|
1154
|
+
transition-colors duration-150
|
|
1155
|
+
${dragOver
|
|
1156
|
+
? "border-blue-400 bg-blue-50/50"
|
|
1157
|
+
: "border-neutral-200 bg-neutral-50/50 hover:border-neutral-300 hover:bg-neutral-50"
|
|
1158
|
+
}
|
|
1159
|
+
`}
|
|
1160
|
+
>
|
|
1161
|
+
<span className="text-neutral-400"><UploadIcon size={28} /></span>
|
|
1162
|
+
<p className="text-sm text-neutral-600">
|
|
1163
|
+
Drop a <span className="font-medium">.zip</span> backup file here, or click to browse
|
|
1164
|
+
</p>
|
|
1165
|
+
<p className="text-[11px] text-neutral-400">Maximum file size: 500 MB</p>
|
|
1166
|
+
</div>
|
|
1167
|
+
<input
|
|
1168
|
+
ref={fileInputRef}
|
|
1169
|
+
type="file"
|
|
1170
|
+
accept=".zip"
|
|
1171
|
+
onChange={handleInputChange}
|
|
1172
|
+
className="hidden"
|
|
1173
|
+
/>
|
|
1174
|
+
|
|
1175
|
+
{parseError && (
|
|
1176
|
+
<div className="p-2.5 rounded-lg bg-red-50 border border-red-200">
|
|
1177
|
+
<p className="text-xs text-red-700">{parseError}</p>
|
|
1178
|
+
</div>
|
|
1179
|
+
)}
|
|
1180
|
+
</>
|
|
1181
|
+
)}
|
|
1182
|
+
|
|
1183
|
+
{/* ── Phase: preview — Show manifest contents ── */}
|
|
1184
|
+
{phase === "preview" && manifest && file && (
|
|
1185
|
+
<div className="space-y-3">
|
|
1186
|
+
{/* File info */}
|
|
1187
|
+
<div className="flex items-center justify-between">
|
|
1188
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
1189
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
1190
|
+
<path d="M21 8v13H3V8" />
|
|
1191
|
+
<path d="M1 3h22v5H1z" />
|
|
1192
|
+
<path d="M10 12h4" />
|
|
1193
|
+
</svg>
|
|
1194
|
+
<span className="text-sm font-medium text-neutral-900 truncate">{file.name}</span>
|
|
1195
|
+
<span className="text-xs text-neutral-400 shrink-0">{formatBytes(file.size)}</span>
|
|
1196
|
+
</div>
|
|
1197
|
+
<button
|
|
1198
|
+
onClick={handleReset}
|
|
1199
|
+
className="text-[11px] text-neutral-400 hover:text-neutral-600 transition-colors px-2 py-0.5 rounded hover:bg-neutral-50"
|
|
1200
|
+
>
|
|
1201
|
+
Change file
|
|
1202
|
+
</button>
|
|
1203
|
+
</div>
|
|
1204
|
+
|
|
1205
|
+
{/* Manifest details */}
|
|
1206
|
+
<div className="p-3 rounded-lg bg-neutral-50 border border-neutral-100 space-y-2">
|
|
1207
|
+
<div className="flex items-center justify-between">
|
|
1208
|
+
<p className="text-[10px] uppercase tracking-wider text-neutral-400 font-medium">Backup Contents</p>
|
|
1209
|
+
<p className="text-[11px] text-neutral-400">v{manifest.framework_version}</p>
|
|
1210
|
+
</div>
|
|
1211
|
+
|
|
1212
|
+
<div className="grid grid-cols-2 gap-2">
|
|
1213
|
+
<div>
|
|
1214
|
+
<p className="text-[11px] text-neutral-500">Created</p>
|
|
1215
|
+
<p className="text-xs font-medium text-neutral-800">{formatDate(manifest.created_at)}</p>
|
|
1216
|
+
</div>
|
|
1217
|
+
<div>
|
|
1218
|
+
<p className="text-[11px] text-neutral-500">Total documents</p>
|
|
1219
|
+
<p className="text-xs font-medium text-neutral-800">{totalManifestDocs(manifest)}</p>
|
|
1220
|
+
</div>
|
|
1221
|
+
</div>
|
|
1222
|
+
|
|
1223
|
+
<div className="grid grid-cols-2 gap-2">
|
|
1224
|
+
<div>
|
|
1225
|
+
<p className="text-[11px] text-neutral-500">Pages</p>
|
|
1226
|
+
<p className="text-xs font-medium text-neutral-800">{manifest.documents.pages}</p>
|
|
1227
|
+
</div>
|
|
1228
|
+
<div>
|
|
1229
|
+
<p className="text-[11px] text-neutral-500">Custom sections</p>
|
|
1230
|
+
<p className="text-xs font-medium text-neutral-800">{manifest.documents.custom_sections}</p>
|
|
1231
|
+
</div>
|
|
1232
|
+
</div>
|
|
1233
|
+
|
|
1234
|
+
<div className="grid grid-cols-2 gap-2">
|
|
1235
|
+
<div>
|
|
1236
|
+
<p className="text-[11px] text-neutral-500">Assets in ZIP</p>
|
|
1237
|
+
<p className="text-xs font-medium text-neutral-800">
|
|
1238
|
+
{assetCount > 0
|
|
1239
|
+
? `${assetCount.toLocaleString()} files`
|
|
1240
|
+
: manifest.assets.r2_connected
|
|
1241
|
+
? `${manifest.assets.total_files.toLocaleString()} files (${formatBytes(manifest.assets.total_size_bytes)})`
|
|
1242
|
+
: "None (R2 was not connected)"
|
|
1243
|
+
}
|
|
1244
|
+
</p>
|
|
1245
|
+
</div>
|
|
1246
|
+
<div>
|
|
1247
|
+
<p className="text-[11px] text-neutral-500">Settings</p>
|
|
1248
|
+
<p className="text-xs font-medium text-neutral-800">
|
|
1249
|
+
{[
|
|
1250
|
+
manifest.documents.site_settings && "Site settings",
|
|
1251
|
+
manifest.documents.site_styles && "Styles",
|
|
1252
|
+
manifest.documents.asset_registry && "Asset registry",
|
|
1253
|
+
].filter(Boolean).join(", ") || "None"}
|
|
1254
|
+
</p>
|
|
1255
|
+
</div>
|
|
1256
|
+
</div>
|
|
1257
|
+
</div>
|
|
1258
|
+
|
|
1259
|
+
{/* Proceed to confirmation */}
|
|
1260
|
+
<button
|
|
1261
|
+
onClick={() => setPhase("confirming")}
|
|
1262
|
+
className="inline-flex items-center gap-2 rounded-lg bg-red-600 text-white text-sm font-medium px-4 py-2 hover:bg-red-700 transition-colors"
|
|
1263
|
+
>
|
|
1264
|
+
Continue to Restore
|
|
1265
|
+
</button>
|
|
1266
|
+
</div>
|
|
1267
|
+
)}
|
|
1268
|
+
|
|
1269
|
+
{/* ── Phase: confirming — Danger confirmation ── */}
|
|
1270
|
+
{phase === "confirming" && manifest && (
|
|
1271
|
+
<div className="space-y-3">
|
|
1272
|
+
<div className="p-3 rounded-lg bg-red-50 border-2 border-red-300 space-y-2">
|
|
1273
|
+
<div className="flex items-start gap-2">
|
|
1274
|
+
<span className="text-red-600 mt-0.5 shrink-0"><AlertIcon size={16} /></span>
|
|
1275
|
+
<div>
|
|
1276
|
+
<p className="text-sm font-semibold text-red-800">This will replace ALL existing content and assets</p>
|
|
1277
|
+
<p className="text-xs text-red-700 mt-1">
|
|
1278
|
+
All current pages, custom sections, site settings, styles, and assets will be permanently deleted
|
|
1279
|
+
and replaced with the contents of this backup. This action cannot be undone.
|
|
1280
|
+
</p>
|
|
1281
|
+
</div>
|
|
1282
|
+
</div>
|
|
1283
|
+
</div>
|
|
1284
|
+
|
|
1285
|
+
{/* L-12: upload concurrency selector. Only meaningful when R2
|
|
1286
|
+
is connected AND the backup carries assets — otherwise it
|
|
1287
|
+
has nothing to do and would just clutter the confirm screen. */}
|
|
1288
|
+
{assetCount > 0 && status?.r2_connected && (
|
|
1289
|
+
<div>
|
|
1290
|
+
<label className="block text-xs font-medium text-neutral-600 mb-1">
|
|
1291
|
+
Upload concurrency{" "}
|
|
1292
|
+
<span className="text-neutral-400 font-normal">
|
|
1293
|
+
(parallel workers)
|
|
1294
|
+
</span>
|
|
1295
|
+
</label>
|
|
1296
|
+
<div className="flex items-center gap-3">
|
|
1297
|
+
<input
|
|
1298
|
+
type="range"
|
|
1299
|
+
min={2}
|
|
1300
|
+
max={8}
|
|
1301
|
+
step={1}
|
|
1302
|
+
value={uploadConcurrency}
|
|
1303
|
+
onChange={(e) => persistConcurrency(Number(e.target.value))}
|
|
1304
|
+
className="flex-1 max-w-xs accent-red-600"
|
|
1305
|
+
/>
|
|
1306
|
+
<span className="font-mono text-xs text-neutral-700 w-6 text-right">
|
|
1307
|
+
{uploadConcurrency}
|
|
1308
|
+
</span>
|
|
1309
|
+
</div>
|
|
1310
|
+
<p className="text-[11px] text-neutral-400 mt-1">
|
|
1311
|
+
Lower (2) for slower connections, higher (8) for fast
|
|
1312
|
+
pipes. Your choice is remembered for future restores.
|
|
1313
|
+
</p>
|
|
1314
|
+
</div>
|
|
1315
|
+
)}
|
|
1316
|
+
|
|
1317
|
+
<div>
|
|
1318
|
+
<label className="block text-xs font-medium text-neutral-600 mb-1">
|
|
1319
|
+
Type{" "}
|
|
1320
|
+
<span className="font-mono font-bold text-neutral-900">
|
|
1321
|
+
{confirmTarget}
|
|
1322
|
+
</span>{" "}
|
|
1323
|
+
to confirm (exact match, case-sensitive)
|
|
1324
|
+
</label>
|
|
1325
|
+
<input
|
|
1326
|
+
type="text"
|
|
1327
|
+
value={confirmText}
|
|
1328
|
+
onChange={(e) => setConfirmText(e.target.value)}
|
|
1329
|
+
placeholder={confirmTarget}
|
|
1330
|
+
spellCheck={false}
|
|
1331
|
+
autoCapitalize="off"
|
|
1332
|
+
autoCorrect="off"
|
|
1333
|
+
className="w-full max-w-xs rounded-lg border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-900 placeholder:text-neutral-300 focus:outline-none focus:ring-2 focus:ring-red-300 focus:border-red-400 font-mono"
|
|
1334
|
+
autoFocus
|
|
1335
|
+
/>
|
|
1336
|
+
{!hasSiteName && (
|
|
1337
|
+
<div className="mt-2 p-2 rounded-md bg-amber-50 border border-amber-200">
|
|
1338
|
+
<p className="text-[11px] text-amber-800 font-medium">
|
|
1339
|
+
Site name missing from <span className="font-mono">site.config.ts</span>
|
|
1340
|
+
</p>
|
|
1341
|
+
<p className="text-[11px] text-amber-700 mt-0.5">
|
|
1342
|
+
We couldn't detect a site name to use as the confirmation phrase, so you must type the literal word <span className="font-mono font-semibold">CONFIRM</span> instead. Adding <span className="font-mono">name: "…"</span> to your instance config will restore the stricter per-site confirmation going forward.
|
|
1343
|
+
</p>
|
|
1344
|
+
</div>
|
|
1345
|
+
)}
|
|
1346
|
+
</div>
|
|
1347
|
+
|
|
1348
|
+
<div className="flex items-center gap-2">
|
|
1349
|
+
<button
|
|
1350
|
+
onClick={handleRestore}
|
|
1351
|
+
disabled={!confirmationMatch}
|
|
1352
|
+
className="inline-flex items-center gap-2 rounded-lg bg-red-600 text-white text-sm font-medium px-4 py-2 hover:bg-red-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
|
1353
|
+
>
|
|
1354
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
1355
|
+
<path d="M12 11V21" />
|
|
1356
|
+
<polyline points="8 17 12 21 16 17" />
|
|
1357
|
+
<path d="M20 17.58A5 5 0 0 0 18 8h-1.26A8 8 0 1 0 4 16.25" />
|
|
1358
|
+
</svg>
|
|
1359
|
+
Restore Backup
|
|
1360
|
+
</button>
|
|
1361
|
+
<button
|
|
1362
|
+
onClick={() => setPhase("preview")}
|
|
1363
|
+
className="text-xs text-neutral-400 hover:text-neutral-600 transition-colors px-3 py-2"
|
|
1364
|
+
>
|
|
1365
|
+
Back
|
|
1366
|
+
</button>
|
|
1367
|
+
</div>
|
|
1368
|
+
</div>
|
|
1369
|
+
)}
|
|
1370
|
+
|
|
1371
|
+
{/* ── Phase: restoring_docs — Sanity restore progress ── */}
|
|
1372
|
+
{phase === "restoring_docs" && (
|
|
1373
|
+
<div className="space-y-3 py-4">
|
|
1374
|
+
<ProgressBar phase="Restoring documents..." progress={40} />
|
|
1375
|
+
<p className="text-[11px] text-neutral-400">
|
|
1376
|
+
Wiping existing content and restoring from backup. Do not close this page.
|
|
1377
|
+
</p>
|
|
1378
|
+
</div>
|
|
1379
|
+
)}
|
|
1380
|
+
|
|
1381
|
+
{/* ── Phase: uploading_assets — R2 upload progress ── */}
|
|
1382
|
+
{phase === "uploading_assets" && uploadProgress && (
|
|
1383
|
+
<div className="space-y-3 py-4">
|
|
1384
|
+
{/* Doc phase complete indicator */}
|
|
1385
|
+
<div className="flex items-center gap-2 text-xs text-green-700">
|
|
1386
|
+
<CheckIcon size={12} />
|
|
1387
|
+
<span>Documents restored</span>
|
|
1388
|
+
</div>
|
|
1389
|
+
|
|
1390
|
+
<ProgressBar
|
|
1391
|
+
phase={`Uploading assets... ${uploadProgress.completed} / ${uploadProgress.total} files`}
|
|
1392
|
+
progress={uploadProgress.total > 0
|
|
1393
|
+
? (uploadProgress.completed / uploadProgress.total) * 100
|
|
1394
|
+
: 0
|
|
1395
|
+
}
|
|
1396
|
+
totalFiles={uploadProgress.total}
|
|
1397
|
+
bytesTransferred={uploadProgress.bytesTransferred}
|
|
1398
|
+
totalBytes={uploadProgress.totalBytes}
|
|
1399
|
+
failedCount={uploadProgress.failed.length}
|
|
1400
|
+
/>
|
|
1401
|
+
|
|
1402
|
+
{/* M-8: TTL warning banner — presigned URLs expire after
|
|
1403
|
+
`uploadTtlSeconds`; once we're past ~80% we warn the admin. */}
|
|
1404
|
+
{uploadNearExpiry && uploadTtlSeconds && (
|
|
1405
|
+
<div className="flex items-start gap-2 p-2.5 rounded-lg bg-amber-50 border border-amber-200">
|
|
1406
|
+
<span className="text-amber-600 mt-0.5 shrink-0">
|
|
1407
|
+
<AlertIcon size={14} />
|
|
1408
|
+
</span>
|
|
1409
|
+
<div>
|
|
1410
|
+
<p className="text-xs text-amber-800 font-medium">
|
|
1411
|
+
Upload taking longer than expected
|
|
1412
|
+
</p>
|
|
1413
|
+
<p className="text-[11px] text-amber-700 mt-0.5">
|
|
1414
|
+
The presigned upload URLs expire {Math.round(uploadTtlSeconds / 60)} minutes after they were generated.
|
|
1415
|
+
Remaining files may fail to upload — cancel and retry to get a fresh batch.
|
|
1416
|
+
</p>
|
|
1417
|
+
</div>
|
|
1418
|
+
</div>
|
|
1419
|
+
)}
|
|
1420
|
+
|
|
1421
|
+
<button
|
|
1422
|
+
onClick={handleCancel}
|
|
1423
|
+
className="text-xs text-neutral-400 hover:text-neutral-600 transition-colors px-2 py-1 rounded hover:bg-neutral-50"
|
|
1424
|
+
>
|
|
1425
|
+
Cancel asset upload
|
|
1426
|
+
</button>
|
|
1427
|
+
|
|
1428
|
+
<p className="text-[11px] text-neutral-400">
|
|
1429
|
+
Uploading assets directly to R2. Documents have already been restored.
|
|
1430
|
+
</p>
|
|
1431
|
+
</div>
|
|
1432
|
+
)}
|
|
1433
|
+
|
|
1434
|
+
{/* ── Phase: done — Result ── */}
|
|
1435
|
+
{phase === "done" && docResult && (
|
|
1436
|
+
<div className="space-y-3">
|
|
1437
|
+
<div className="flex items-start gap-2 p-3 rounded-lg bg-green-50 border border-green-200">
|
|
1438
|
+
<span className="text-green-600 mt-0.5 shrink-0"><CheckIcon size={16} /></span>
|
|
1439
|
+
<div>
|
|
1440
|
+
<p className="text-sm font-semibold text-green-800">Restore completed successfully</p>
|
|
1441
|
+
<p className="text-xs text-green-700 mt-0.5">
|
|
1442
|
+
Completed in {(totalDuration / 1000).toFixed(1)}s
|
|
1443
|
+
</p>
|
|
1444
|
+
</div>
|
|
1445
|
+
</div>
|
|
1446
|
+
|
|
1447
|
+
{/* Result stats */}
|
|
1448
|
+
<div className="p-3 rounded-lg bg-neutral-50 border border-neutral-100 space-y-2">
|
|
1449
|
+
<p className="text-[10px] uppercase tracking-wider text-neutral-400 font-medium">Results</p>
|
|
1450
|
+
|
|
1451
|
+
<div className="grid grid-cols-2 gap-2">
|
|
1452
|
+
<div>
|
|
1453
|
+
<p className="text-[11px] text-neutral-500">Documents restored</p>
|
|
1454
|
+
<p className="text-xs font-medium text-neutral-800">
|
|
1455
|
+
{Object.values(docResult.sanity.restored).reduce((a, b) => a + b, 0)}
|
|
1456
|
+
</p>
|
|
1457
|
+
</div>
|
|
1458
|
+
<div>
|
|
1459
|
+
<p className="text-[11px] text-neutral-500">Documents wiped</p>
|
|
1460
|
+
<p className="text-xs font-medium text-neutral-800">
|
|
1461
|
+
{Object.values(docResult.sanity.wiped).reduce((a, b) => a + b, 0)}
|
|
1462
|
+
</p>
|
|
1463
|
+
</div>
|
|
1464
|
+
</div>
|
|
1465
|
+
|
|
1466
|
+
{uploadProgress && (
|
|
1467
|
+
<div className="grid grid-cols-2 gap-2">
|
|
1468
|
+
<div>
|
|
1469
|
+
<p className="text-[11px] text-neutral-500">Assets uploaded</p>
|
|
1470
|
+
<p className="text-xs font-medium text-neutral-800">
|
|
1471
|
+
{uploadProgress.completed - uploadProgress.failed.length} / {uploadProgress.total}
|
|
1472
|
+
</p>
|
|
1473
|
+
</div>
|
|
1474
|
+
{uploadProgress.failed.length > 0 && (
|
|
1475
|
+
<div>
|
|
1476
|
+
<p className="text-[11px] text-neutral-500">Assets failed</p>
|
|
1477
|
+
<p className="text-xs font-medium text-red-600">
|
|
1478
|
+
{uploadProgress.failed.length}
|
|
1479
|
+
</p>
|
|
1480
|
+
</div>
|
|
1481
|
+
)}
|
|
1482
|
+
</div>
|
|
1483
|
+
)}
|
|
1484
|
+
|
|
1485
|
+
{!uploadProgress && assetCount > 0 && status && !status.r2_connected && (
|
|
1486
|
+
<p className="text-[11px] text-amber-600">
|
|
1487
|
+
Assets skipped: R2 not connected
|
|
1488
|
+
</p>
|
|
1489
|
+
)}
|
|
1490
|
+
|
|
1491
|
+
{uploadProgress && uploadProgress.failed.length > 0 && (
|
|
1492
|
+
<details className="text-xs">
|
|
1493
|
+
<summary className="text-red-600 cursor-pointer hover:text-red-700">
|
|
1494
|
+
View failed assets ({uploadProgress.failed.length})
|
|
1495
|
+
</summary>
|
|
1496
|
+
<ul className="mt-1 space-y-0.5 text-neutral-500 max-h-32 overflow-y-auto">
|
|
1497
|
+
{uploadProgress.failed.map((path) => (
|
|
1498
|
+
<li key={path} className="font-mono text-[11px] truncate">{path}</li>
|
|
1499
|
+
))}
|
|
1500
|
+
</ul>
|
|
1501
|
+
</details>
|
|
1502
|
+
)}
|
|
1503
|
+
</div>
|
|
1504
|
+
|
|
1505
|
+
<button
|
|
1506
|
+
onClick={handleReset}
|
|
1507
|
+
className="text-xs text-neutral-400 hover:text-neutral-600 transition-colors px-2 py-1 rounded hover:bg-neutral-50"
|
|
1508
|
+
>
|
|
1509
|
+
Done
|
|
1510
|
+
</button>
|
|
1511
|
+
</div>
|
|
1512
|
+
)}
|
|
1513
|
+
|
|
1514
|
+
{/* ── Phase: error ── */}
|
|
1515
|
+
{phase === "error" && (
|
|
1516
|
+
<div className="space-y-3">
|
|
1517
|
+
<div className="p-3 rounded-lg bg-red-50 border border-red-200">
|
|
1518
|
+
<p className="text-sm font-semibold text-red-800 mb-1">Restore failed</p>
|
|
1519
|
+
<p className="text-xs text-red-700">{restoreError}</p>
|
|
1520
|
+
</div>
|
|
1521
|
+
<button
|
|
1522
|
+
onClick={handleReset}
|
|
1523
|
+
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors px-2 py-1 rounded hover:bg-neutral-50"
|
|
1524
|
+
>
|
|
1525
|
+
Try again
|
|
1526
|
+
</button>
|
|
1527
|
+
</div>
|
|
1528
|
+
)}
|
|
1529
|
+
</div>
|
|
1530
|
+
</div>
|
|
1531
|
+
);
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// ============================================
|
|
1535
|
+
// Main Page Component
|
|
1536
|
+
// ============================================
|
|
1537
|
+
|
|
1538
|
+
export default function BackupsPage() {
|
|
1539
|
+
const [status, setStatus] = useState<BackupStatus | null>(null);
|
|
1540
|
+
const [statusLoading, setStatusLoading] = useState(true);
|
|
1541
|
+
const [statusError, setStatusError] = useState<string | null>(null);
|
|
1542
|
+
|
|
1543
|
+
const fetchStatus = useCallback(async () => {
|
|
1544
|
+
try {
|
|
1545
|
+
const res = await fetch("/api/admin/backups/status");
|
|
1546
|
+
if (!res.ok) {
|
|
1547
|
+
const data = await res.json().catch(() => ({ error: "Failed to fetch status" }));
|
|
1548
|
+
throw new Error(data.error || `Status check failed (${res.status})`);
|
|
1549
|
+
}
|
|
1550
|
+
const data: BackupStatus = await res.json();
|
|
1551
|
+
setStatus(data);
|
|
1552
|
+
} catch (err) {
|
|
1553
|
+
setStatusError(err instanceof Error ? err.message : "Failed to load backup status");
|
|
1554
|
+
} finally {
|
|
1555
|
+
setStatusLoading(false);
|
|
1556
|
+
}
|
|
1557
|
+
}, []);
|
|
1558
|
+
|
|
1559
|
+
useEffect(() => {
|
|
1560
|
+
fetchStatus();
|
|
1561
|
+
}, [fetchStatus]);
|
|
1562
|
+
|
|
1563
|
+
return (
|
|
1564
|
+
<div className="space-y-4">
|
|
1565
|
+
{/* Header */}
|
|
1566
|
+
<div className="flex items-center justify-between mb-2">
|
|
1567
|
+
<h1 className="text-2xl font-semibold text-neutral-900">Backups</h1>
|
|
1568
|
+
</div>
|
|
1569
|
+
|
|
1570
|
+
<div className="max-w-xl space-y-4">
|
|
1571
|
+
<CreateBackupSection
|
|
1572
|
+
status={status}
|
|
1573
|
+
statusLoading={statusLoading}
|
|
1574
|
+
statusError={statusError}
|
|
1575
|
+
/>
|
|
1576
|
+
|
|
1577
|
+
<RestoreSection status={status} onRestoreSuccess={fetchStatus} />
|
|
1578
|
+
</div>
|
|
1579
|
+
</div>
|
|
1580
|
+
);
|
|
1581
|
+
}
|