diff --git a/client/www/app/intern/content.tsx b/client/www/app/intern/content.tsx index be3d62d8e9..4b391d63f8 100644 --- a/client/www/app/intern/content.tsx +++ b/client/www/app/intern/content.tsx @@ -91,6 +91,13 @@ const tools: ToolCard[] = [ 'Preview all og:image cards across the site to make sure they look good before deploying.', category: 'Other', }, + { + title: 'Restore App', + href: '/intern/restore', + description: + 'Upload a backup zip to restore it into a new app. Runs in the background on the machine that receives the upload.', + category: 'Other', + }, ]; const categories = ['All', 'KPIs', 'Analytics', 'Comms', 'Other']; diff --git a/client/www/app/intern/restore/content.tsx b/client/www/app/intern/restore/content.tsx new file mode 100644 index 0000000000..0714fde257 --- /dev/null +++ b/client/www/app/intern/restore/content.tsx @@ -0,0 +1,417 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { + LandingContainer, + LegacyNav, + Section, + H2, +} from '@/components/marketingUi'; +import { + Button, + Content, + Dialog, + FullscreenLoading, + SubsectionHeading, + TextInput, + useDialog, +} from '@/components/ui'; +import { Footer } from '@/components/new-landing/Footer'; +import { useAdmin, useAuthInfo, useTokenFetch } from '@/lib/auth'; +import { useIsHydrated } from '@/lib/hooks/useIsHydrated'; +import { errorToast, successToast } from '@/lib/toast'; +import config from '@/lib/config'; + +type RestoreJob = { + id: string; + app_id: string; + title: string | null; + job_status: 'waiting' | 'processing' | 'completed' | 'errored' | 'cancelled'; + progress: string | null; + error: string | null; + created_at: string; + done_at: string | null; + updated_at: string; +}; + +const TERMINAL = new Set(['completed', 'errored', 'cancelled']); + +// A live restore bumps `updated_at` every second (see report-progress! on the +// server). If a non-terminal job hasn't updated in this long, the machine +// running it probably died/restarted -- the row will never finalize on its own, +// so we surface a "may be stuck" hint and let the operator cancel + retry. +const STALE_MS = 30_000; + +function isStale(job: RestoreJob) { + if (TERMINAL.has(job.job_status)) return false; + return Date.now() - new Date(job.updated_at).getTime() > STALE_MS; +} + +function RestoreDialog({ + token, + email, + onStarted, +}: { + token: string | undefined; + email: string | undefined; + onStarted: () => void; +}) { + const dialog = useDialog(); + const [file, setFile] = useState(null); + const [appId, setAppId] = useState(''); + const [title, setTitle] = useState(''); + const [uploading, setUploading] = useState(false); + const [errorMsg, setErrorMsg] = useState(null); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!file) { + setErrorMsg('Choose a zip file to restore.'); + return; + } + setUploading(true); + setErrorMsg(null); + try { + const params = new URLSearchParams(); + if (appId.trim()) params.set('app_id', appId.trim()); + if (title.trim()) params.set('title', title.trim()); + const qs = params.toString(); + + const res = await fetch( + `${config.apiURI}/dash/restores/zip${qs ? `?${qs}` : ''}`, + { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/zip', + }, + body: file, + }, + ); + const json = await res.json(); + if (!res.ok) { + throw new Error(json?.message ?? `Restore failed (${res.status})`); + } + successToast('Restore started'); + setFile(null); + setAppId(''); + setTitle(''); + dialog.onClose(); + onStarted(); + } catch (err: any) { + setErrorMsg(err?.message ?? 'Restore failed'); + } finally { + setUploading(false); + } + } + + return ( + <> + + +
+ Restore from backup + + Upload a backup zip to restore the app. The app will have the same + schema, rules, data, and files as the app that was backed up. + + + If you have OAuth client secrets for your social logins, you will + need to update them from the `Auth` tab after the restore finishes. + +
+ Only upload zip files that were downloaded from a valid Instant + backup. +
+ {email && ( + + The restored app will be owned by your account ( + {email}). + + )} + +
+
+ + Backup zip + + +
+ + + + + + {errorMsg && ( +
+ {errorMsg} +
+ )} + +
+ +
+
+
+
+ + ); +} + +function statusText(job: RestoreJob) { + if (job.job_status === 'waiting') return 'Waiting…'; + if (job.job_status === 'processing') return job.progress ?? 'Restoring…'; + return job.job_status; +} + +function RecentRestores({ + jobs, + token, + onChanged, +}: { + jobs: RestoreJob[]; + token: string | undefined; + onChanged: () => void; +}) { + const [cancellingId, setCancellingId] = useState(null); + + async function cancel(id: string) { + setCancellingId(id); + try { + const res = await fetch(`${config.apiURI}/dash/restore-jobs/${id}`, { + method: 'DELETE', + headers: { authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + const json = await res.json().catch(() => null); + throw new Error(json?.message ?? `Cancel failed (${res.status})`); + } + onChanged(); + } catch (err: any) { + errorToast(err?.message ?? 'Cancel failed'); + } finally { + setCancellingId(null); + } + } + + if (jobs.length === 0) { + return

No restores yet.

; + } + return ( +
+ {jobs.map((job) => ( +
+
+
+ {job.job_status === 'completed' ? ( + + {job.title || 'Restored app'} + + ) : ( + + {job.title || 'Restored app'} + + )} + + app id: {job.app_id} + + + {new Date(job.created_at).toLocaleString()} + +
+
+
+ + {statusText(job)} + + {job.job_status === 'errored' && job.error && ( + + {job.error} + + )} +
+ {!TERMINAL.has(job.job_status) && ( + + )} +
+
+ {isStale(job) && ( + + This job hasn't updated in a while and may be stuck. You can + cancel it and try again. + + )} +
+ ))} +
+ ); +} + +function RestoreContent() { + const { token, user } = useAuthInfo(); + + const restoresRes = useTokenFetch<{ 'restore-jobs': RestoreJob[] }>( + `${config.apiURI}/dash/restore-jobs`, + token, + ); + const jobs = restoresRes.data?.['restore-jobs'] ?? []; + const anyActive = jobs.some((j) => !TERMINAL.has(j.job_status)); + + // Poll the list while any restore is in flight so progress advances. + useEffect(() => { + if (!anyActive) return; + const t = setInterval(() => restoresRes.mutate(), 1000); + return () => clearInterval(t); + }, [anyActive, restoresRes.mutate]); + + return ( +
+
+

Restore from backup

+

+ Restore an app from a backup zip you downloaded from Instant. +

+
+ +
+ restoresRes.mutate()} + /> +
+ +
+ + Recent restores + + restoresRes.mutate()} + /> +
+
+ ); +} + +export default function RestorePage() { + const isHydrated = useIsHydrated(); + const { isAdmin, isLoading, error } = useAdmin(); + + if (!isHydrated || isLoading) { + return ( + + +
+
+ +
+
+