From 174e0a76064749682610fa23cb19cda2ef96929a Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 14 Aug 2026 02:10:33 +0200 Subject: [PATCH] fix(onboarding): streamline backup restore --- apps/docs/docs/management/backup.mdx | 3 ++ .../nextjs/src/app/api/backup/import/route.ts | 26 ++++++++++++++ .../app/api/backup/test/import-route.spec.ts | 6 +++- .../backup/database-restore-flow.tsx | 35 ++++++++++++------- .../components/backup/use-backup-analysis.ts | 6 ++-- .../src/onboarding-studio.module.css | 2 +- packages/onboarding/src/welcome.tsx | 2 +- 7 files changed, 63 insertions(+), 17 deletions(-) diff --git a/apps/docs/docs/management/backup.mdx b/apps/docs/docs/management/backup.mdx index 7219d7efc..7b0e60077 100644 --- a/apps/docs/docs/management/backup.mdx +++ b/apps/docs/docs/management/backup.mdx @@ -53,6 +53,9 @@ Before restoring, Homarr uses **sql.js WASM** to analyze the backup directly in Type `I understand` to acknowledge that restoring will replace your current database. This cannot be undone. +During first-run onboarding, the database is still empty, so the restore preview goes directly to **Restore Database** +without the extra typed confirmation. After Homarr restarts, onboarding opens the restored default home board. + ### 4. Restore The backup ZIP is sent to the server, validated, and applied. After a successful restore: diff --git a/apps/nextjs/src/app/api/backup/import/route.ts b/apps/nextjs/src/app/api/backup/import/route.ts index 92f81dd23..f7fc87471 100644 --- a/apps/nextjs/src/app/api/backup/import/route.ts +++ b/apps/nextjs/src/app/api/backup/import/route.ts @@ -172,6 +172,29 @@ const reEncryptSecrets = (tempDb: InstanceType, importedKeyHex: transaction(); }; +const getHomeBoardName = (tempDb: InstanceType): string | null => { + const row = tempDb + .prepare( + `SELECT "name" FROM ( + SELECT "board"."name" AS "name", 0 AS "priority" + FROM "group" + INNER JOIN "board" ON "board"."id" = "group"."home_board_id" + WHERE "group"."name" = 'everyone' + UNION ALL + SELECT "board"."name" AS "name", 1 AS "priority" + FROM "user" + INNER JOIN "board" ON "board"."id" = "user"."home_board_id" + UNION ALL + SELECT "board"."name" AS "name", 2 AS "priority" + FROM "board" + ) + ORDER BY "priority", "name" + LIMIT 1`, + ) + .get() as { name: string } | undefined; + return row?.name ?? null; +}; + const isOnboardingActiveAsync = async (): Promise => { const onboardingRow = await db.query.onboarding.findFirst(); if (!onboardingRow) return false; @@ -289,6 +312,8 @@ export async function POST(req: Request) { reEncryptSecrets(tempDb, importedKey); } + const homeBoardName = getHomeBoardName(tempDb); + tempDb.close(); tempDb = null; @@ -313,6 +338,7 @@ export async function POST(req: Request) { success: true, message: "Database restored. Server is restarting...", restartAfterMs: RESTART_DELAY_MS, + homeBoardName, }); } catch (error) { console.error("[backup/import] Restore failed:", error); diff --git a/apps/nextjs/src/app/api/backup/test/import-route.spec.ts b/apps/nextjs/src/app/api/backup/test/import-route.spec.ts index a8e0a3bdb..a2784b14f 100644 --- a/apps/nextjs/src/app/api/backup/test/import-route.spec.ts +++ b/apps/nextjs/src/app/api/backup/test/import-route.spec.ts @@ -128,7 +128,11 @@ describe("POST /api/backup/import", () => { const response = await POST(createImportRequest(backup)); expect(response.status).toBe(200); - await expect(response.json()).resolves.toMatchObject({ success: true, restartAfterMs: 500 }); + await expect(response.json()).resolves.toMatchObject({ + success: true, + restartAfterMs: 500, + homeBoardName: "Restored board", + }); expect(readBoardNames(activeDatabasePath)).toEqual(["Restored board"]); }); diff --git a/apps/nextjs/src/components/backup/database-restore-flow.tsx b/apps/nextjs/src/components/backup/database-restore-flow.tsx index 5b4d7f5c8..0708cd525 100644 --- a/apps/nextjs/src/components/backup/database-restore-flow.tsx +++ b/apps/nextjs/src/components/backup/database-restore-flow.tsx @@ -109,6 +109,7 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat 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( @@ -141,8 +142,9 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat reset(); }, [reset]); - const startReadinessCheck = useCallback((restartAfterMs: number) => { + const startReadinessCheck = useCallback((restartAfterMs: number, destination = restoreDestinationRef.current) => { restartControllerRef.current?.abort(); + restoreDestinationRef.current = destination; const restartController = new AbortController(); restartControllerRef.current = restartController; setRestoreStatus("restarting"); @@ -150,7 +152,8 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat void waitForServerReadinessAsync({ restartAfterMs, signal: restartController.signal }).then((result) => { if (restartControllerRef.current !== restartController || result === "aborted") return; if (result === "ready") { - window.location.reload(); + if (destination) window.location.assign(destination); + else window.location.reload(); return; } restartControllerRef.current = null; @@ -167,12 +170,14 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat 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; @@ -190,6 +195,7 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat ) { restartAfterMs = data.restartAfterMs; } + if (typeof data?.homeBoardName === "string") homeBoardName = data.homeBoardName; } catch { setImportError(t("failed.title")); setStep("error"); @@ -197,8 +203,10 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat } onRestoreComplete?.(); - startReadinessCheck(restartAfterMs); - }, [file, onRestoreComplete, startReadinessCheck, t]); + const destination = + variant === "standalone" && homeBoardName ? `/boards/${encodeURIComponent(homeBoardName)}` : null; + startReadinessCheck(restartAfterMs, destination); + }, [file, onRestoreComplete, startReadinessCheck, t, variant]); if (step === "upload") { return ( @@ -243,19 +251,19 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat if (step === "preview") { return ( - - + + -
- +
+ {file?.name} - + {file ? `${(file.size / 1024 / 1024).toFixed(2)} MB` : ""}
- @@ -281,8 +289,11 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat <> - diff --git a/apps/nextjs/src/components/backup/use-backup-analysis.ts b/apps/nextjs/src/components/backup/use-backup-analysis.ts index 7897c1d24..86a0a1cc1 100644 --- a/apps/nextjs/src/components/backup/use-backup-analysis.ts +++ b/apps/nextjs/src/components/backup/use-backup-analysis.ts @@ -9,6 +9,8 @@ import type { BackupAnalysis, MigrationFile, MigrationStatus } from "./types"; import { PREVIEW_TABLE_KEYS } from "./types"; const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations"; +const MIGRATION_PREVIEW_DELAY_MS = 250; +const MIGRATION_COMPLETE_DELAY_MS = 100; const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -120,7 +122,7 @@ export const useBackupAnalysis = () => { phase: "applying", }); - await delay(100); + await delay(MIGRATION_PREVIEW_DELAY_MS); try { const statements = migration.sql @@ -158,7 +160,7 @@ export const useBackupAnalysis = () => { }); sounds.success(); - await delay(50); + await delay(MIGRATION_COMPLETE_DELAY_MS); } } diff --git a/packages/onboarding/src/onboarding-studio.module.css b/packages/onboarding/src/onboarding-studio.module.css index ec6908858..3143c73a3 100644 --- a/packages/onboarding/src/onboarding-studio.module.css +++ b/packages/onboarding/src/onboarding-studio.module.css @@ -106,7 +106,7 @@ .hero { margin-inline: auto; - max-width: 42rem; + max-width: 58rem; padding-block: 5rem 3rem; text-align: center; } diff --git a/packages/onboarding/src/welcome.tsx b/packages/onboarding/src/welcome.tsx index 34cfe0aaa..219969d4b 100644 --- a/packages/onboarding/src/welcome.tsx +++ b/packages/onboarding/src/welcome.tsx @@ -135,7 +135,7 @@ export const Welcome = ({ environment, sqliteRestore }: OnboardingStudioProps) = {showRestore && sqliteRestore ? ( - +