fix: harden onboarding completion
This commit is contained in:
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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user