fix: harden onboarding completion
This commit is contained in:
1 parent
576cc0b418
commit
07770e862c
44 files changed
+3003
-1000
No files matched your search
@@ -45,12 +45,12 @@ cannot change credentials or integrations during that setup.
|
||||
|
||||
The studio keeps related choices together:
|
||||
|
||||
1. **Essentials** sets the default language, color scheme, analytics preference, and the usual host, IP address, or domain used by your services.
|
||||
1. **Essentials** sets the default language, color scheme, analytics preference, and the usual host, IP address, domain, or reverse-proxy path used by your services. URL suggestions can use separate ports, subdomains, or one path per service.
|
||||
2. **Discover** checks the actual database, Docker-compatible sockets, Kubernetes configuration, Workshop, and Assistant capabilities. Unavailable sources never block manual setup.
|
||||
3. **Connect** combines explicit `homarr.*` or Homepage Docker labels with image matching. Select several integrations and apps at once, review every URL, and use the service-specific link to open its API-key settings when one is available.
|
||||
4. **Board** previews the protected Mobile and Base layouts, colors, widget radius, sidebars, selected apps, and widgets inferred from integrations or Docker labels.
|
||||
3. **Connect** combines explicit `homarr.*` or Homepage Docker labels with image matching. Select several integrations and apps at once, review every URL, and use the service-specific link to open its API-key settings when one is available. If a container has no published port or URL label, add its HTTP or HTTPS address before importing it.
|
||||
4. **Board** previews the protected Mobile and Base layouts, colors, widget radius, sidebars, selected apps, and widgets inferred from integrations or Docker labels. A fast setup still adds the integration-free Clock, Weather, and Bookmarks starter widgets.
|
||||
5. **Extend** explains and optionally configures Homarr Assistant, the community Workshop, and the separate Homarr MCP endpoint.
|
||||
6. **Review** tests and saves the selected connections, creates the chosen apps and widgets, and configures the exact first board without clearing unrelated data.
|
||||
6. **Review** tests the selected connections, then saves integrations, apps, widgets, settings, and the exact first board together. If any part fails, the database changes are rolled back and your form stays available to correct.
|
||||
|
||||
Docker discovery reports each configured host separately. Results from reachable hosts remain selectable if another socket
|
||||
is unavailable, which is useful for remote Docker, rootless Docker, Synology, and mixed deployments. On Kubernetes or
|
||||
|
||||
@@ -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