fix: harden onboarding completion
This commit is contained in:
1 parent
576cc0b418
commit
07770e862c
44 files changed
+3003
-1000
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { getOnboardingClaimTokenFromCookieHeader, isOnboardingClaimValidAsync } from "@homarr/api/onboarding-claim";
|
||||
import { normalizeOnboardingStep } from "@homarr/api/onboarding-step";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
@@ -16,28 +17,38 @@ import { DatabaseRestoreFlow } from "~/components/backup";
|
||||
import { env } from "~/env";
|
||||
|
||||
export default async function InitPage() {
|
||||
const [state, session, requestHeaders, defaultGroup, firstUser, availableBoards] = await Promise.all([
|
||||
const [state, session, requestHeaders, firstUser] = await Promise.all([
|
||||
db.query.onboarding.findFirst(),
|
||||
auth(),
|
||||
headers(),
|
||||
db.query.groups.findFirst({
|
||||
where: eq(groups.name, everyoneGroup),
|
||||
columns: { homeBoardId: true },
|
||||
}),
|
||||
db.query.users.findFirst({ columns: { id: true } }),
|
||||
db.query.boards.findMany({ columns: { id: true, name: true } }),
|
||||
]);
|
||||
const canConfigurePrivileged = session?.user.permissions.includes("admin") ?? false;
|
||||
const onboardingClaim = getOnboardingClaimTokenFromCookieHeader(requestHeaders.get("cookie"));
|
||||
const canReadSetupContext =
|
||||
canConfigurePrivileged || (!firstUser && (await isOnboardingClaimValidAsync(db, onboardingClaim)));
|
||||
const [defaultGroup, availableBoards] = canReadSetupContext
|
||||
? await Promise.all([
|
||||
db.query.groups.findFirst({
|
||||
where: eq(groups.name, everyoneGroup),
|
||||
columns: { homeBoardId: true },
|
||||
}),
|
||||
db.query.boards.findMany({ columns: { id: true, name: true } }),
|
||||
])
|
||||
: [undefined, []];
|
||||
const homeBoardId = defaultGroup?.homeBoardId;
|
||||
const homeBoard = availableBoards.find((board) => board.id === homeBoardId);
|
||||
const initialBoard = homeBoard ?? (availableBoards.length === 1 ? availableBoards[0] : null);
|
||||
const currentStep = normalizeOnboardingStep(state?.step);
|
||||
const databaseDriver =
|
||||
dbEnv.DRIVER === "better-sqlite3" ? "sqlite" : dbEnv.DRIVER === "mysql2" ? "mysql" : "postgresql";
|
||||
const { workshopApiUrl, workshopWebUrl } = resolveHomarrUrlConfig({
|
||||
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
|
||||
workshopApiUrl: env.WORKSHOP_API_URL,
|
||||
workshopWebUrl: env.WORKSHOP_WEB_URL,
|
||||
});
|
||||
const { workshopApiUrl, workshopWebUrl } = canReadSetupContext
|
||||
? resolveHomarrUrlConfig({
|
||||
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
|
||||
workshopApiUrl: env.WORKSHOP_API_URL,
|
||||
workshopWebUrl: env.WORKSHOP_WEB_URL,
|
||||
})
|
||||
: { workshopApiUrl: "", workshopWebUrl: "" };
|
||||
const baseUrl = extractBaseUrlFromHeaders(requestHeaders);
|
||||
|
||||
return (
|
||||
@@ -45,15 +56,13 @@ export default async function InitPage() {
|
||||
environment={{
|
||||
currentStep,
|
||||
databaseDriver,
|
||||
credentialsEnabled: isProviderEnabled("credentials"),
|
||||
externalAuthEnabled: isProviderEnabled("ldap") || isProviderEnabled("oidc"),
|
||||
dockerConfigured: dockerEnv.ENABLE_DOCKER,
|
||||
kubernetesConfigured: dockerEnv.ENABLE_KUBERNETES,
|
||||
workshopEnabled: true,
|
||||
dockerConfigured: canReadSetupContext && dockerEnv.ENABLE_DOCKER,
|
||||
kubernetesConfigured: canReadSetupContext && dockerEnv.ENABLE_KUBERNETES,
|
||||
workshopApiUrl,
|
||||
workshopUrl: workshopWebUrl,
|
||||
mcpEndpoint: `${baseUrl}/api/mcp`,
|
||||
canConfigurePrivileged: session?.user.permissions.includes("admin") ?? false,
|
||||
canConfigurePrivileged,
|
||||
hasUsers: Boolean(firstUser),
|
||||
initialBoard: initialBoard ?? null,
|
||||
availableBoards,
|
||||
|
||||
@@ -173,8 +173,12 @@ export default async function Layout(props: {
|
||||
>
|
||||
<head>
|
||||
<meta name="homarr-website-url" content={publicUrls.homarrWebsiteUrl} />
|
||||
<meta name="homarr-workshop-api-url" content={publicUrls.workshopApiUrl} />
|
||||
<meta name="homarr-workshop-web-url" content={publicUrls.workshopWebUrl} />
|
||||
{session ? (
|
||||
<>
|
||||
<meta name="homarr-workshop-api-url" content={publicUrls.workshopApiUrl} />
|
||||
<meta name="homarr-workshop-web-url" content={publicUrls.workshopWebUrl} />
|
||||
</>
|
||||
) : null}
|
||||
<SearchEngineOptimization />
|
||||
<CrowdinLiveTranslation locale={locale} />
|
||||
</head>
|
||||
|
||||
@@ -51,8 +51,6 @@ interface NewIntegrationFormProps {
|
||||
initialName?: string;
|
||||
onSuccess: () => void;
|
||||
onCancel?: () => void;
|
||||
onSkip?: () => void;
|
||||
isOnboarding?: boolean;
|
||||
}
|
||||
|
||||
const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
|
||||
@@ -69,14 +67,12 @@ export const NewIntegrationForm = ({
|
||||
initialName,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
onSkip,
|
||||
isOnboarding = false,
|
||||
}: NewIntegrationFormProps) => {
|
||||
const t = useI18n();
|
||||
const secretKinds = getAllSecretKindOptions(kind);
|
||||
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
|
||||
const { data: session } = useSession();
|
||||
const canCreateApps = !isOnboarding && (session?.user.permissions.includes("app-create") ?? false);
|
||||
const canCreateApps = session?.user.permissions.includes("app-create") ?? false;
|
||||
|
||||
let url = initialUrl ?? getIntegrationDefaultUrl(kind) ?? "";
|
||||
if (hasUrlSecret) {
|
||||
@@ -90,9 +86,9 @@ export const NewIntegrationForm = ({
|
||||
kind,
|
||||
value: "",
|
||||
})),
|
||||
attemptSearchEngineCreation: !isOnboarding,
|
||||
hasApp: !isOnboarding,
|
||||
appHref: isOnboarding ? null : url,
|
||||
attemptSearchEngineCreation: true,
|
||||
hasApp: true,
|
||||
appHref: url,
|
||||
appId: null,
|
||||
},
|
||||
});
|
||||
@@ -104,13 +100,6 @@ export const NewIntegrationForm = ({
|
||||
await utils.integration.invalidate();
|
||||
},
|
||||
});
|
||||
const { mutateAsync: createOnboardingIntegrationAsync, isPending: isOnboardingCreatePending } =
|
||||
clientApi.onboard.createIntegration.useMutation({
|
||||
async onSuccess() {
|
||||
await utils.integration.invalidate();
|
||||
},
|
||||
});
|
||||
const isPending = isCreatePending || isOnboardingCreatePending;
|
||||
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
|
||||
|
||||
const handleSubmitAsync = async ({ appId, appHref, hasApp, ...values }: FormType) => {
|
||||
@@ -143,14 +132,6 @@ export const NewIntegrationForm = ({
|
||||
});
|
||||
};
|
||||
|
||||
if (isOnboarding) {
|
||||
await createOnboardingIntegrationAsync(
|
||||
{ kind, name: values.name, url, secrets: values.secrets },
|
||||
{ onSuccess: onMutationSuccess, onError: onMutationError },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasCustomHref = appHref !== null && appHref.trim().length >= 1;
|
||||
|
||||
const app = hasApp
|
||||
@@ -206,7 +187,7 @@ export const NewIntegrationForm = ({
|
||||
|
||||
{error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />}
|
||||
|
||||
{!isOnboarding && supportsSearchEngine && (
|
||||
{supportsSearchEngine && (
|
||||
<Checkbox
|
||||
label={t("integration.field.attemptSearchEngineCreation.label")}
|
||||
description={t("integration.field.attemptSearchEngineCreation.description", {
|
||||
@@ -216,7 +197,7 @@ export const NewIntegrationForm = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isOnboarding && <AppForm form={form} canCreateApps={canCreateApps} />}
|
||||
<AppForm form={form} canCreateApps={canCreateApps} />
|
||||
|
||||
<Group justify="end" align="center">
|
||||
{onCancel ? (
|
||||
@@ -228,12 +209,7 @@ export const NewIntegrationForm = ({
|
||||
{t("common.action.backToOverview")}
|
||||
</Button>
|
||||
)}
|
||||
{onSkip && (
|
||||
<Button variant="default" onClick={onSkip}>
|
||||
{t("common.action.skip")}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" loading={isPending}>
|
||||
<Button type="submit" loading={isCreatePending}>
|
||||
{t("integration.testConnection.action.create")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -24,6 +24,101 @@ import { findMigrationsFolder } from "../shared";
|
||||
const REQUIRED_ZIP_ENTRIES = ["db.sqlite", "metadata.json"] as const;
|
||||
const ALGORITHM = "aes-256-cbc";
|
||||
const HEX_KEY_REGEX = /^[0-9a-fA-F]{64}$/;
|
||||
const MEBIBYTE = 1024 * 1024;
|
||||
const MAX_COMPRESSED_BACKUP_BYTES = 256 * MEBIBYTE;
|
||||
const MAX_MULTIPART_REQUEST_BYTES = MAX_COMPRESSED_BACKUP_BYTES + MEBIBYTE;
|
||||
const MAX_UNCOMPRESSED_DATABASE_BYTES = 512 * MEBIBYTE;
|
||||
const MAX_UNCOMPRESSED_ARCHIVE_BYTES = MAX_UNCOMPRESSED_DATABASE_BYTES + 2 * MEBIBYTE;
|
||||
const MAX_METADATA_BYTES = MEBIBYTE;
|
||||
const MAX_ENCRYPTION_KEY_BYTES = 1024;
|
||||
const RESTART_DELAY_MS = 500;
|
||||
|
||||
let restoreInProgress = false;
|
||||
|
||||
class BackupTooLargeError extends Error {}
|
||||
|
||||
const readBoundedRequestBodyAsync = async (request: Request): Promise<Uint8Array<ArrayBuffer>> => {
|
||||
const declaredLength = request.headers.get("content-length");
|
||||
if (declaredLength !== null) {
|
||||
const parsedLength = Number(declaredLength);
|
||||
if (!Number.isSafeInteger(parsedLength) || parsedLength < 0) {
|
||||
throw new Error("Invalid Content-Length header");
|
||||
}
|
||||
if (parsedLength > MAX_MULTIPART_REQUEST_BYTES) {
|
||||
throw new BackupTooLargeError("Backup upload exceeds the 256 MB limit");
|
||||
}
|
||||
}
|
||||
|
||||
if (!request.body) return new Uint8Array();
|
||||
|
||||
const reader = request.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let totalBytes = 0;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
totalBytes += value.byteLength;
|
||||
if (totalBytes > MAX_MULTIPART_REQUEST_BYTES) {
|
||||
await reader.cancel();
|
||||
throw new BackupTooLargeError("Backup upload exceeds the 256 MB limit");
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
const body = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
body.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return body;
|
||||
};
|
||||
|
||||
const parseBoundedFormDataAsync = async (request: Request): Promise<FormData> => {
|
||||
const contentType = request.headers.get("content-type");
|
||||
if (!contentType) throw new Error("Missing Content-Type header");
|
||||
|
||||
const body = await readBoundedRequestBodyAsync(request);
|
||||
return new Response(body, { headers: { "content-type": contentType } }).formData();
|
||||
};
|
||||
|
||||
const assertArchiveSizeLimits = (zip: AdmZip) => {
|
||||
const entries = zip.getEntries();
|
||||
let totalSize = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory) continue;
|
||||
const entrySize = entry.header.size;
|
||||
if (!Number.isSafeInteger(entrySize) || entrySize < 0) {
|
||||
throw new Error(`Invalid uncompressed size for ${entry.entryName}`);
|
||||
}
|
||||
totalSize += entrySize;
|
||||
if (totalSize > MAX_UNCOMPRESSED_ARCHIVE_BYTES) {
|
||||
throw new BackupTooLargeError("Uncompressed backup archive exceeds the 514 MB limit");
|
||||
}
|
||||
}
|
||||
|
||||
const dbEntry = zip.getEntry("db.sqlite");
|
||||
if (dbEntry && dbEntry.header.size > MAX_UNCOMPRESSED_DATABASE_BYTES) {
|
||||
throw new BackupTooLargeError("Uncompressed SQLite database exceeds the 512 MB limit");
|
||||
}
|
||||
|
||||
const metadataEntry = zip.getEntry("metadata.json");
|
||||
if (metadataEntry && metadataEntry.header.size > MAX_METADATA_BYTES) {
|
||||
throw new BackupTooLargeError("Backup metadata exceeds the 1 MB limit");
|
||||
}
|
||||
|
||||
const encryptionKeyEntry = zip.getEntry("encryption-key.txt");
|
||||
if (encryptionKeyEntry && encryptionKeyEntry.header.size > MAX_ENCRYPTION_KEY_BYTES) {
|
||||
throw new BackupTooLargeError("Backup encryption key entry is too large");
|
||||
}
|
||||
};
|
||||
|
||||
const reEncryptSecrets = (tempDb: InstanceType<typeof Database>, importedKeyHex: string) => {
|
||||
const currentKeyHex = env.SECRET_ENCRYPTION_KEY;
|
||||
@@ -105,41 +200,62 @@ export async function POST(req: Request) {
|
||||
return NextResponse.json({ error: "Database path not configured" }, { status: 500 });
|
||||
}
|
||||
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await req.formData();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid form data" }, { status: 400 });
|
||||
}
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
let zip: AdmZip;
|
||||
try {
|
||||
zip = new AdmZip(Buffer.from(arrayBuffer));
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid ZIP file" }, { status: 400 });
|
||||
}
|
||||
|
||||
const entries = zip.getEntries().map((e) => e.entryName);
|
||||
const missingEntries = REQUIRED_ZIP_ENTRIES.filter((name) => !entries.includes(name));
|
||||
if (missingEntries.length > 0) {
|
||||
return NextResponse.json({ error: `Invalid backup: missing ${missingEntries.join(", ")}` }, { status: 400 });
|
||||
}
|
||||
|
||||
const migrationsFolder = findMigrationsFolder();
|
||||
if (!migrationsFolder) {
|
||||
return NextResponse.json({ error: "Migration files not found" }, { status: 500 });
|
||||
}
|
||||
|
||||
const tempPath = path.join(path.dirname(dbPath), "db.import.sqlite");
|
||||
if (restoreInProgress) {
|
||||
return NextResponse.json({ error: "A database restore is already in progress" }, { status: 409 });
|
||||
}
|
||||
|
||||
restoreInProgress = true;
|
||||
let tempDirectory: string | null = null;
|
||||
let tempDb: InstanceType<typeof Database> | null = null;
|
||||
|
||||
try {
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await parseBoundedFormDataAsync(req);
|
||||
} catch (error) {
|
||||
if (error instanceof BackupTooLargeError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 413 });
|
||||
}
|
||||
return NextResponse.json({ error: "Invalid form data" }, { status: 400 });
|
||||
}
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||
}
|
||||
if (file.size > MAX_COMPRESSED_BACKUP_BYTES) {
|
||||
return NextResponse.json({ error: "Backup upload exceeds the 256 MB limit" }, { status: 413 });
|
||||
}
|
||||
|
||||
let zip: AdmZip;
|
||||
try {
|
||||
zip = new AdmZip(Buffer.from(await file.arrayBuffer()));
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid ZIP file" }, { status: 400 });
|
||||
}
|
||||
|
||||
const entries = zip.getEntries().map((entry) => entry.entryName);
|
||||
const missingEntries = REQUIRED_ZIP_ENTRIES.filter((name) => !entries.includes(name));
|
||||
if (missingEntries.length > 0) {
|
||||
return NextResponse.json({ error: `Invalid backup: missing ${missingEntries.join(", ")}` }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
assertArchiveSizeLimits(zip);
|
||||
} catch (error) {
|
||||
if (error instanceof BackupTooLargeError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 413 });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
tempDirectory = fs.mkdtempSync(path.join(path.dirname(dbPath), ".homarr-restore-"));
|
||||
const tempPath = path.join(tempDirectory, "db.sqlite");
|
||||
const dbEntry = zip.getEntry("db.sqlite");
|
||||
if (!dbEntry) {
|
||||
return NextResponse.json({ error: "Invalid backup: missing db.sqlite" }, { status: 400 });
|
||||
@@ -187,23 +303,33 @@ export async function POST(req: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const restartTimer = setTimeout(() => {
|
||||
console.log("Database restored, restarting server...");
|
||||
process.exit(0);
|
||||
}, 500);
|
||||
}, RESTART_DELAY_MS);
|
||||
restartTimer.unref();
|
||||
|
||||
return NextResponse.json({ success: true, message: "Database restored. Server is restarting..." });
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Database restored. Server is restarting...",
|
||||
restartAfterMs: RESTART_DELAY_MS,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[backup/import] Restore failed:", error);
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return NextResponse.json({ error: `Restore failed: ${message}` }, { status: 500 });
|
||||
} finally {
|
||||
tempDb?.close();
|
||||
if (fs.existsSync(tempPath)) {
|
||||
restoreInProgress = false;
|
||||
try {
|
||||
tempDb?.close();
|
||||
} catch (error) {
|
||||
console.error("[backup/import] Failed to close temporary database:", error);
|
||||
}
|
||||
if (tempDirectory) {
|
||||
try {
|
||||
fs.unlinkSync(tempPath);
|
||||
} catch {
|
||||
// temp file may have been renamed already
|
||||
fs.rmSync(tempDirectory, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
console.error("[backup/import] Failed to remove temporary restore directory:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
|
||||
import AdmZip from "adm-zip";
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
|
||||
|
||||
const routeMocks = vi.hoisted(() => ({
|
||||
auth: vi.fn(),
|
||||
claimAllowed: vi.fn(),
|
||||
onboardingFindFirst: vi.fn(),
|
||||
dbEnv: {
|
||||
DRIVER: "better-sqlite3",
|
||||
URL: "",
|
||||
},
|
||||
commonEnv: {
|
||||
SECRET_ENCRYPTION_KEY: "1".repeat(64),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/auth/next", () => ({ auth: routeMocks.auth }));
|
||||
vi.mock("@homarr/auth/server", () => ({ isProviderEnabled: () => false }));
|
||||
vi.mock("@homarr/api/onboarding-claim", () => ({
|
||||
getOnboardingClaimTokenFromCookieHeader: () => "test-claim",
|
||||
isClaimOnlyOnboardingAccessAllowedAsync: routeMocks.claimAllowed,
|
||||
}));
|
||||
vi.mock("@homarr/common/env", () => ({ env: routeMocks.commonEnv }));
|
||||
vi.mock("@homarr/core/infrastructure/db/env", () => ({ dbEnv: routeMocks.dbEnv }));
|
||||
vi.mock("@homarr/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
onboarding: {
|
||||
findFirst: routeMocks.onboardingFindFirst,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { POST } from "../import/route";
|
||||
|
||||
const MEBIBYTE = 1024 * 1024;
|
||||
const MIGRATIONS_FOLDER = "./packages/db/migrations/sqlite";
|
||||
|
||||
const createMigratedDatabase = (databasePath: string, boardId: string, boardName: string) => {
|
||||
const sqlite = new Database(databasePath);
|
||||
migrate(drizzle(sqlite, { casing: DB_CASING }), { migrationsFolder: MIGRATIONS_FOLDER });
|
||||
sqlite.exec(`
|
||||
INSERT INTO "board" (
|
||||
"id", "name", "is_public", "primary_color", "secondary_color", "opacity", "item_radius",
|
||||
"background_image_attachment", "background_image_repeat", "background_image_size", "disable_status"
|
||||
) VALUES (
|
||||
'${boardId}', '${boardName}', 0, '#fa5252', '#fd7e14', 100, 'lg', 'fixed', 'no-repeat', 'cover', 0
|
||||
)
|
||||
`);
|
||||
sqlite.close();
|
||||
};
|
||||
|
||||
const createBackup = (directory: string, name: string, boardName: string) => {
|
||||
const sourcePath = path.join(directory, `${name}.sqlite`);
|
||||
createMigratedDatabase(sourcePath, `board-${name}`, boardName);
|
||||
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("db.sqlite", fs.readFileSync(sourcePath));
|
||||
zip.addFile(
|
||||
"metadata.json",
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
dbDialect: "sqlite",
|
||||
encryptionKey: routeMocks.commonEnv.SECRET_ENCRYPTION_KEY,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return zip.toBuffer();
|
||||
};
|
||||
|
||||
const createImportRequest = (backup: Buffer) => {
|
||||
const formData = new FormData();
|
||||
formData.set("file", new File([Uint8Array.from(backup)], "homarr-backup.zip", { type: "application/zip" }));
|
||||
return new Request("http://homarr.test/api/backup/import", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
};
|
||||
|
||||
const readBoardNames = (databasePath: string) => {
|
||||
const sqlite = new Database(databasePath, { readonly: true });
|
||||
const rows = sqlite.prepare('SELECT "name" FROM "board" ORDER BY "name"').all() as { name: string }[];
|
||||
sqlite.close();
|
||||
return rows.map((row) => row.name);
|
||||
};
|
||||
|
||||
describe("POST /api/backup/import", () => {
|
||||
let temporaryDirectory: string;
|
||||
let activeDatabasePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "homarr-import-route-test-"));
|
||||
activeDatabasePath = path.join(temporaryDirectory, "db.sqlite");
|
||||
routeMocks.dbEnv.DRIVER = "better-sqlite3";
|
||||
routeMocks.dbEnv.URL = activeDatabasePath;
|
||||
routeMocks.auth.mockResolvedValue({ user: { permissions: ["admin"] } });
|
||||
routeMocks.claimAllowed.mockResolvedValue(false);
|
||||
routeMocks.onboardingFindFirst.mockResolvedValue({ step: "start" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("accepts the legacy import step through normalized onboarding claim access", async () => {
|
||||
createMigratedDatabase(activeDatabasePath, "board-current", "Current board");
|
||||
const backup = createBackup(temporaryDirectory, "legacy", "Restored board");
|
||||
routeMocks.auth.mockResolvedValue(null);
|
||||
routeMocks.claimAllowed.mockResolvedValue(true);
|
||||
routeMocks.onboardingFindFirst.mockResolvedValue({ step: "import" });
|
||||
|
||||
const response = await POST(createImportRequest(backup));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({ success: true, restartAfterMs: 500 });
|
||||
expect(readBoardNames(activeDatabasePath)).toEqual(["Restored board"]);
|
||||
});
|
||||
|
||||
it("rejects an oversized declared upload before reading its body", async () => {
|
||||
const originalDatabase = Buffer.from("active database remains untouched");
|
||||
fs.writeFileSync(activeDatabasePath, originalDatabase);
|
||||
const request = new Request("http://homarr.test/api/backup/import", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-length": String(258 * MEBIBYTE),
|
||||
"content-type": "multipart/form-data; boundary=oversized",
|
||||
},
|
||||
body: "--oversized--\r\n",
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(fs.readFileSync(activeDatabasePath)).toEqual(originalDatabase);
|
||||
});
|
||||
|
||||
it("rejects an oversized uncompressed SQLite entry without replacing the active database", async () => {
|
||||
const originalDatabase = Buffer.from("active database remains untouched");
|
||||
fs.writeFileSync(activeDatabasePath, originalDatabase);
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("db.sqlite", Buffer.from("small compressed payload"));
|
||||
zip.addFile("metadata.json", Buffer.from('{"dbDialect":"sqlite"}'));
|
||||
const databaseEntry = zip.getEntry("db.sqlite");
|
||||
if (!databaseEntry) throw new Error("Expected database entry");
|
||||
databaseEntry.header.size = 513 * MEBIBYTE;
|
||||
|
||||
const response = await POST(createImportRequest(zip.toBuffer()));
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(fs.readFileSync(activeDatabasePath)).toEqual(originalDatabase);
|
||||
});
|
||||
|
||||
it("rejects an oversized uncompressed archive before extracting any entry", async () => {
|
||||
const originalDatabase = Buffer.from("active database remains untouched");
|
||||
fs.writeFileSync(activeDatabasePath, originalDatabase);
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("db.sqlite", Buffer.from("small compressed payload"));
|
||||
zip.addFile("metadata.json", Buffer.from('{"dbDialect":"sqlite"}'));
|
||||
zip.addFile("unexpected.bin", Buffer.from("small compressed payload"));
|
||||
const unexpectedEntry = zip.getEntry("unexpected.bin");
|
||||
if (!unexpectedEntry) throw new Error("Expected oversized test entry");
|
||||
unexpectedEntry.header.size = 515 * MEBIBYTE;
|
||||
|
||||
const response = await POST(createImportRequest(zip.toBuffer()));
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(fs.readFileSync(activeDatabasePath)).toEqual(originalDatabase);
|
||||
});
|
||||
|
||||
it("uses and cleans a unique temporary directory for every restore", async () => {
|
||||
createMigratedDatabase(activeDatabasePath, "board-current", "Current board");
|
||||
const createdDirectories: string[] = [];
|
||||
const makeTemporaryDirectory = fs.mkdtempSync.bind(fs);
|
||||
vi.spyOn(fs, "mkdtempSync").mockImplementation((prefix, options) => {
|
||||
const directory = makeTemporaryDirectory(prefix, options);
|
||||
createdDirectories.push(directory);
|
||||
return directory;
|
||||
});
|
||||
|
||||
const firstResponse = await POST(createImportRequest(createBackup(temporaryDirectory, "first", "First restore")));
|
||||
const secondResponse = await POST(
|
||||
createImportRequest(createBackup(temporaryDirectory, "second", "Second restore")),
|
||||
);
|
||||
|
||||
expect(firstResponse.status).toBe(200);
|
||||
expect(secondResponse.status).toBe(200);
|
||||
expect(new Set(createdDirectories).size).toBe(2);
|
||||
expect(createdDirectories.every((directory) => !fs.existsSync(directory))).toBe(true);
|
||||
expect(readBoardNames(activeDatabasePath)).toEqual(["Second restore"]);
|
||||
});
|
||||
|
||||
it("keeps a successful restore response when temporary directory cleanup fails", async () => {
|
||||
createMigratedDatabase(activeDatabasePath, "board-current", "Current board");
|
||||
const backup = createBackup(temporaryDirectory, "cleanup-failure", "Restored despite cleanup failure");
|
||||
const removeDirectory = fs.rmSync.bind(fs);
|
||||
const cleanupError = new Error("simulated cleanup failure");
|
||||
const errorLog = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
vi.spyOn(fs, "rmSync").mockImplementation((target, options) => {
|
||||
if (String(target).includes(".homarr-restore-")) throw cleanupError;
|
||||
return removeDirectory(target, options);
|
||||
});
|
||||
|
||||
const response = await POST(createImportRequest(backup));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({ success: true });
|
||||
expect(readBoardNames(activeDatabasePath)).toEqual(["Restored despite cleanup failure"]);
|
||||
expect(errorLog).toHaveBeenCalledWith(
|
||||
"[backup/import] Failed to remove temporary restore directory:",
|
||||
cleanupError,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a concurrent restore while the first request owns the restore lock", async () => {
|
||||
createMigratedDatabase(activeDatabasePath, "board-current", "Current board");
|
||||
const backup = createBackup(temporaryDirectory, "concurrent", "Concurrent restore");
|
||||
const originalArrayBuffer = File.prototype.arrayBuffer;
|
||||
let signalStarted!: () => void;
|
||||
let releaseFirstRequest!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
signalStarted = resolve;
|
||||
});
|
||||
const release = new Promise<void>((resolve) => {
|
||||
releaseFirstRequest = resolve;
|
||||
});
|
||||
vi.spyOn(File.prototype, "arrayBuffer").mockImplementationOnce(async function (this: File) {
|
||||
signalStarted();
|
||||
await release;
|
||||
return originalArrayBuffer.call(this);
|
||||
});
|
||||
|
||||
const firstRestore = POST(createImportRequest(backup));
|
||||
await started;
|
||||
const concurrentResponse = await POST(createImportRequest(backup));
|
||||
releaseFirstRequest();
|
||||
const firstResponse = await firstRestore;
|
||||
|
||||
expect(concurrentResponse.status).toBe(409);
|
||||
expect(firstResponse.status).toBe(200);
|
||||
expect(readBoardNames(activeDatabasePath)).toEqual(["Concurrent restore"]);
|
||||
});
|
||||
});
|
||||
@@ -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