Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6befc5ad41 | ||
|
|
2a76bb185b |
No files matched your search
@@ -12,10 +12,14 @@ import { auth } from "@homarr/auth/next";
|
|||||||
import { env } from "@homarr/common/env";
|
import { env } from "@homarr/common/env";
|
||||||
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
|
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
|
||||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||||
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
|
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||||
import { db } from "@homarr/db";
|
import { db } from "@homarr/db";
|
||||||
|
|
||||||
import { findMigrationsFolder } from "../shared";
|
import { findMigrationsFolder } from "../shared";
|
||||||
|
|
||||||
|
const logger = createLogger({ module: "backupImportRoute" });
|
||||||
|
|
||||||
const REQUIRED_ZIP_ENTRIES = ["db.sqlite", "metadata.json"] as const;
|
const REQUIRED_ZIP_ENTRIES = ["db.sqlite", "metadata.json"] as const;
|
||||||
const ALGORITHM = "aes-256-cbc";
|
const ALGORITHM = "aes-256-cbc";
|
||||||
const HEX_KEY_REGEX = /^[0-9a-fA-F]{64}$/;
|
const HEX_KEY_REGEX = /^[0-9a-fA-F]{64}$/;
|
||||||
@@ -174,22 +178,26 @@ export async function POST(req: Request) {
|
|||||||
const sidecar = `${dbPath}${suffix}`;
|
const sidecar = `${dbPath}${suffix}`;
|
||||||
if (fs.existsSync(sidecar)) fs.unlinkSync(sidecar);
|
if (fs.existsSync(sidecar)) fs.unlinkSync(sidecar);
|
||||||
} catch (cleanupErr) {
|
} catch (cleanupErr) {
|
||||||
console.error(`Failed to remove ${suffix} file:`, cleanupErr);
|
logger.warn("Failed to remove SQLite sidecar file after restore", { suffix, dbPath, cause: cleanupErr });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.log("Database restored, restarting server...");
|
logger.info("Database restored, restarting server...");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
return NextResponse.json({ success: true, message: "Database restored. Server is restarting..." });
|
return NextResponse.json({ success: true, message: "Database restored. Server is restarting..." });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[backup/import] Restore failed:", error);
|
logger.error(new ErrorWithMetadata("Backup restore failed", { dbPath }, { cause: error }));
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
return NextResponse.json({ error: `Restore failed: ${message}` }, { status: 500 });
|
return NextResponse.json({ error: `Restore failed: ${message}` }, { status: 500 });
|
||||||
} finally {
|
} finally {
|
||||||
tempDb?.close();
|
try {
|
||||||
|
tempDb?.close();
|
||||||
|
} catch (closeErr) {
|
||||||
|
logger.warn("Failed to close temporary database after restore", { cause: closeErr });
|
||||||
|
}
|
||||||
if (fs.existsSync(tempPath)) {
|
if (fs.existsSync(tempPath)) {
|
||||||
try {
|
try {
|
||||||
fs.unlinkSync(tempPath);
|
fs.unlinkSync(tempPath);
|
||||||
|
|||||||
@@ -3,8 +3,13 @@ import path from "path";
|
|||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
|
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||||
|
|
||||||
import { findMigrationsFolder } from "../shared";
|
import { findMigrationsFolder } from "../shared";
|
||||||
|
|
||||||
|
const logger = createLogger({ module: "backupMigrationsRoute" });
|
||||||
|
|
||||||
interface JournalEntry {
|
interface JournalEntry {
|
||||||
idx: number;
|
idx: number;
|
||||||
version: string;
|
version: string;
|
||||||
@@ -56,7 +61,7 @@ export async function GET() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[backup/migrations] Failed to read migrations:", error);
|
logger.error(new ErrorWithMetadata("Failed to read migration files", {}, { cause: error }));
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
return NextResponse.json({ error: `Failed to read migrations: ${message}` }, { status: 500 });
|
return NextResponse.json({ error: `Failed to read migrations: ${message}` }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"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 { Alert, Button, Group, Loader, rem, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||||
import type { FileWithPath } from "@mantine/dropzone";
|
import type { FileWithPath } from "@mantine/dropzone";
|
||||||
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
|
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
|
||||||
import { IconAlertTriangle, IconArrowRight, IconFileZip, IconUpload, IconX } from "@tabler/icons-react";
|
import { IconAlertTriangle, IconArrowRight, IconCheck, IconFileZip, IconUpload, IconX } from "@tabler/icons-react";
|
||||||
|
|
||||||
import "@mantine/dropzone/styles.css";
|
import "@mantine/dropzone/styles.css";
|
||||||
|
|
||||||
@@ -30,8 +30,17 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
|
|||||||
const apiDoneRef = useRef(false);
|
const apiDoneRef = useRef(false);
|
||||||
const animDoneRef = useRef(false);
|
const animDoneRef = useRef(false);
|
||||||
const apiErrorRef = useRef<string | null>(null);
|
const apiErrorRef = useRef<string | null>(null);
|
||||||
|
const redirectTimerRef = useRef<number | null>(null);
|
||||||
const { analysis, loading, error: analysisError, migrationProgress, analyzeFile, reset } = useBackupAnalysis();
|
const { analysis, loading, error: analysisError, migrationProgress, analyzeFile, reset } = useBackupAnalysis();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (redirectTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(redirectTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleFileDrop = useCallback(
|
const handleFileDrop = useCallback(
|
||||||
(files: FileWithPath[]) => {
|
(files: FileWithPath[]) => {
|
||||||
const droppedFile = files[0];
|
const droppedFile = files[0];
|
||||||
@@ -45,6 +54,10 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleClear = useCallback(() => {
|
const handleClear = useCallback(() => {
|
||||||
|
if (redirectTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(redirectTimerRef.current);
|
||||||
|
redirectTimerRef.current = null;
|
||||||
|
}
|
||||||
setFile(null);
|
setFile(null);
|
||||||
setStep("upload");
|
setStep("upload");
|
||||||
setImportError(null);
|
setImportError(null);
|
||||||
@@ -65,9 +78,20 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
|
|||||||
|
|
||||||
if (!animDoneRef.current) return;
|
if (!animDoneRef.current) return;
|
||||||
onRestoreComplete?.();
|
onRestoreComplete?.();
|
||||||
window.location.reload();
|
setStep("complete");
|
||||||
|
redirectTimerRef.current = window.setTimeout(() => {
|
||||||
|
window.location.assign("/auth/login");
|
||||||
|
}, 2000);
|
||||||
}, [onRestoreComplete]);
|
}, [onRestoreComplete]);
|
||||||
|
|
||||||
|
const handleSignInAgain = useCallback(() => {
|
||||||
|
if (redirectTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(redirectTimerRef.current);
|
||||||
|
redirectTimerRef.current = null;
|
||||||
|
}
|
||||||
|
window.location.assign("/auth/login");
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleConfirm = useCallback(async () => {
|
const handleConfirm = useCallback(async () => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setStep("restoring");
|
setStep("restoring");
|
||||||
@@ -89,7 +113,8 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
|
|||||||
apiErrorRef.current = `Server returned ${response.status}`;
|
apiErrorRef.current = `Server returned ${response.status}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
console.error("Restore request failed", error);
|
||||||
apiErrorRef.current = t("failed.title");
|
apiErrorRef.current = t("failed.title");
|
||||||
} finally {
|
} finally {
|
||||||
apiDoneRef.current = true;
|
apiDoneRef.current = true;
|
||||||
@@ -212,6 +237,23 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (step === "complete") {
|
||||||
|
return (
|
||||||
|
<Stack gap="md" align="center" py="lg">
|
||||||
|
<ThemeIcon size={64} radius="xl" color="green" variant="light">
|
||||||
|
<IconCheck size={32} style={{ width: rem(40), height: rem(40) }} stroke={1.5} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="lg" fw={600} ta="center">
|
||||||
|
{t("complete.title")}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed" ta="center" maw={480}>
|
||||||
|
{t("complete.message")}
|
||||||
|
</Text>
|
||||||
|
<Button onClick={handleSignInAgain}>{t("complete.button")}</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (step === "error") {
|
if (step === "error") {
|
||||||
return (
|
return (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
|
|||||||
@@ -25,6 +25,6 @@ export interface BackupAnalysis {
|
|||||||
migrations: MigrationStatus;
|
migrations: MigrationStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RestoreStep = "upload" | "preview" | "confirm" | "restoring" | "error";
|
export type RestoreStep = "upload" | "preview" | "confirm" | "restoring" | "complete" | "error";
|
||||||
|
|
||||||
export const PREVIEW_TABLE_KEYS = ["board", "user", "app", "integration", "item", "media", "search_engine"] as const;
|
export const PREVIEW_TABLE_KEYS = ["board", "user", "app", "integration", "item", "media", "search_engine"] as const;
|
||||||
@@ -168,6 +168,7 @@ export const useBackupAnalysis = () => {
|
|||||||
dbRef.current = null;
|
dbRef.current = null;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error("Backup analysis failed", err);
|
||||||
setError(err instanceof Error ? err.message : t("analyzeError"));
|
setError(err instanceof Error ? err.message : t("analyzeError"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -5276,7 +5276,8 @@
|
|||||||
},
|
},
|
||||||
"complete": {
|
"complete": {
|
||||||
"title": "Restore Complete",
|
"title": "Restore Complete",
|
||||||
"message": "Database has been restored successfully. The server is restarting — please refresh the page in a few seconds."
|
"message": "Database has been restored successfully and the server has restarted. Your previous session was invalidated, so you need to sign in again to see the restored board.",
|
||||||
|
"button": "Sign in now"
|
||||||
},
|
},
|
||||||
"failed": {
|
"failed": {
|
||||||
"title": "Restore Failed"
|
"title": "Restore Failed"
|
||||||
|
|||||||
Reference in new issue
Block a user