fix(onboarding): streamline backup restore
This commit is contained in:
1 parent
30cb066b7f
commit
174e0a7606
7 files changed
+63
-17
No files matched your search
@@ -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:
|
||||
|
||||
@@ -172,6 +172,29 @@ const reEncryptSecrets = (tempDb: InstanceType<typeof Database>, importedKeyHex:
|
||||
transaction();
|
||||
};
|
||||
|
||||
const getHomeBoardName = (tempDb: InstanceType<typeof Database>): 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<boolean> => {
|
||||
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);
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [restoreStatus, setRestoreStatus] = useState<"restoring" | "restarting" | "timedOut">("restoring");
|
||||
const restartControllerRef = useRef<AbortController | null>(null);
|
||||
const restoreDestinationRef = useRef<string | null>(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 (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap" miw={0}>
|
||||
<IconFileZip size={20} />
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{file?.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{file ? `${(file.size / 1024 / 1024).toFixed(2)} MB` : ""}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Button variant="subtle" size="xs" onClick={handleClear}>
|
||||
<Button variant="subtle" size="xs" onClick={handleClear} style={{ flexShrink: 0 }}>
|
||||
{t("changeFile")}
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -281,8 +289,11 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
|
||||
<>
|
||||
<BackupPreviewPanel analysis={analysis} />
|
||||
<Group justify="flex-end">
|
||||
<Button rightSection={<IconArrowRight size={16} />} onClick={() => setStep("confirm")}>
|
||||
{t("continueToRestore")}
|
||||
<Button
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => (variant === "standalone" ? void handleConfirm() : setStep("confirm"))}
|
||||
>
|
||||
{variant === "standalone" ? t("confirm.submit") : t("continueToRestore")}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
|
||||
.hero {
|
||||
margin-inline: auto;
|
||||
max-width: 42rem;
|
||||
max-width: 58rem;
|
||||
padding-block: 5rem 3rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ export const Welcome = ({ environment, sqliteRestore }: OnboardingStudioProps) =
|
||||
</Group>
|
||||
|
||||
{showRestore && sqliteRestore ? (
|
||||
<Paper id="sqlite-restore-flow" withBorder p="lg" radius="lg" w="100%" maw="38rem">
|
||||
<Paper id="sqlite-restore-flow" withBorder p="lg" radius="lg" w="100%" maw="56rem">
|
||||
<Stack>
|
||||
<Group gap="xs">
|
||||
<ThemeIcon variant="light" color="orange">
|
||||
|
||||
Reference in new issue
Block a user