@bygghammar/cms-platform 0.25.0 → 0.27.0
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.
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
import { useMemo, useRef, useState } from "react";
|
|
2
|
+
import { toast } from "sonner";
|
|
3
|
+
import { Download, Upload, ShieldAlert, Loader2, CheckCircle2, AlertTriangle } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
import { ProgressBar } from "./ProgressBar";
|
|
6
|
+
import { ALL_GROUP_IDS, DATASET_GROUPS, DEFAULT_GROUP_IDS, type ImportMode } from "../backup/datasets";
|
|
7
|
+
|
|
8
|
+
type Estimate = {
|
|
9
|
+
counts: Record<string, number>;
|
|
10
|
+
mediaFiles: number;
|
|
11
|
+
mediaBytes: number;
|
|
12
|
+
mediaError?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type Analysis = {
|
|
16
|
+
manifest: {
|
|
17
|
+
createdAt: string;
|
|
18
|
+
createdBy: string | null;
|
|
19
|
+
site: string | null;
|
|
20
|
+
groups: string[];
|
|
21
|
+
mediaFiles: number;
|
|
22
|
+
mediaBytes: number;
|
|
23
|
+
};
|
|
24
|
+
compatible: boolean;
|
|
25
|
+
problems: string[];
|
|
26
|
+
mediaFiles: number;
|
|
27
|
+
mediaBytes: number;
|
|
28
|
+
datasets: {
|
|
29
|
+
group: string;
|
|
30
|
+
label: string;
|
|
31
|
+
insertOnly: boolean;
|
|
32
|
+
tables: { table: string; incoming: number; existing: number }[];
|
|
33
|
+
mediaFiles?: number;
|
|
34
|
+
mediaBytes?: number;
|
|
35
|
+
}[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type ImportResult = {
|
|
39
|
+
ok: boolean;
|
|
40
|
+
applied: {
|
|
41
|
+
table: string;
|
|
42
|
+
inserted: number;
|
|
43
|
+
skipped: number;
|
|
44
|
+
mode: string;
|
|
45
|
+
failedKeys?: string[];
|
|
46
|
+
}[];
|
|
47
|
+
aborted?: string[];
|
|
48
|
+
mediaRestored: number;
|
|
49
|
+
mediaBytesRestored: number;
|
|
50
|
+
mediaFilesInArchive: number;
|
|
51
|
+
mediaBytesInArchive: number;
|
|
52
|
+
quarantinedMedia?: string[];
|
|
53
|
+
warnings: string[];
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type MediaArchiveProgress = {
|
|
57
|
+
phase: string;
|
|
58
|
+
completedFiles: number;
|
|
59
|
+
completedBytes: number;
|
|
60
|
+
totalFiles: number;
|
|
61
|
+
totalBytes: number;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Host-provided permissions, credentials and media assembly for the backup tool. */
|
|
65
|
+
export type BackupDeps = {
|
|
66
|
+
/** Permission state, resolved by the host. */
|
|
67
|
+
isSuper: boolean;
|
|
68
|
+
loading?: boolean;
|
|
69
|
+
error?: boolean;
|
|
70
|
+
retry?: () => void;
|
|
71
|
+
/** Authorization header for the backup HTTP endpoints. */
|
|
72
|
+
authHeaders: () => Promise<Record<string, string>>;
|
|
73
|
+
/**
|
|
74
|
+
* Assembles the large media payload in the browser. The host loads the
|
|
75
|
+
* implementation lazily and supplies its own storage client.
|
|
76
|
+
*/
|
|
77
|
+
buildBrowserMediaArchive: (args: {
|
|
78
|
+
baseArchive: Uint8Array;
|
|
79
|
+
groups: string[];
|
|
80
|
+
onProgress: (p: MediaArchiveProgress) => void;
|
|
81
|
+
}) => Promise<{ blob: Blob }>;
|
|
82
|
+
/** Endpoint overrides; defaults match the canonical route layout. */
|
|
83
|
+
endpoints?: { estimate?: string; export?: string; import?: string; analyze?: string };
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
function bytes(n: number) {
|
|
87
|
+
if (!n) return "0 B";
|
|
88
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
89
|
+
const i = Math.min(units.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
|
|
90
|
+
return `${(n / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type Progress = { label: string; value: number | null; detail?: string };
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Ramps a value towards `ceiling` while the server works without reporting
|
|
97
|
+
* progress, so the bar keeps moving during opaque phases.
|
|
98
|
+
*/
|
|
99
|
+
function createRamp(from: number, ceiling: number, ms: number, onTick: (v: number) => void) {
|
|
100
|
+
const start = Date.now();
|
|
101
|
+
const id = setInterval(() => {
|
|
102
|
+
const t = Math.min(1, (Date.now() - start) / ms);
|
|
103
|
+
onTick(from + (ceiling - from) * (1 - Math.pow(1 - t, 2)));
|
|
104
|
+
}, 200);
|
|
105
|
+
return () => clearInterval(id);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** POST a body with real upload progress (fetch cannot report upload bytes). */
|
|
109
|
+
function postWithUploadProgress(
|
|
110
|
+
url: string,
|
|
111
|
+
body: XMLHttpRequestBodyInit,
|
|
112
|
+
headers: Record<string, string>,
|
|
113
|
+
onUpload: (loaded: number, total: number) => void,
|
|
114
|
+
onUploaded: () => void,
|
|
115
|
+
): Promise<{ ok: boolean; status: number; text: string }> {
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
const xhr = new XMLHttpRequest();
|
|
118
|
+
xhr.open("POST", url, true);
|
|
119
|
+
for (const [k, v] of Object.entries(headers)) xhr.setRequestHeader(k, v);
|
|
120
|
+
xhr.upload.onprogress = (e) => {
|
|
121
|
+
if (e.lengthComputable) onUpload(e.loaded, e.total);
|
|
122
|
+
};
|
|
123
|
+
xhr.upload.onload = () => onUploaded();
|
|
124
|
+
xhr.onerror = () => reject(new Error("Network error"));
|
|
125
|
+
xhr.onload = () =>
|
|
126
|
+
resolve({
|
|
127
|
+
ok: xhr.status >= 200 && xhr.status < 300,
|
|
128
|
+
status: xhr.status,
|
|
129
|
+
text: xhr.responseText,
|
|
130
|
+
});
|
|
131
|
+
xhr.send(body);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Shared Backup & restore screen. Permissions and media come from the host. */
|
|
136
|
+
export function BackupView({ deps }: { deps: BackupDeps }) {
|
|
137
|
+
if (deps.loading) {
|
|
138
|
+
return <div className="p-8 md:p-12 text-sm text-ink/60">Loading…</div>;
|
|
139
|
+
}
|
|
140
|
+
if (deps.error) {
|
|
141
|
+
return (
|
|
142
|
+
<div className="p-8 md:p-12">
|
|
143
|
+
<div className="flex items-start gap-3 border border-ink/10 bg-white p-6">
|
|
144
|
+
<ShieldAlert className="size-5 text-ink/60" />
|
|
145
|
+
<div>
|
|
146
|
+
<div className="font-serif text-lg text-ink">Could not verify your permissions</div>
|
|
147
|
+
<p className="mt-1 text-sm text-ink/60">
|
|
148
|
+
The connection to the server failed. Please try again.
|
|
149
|
+
</p>
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
onClick={() => deps.retry?.()}
|
|
153
|
+
className="mt-3 border border-ink/20 px-3 py-1.5 text-xs uppercase tracking-wide text-ink hover:bg-ink/5"
|
|
154
|
+
>
|
|
155
|
+
Try again
|
|
156
|
+
</button>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (!deps.isSuper) {
|
|
163
|
+
return (
|
|
164
|
+
<div className="p-8 md:p-12">
|
|
165
|
+
<div className="flex items-start gap-3 border border-ink/10 bg-white p-6">
|
|
166
|
+
<ShieldAlert className="size-5 text-ink/60" />
|
|
167
|
+
<div>
|
|
168
|
+
<div className="font-serif text-lg text-ink">Superadmin only</div>
|
|
169
|
+
<p className="mt-1 text-sm text-ink/60">
|
|
170
|
+
Backup and restore is restricted to superadmins.
|
|
171
|
+
</p>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
return <BackupTool deps={deps} />;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function BackupTool({ deps }: { deps: BackupDeps }) {
|
|
181
|
+
const endpoints = {
|
|
182
|
+
estimate: deps.endpoints?.estimate ?? "/api/backup/estimate",
|
|
183
|
+
export: deps.endpoints?.export ?? "/api/backup/export",
|
|
184
|
+
import: deps.endpoints?.import ?? "/api/backup/import",
|
|
185
|
+
analyze: deps.endpoints?.analyze ?? "/api/backup/analyze",
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/* ---------------- export ---------------- */
|
|
189
|
+
const [selected, setSelected] = useState<string[]>(DEFAULT_GROUP_IDS);
|
|
190
|
+
const [estimate, setEstimate] = useState<Estimate | null>(null);
|
|
191
|
+
const [estimating, setEstimating] = useState(false);
|
|
192
|
+
const [exporting, setExporting] = useState(false);
|
|
193
|
+
const [exportProgress, setExportProgress] = useState<Progress | null>(null);
|
|
194
|
+
|
|
195
|
+
const toggle = (id: string) =>
|
|
196
|
+
setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
|
|
197
|
+
|
|
198
|
+
const estimateRows = useMemo(
|
|
199
|
+
() => (estimate ? Object.values(estimate.counts).reduce((a, b) => a + b, 0) : null),
|
|
200
|
+
[estimate],
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const runEstimate = async (groups = selected) => {
|
|
204
|
+
setEstimating(true);
|
|
205
|
+
try {
|
|
206
|
+
const res = await fetch(endpoints.estimate, {
|
|
207
|
+
method: "POST",
|
|
208
|
+
headers: { ...(await deps.authHeaders()), "content-type": "application/json" },
|
|
209
|
+
body: JSON.stringify({ groups }),
|
|
210
|
+
});
|
|
211
|
+
if (!res.ok) throw new Error(await res.text());
|
|
212
|
+
setEstimate((await res.json()) as Estimate);
|
|
213
|
+
} catch (e) {
|
|
214
|
+
toast.error(e instanceof Error ? e.message : "Could not calculate size");
|
|
215
|
+
} finally {
|
|
216
|
+
setEstimating(false);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const download = async (groups: string[], label: string, onProgress?: (p: Progress) => void) => {
|
|
221
|
+
onProgress?.({ label: "Collecting data", value: 2 });
|
|
222
|
+
const headers = { ...(await deps.authHeaders()), "content-type": "application/json" };
|
|
223
|
+
const browserMedia = groups.includes("mediaFiles");
|
|
224
|
+
|
|
225
|
+
// Non-media exports are built entirely by the server. Media exports defer
|
|
226
|
+
// their large payload to the authenticated browser below.
|
|
227
|
+
let ramped = 2;
|
|
228
|
+
const stopRamp = createRamp(2, browserMedia ? 10 : 60, 25_000, (v) => {
|
|
229
|
+
ramped = v;
|
|
230
|
+
onProgress?.({ label: browserMedia ? "Collecting metadata" : "Building archive", value: v });
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
let res: Response;
|
|
234
|
+
try {
|
|
235
|
+
res = await fetch(endpoints.export, {
|
|
236
|
+
method: "POST",
|
|
237
|
+
headers,
|
|
238
|
+
body: JSON.stringify({ groups, label, deferMediaToBrowser: browserMedia }),
|
|
239
|
+
});
|
|
240
|
+
} finally {
|
|
241
|
+
stopRamp();
|
|
242
|
+
}
|
|
243
|
+
if (!res.ok) throw new Error(await res.text());
|
|
244
|
+
|
|
245
|
+
const total = Number(res.headers.get("content-length") ?? 0);
|
|
246
|
+
let blob: Blob;
|
|
247
|
+
if (browserMedia) {
|
|
248
|
+
onProgress?.({ label: "Downloading metadata", value: 10 });
|
|
249
|
+
const baseArchive = new Uint8Array(await res.arrayBuffer());
|
|
250
|
+
const built = await deps.buildBrowserMediaArchive({
|
|
251
|
+
baseArchive,
|
|
252
|
+
groups,
|
|
253
|
+
onProgress: (progress) => {
|
|
254
|
+
if (progress.phase === "listing") {
|
|
255
|
+
onProgress?.({ label: "Listing media files", value: 12 });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (progress.phase === "finalizing") {
|
|
259
|
+
onProgress?.({
|
|
260
|
+
label: "Finalizing archive",
|
|
261
|
+
value: 98,
|
|
262
|
+
detail: `${progress.completedFiles.toLocaleString()} files · ${bytes(
|
|
263
|
+
progress.completedBytes,
|
|
264
|
+
)}`,
|
|
265
|
+
});
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const fileRatio = progress.totalFiles ? progress.completedFiles / progress.totalFiles : 0;
|
|
270
|
+
const byteRatio = progress.totalBytes
|
|
271
|
+
? progress.completedBytes / progress.totalBytes
|
|
272
|
+
: fileRatio;
|
|
273
|
+
const ratio = Math.max(0, Math.min(1, byteRatio));
|
|
274
|
+
onProgress?.({
|
|
275
|
+
label: `Downloading media ${progress.completedFiles.toLocaleString()} / ${progress.totalFiles.toLocaleString()}`,
|
|
276
|
+
value: 12 + ratio * 84,
|
|
277
|
+
detail: `${bytes(progress.completedBytes)} of ${bytes(progress.totalBytes)}`,
|
|
278
|
+
});
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
blob = built.blob;
|
|
282
|
+
} else if (res.body) {
|
|
283
|
+
const reader = res.body.getReader();
|
|
284
|
+
const chunks: BlobPart[] = [];
|
|
285
|
+
let loaded = 0;
|
|
286
|
+
for (;;) {
|
|
287
|
+
const { done, value } = await reader.read();
|
|
288
|
+
if (done) break;
|
|
289
|
+
chunks.push(value as unknown as BlobPart);
|
|
290
|
+
loaded += value.byteLength;
|
|
291
|
+
onProgress?.({
|
|
292
|
+
label: "Downloading archive",
|
|
293
|
+
value: total ? 60 + (loaded / total) * 39 : Math.min(98, ramped + loaded / 2_000_000),
|
|
294
|
+
detail: total ? `${bytes(loaded)} of ${bytes(total)}` : bytes(loaded),
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
blob = new Blob(chunks, { type: "application/zip" });
|
|
298
|
+
} else {
|
|
299
|
+
blob = await res.blob();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const name =
|
|
303
|
+
res.headers.get("content-disposition")?.match(/filename="(.+?)"/)?.[1] ?? `${label}.zip`;
|
|
304
|
+
const url = URL.createObjectURL(blob);
|
|
305
|
+
const a = document.createElement("a");
|
|
306
|
+
a.href = url;
|
|
307
|
+
a.download = name;
|
|
308
|
+
document.body.appendChild(a);
|
|
309
|
+
a.click();
|
|
310
|
+
a.remove();
|
|
311
|
+
// Revoking immediately can cancel a large browser download.
|
|
312
|
+
setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
|
313
|
+
onProgress?.({ label: "Done", value: 100, detail: bytes(blob.size) });
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const runExport = async (groups: string[], label: string) => {
|
|
317
|
+
setExporting(true);
|
|
318
|
+
setExportProgress({ label: "Starting", value: 0 });
|
|
319
|
+
try {
|
|
320
|
+
await download(groups, label, setExportProgress);
|
|
321
|
+
toast.success("Backup downloaded.");
|
|
322
|
+
setTimeout(() => setExportProgress(null), 2500);
|
|
323
|
+
} catch (e) {
|
|
324
|
+
toast.error(e instanceof Error ? e.message : "Export failed");
|
|
325
|
+
setExportProgress(null);
|
|
326
|
+
} finally {
|
|
327
|
+
setExporting(false);
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
/* ---------------- import ---------------- */
|
|
332
|
+
const fileRef = useRef<HTMLInputElement>(null);
|
|
333
|
+
const [file, setFile] = useState<File | null>(null);
|
|
334
|
+
const [analysis, setAnalysis] = useState<Analysis | null>(null);
|
|
335
|
+
const [analyzing, setAnalyzing] = useState(false);
|
|
336
|
+
const [importGroups, setImportGroups] = useState<string[]>([]);
|
|
337
|
+
const [mode, setMode] = useState<ImportMode>("merge");
|
|
338
|
+
const [includeMedia, setIncludeMedia] = useState(true);
|
|
339
|
+
const [rewriteMedia, setRewriteMedia] = useState(false);
|
|
340
|
+
const [mediaBase, setMediaBase] = useState("");
|
|
341
|
+
|
|
342
|
+
const [importing, setImporting] = useState(false);
|
|
343
|
+
const [result, setResult] = useState<ImportResult | null>(null);
|
|
344
|
+
const [confirmText, setConfirmText] = useState("");
|
|
345
|
+
const [analyzeProgress, setAnalyzeProgress] = useState<Progress | null>(null);
|
|
346
|
+
const [importProgress, setImportProgress] = useState<Progress | null>(null);
|
|
347
|
+
|
|
348
|
+
const onPick = async (f: File | null) => {
|
|
349
|
+
setFile(f);
|
|
350
|
+
setAnalysis(null);
|
|
351
|
+
setResult(null);
|
|
352
|
+
setConfirmText("");
|
|
353
|
+
setImportProgress(null);
|
|
354
|
+
setAnalyzeProgress(null);
|
|
355
|
+
if (!f) return;
|
|
356
|
+
setAnalyzing(true);
|
|
357
|
+
const ramp: { stop: (() => void) | null } = { stop: null };
|
|
358
|
+
try {
|
|
359
|
+
setAnalyzeProgress({ label: "Uploading archive", value: 0, detail: bytes(0) });
|
|
360
|
+
const res = await postWithUploadProgress(
|
|
361
|
+
endpoints.analyze,
|
|
362
|
+
f,
|
|
363
|
+
{ ...(await deps.authHeaders()), "content-type": "application/zip" },
|
|
364
|
+
(loaded, total) =>
|
|
365
|
+
setAnalyzeProgress({
|
|
366
|
+
label: "Uploading archive",
|
|
367
|
+
value: (loaded / total) * 70,
|
|
368
|
+
detail: `${bytes(loaded)} of ${bytes(total)}`,
|
|
369
|
+
}),
|
|
370
|
+
() => {
|
|
371
|
+
ramp.stop = createRamp(70, 97, 15_000, (v) =>
|
|
372
|
+
setAnalyzeProgress({ label: "Reading archive", value: v }),
|
|
373
|
+
);
|
|
374
|
+
},
|
|
375
|
+
);
|
|
376
|
+
ramp.stop?.();
|
|
377
|
+
if (!res.ok) throw new Error(res.text || `Request failed (${res.status})`);
|
|
378
|
+
const a = JSON.parse(res.text) as Analysis;
|
|
379
|
+
setAnalysis(a);
|
|
380
|
+
setImportGroups(a.manifest.groups ?? []);
|
|
381
|
+
setAnalyzeProgress({ label: "Archive read", value: 100 });
|
|
382
|
+
setTimeout(() => setAnalyzeProgress(null), 1500);
|
|
383
|
+
} catch (e) {
|
|
384
|
+
ramp.stop?.();
|
|
385
|
+
setAnalyzeProgress(null);
|
|
386
|
+
toast.error(e instanceof Error ? e.message : "Could not read the archive");
|
|
387
|
+
} finally {
|
|
388
|
+
setAnalyzing(false);
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
const runImport = async () => {
|
|
393
|
+
if (!file || !analysis) return;
|
|
394
|
+
setImporting(true);
|
|
395
|
+
setResult(null);
|
|
396
|
+
const ramp: { stop: (() => void) | null } = { stop: null };
|
|
397
|
+
try {
|
|
398
|
+
// Safety snapshot of everything the import will touch, taken first.
|
|
399
|
+
toast.info("Taking a safety snapshot before importing…");
|
|
400
|
+
await download(importGroups, "pre-import-snapshot", (p) =>
|
|
401
|
+
// Snapshot occupies the first 40% of the import progress.
|
|
402
|
+
setImportProgress({
|
|
403
|
+
label: `Safety snapshot — ${p.label.toLowerCase()}`,
|
|
404
|
+
value: p.value === null ? null : (p.value / 100) * 40,
|
|
405
|
+
detail: p.detail,
|
|
406
|
+
}),
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
const qs = new URLSearchParams({
|
|
410
|
+
groups: importGroups.join(","),
|
|
411
|
+
mode,
|
|
412
|
+
media: includeMedia ? "1" : "0",
|
|
413
|
+
rewrite: rewriteMedia ? "1" : "0",
|
|
414
|
+
...(rewriteMedia && mediaBase.trim() ? { mediaBase: mediaBase.trim() } : {}),
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
const res = await postWithUploadProgress(
|
|
418
|
+
`${endpoints.import}?${qs}`,
|
|
419
|
+
file,
|
|
420
|
+
{ ...(await deps.authHeaders()), "content-type": "application/zip" },
|
|
421
|
+
(loaded, total) =>
|
|
422
|
+
setImportProgress({
|
|
423
|
+
label: "Uploading archive",
|
|
424
|
+
value: 40 + (loaded / total) * 30,
|
|
425
|
+
detail: `${bytes(loaded)} of ${bytes(total)}`,
|
|
426
|
+
}),
|
|
427
|
+
() => {
|
|
428
|
+
ramp.stop = createRamp(70, 97, 30_000, (v) =>
|
|
429
|
+
setImportProgress({ label: "Restoring data & media", value: v }),
|
|
430
|
+
);
|
|
431
|
+
},
|
|
432
|
+
);
|
|
433
|
+
ramp.stop?.();
|
|
434
|
+
if (!res.ok) throw new Error(res.text || `Request failed (${res.status})`);
|
|
435
|
+
const r = JSON.parse(res.text) as ImportResult;
|
|
436
|
+
setResult(r);
|
|
437
|
+
setImportProgress({ label: "Import complete", value: 100 });
|
|
438
|
+
toast.success("Import finished.");
|
|
439
|
+
setTimeout(() => setImportProgress(null), 2500);
|
|
440
|
+
} catch (e) {
|
|
441
|
+
ramp.stop?.();
|
|
442
|
+
setImportProgress(null);
|
|
443
|
+
toast.error(e instanceof Error ? e.message : "Import failed");
|
|
444
|
+
} finally {
|
|
445
|
+
setImporting(false);
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
const canImport =
|
|
450
|
+
Boolean(analysis?.compatible) &&
|
|
451
|
+
importGroups.length > 0 &&
|
|
452
|
+
(mode === "merge" || confirmText === "REPLACE") &&
|
|
453
|
+
!importing;
|
|
454
|
+
|
|
455
|
+
return (
|
|
456
|
+
<div className="p-8 md:p-12 w-full space-y-10">
|
|
457
|
+
<header>
|
|
458
|
+
<div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">Superadmin</div>
|
|
459
|
+
<h2 className="mt-2 font-serif text-2xl text-ink">Backup & restore</h2>
|
|
460
|
+
<p className="mt-2 max-w-2xl text-sm text-ink/60">
|
|
461
|
+
Export the whole site — content, settings, forms, submissions, media and roles — as a
|
|
462
|
+
single archive, and restore it again on this or another installation. Customer submissions
|
|
463
|
+
are always insert-only and can never be overwritten or deleted by an import.
|
|
464
|
+
</p>
|
|
465
|
+
</header>
|
|
466
|
+
|
|
467
|
+
{/* EXPORT */}
|
|
468
|
+
<section className="border border-ink/10 bg-white p-6">
|
|
469
|
+
<div className="flex items-center gap-3">
|
|
470
|
+
<Download className="size-5 text-ink/70" />
|
|
471
|
+
<h3 className="font-serif text-lg text-ink">Export</h3>
|
|
472
|
+
</div>
|
|
473
|
+
|
|
474
|
+
<div className="mt-6 grid gap-3 md:grid-cols-2">
|
|
475
|
+
{DATASET_GROUPS.map((g) => (
|
|
476
|
+
<label
|
|
477
|
+
key={g.id}
|
|
478
|
+
className="flex cursor-pointer items-start gap-3 border border-ink/10 p-4 hover:border-sea"
|
|
479
|
+
>
|
|
480
|
+
<input
|
|
481
|
+
type="checkbox"
|
|
482
|
+
checked={selected.includes(g.id)}
|
|
483
|
+
onChange={() => toggle(g.id)}
|
|
484
|
+
className="mt-1"
|
|
485
|
+
/>
|
|
486
|
+
<span>
|
|
487
|
+
<span className="block text-sm text-ink">{g.label}</span>
|
|
488
|
+
<span className="mt-1 block text-xs text-ink/55">{g.description}</span>
|
|
489
|
+
{estimate && selected.includes(g.id) ? (
|
|
490
|
+
<span className="mt-2 block text-[0.65rem] uppercase tracking-[0.18em] text-ink/45">
|
|
491
|
+
{g.special === "mediaFiles"
|
|
492
|
+
? estimate.mediaError
|
|
493
|
+
? `Media unavailable — ${estimate.mediaError}`
|
|
494
|
+
: `${estimate.mediaFiles.toLocaleString()} files · ${bytes(estimate.mediaBytes)}`
|
|
495
|
+
: `${g.tables
|
|
496
|
+
.reduce((s, t) => s + (estimate.counts[t.table] ?? 0), 0)
|
|
497
|
+
.toLocaleString()} rows`}
|
|
498
|
+
</span>
|
|
499
|
+
) : null}
|
|
500
|
+
</span>
|
|
501
|
+
</label>
|
|
502
|
+
))}
|
|
503
|
+
</div>
|
|
504
|
+
|
|
505
|
+
<div className="mt-6 flex flex-wrap items-center gap-3">
|
|
506
|
+
<button
|
|
507
|
+
type="button"
|
|
508
|
+
onClick={() => setSelected(ALL_GROUP_IDS)}
|
|
509
|
+
className="border border-ink/20 px-3 py-2 text-xs uppercase tracking-[0.22em] text-ink hover:border-sea"
|
|
510
|
+
>
|
|
511
|
+
Select everything
|
|
512
|
+
</button>
|
|
513
|
+
<button
|
|
514
|
+
type="button"
|
|
515
|
+
onClick={() => setSelected(DEFAULT_GROUP_IDS)}
|
|
516
|
+
className="border border-ink/20 px-3 py-2 text-xs uppercase tracking-[0.22em] text-ink hover:border-sea"
|
|
517
|
+
>
|
|
518
|
+
Recommended
|
|
519
|
+
</button>
|
|
520
|
+
<button
|
|
521
|
+
type="button"
|
|
522
|
+
onClick={() => runEstimate()}
|
|
523
|
+
disabled={estimating || !selected.length}
|
|
524
|
+
className="border border-ink/20 px-3 py-2 text-xs uppercase tracking-[0.22em] text-ink hover:border-sea disabled:opacity-50"
|
|
525
|
+
>
|
|
526
|
+
{estimating ? "Calculating…" : "Calculate size"}
|
|
527
|
+
</button>
|
|
528
|
+
<button
|
|
529
|
+
type="button"
|
|
530
|
+
onClick={() => runExport(selected, "backup")}
|
|
531
|
+
disabled={exporting || !selected.length}
|
|
532
|
+
className="inline-flex items-center gap-2 border border-ink bg-ink px-4 py-2 text-xs uppercase tracking-[0.22em] text-white hover:bg-sea hover:border-sea disabled:opacity-50"
|
|
533
|
+
>
|
|
534
|
+
{exporting ? (
|
|
535
|
+
<Loader2 className="size-3.5 animate-spin" />
|
|
536
|
+
) : (
|
|
537
|
+
<Download className="size-3.5" />
|
|
538
|
+
)}
|
|
539
|
+
{exporting ? "Building archive…" : "Download backup"}
|
|
540
|
+
</button>
|
|
541
|
+
<button
|
|
542
|
+
type="button"
|
|
543
|
+
onClick={() => runExport(ALL_GROUP_IDS, "full-backup")}
|
|
544
|
+
disabled={exporting}
|
|
545
|
+
className="border border-ink/20 px-3 py-2 text-xs uppercase tracking-[0.22em] text-ink hover:border-sea disabled:opacity-50"
|
|
546
|
+
>
|
|
547
|
+
Full backup (everything)
|
|
548
|
+
</button>
|
|
549
|
+
</div>
|
|
550
|
+
|
|
551
|
+
{selected.includes("mediaFiles") ? (
|
|
552
|
+
<p className="mt-4 max-w-2xl text-xs text-ink/55">
|
|
553
|
+
Media files are downloaded and verified one at a time in this browser. Keep this tab
|
|
554
|
+
open until the ZIP download starts; the progress bar shows the real file and byte count.
|
|
555
|
+
</p>
|
|
556
|
+
) : null}
|
|
557
|
+
|
|
558
|
+
{exportProgress ? (
|
|
559
|
+
<div className="mt-6">
|
|
560
|
+
<ProgressBar
|
|
561
|
+
value={exportProgress.value}
|
|
562
|
+
label={exportProgress.label}
|
|
563
|
+
detail={exportProgress.detail}
|
|
564
|
+
/>
|
|
565
|
+
</div>
|
|
566
|
+
) : null}
|
|
567
|
+
|
|
568
|
+
{estimateRows !== null ? (
|
|
569
|
+
<p className="mt-4 text-xs text-ink/55">
|
|
570
|
+
{estimateRows.toLocaleString()} rows
|
|
571
|
+
{estimate?.mediaFiles
|
|
572
|
+
? ` · ${estimate.mediaFiles.toLocaleString()} media files (${bytes(estimate.mediaBytes)})`
|
|
573
|
+
: ""}
|
|
574
|
+
{estimate?.mediaError ? ` · media: ${estimate.mediaError}` : ""}
|
|
575
|
+
</p>
|
|
576
|
+
) : null}
|
|
577
|
+
</section>
|
|
578
|
+
|
|
579
|
+
{/* IMPORT */}
|
|
580
|
+
<section className="border border-ink/10 bg-white p-6">
|
|
581
|
+
<div className="flex items-center gap-3">
|
|
582
|
+
<Upload className="size-5 text-ink/70" />
|
|
583
|
+
<h3 className="font-serif text-lg text-ink">Import / restore</h3>
|
|
584
|
+
</div>
|
|
585
|
+
<p className="mt-2 max-w-2xl text-sm text-ink/60">
|
|
586
|
+
Upload an archive created by this tool. Nothing is written until you confirm — and a
|
|
587
|
+
safety snapshot of the affected data is downloaded automatically first.
|
|
588
|
+
</p>
|
|
589
|
+
|
|
590
|
+
<input
|
|
591
|
+
ref={fileRef}
|
|
592
|
+
type="file"
|
|
593
|
+
accept=".zip,application/zip"
|
|
594
|
+
disabled={analyzing}
|
|
595
|
+
onChange={(e) => onPick(e.target.files?.[0] ?? null)}
|
|
596
|
+
className="mt-4 block text-sm text-ink"
|
|
597
|
+
/>
|
|
598
|
+
|
|
599
|
+
{analyzeProgress ? (
|
|
600
|
+
<div className="mt-4">
|
|
601
|
+
<ProgressBar
|
|
602
|
+
value={analyzeProgress.value}
|
|
603
|
+
label={analyzeProgress.label}
|
|
604
|
+
detail={analyzeProgress.detail}
|
|
605
|
+
/>
|
|
606
|
+
</div>
|
|
607
|
+
) : null}
|
|
608
|
+
|
|
609
|
+
{analysis ? (
|
|
610
|
+
<div className="mt-6 space-y-5">
|
|
611
|
+
<div className="text-xs text-ink/60">
|
|
612
|
+
Created {new Date(analysis.manifest.createdAt).toLocaleString()}
|
|
613
|
+
{analysis.manifest.createdBy ? ` by ${analysis.manifest.createdBy}` : ""}
|
|
614
|
+
{analysis.manifest.site ? ` on ${analysis.manifest.site}` : ""}
|
|
615
|
+
{analysis.mediaFiles
|
|
616
|
+
? ` · verified ${analysis.mediaFiles.toLocaleString()} media files (${bytes(analysis.mediaBytes)})`
|
|
617
|
+
: ""}
|
|
618
|
+
</div>
|
|
619
|
+
|
|
620
|
+
{analysis.problems.length ? (
|
|
621
|
+
<ul className="border border-red-300 bg-red-50 p-4 text-xs text-red-700 space-y-1">
|
|
622
|
+
{analysis.problems.map((p) => (
|
|
623
|
+
<li key={p}>{p}</li>
|
|
624
|
+
))}
|
|
625
|
+
</ul>
|
|
626
|
+
) : null}
|
|
627
|
+
|
|
628
|
+
<div className="space-y-3">
|
|
629
|
+
{analysis.datasets.map((d) => (
|
|
630
|
+
<label key={d.group} className="flex items-start gap-3 border border-ink/10 p-4">
|
|
631
|
+
<input
|
|
632
|
+
type="checkbox"
|
|
633
|
+
checked={importGroups.includes(d.group)}
|
|
634
|
+
onChange={() =>
|
|
635
|
+
setImportGroups((s) =>
|
|
636
|
+
s.includes(d.group) ? s.filter((x) => x !== d.group) : [...s, d.group],
|
|
637
|
+
)
|
|
638
|
+
}
|
|
639
|
+
className="mt-1"
|
|
640
|
+
/>
|
|
641
|
+
<span className="flex-1">
|
|
642
|
+
<span className="block text-sm text-ink">
|
|
643
|
+
{d.label}
|
|
644
|
+
{d.insertOnly ? (
|
|
645
|
+
<span className="ml-2 text-[0.6rem] uppercase tracking-[0.18em] text-ink/45">
|
|
646
|
+
insert-only
|
|
647
|
+
</span>
|
|
648
|
+
) : null}
|
|
649
|
+
</span>
|
|
650
|
+
<span className="mt-1 block text-xs text-ink/55">
|
|
651
|
+
{d.tables
|
|
652
|
+
.map((t) => `${t.table}: ${t.incoming} in archive / ${t.existing} here`)
|
|
653
|
+
.join(" · ")}
|
|
654
|
+
{d.mediaFiles !== undefined
|
|
655
|
+
? ` · ${d.mediaFiles.toLocaleString()} media files · ${bytes(d.mediaBytes ?? 0)}`
|
|
656
|
+
: ""}
|
|
657
|
+
</span>
|
|
658
|
+
</span>
|
|
659
|
+
</label>
|
|
660
|
+
))}
|
|
661
|
+
</div>
|
|
662
|
+
|
|
663
|
+
<div className="flex flex-wrap items-center gap-4">
|
|
664
|
+
<label className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">Mode</label>
|
|
665
|
+
<select
|
|
666
|
+
value={mode}
|
|
667
|
+
onChange={(e) => setMode(e.target.value as ImportMode)}
|
|
668
|
+
className="border border-ink/20 bg-white px-3 py-2 text-xs uppercase tracking-[0.22em] text-ink"
|
|
669
|
+
>
|
|
670
|
+
<option value="merge">Merge — update and add, delete nothing</option>
|
|
671
|
+
<option value="replace">Replace — clear tables first</option>
|
|
672
|
+
</select>
|
|
673
|
+
<label className="inline-flex items-center gap-2 text-xs text-ink/70">
|
|
674
|
+
<input
|
|
675
|
+
type="checkbox"
|
|
676
|
+
checked={includeMedia}
|
|
677
|
+
onChange={(e) => setIncludeMedia(e.target.checked)}
|
|
678
|
+
/>
|
|
679
|
+
Restore media files
|
|
680
|
+
</label>
|
|
681
|
+
<label className="inline-flex items-center gap-2 text-xs text-ink/70">
|
|
682
|
+
<input
|
|
683
|
+
type="checkbox"
|
|
684
|
+
checked={rewriteMedia}
|
|
685
|
+
onChange={(e) => setRewriteMedia(e.target.checked)}
|
|
686
|
+
/>
|
|
687
|
+
Rewrite media URLs to this installation
|
|
688
|
+
</label>
|
|
689
|
+
{rewriteMedia ? (
|
|
690
|
+
<input
|
|
691
|
+
value={mediaBase}
|
|
692
|
+
onChange={(e) => setMediaBase(e.target.value)}
|
|
693
|
+
placeholder="/media"
|
|
694
|
+
className="w-56 border border-ink/20 bg-white px-2 py-1 text-xs text-ink"
|
|
695
|
+
/>
|
|
696
|
+
) : null}
|
|
697
|
+
</div>
|
|
698
|
+
|
|
699
|
+
{mode === "replace" ? (
|
|
700
|
+
<div className="border border-amber-300 bg-amber-50 p-4 text-xs text-amber-800">
|
|
701
|
+
Replace clears the selected tables before loading. Customer data is never cleared.
|
|
702
|
+
Type <strong>REPLACE</strong> to confirm.
|
|
703
|
+
<input
|
|
704
|
+
value={confirmText}
|
|
705
|
+
onChange={(e) => setConfirmText(e.target.value)}
|
|
706
|
+
className="mt-2 block w-40 border border-amber-400 bg-white px-2 py-1 text-xs text-ink"
|
|
707
|
+
/>
|
|
708
|
+
</div>
|
|
709
|
+
) : null}
|
|
710
|
+
|
|
711
|
+
<button
|
|
712
|
+
type="button"
|
|
713
|
+
onClick={runImport}
|
|
714
|
+
disabled={!canImport}
|
|
715
|
+
className="inline-flex items-center gap-2 border border-ink bg-ink px-4 py-2 text-xs uppercase tracking-[0.22em] text-white hover:bg-sea hover:border-sea disabled:opacity-50"
|
|
716
|
+
>
|
|
717
|
+
{importing ? (
|
|
718
|
+
<Loader2 className="size-3.5 animate-spin" />
|
|
719
|
+
) : (
|
|
720
|
+
<Upload className="size-3.5" />
|
|
721
|
+
)}
|
|
722
|
+
{importing ? "Importing…" : "Snapshot & import"}
|
|
723
|
+
</button>
|
|
724
|
+
|
|
725
|
+
{importProgress ? (
|
|
726
|
+
<ProgressBar
|
|
727
|
+
value={importProgress.value}
|
|
728
|
+
label={importProgress.label}
|
|
729
|
+
detail={importProgress.detail}
|
|
730
|
+
/>
|
|
731
|
+
) : null}
|
|
732
|
+
</div>
|
|
733
|
+
) : null}
|
|
734
|
+
|
|
735
|
+
{result ? (
|
|
736
|
+
<div className="mt-6 border border-ink/10 p-4">
|
|
737
|
+
<div className="flex items-center gap-2 text-sm text-ink">
|
|
738
|
+
{result.ok ? (
|
|
739
|
+
<>
|
|
740
|
+
<CheckCircle2 className="size-4 text-sea" /> Import complete
|
|
741
|
+
</>
|
|
742
|
+
) : (
|
|
743
|
+
<>
|
|
744
|
+
<AlertTriangle className="size-4 text-red-600" /> Import finished with errors —
|
|
745
|
+
the restore is incomplete
|
|
746
|
+
</>
|
|
747
|
+
)}
|
|
748
|
+
</div>
|
|
749
|
+
<ul className="mt-3 space-y-1 text-xs text-ink/60">
|
|
750
|
+
{result.applied.map((a) => (
|
|
751
|
+
<li key={a.table} className={a.skipped ? "text-red-700" : undefined}>
|
|
752
|
+
{a.table}: {a.inserted} written{a.skipped ? `, ${a.skipped} failed` : ""} (
|
|
753
|
+
{a.mode})
|
|
754
|
+
{a.failedKeys?.length
|
|
755
|
+
? ` — ${a.failedKeys.slice(0, 5).join(", ")}${a.failedKeys.length > 5 ? " …" : ""}`
|
|
756
|
+
: ""}
|
|
757
|
+
</li>
|
|
758
|
+
))}
|
|
759
|
+
{result.aborted?.length ? (
|
|
760
|
+
<li className="text-red-700">not attempted: {result.aborted.join(", ")}</li>
|
|
761
|
+
) : null}
|
|
762
|
+
{result.mediaFilesInArchive ? (
|
|
763
|
+
<li>
|
|
764
|
+
media restored: {result.mediaRestored.toLocaleString()}/
|
|
765
|
+
{result.mediaFilesInArchive.toLocaleString()} files ·{" "}
|
|
766
|
+
{bytes(result.mediaBytesRestored)} of {bytes(result.mediaBytesInArchive)}
|
|
767
|
+
</li>
|
|
768
|
+
) : null}
|
|
769
|
+
{result.quarantinedMedia?.length ? (
|
|
770
|
+
<li className="text-amber-700">
|
|
771
|
+
{result.quarantinedMedia.length} non-media entries were not restored
|
|
772
|
+
</li>
|
|
773
|
+
) : null}
|
|
774
|
+
</ul>
|
|
775
|
+
|
|
776
|
+
{result.warnings.length ? (
|
|
777
|
+
<ul className="mt-3 space-y-1 text-xs text-amber-700">
|
|
778
|
+
{result.warnings.map((w, i) => (
|
|
779
|
+
<li key={i}>{w}</li>
|
|
780
|
+
))}
|
|
781
|
+
</ul>
|
|
782
|
+
) : null}
|
|
783
|
+
</div>
|
|
784
|
+
) : null}
|
|
785
|
+
</section>
|
|
786
|
+
</div>
|
|
787
|
+
);
|
|
788
|
+
}
|