CI / ci (push) Canceled after 0s
The session built: Appointments page: a single list, newest first. Block Time: edit and delete. Customer CSV export: with barber notes excluded. Guest-link regression test: a test that an appointment id alone is rejected, plus fixes to the stale docs. It built the scheduler, job runner with live progress, backup and restore, and the health alerts. Its own report says a real delete-and-restore on throwaway Docker passed, but I haven’t repeated that. It also says real SMTP and large dumps were not exercised. The manager image needs rebuilding for the backups to work, because it now installs 7zip.
297 lines
11 KiB
TypeScript
297 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useRef, useState, useTransition } from "react";
|
|
import { JobProgress } from "../job-progress";
|
|
import { PassphraseNotice } from "../passphrase-notice";
|
|
import { inspectRestoreAction, startRestoreAction, type RestorePreview } from "./actions";
|
|
|
|
interface BackupOption {
|
|
key: string;
|
|
slug: string;
|
|
fileName: string;
|
|
createdAt: string;
|
|
kind: string;
|
|
sizeBytes: number;
|
|
}
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
|
}
|
|
|
|
export function RestoreWizard({
|
|
backups,
|
|
preselected,
|
|
hasStoredPassphrase,
|
|
maxUploadBytes,
|
|
}: {
|
|
backups: BackupOption[];
|
|
preselected: string | null;
|
|
hasStoredPassphrase: boolean;
|
|
maxUploadBytes: number;
|
|
}) {
|
|
const [mode, setMode] = useState<"stored" | "upload">(backups.length === 0 ? "upload" : "stored");
|
|
const [selected, setSelected] = useState(preselected ?? backups[0]?.key ?? "");
|
|
const [uploadKey, setUploadKey] = useState<string | null>(null);
|
|
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
|
const [uploadName, setUploadName] = useState<string | null>(null);
|
|
const [passphrase, setPassphrase] = useState("");
|
|
const [preview, setPreview] = useState<RestorePreview | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [replace, setReplace] = useState(false);
|
|
const [confirmSlug, setConfirmSlug] = useState("");
|
|
const [jobId, setJobId] = useState<string | null>(null);
|
|
const [restoredSlug, setRestoredSlug] = useState<string | null>(null);
|
|
const [newPassphrase, setNewPassphrase] = useState<string | null>(null);
|
|
const [isPending, startTransition] = useTransition();
|
|
const xhrRef = useRef<XMLHttpRequest | null>(null);
|
|
|
|
const sourceKey = mode === "upload" ? uploadKey : selected;
|
|
const resetPreview = () => {
|
|
setPreview(null);
|
|
setError(null);
|
|
};
|
|
|
|
function upload(file: File) {
|
|
resetPreview();
|
|
setUploadKey(null);
|
|
if (file.size > maxUploadBytes) {
|
|
setError(`That file is ${formatBytes(file.size)}, over the ${formatBytes(maxUploadBytes)} upload limit. Copy it into the server's backups folder instead.`);
|
|
return;
|
|
}
|
|
setUploadName(file.name);
|
|
setUploadProgress(0);
|
|
const xhr = new XMLHttpRequest();
|
|
xhrRef.current = xhr;
|
|
xhr.open("POST", "/api/backups/upload");
|
|
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
|
xhr.upload.onprogress = (event) => {
|
|
if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 100));
|
|
};
|
|
xhr.onload = () => {
|
|
setUploadProgress(null);
|
|
try {
|
|
const body = JSON.parse(xhr.responseText) as { uploadId?: string; error?: string };
|
|
if (xhr.status === 200 && body.uploadId) setUploadKey(`upload:${body.uploadId}`);
|
|
else setError(body.error ?? "Upload failed.");
|
|
} catch {
|
|
setError("Upload failed.");
|
|
}
|
|
};
|
|
xhr.onerror = () => {
|
|
setUploadProgress(null);
|
|
setError("Upload failed — the connection dropped.");
|
|
};
|
|
xhr.send(file);
|
|
}
|
|
|
|
function inspect() {
|
|
if (!sourceKey) return;
|
|
resetPreview();
|
|
startTransition(async () => {
|
|
const result = await inspectRestoreAction(sourceKey, passphrase);
|
|
if (result.error) setError(result.error);
|
|
setPreview(result.preview);
|
|
});
|
|
}
|
|
|
|
function start() {
|
|
if (!sourceKey || !preview) return;
|
|
setError(null);
|
|
startTransition(async () => {
|
|
const result = await startRestoreAction(sourceKey, passphrase, replace, confirmSlug);
|
|
if (result.error) {
|
|
setError(result.error);
|
|
return;
|
|
}
|
|
setNewPassphrase(result.newPassphrase);
|
|
setRestoredSlug(preview.slug);
|
|
setJobId(result.jobId);
|
|
});
|
|
}
|
|
|
|
const blocked = preview ? Boolean(preview.domainConflict || preview.schemaProblem) : false;
|
|
const replaceConfirmed = !preview?.slugExists || (replace && confirmSlug === preview.slug);
|
|
|
|
if (jobId) {
|
|
return (
|
|
<div className="space-y-4">
|
|
{newPassphrase && <PassphraseNotice passphrase={newPassphrase} />}
|
|
<JobProgress jobId={jobId}>
|
|
{restoredSlug && (
|
|
<Link href={`/dashboard/${restoredSlug}`} className="inline-block rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white">
|
|
Open {restoredSlug}
|
|
</Link>
|
|
)}
|
|
</JobProgress>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<fieldset className="space-y-3">
|
|
<legend className="text-sm font-semibold">1. Choose the backup</legend>
|
|
<div className="flex gap-4 text-sm">
|
|
<label className="flex items-center gap-2">
|
|
<input
|
|
type="radio"
|
|
checked={mode === "stored"}
|
|
onChange={() => {
|
|
setMode("stored");
|
|
resetPreview();
|
|
}}
|
|
disabled={backups.length === 0}
|
|
/>
|
|
Stored on this server
|
|
</label>
|
|
<label className="flex items-center gap-2">
|
|
<input
|
|
type="radio"
|
|
checked={mode === "upload"}
|
|
onChange={() => {
|
|
setMode("upload");
|
|
resetPreview();
|
|
}}
|
|
/>
|
|
Upload a .7z
|
|
</label>
|
|
</div>
|
|
|
|
{mode === "stored" ? (
|
|
<select
|
|
value={selected}
|
|
onChange={(e) => {
|
|
setSelected(e.target.value);
|
|
resetPreview();
|
|
}}
|
|
className="w-full rounded-md border border-line px-3 py-2 font-mono text-xs"
|
|
aria-label="Stored backup"
|
|
>
|
|
{backups.map((b) => (
|
|
<option key={b.key} value={b.key}>
|
|
{b.slug} — {new Date(b.createdAt).toLocaleString()} — {formatBytes(b.sizeBytes)} — {b.kind}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<input
|
|
type="file"
|
|
accept=".7z"
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) upload(file);
|
|
}}
|
|
className="block w-full text-sm"
|
|
/>
|
|
<p className="text-xs text-muted">Up to {formatBytes(maxUploadBytes)}. Streamed to the server, not held in memory.</p>
|
|
{uploadProgress !== null && (
|
|
<div className="h-2 w-full overflow-hidden rounded bg-stone" role="progressbar" aria-valuenow={uploadProgress} aria-valuemin={0} aria-valuemax={100}>
|
|
<div className="h-full bg-accent" style={{ width: `${uploadProgress}%` }} />
|
|
</div>
|
|
)}
|
|
{uploadKey && <p className="text-xs text-green-700">Uploaded {uploadName}.</p>}
|
|
</div>
|
|
)}
|
|
</fieldset>
|
|
|
|
<div className="space-y-2">
|
|
<label htmlFor="passphrase" className="block text-sm font-semibold">
|
|
2. Passphrase
|
|
</label>
|
|
<input
|
|
id="passphrase"
|
|
type="password"
|
|
autoComplete="off"
|
|
value={passphrase}
|
|
onChange={(e) => {
|
|
setPassphrase(e.target.value);
|
|
resetPreview();
|
|
}}
|
|
placeholder={hasStoredPassphrase ? "Leave blank to use this server's stored passphrase" : "Required on a fresh server"}
|
|
className="w-full rounded-md border border-line px-3 py-2 font-mono text-sm"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={inspect}
|
|
disabled={isPending || !sourceKey || (!passphrase && !hasStoredPassphrase)}
|
|
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
|
|
>
|
|
{isPending && !preview ? "Checking…" : "Check this backup"}
|
|
</button>
|
|
</div>
|
|
|
|
{error && (
|
|
<p role="alert" className="text-sm font-medium text-danger">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
{preview && (
|
|
<div className="space-y-4">
|
|
<h2 className="text-sm font-semibold">3. Review and confirm</h2>
|
|
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 rounded-md border border-line bg-surface p-4 text-sm">
|
|
<dt className="text-muted">Business</dt>
|
|
<dd>{preview.name ?? preview.slug}</dd>
|
|
<dt className="text-muted">Slug</dt>
|
|
<dd className="font-mono">{preview.slug}</dd>
|
|
<dt className="text-muted">Domain</dt>
|
|
<dd className="font-mono">{preview.domain}</dd>
|
|
<dt className="text-muted">Backup taken</dt>
|
|
<dd>{new Date(preview.createdAt).toLocaleString()}</dd>
|
|
<dt className="text-muted">Schema</dt>
|
|
<dd className="font-mono text-xs">{preview.lastMigration ?? "unknown"}</dd>
|
|
</dl>
|
|
|
|
{preview.domainConflict && (
|
|
<p role="alert" className="text-sm font-medium text-danger">
|
|
{preview.domainConflict}
|
|
</p>
|
|
)}
|
|
{preview.schemaProblem && (
|
|
<p role="alert" className="text-sm font-medium text-danger">
|
|
{preview.schemaProblem}
|
|
</p>
|
|
)}
|
|
|
|
{preview.slugExists ? (
|
|
<div className="space-y-2 rounded-md border border-danger/30 bg-danger/5 p-4 text-sm">
|
|
<p className="font-semibold text-danger">
|
|
“{preview.slug}” already exists on this server. Restoring REPLACES its database, media and configuration.
|
|
</p>
|
|
<p className="text-muted">A safety backup of the current state is taken first.</p>
|
|
<label className="flex items-center gap-2">
|
|
<input type="checkbox" checked={replace} onChange={(e) => setReplace(e.target.checked)} />
|
|
Replace the existing business
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={confirmSlug}
|
|
onChange={(e) => setConfirmSlug(e.target.value)}
|
|
placeholder={`Type ${preview.slug} to confirm`}
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
className="w-full rounded-md border border-line px-3 py-2 font-mono text-sm"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted">No business named “{preview.slug}” exists here — it will be created from the backup.</p>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={start}
|
|
disabled={isPending || blocked || !replaceConfirmed}
|
|
className="rounded-md bg-danger px-4 py-2 text-sm font-semibold text-white disabled:opacity-40"
|
|
>
|
|
{isPending ? "Starting…" : preview.slugExists ? "Replace and restore" : "Restore"}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|