fix: harden onboarding completion

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:32:12 +02:00
1 parent e41605640b
commit dc71084323
44 files changed
+3003 -1000

No files matched your search

@@ -0,0 +1,75 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { waitForServerReadinessAsync } from "./database-restore-flow";
describe("waitForServerReadinessAsync", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("starts checking after the server-provided restart delay and returns when ready", async () => {
const controller = new AbortController();
const requestReady = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true);
const readiness = waitForServerReadinessAsync({
restartAfterMs: 500,
signal: controller.signal,
timeoutMs: 5_000,
pollIntervalMs: 250,
requestReady,
});
await vi.advanceTimersByTimeAsync(499);
expect(requestReady).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(requestReady).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(250);
await expect(readiness).resolves.toBe("ready");
expect(requestReady).toHaveBeenCalledTimes(2);
});
it("times out even when a readiness request never settles on its own", async () => {
const controller = new AbortController();
const requestReady = vi.fn(
(signal: AbortSignal) =>
new Promise<boolean>((resolve) => {
signal.addEventListener("abort", () => resolve(false), { once: true });
}),
);
const readiness = waitForServerReadinessAsync({
restartAfterMs: 0,
signal: controller.signal,
timeoutMs: 1_000,
pollIntervalMs: 100,
requestTimeoutMs: 400,
requestReady,
});
await vi.advanceTimersByTimeAsync(1_001);
await expect(readiness).resolves.toBe("timedOut");
expect(requestReady).toHaveBeenCalledTimes(3);
});
it("stops cleanly when the restore flow is unmounted", async () => {
const controller = new AbortController();
const requestReady = vi.fn().mockResolvedValue(false);
const readiness = waitForServerReadinessAsync({
restartAfterMs: 500,
signal: controller.signal,
requestReady,
});
controller.abort();
await expect(readiness).resolves.toBe("aborted");
expect(requestReady).not.toHaveBeenCalled();
});
});
@@ -1,12 +1,12 @@
"use client";
import { useCallback, useRef, useState } from "react";
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";
import "@mantine/dropzone/styles.css"; // oxlint-disable-line import/no-unassigned-import
import { useScopedI18n } from "@homarr/translation/client";
@@ -22,16 +22,102 @@ interface DatabaseRestoreFlowProps {
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<boolean>;
}
const waitAsync = (durationMs: number, signal: AbortSignal) =>
new Promise<boolean>((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<ServerReadinessResult> => {
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 = useScopedI18n("management.page.tool.backup.restore");
const [file, setFile] = useState<FileWithPath | null>(null);
const [step, setStep] = useState<RestoreStep>("upload");
const [importError, setImportError] = useState<string | null>(null);
const apiDoneRef = useRef(false);
const animDoneRef = useRef(false);
const apiErrorRef = useRef<string | null>(null);
const [restoreStatus, setRestoreStatus] = useState<"restoring" | "restarting" | "timedOut">("restoring");
const restartControllerRef = useRef<AbortController | null>(null);
const { analysis, loading, error: analysisError, migrationProgress, analyzeFile, reset } = useBackupAnalysis();
useEffect(
() => () => {
restartControllerRef.current?.abort();
},
[],
);
const handleFileDrop = useCallback(
(files: FileWithPath[]) => {
const droppedFile = files[0];
@@ -39,6 +125,7 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
setFile(droppedFile);
setStep("preview");
setImportError(null);
setRestoreStatus("restoring");
void analyzeFile(droppedFile);
},
[analyzeFile],
@@ -48,59 +135,70 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
setFile(null);
setStep("upload");
setImportError(null);
apiDoneRef.current = false;
animDoneRef.current = false;
apiErrorRef.current = null;
setRestoreStatus("restoring");
restartControllerRef.current?.abort();
restartControllerRef.current = null;
reset();
}, [reset]);
const tryFinalize = useCallback(() => {
if (!apiDoneRef.current) return;
const startReadinessCheck = useCallback((restartAfterMs: number) => {
restartControllerRef.current?.abort();
const restartController = new AbortController();
restartControllerRef.current = restartController;
setRestoreStatus("restarting");
if (apiErrorRef.current) {
setImportError(apiErrorRef.current);
setStep("error");
return;
}
if (!animDoneRef.current) return;
onRestoreComplete?.();
window.location.reload();
}, [onRestoreComplete]);
void waitForServerReadinessAsync({ restartAfterMs, signal: restartController.signal }).then((result) => {
if (restartControllerRef.current !== restartController || result === "aborted") return;
if (result === "ready") {
window.location.reload();
return;
}
restartControllerRef.current = null;
setRestoreStatus("timedOut");
});
}, []);
const handleConfirm = useCallback(async () => {
if (!file) return;
setStep("restoring");
setImportError(null);
apiDoneRef.current = false;
animDoneRef.current = false;
apiErrorRef.current = null;
setRestoreStatus("restoring");
restartControllerRef.current?.abort();
restartControllerRef.current = null;
let restartAfterMs = DEFAULT_RESTART_DELAY_MS;
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;
restartAfterMs?: unknown;
} | null;
if (!response.ok) {
try {
const data = await response.json();
apiErrorRef.current = data.error ?? t("failed.title");
} catch {
apiErrorRef.current = `Server returned ${response.status}`;
}
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;
}
} catch {
apiErrorRef.current = t("failed.title");
} finally {
apiDoneRef.current = true;
tryFinalize();
setImportError(t("failed.title"));
setStep("error");
return;
}
}, [file, t, tryFinalize]);
const handleAnimationComplete = useCallback(() => {
animDoneRef.current = true;
tryFinalize();
}, [tryFinalize]);
onRestoreComplete?.();
startReadinessCheck(restartAfterMs);
}, [file, onRestoreComplete, startReadinessCheck, t]);
if (step === "upload") {
return (
@@ -206,8 +304,9 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
return (
<RestoreProgressPanel
active
migrations={analysis?.migrations.pending ?? []}
onComplete={handleAnimationComplete}
status={restoreStatus}
onRetry={() => startReadinessCheck(0)}
onReload={() => window.location.reload()}
/>
);
}
@@ -0,0 +1,99 @@
// @vitest-environment jsdom
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@homarr/translation/client", () => {
const translations: Record<string, string> = {
"progress.title": "Restoring database...",
"progress.restarting": "Restarting server",
"timeout.title": "Server did not come back online",
"timeout.message": "Retry the connection check or refresh this page manually.",
tryAgain: "Try again",
"docker.action.refresh.label": "Refresh",
};
return {
useScopedI18n: () => (key: string) => translations[key] ?? key,
useI18n: () => (key: string) => translations[key] ?? key,
};
});
import { RestoreProgressPanel } from "./restore-progress-panel";
describe("RestoreProgressPanel", () => {
let host: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
vi.stubGlobal(
"matchMedia",
vi.fn().mockImplementation((query: string) => ({
matches: query.includes("prefers-reduced-motion"),
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
);
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
vi.unstubAllGlobals();
});
const renderPanel = async (
status: "restoring" | "restarting" | "timedOut",
onRetry = vi.fn(),
onReload = vi.fn(),
) => {
await act(async () =>
root.render(
createElement(
MantineProvider,
null,
createElement(RestoreProgressPanel, { active: true, status, onRetry, onReload }),
),
),
);
return { onRetry, onReload };
};
it("announces real restore and restart state without a motion-only cue", async () => {
await renderPanel("restoring");
const output = host.querySelector("output");
expect(output?.getAttribute("aria-live")).toBe("polite");
expect(output?.getAttribute("aria-busy")).toBe("true");
expect(output?.textContent).toContain("Restoring database...");
expect(output?.querySelector("svg")).not.toBeNull();
await renderPanel("restarting");
expect(output?.getAttribute("aria-busy")).toBe("true");
expect(output?.textContent).toContain("Restarting server");
});
it("exposes keyboard-native retry and manual refresh actions after timeout", async () => {
const { onRetry, onReload } = await renderPanel("timedOut");
const output = host.querySelector("output");
const buttons = Array.from(host.querySelectorAll("button"));
expect(output?.getAttribute("aria-busy")).toBe("false");
expect(output?.textContent).toContain("Server did not come back online");
expect(output?.textContent).toContain("Retry the connection check or refresh this page manually.");
expect(buttons.map((button) => button.textContent)).toEqual(["Try again", "Refresh"]);
buttons[0]?.click();
buttons[1]?.click();
expect(onRetry).toHaveBeenCalledOnce();
expect(onReload).toHaveBeenCalledOnce();
});
});
@@ -1,164 +1,78 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Box, Group, Loader, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
import {
IconCheck,
IconDatabaseImport,
IconFileZip,
IconKey,
IconLoader,
IconRefresh,
IconTransform,
} from "@tabler/icons-react";
import type { Icon } from "@tabler/icons-react";
import { Button, Group, Loader, Paper, Stack, Text, ThemeIcon } from "@mantine/core";
import { useReducedMotion } from "@mantine/hooks";
import { IconAlertTriangle, IconCheck, IconDatabaseImport, IconRefresh } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { MigrationFile } from "./types";
const BASE_PHASE_DURATIONS = {
extracting: 1200,
encrypting: 1500,
swapping: 800,
restarting: 10000,
} as const;
const MIGRATION_STEP_DURATION = 100;
interface TimelineStep {
key: string;
label: string;
icon: Icon;
duration: number;
}
export type RestoreProgressStatus = "restoring" | "restarting" | "timedOut";
interface RestoreProgressPanelProps {
active: boolean;
migrations: MigrationFile[];
onComplete?: () => void;
status: RestoreProgressStatus;
onRetry: () => void;
onReload: () => void;
}
export const RestoreProgressPanel = ({ active, migrations, onComplete }: RestoreProgressPanelProps) => {
const t = useScopedI18n("management.page.tool.backup.restore.progress");
const [currentStepIdx, setCurrentStepIdx] = useState(-1);
export const RestoreProgressPanel = ({ active, status, onRetry, onReload }: RestoreProgressPanelProps) => {
const t = useScopedI18n("management.page.tool.backup.restore");
const tRoot = useI18n();
const reduceMotion = useReducedMotion();
const steps = useMemo((): TimelineStep[] => {
const result: TimelineStep[] = [
{ key: "extracting", label: t("extracting"), icon: IconFileZip, duration: BASE_PHASE_DURATIONS.extracting },
];
if (!active) return null;
if (migrations.length > 0) {
for (const migration of migrations) {
result.push({
key: `migration-${migration.idx}`,
label: migration.tag,
icon: IconDatabaseImport,
duration: MIGRATION_STEP_DURATION,
});
}
} else {
result.push({
key: "migrating",
label: t("migrating"),
icon: IconDatabaseImport,
duration: 300,
});
}
result.push(
{ key: "encrypting", label: t("encrypting"), icon: IconKey, duration: BASE_PHASE_DURATIONS.encrypting },
{ key: "swapping", label: t("swapping"), icon: IconTransform, duration: BASE_PHASE_DURATIONS.swapping },
{ key: "restarting", label: t("restarting"), icon: IconRefresh, duration: BASE_PHASE_DURATIONS.restarting },
);
return result;
}, [migrations, t]);
useEffect(() => {
if (!active) return;
let cancelled = false;
const run = async () => {
for (const [i, step] of steps.entries()) {
if (cancelled) return;
setCurrentStepIdx(i);
await new Promise((r) => setTimeout(r, step.duration));
}
if (!cancelled) {
onComplete?.();
}
};
void run();
return () => {
cancelled = true;
};
}, [active, onComplete, steps]);
if (!active && currentStepIdx === -1) return null;
const isRestoring = status === "restoring";
const isTimedOut = status === "timedOut";
const statusLabel = isRestoring ? t("progress.title") : isTimedOut ? t("timeout.message") : t("progress.restarting");
return (
<Paper p="lg" radius="md" bg="var(--mantine-color-dark-7)">
<Paper
component="output"
p="lg"
radius="md"
bg="var(--mantine-color-dark-7)"
aria-live="polite"
aria-atomic="true"
aria-busy={!isTimedOut}
>
<Stack gap="md">
<Group gap="xs">
<Loader size="sm" color="blue" />
<Text size="sm" fw={600}>
{t("title")}
</Text>
<Group gap="sm">
{isTimedOut ? (
<ThemeIcon size="lg" radius="xl" color="orange" variant="light" aria-hidden="true">
<IconAlertTriangle size={18} />
</ThemeIcon>
) : !isRestoring ? (
<ThemeIcon size="lg" radius="xl" color="green" variant="light" aria-hidden="true">
<IconCheck size={18} />
</ThemeIcon>
) : reduceMotion ? (
<ThemeIcon size="lg" radius="xl" color="blue" variant="light" aria-hidden="true">
<IconDatabaseImport size={18} />
</ThemeIcon>
) : (
<Loader size="sm" color="blue" aria-hidden="true" />
)}
<div>
<Text size="sm" fw={600}>
{isTimedOut ? t("timeout.title") : t("progress.title")}
</Text>
<Text size="xs" c="dimmed">
{statusLabel}
</Text>
</div>
</Group>
<Box pl={4}>
<Timeline active={currentStepIdx} bulletSize={24} lineWidth={2} color="blue">
{steps.map((step, i) => {
const isDone = i < currentStepIdx;
const isActive = i === currentStepIdx;
const isMigration = step.key.startsWith("migration-");
return (
<Timeline.Item
key={step.key}
bullet={
isDone ? (
<ThemeIcon size={24} radius="xl" color="green" variant="filled">
<IconCheck size={14} />
</ThemeIcon>
) : isActive ? (
<ThemeIcon size={24} radius="xl" color="blue" variant="filled">
<Loader size={12} color="white" />
</ThemeIcon>
) : (
<ThemeIcon size={24} radius="xl" color="gray" variant="light">
<IconLoader size={14} />
</ThemeIcon>
)
}
>
<Group gap="xs" mt={-4}>
<step.icon
size={16}
color={
isDone
? "var(--mantine-color-green-6)"
: isActive
? "var(--mantine-color-blue-5)"
: "var(--mantine-color-dimmed)"
}
/>
<Text
size={isMigration ? "xs" : "sm"}
fw={isActive ? 600 : 400}
ff={isMigration ? "monospace" : undefined}
c={isDone ? "green" : isActive ? "white" : "dimmed"}
>
{step.label}
</Text>
</Group>
</Timeline.Item>
);
})}
</Timeline>
</Box>
{isTimedOut ? (
<Group>
<Button variant="light" onClick={onRetry} leftSection={<IconRefresh size={16} />}>
{t("tryAgain")}
</Button>
<Button variant="default" onClick={onReload}>
{tRoot("docker.action.refresh.label")}
</Button>
</Group>
) : null}
</Stack>
</Paper>
);