"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { Alert, Button, Group, Loader, rem, Stack, Text } from "@mantine/core"; import type { FileWithPath } from "@mantine/dropzone"; import { Dropzone, MIME_TYPES } from "@mantine/dropzone"; import { IconAlertTriangle, IconArrowRight, IconFileZip, IconUpload, IconX } from "@tabler/icons-react"; import "@mantine/dropzone/styles.css"; // oxlint-disable-line import/no-unassigned-import import { useI18n } from "@homarr/translation/client"; import type { RestoreStep } from "./types"; import { BackupPreviewPanel } from "./backup-preview-panel"; import { MigrationProgressPanel } from "./migration-progress-panel"; import { RestoreConfirmation } from "./restore-confirmation"; import { RestoreProgressPanel } from "./restore-progress-panel"; import { useBackupAnalysis } from "./use-backup-analysis"; interface DatabaseRestoreFlowProps { variant?: "card" | "standalone"; onRestoreComplete?: () => void; } const DEFAULT_RESTART_DELAY_MS = 500; const RESTART_READINESS_TIMEOUT_MS = 45_000; const RESTART_POLL_INTERVAL_MS = 750; const RESTART_REQUEST_TIMEOUT_MS = 5_000; export type ServerReadinessResult = "ready" | "timedOut" | "aborted"; interface WaitForServerReadinessOptions { restartAfterMs: number; signal: AbortSignal; timeoutMs?: number; pollIntervalMs?: number; requestTimeoutMs?: number; requestReady?: (signal: AbortSignal) => Promise; } const waitAsync = (durationMs: number, signal: AbortSignal) => new Promise((resolve) => { if (signal.aborted) { resolve(false); return; } const timer = window.setTimeout(() => { signal.removeEventListener("abort", handleAbort); resolve(true); }, durationMs); const handleAbort = () => { window.clearTimeout(timer); resolve(false); }; signal.addEventListener("abort", handleAbort, { once: true }); }); const requestServerReadinessAsync = async (signal: AbortSignal) => { const response = await fetch("/api/health/ready", { cache: "no-store", signal }); return response.ok; }; export const waitForServerReadinessAsync = async ({ restartAfterMs, signal, timeoutMs = RESTART_READINESS_TIMEOUT_MS, pollIntervalMs = RESTART_POLL_INTERVAL_MS, requestTimeoutMs = RESTART_REQUEST_TIMEOUT_MS, requestReady = requestServerReadinessAsync, }: WaitForServerReadinessOptions): Promise => { if (!(await waitAsync(Math.max(0, restartAfterMs), signal))) return "aborted"; const deadline = Date.now() + Math.max(0, timeoutMs); while (!signal.aborted) { const remainingMs = deadline - Date.now(); if (remainingMs < 0) return "timedOut"; const requestController = new AbortController(); const abortRequest = () => requestController.abort(); signal.addEventListener("abort", abortRequest, { once: true }); const requestTimer = window.setTimeout(abortRequest, Math.min(requestTimeoutMs, remainingMs)); let ready = false; try { ready = await requestReady(requestController.signal); } catch { // The server is expected to be temporarily unreachable while it restarts. } finally { window.clearTimeout(requestTimer); signal.removeEventListener("abort", abortRequest); } if (signal.aborted) return "aborted"; if (ready) return "ready"; const delayMs = Math.min(pollIntervalMs, deadline - Date.now()); if (delayMs <= 0) return "timedOut"; if (!(await waitAsync(delayMs, signal))) return "aborted"; } return "aborted"; }; export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: DatabaseRestoreFlowProps) => { const t = useI18n("management.page.tool.backup.restore"); const tCommon = useI18n("common"); const [file, setFile] = useState(null); const [step, setStep] = useState("upload"); const [importError, setImportError] = useState(null); const [restoreStatus, setRestoreStatus] = useState<"restoring" | "restarting" | "timedOut">("restoring"); const restartControllerRef = useRef(null); const restoreDestinationRef = useRef(null); const { analysis, loading, error: analysisError, migrationProgress, analyzeFile, reset } = useBackupAnalysis(); useEffect( () => () => { restartControllerRef.current?.abort(); }, [], ); const handleFileDrop = useCallback( (files: FileWithPath[]) => { const droppedFile = files[0]; if (!droppedFile) return; setFile(droppedFile); setStep("preview"); setImportError(null); setRestoreStatus("restoring"); void analyzeFile(droppedFile); }, [analyzeFile], ); const handleClear = useCallback(() => { setFile(null); setStep("upload"); setImportError(null); setRestoreStatus("restoring"); restartControllerRef.current?.abort(); restartControllerRef.current = null; reset(); }, [reset]); const startReadinessCheck = useCallback((restartAfterMs: number, destination = restoreDestinationRef.current) => { restartControllerRef.current?.abort(); restoreDestinationRef.current = destination; const restartController = new AbortController(); restartControllerRef.current = restartController; setRestoreStatus("restarting"); void waitForServerReadinessAsync({ restartAfterMs, signal: restartController.signal }).then((result) => { if (restartControllerRef.current !== restartController || result === "aborted") return; if (result === "ready") { if (destination) window.location.assign(destination); else window.location.reload(); return; } restartControllerRef.current = null; setRestoreStatus("timedOut"); }); }, []); const handleConfirm = useCallback(async () => { if (!file) return; setStep("restoring"); setImportError(null); setRestoreStatus("restoring"); restartControllerRef.current?.abort(); restartControllerRef.current = null; let restartAfterMs = DEFAULT_RESTART_DELAY_MS; let homeBoardName: string | null = null; try { const formData = new FormData(); formData.append("file", file); const response = await fetch("/api/backup/import", { method: "POST", body: formData }); const data = (await response.json().catch(() => null)) as { error?: unknown; homeBoardName?: unknown; restartAfterMs?: unknown; } | null; if (!response.ok) { const message = typeof data?.error === "string" ? data.error : `Server returned ${response.status}`; setImportError(message); setStep("error"); return; } if ( typeof data?.restartAfterMs === "number" && Number.isFinite(data.restartAfterMs) && data.restartAfterMs >= 0 ) { restartAfterMs = data.restartAfterMs; } if (typeof data?.homeBoardName === "string") homeBoardName = data.homeBoardName; } catch { setImportError(t("failed.title")); setStep("error"); return; } onRestoreComplete?.(); const destination = variant === "standalone" && homeBoardName ? `/boards/${encodeURIComponent(homeBoardName)}` : null; startReadinessCheck(restartAfterMs, destination); }, [file, onRestoreComplete, startReadinessCheck, t, variant]); if (step === "upload") { return (
{t("dropzone.title")} {t("dropzone.description")}
); } if (step === "preview") { return ( {file?.name} {file ? `${(file.size / 1024 / 1024).toFixed(2)} MB` : ""} {loading && migrationProgress && } {loading && !migrationProgress && ( {t("analyzing")} )} {analysisError && ( } title={t("analysisError")}> {analysisError} )} {analysis && ( <> )} ); } if (step === "confirm") { return ( {analysis && } setStep("preview")} /> ); } if (step === "restoring") { return ( startReadinessCheck(0)} onReload={() => window.location.reload()} /> ); } if (step === "error") { return ( } title={t("failed.title")} radius="md"> {importError} ); } return null; };