From 76eacf83534067c80ca8e06d96270a9adcfb8cc7 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 7 Jun 2026 01:06:45 +0200 Subject: [PATCH] feat: expand migration phase into per-migration timeline steps Show each migration as its own 100ms step in the restore progress timeline instead of a single generic "Applying migrations" phase. --- .../backup/database-restore-flow.tsx | 1 + apps/nextjs/src/components/backup/index.ts | 2 +- .../backup/restore-progress-panel.tsx | 93 +++++++++++++------ apps/nextjs/src/components/backup/types.ts | 9 -- .../components/backup/use-backup-analysis.ts | 4 +- 5 files changed, 70 insertions(+), 39 deletions(-) diff --git a/apps/nextjs/src/components/backup/database-restore-flow.tsx b/apps/nextjs/src/components/backup/database-restore-flow.tsx index 0b2e478f0..2bc8b113f 100644 --- a/apps/nextjs/src/components/backup/database-restore-flow.tsx +++ b/apps/nextjs/src/components/backup/database-restore-flow.tsx @@ -195,6 +195,7 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat return ( ); diff --git a/apps/nextjs/src/components/backup/index.ts b/apps/nextjs/src/components/backup/index.ts index d7e42a7d6..accd1a935 100644 --- a/apps/nextjs/src/components/backup/index.ts +++ b/apps/nextjs/src/components/backup/index.ts @@ -1,3 +1,3 @@ export { DatabaseRestoreFlow } from "./database-restore-flow"; export { DangerousActionConfirmation } from "./dangerous-action-confirmation"; -export type { BackupAnalysis, RestoreStep, RestoreProgress } from "./types"; +export type { BackupAnalysis, RestoreStep } from "./types"; diff --git a/apps/nextjs/src/components/backup/restore-progress-panel.tsx b/apps/nextjs/src/components/backup/restore-progress-panel.tsx index 46169b351..d32aa300c 100644 --- a/apps/nextjs/src/components/backup/restore-progress-panel.tsx +++ b/apps/nextjs/src/components/backup/restore-progress-panel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Box, Group, Loader, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core"; import { IconCheck, @@ -11,49 +11,87 @@ import { IconRefresh, IconTransform, } from "@tabler/icons-react"; +import type { Icon } from "@tabler/icons-react"; import { useScopedI18n } from "@homarr/translation/client"; -import { RESTORE_PHASES } from "./types"; +import type { MigrationFile } from "./types"; -const PHASE_ICONS = { +const PHASE_ICONS: Record = { extracting: IconFileZip, migrating: IconDatabaseImport, encrypting: IconKey, swapping: IconTransform, restarting: IconRefresh, -} as const; +}; -const PHASE_DURATIONS = { +const BASE_PHASE_DURATIONS: Record = { extracting: 1200, - migrating: 2000, encrypting: 1500, swapping: 800, restarting: 10000, -} as const; +}; + +const MIGRATION_STEP_DURATION = 100; + +interface TimelineStep { + key: string; + label: string; + icon: Icon; + duration: number; +} interface RestoreProgressPanelProps { active: boolean; + migrations: MigrationFile[]; onComplete?: () => void; } -export const RestoreProgressPanel = ({ active, onComplete }: RestoreProgressPanelProps) => { +export const RestoreProgressPanel = ({ active, migrations, onComplete }: RestoreProgressPanelProps) => { const t = useScopedI18n("management.page.tool.backup.restore.progress"); - const [currentPhaseIdx, setCurrentPhaseIdx] = useState(-1); + const [currentStepIdx, setCurrentStepIdx] = useState(-1); + + const steps = useMemo((): TimelineStep[] => { + const result: TimelineStep[] = [ + { key: "extracting", label: t("extracting"), icon: IconFileZip, duration: BASE_PHASE_DURATIONS.extracting! }, + ]; + + 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 (let i = 0; i < RESTORE_PHASES.length; i++) { + for (let i = 0; i < steps.length; i++) { if (cancelled) return; - setCurrentPhaseIdx(i); - const phase = RESTORE_PHASES[i]!; - const duration = PHASE_DURATIONS[phase]; - if (duration > 0) { - await new Promise((r) => setTimeout(r, duration + Math.random() * 500)); - } + setCurrentStepIdx(i); + await new Promise((r) => setTimeout(r, steps[i]!.duration)); } if (!cancelled) { onComplete?.(); @@ -62,9 +100,9 @@ export const RestoreProgressPanel = ({ active, onComplete }: RestoreProgressPane void run(); return () => { cancelled = true; }; - }, [active, onComplete]); + }, [active, onComplete, steps]); - if (!active && currentPhaseIdx === -1) return null; + if (!active && currentStepIdx === -1) return null; return ( @@ -78,19 +116,19 @@ export const RestoreProgressPanel = ({ active, onComplete }: RestoreProgressPane - {RESTORE_PHASES.map((phase, i) => { - const Icon = PHASE_ICONS[phase]; - const isDone = i < currentPhaseIdx; - const isActive = i === currentPhaseIdx; + {steps.map((step, i) => { + const isDone = i < currentStepIdx; + const isActive = i === currentStepIdx; + const isMigration = step.key.startsWith("migration-"); return ( @@ -108,13 +146,14 @@ export const RestoreProgressPanel = ({ active, onComplete }: RestoreProgressPane } > - + - {t(phase)} + {step.label} diff --git a/apps/nextjs/src/components/backup/types.ts b/apps/nextjs/src/components/backup/types.ts index c7db98183..c197c6544 100644 --- a/apps/nextjs/src/components/backup/types.ts +++ b/apps/nextjs/src/components/backup/types.ts @@ -27,13 +27,4 @@ export interface BackupAnalysis { export type RestoreStep = "upload" | "preview" | "confirm" | "restoring" | "error"; -export interface RestoreProgress { - phase: "extracting" | "migrating" | "encrypting" | "swapping" | "restarting"; - migrationIndex?: number; - migrationTotal?: number; - migrationTag?: string; -} - export const PREVIEW_TABLE_KEYS = ["board", "user", "app", "integration", "item", "media", "search_engine"] as const; - -export const RESTORE_PHASES = ["extracting", "migrating", "encrypting", "swapping", "restarting"] as const; diff --git a/apps/nextjs/src/components/backup/use-backup-analysis.ts b/apps/nextjs/src/components/backup/use-backup-analysis.ts index d188e1831..3ebb844a8 100644 --- a/apps/nextjs/src/components/backup/use-backup-analysis.ts +++ b/apps/nextjs/src/components/backup/use-backup-analysis.ts @@ -117,7 +117,7 @@ export const useBackupAnalysis = () => { phase: "applying", }); - await delay(400 + Math.random() * 300); + await delay(100); try { const statements = migration.sql @@ -149,7 +149,7 @@ export const useBackupAnalysis = () => { phase: "done", }); - await delay(150); + await delay(50); } }