Skip to content

Commit d9b662a

Browse files
committed
feat: implement backup management features and export functionality
1 parent c4ac0a6 commit d9b662a

File tree

6 files changed

+87
-31
lines changed

6 files changed

+87
-31
lines changed

apps/nextjs/src/app/[locale]/manage/tools/backups/_components/backups-table.tsx

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { clientApi } from "@homarr/api/client";
1111
import { humanFileSize, useTimeAgo } from "@homarr/common";
1212
import { useConfirmModal } from "@homarr/modals";
1313
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
14-
import { useI18n, useScopedI18n } from "@homarr/translation/client";
14+
import { useScopedI18n } from "@homarr/translation/client";
1515
import { Link, UserAvatar } from "@homarr/ui";
1616
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
1717

@@ -22,7 +22,6 @@ interface BackupsTableProps {
2222
}
2323

2424
export const BackupsTable = ({ initialBackups }: BackupsTableProps) => {
25-
const t = useI18n();
2625
const tBackup = useScopedI18n("backup");
2726

2827
const utils = clientApi.useUtils();
@@ -72,19 +71,8 @@ export const BackupsTable = ({ initialBackups }: BackupsTableProps) => {
7271
}, []);
7372

7473
const handleRefresh = useCallback(async () => {
75-
try {
76-
await utils.backup.list.invalidate();
77-
showSuccessNotification({
78-
title: t("common.success"),
79-
message: "List refreshed",
80-
});
81-
} catch {
82-
showErrorNotification({
83-
title: t("common.error"),
84-
message: "Failed to refresh",
85-
});
86-
}
87-
}, [utils, t]);
74+
await utils.backup.list.refetch();
75+
}, [utils]);
8876

8977
const columns: MRT_ColumnDef<BackupData>[] = [
9078
{

packages/backup/src/export/full-exporter.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
77
import type { Database } from "@homarr/db";
88
import { apps, backups, medias, searchEngines, serverSettings } from "@homarr/db/schema";
99

10+
import packageJson from "../../../../package.json";
1011
import { backupEnv } from "../env";
1112
import { BACKUP_FORMAT_VERSION } from "../formats/json-format";
1213
import { createZipArchiveAsync } from "../formats/zip-format";
@@ -52,7 +53,7 @@ export class FullExporter {
5253
// Create metadata
5354
const metadata: BackupMetadata = {
5455
version: BACKUP_FORMAT_VERSION,
55-
homarrVersion: process.env.VERSION ?? "unknown",
56+
homarrVersion: packageJson.version,
5657
exportedAt: timestamp.toISOString(),
5758
exportedBy: options.userId ?? null,
5859
checksum,

packages/backup/src/formats/__tests__/json-format.spec.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import {
99
parseJson,
1010
} from "../json-format";
1111

12+
// Get the current Homarr version from formatEntityExport
13+
const getHomarrVersion = (): string => formatEntityExport("board", {}).homarrVersion;
14+
1215
describe("BACKUP_FORMAT_VERSION", () => {
1316
it("should be a semantic version string", () => {
1417
expect(BACKUP_FORMAT_VERSION).toMatch(/^\d+\.\d+\.\d+$/);
@@ -27,6 +30,13 @@ describe("formatEntityExport", () => {
2730
expect(new Date(result.exportedAt).getTime()).toBeLessThanOrEqual(Date.now());
2831
});
2932

33+
it("should include homarrVersion from package.json", () => {
34+
const data = { name: "test-board", items: [] };
35+
const result = formatEntityExport("board", data);
36+
37+
expect(result.homarrVersion).toBe(getHomarrVersion());
38+
});
39+
3040
it("should set correct type for integration exports", () => {
3141
const data = { name: "test-integration", url: "http://example.com" };
3242
const result = formatEntityExport("integration", data);
@@ -47,6 +57,22 @@ describe("parseEntityExport", () => {
4757

4858
const result = parseEntityExport<{ name: string }>(content);
4959

60+
// When homarrVersion is missing (legacy format), it's set to current package version
61+
expect(result).toEqual({ ...exportData, homarrVersion: getHomarrVersion() });
62+
});
63+
64+
it("should preserve homarrVersion when present", () => {
65+
const exportData = {
66+
version: "1.0.0",
67+
homarrVersion: "1.2.3",
68+
type: "board",
69+
data: { name: "test" },
70+
exportedAt: new Date().toISOString(),
71+
};
72+
const content = JSON.stringify(exportData);
73+
74+
const result = parseEntityExport<{ name: string }>(content);
75+
5076
expect(result).toEqual(exportData);
5177
});
5278

packages/backup/src/formats/json-format.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import packageJson from "../../../../package.json";
12
import type { EntityExport } from "../types";
23

34
/**
@@ -11,6 +12,7 @@ export const BACKUP_FORMAT_VERSION = "1.0.0";
1112
export const formatEntityExport = <T>(type: "board" | "integration", data: T): EntityExport<T> => {
1213
return {
1314
version: BACKUP_FORMAT_VERSION,
15+
homarrVersion: packageJson.version,
1416
exportedAt: new Date().toISOString(),
1517
type,
1618
data,
@@ -34,6 +36,11 @@ export const parseEntityExport = <T>(content: string): EntityExport<T> => {
3436
throw new Error("Invalid export format: missing required fields");
3537
}
3638

39+
// For backward compatibility, if homarrVersion is missing, use current version
40+
if (!("homarrVersion" in parsed)) {
41+
(parsed as EntityExport<T>).homarrVersion = packageJson.version;
42+
}
43+
3744
return parsed as EntityExport<T>;
3845
};
3946

packages/backup/src/formats/zip-format.ts

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,35 @@ import JSZip from "jszip";
22

33
import type { BackupMetadata, FullBackupData, MediaFile } from "../types";
44

5+
/**
6+
* Wrapper for data files with Homarr version
7+
*/
8+
interface VersionedData<T> {
9+
homarrVersion: string;
10+
data: T;
11+
}
12+
13+
/**
14+
* Helper to wrap data with version
15+
*/
16+
const wrapData = <T>(homarrVersion: string, itemData: T): VersionedData<T> => ({
17+
homarrVersion,
18+
data: itemData,
19+
});
20+
21+
/**
22+
* Helper to unwrap data, supporting both versioned and legacy formats
23+
*/
24+
const unwrapData = <T>(content: string): T => {
25+
const parsed = JSON.parse(content) as Record<string, unknown> | VersionedData<unknown>;
26+
// Check if data is in versioned format (has homarrVersion and data fields)
27+
if (typeof parsed === "object" && "homarrVersion" in parsed && "data" in parsed) {
28+
return (parsed as VersionedData<T>).data;
29+
}
30+
// Legacy format: return as-is
31+
return parsed as T;
32+
};
33+
534
/**
635
* Creates a ZIP archive buffer from backup data and media files
736
*/
@@ -15,16 +44,16 @@ export const createZipArchiveAsync = async (
1544
// Add metadata
1645
zip.file("metadata.json", JSON.stringify(metadata, null, 2));
1746

18-
// Add data files
19-
zip.file("boards.json", JSON.stringify(data.boards, null, 2));
20-
zip.file("integrations.json", JSON.stringify(data.integrations, null, 2));
21-
zip.file("users.json", JSON.stringify(data.users, null, 2));
22-
zip.file("groups.json", JSON.stringify(data.groups, null, 2));
23-
zip.file("apps.json", JSON.stringify(data.apps, null, 2));
24-
zip.file("searchEngines.json", JSON.stringify(data.searchEngines, null, 2));
47+
// Add data files with version wrapper
48+
zip.file("boards.json", JSON.stringify(wrapData(metadata.homarrVersion, data.boards), null, 2));
49+
zip.file("integrations.json", JSON.stringify(wrapData(metadata.homarrVersion, data.integrations), null, 2));
50+
zip.file("users.json", JSON.stringify(wrapData(metadata.homarrVersion, data.users), null, 2));
51+
zip.file("groups.json", JSON.stringify(wrapData(metadata.homarrVersion, data.groups), null, 2));
52+
zip.file("apps.json", JSON.stringify(wrapData(metadata.homarrVersion, data.apps), null, 2));
53+
zip.file("searchEngines.json", JSON.stringify(wrapData(metadata.homarrVersion, data.searchEngines), null, 2));
2554

2655
if (data.settings) {
27-
zip.file("settings.json", JSON.stringify(data.settings, null, 2));
56+
zip.file("settings.json", JSON.stringify(wrapData(metadata.homarrVersion, data.settings), null, 2));
2857
}
2958

3059
// Add media files to media folder
@@ -68,16 +97,16 @@ export const extractZipArchiveAsync = async (
6897
const searchEnginesFile = zip.file("searchEngines.json");
6998

7099
const data: FullBackupData = {
71-
boards: boardsFile ? (JSON.parse(await boardsFile.async("string")) as FullBackupData["boards"]) : [],
100+
boards: boardsFile ? unwrapData<FullBackupData["boards"]>(await boardsFile.async("string")) : [],
72101
integrations: integrationsFile
73-
? (JSON.parse(await integrationsFile.async("string")) as FullBackupData["integrations"])
102+
? unwrapData<FullBackupData["integrations"]>(await integrationsFile.async("string"))
74103
: [],
75-
users: usersFile ? (JSON.parse(await usersFile.async("string")) as FullBackupData["users"]) : [],
76-
groups: groupsFile ? (JSON.parse(await groupsFile.async("string")) as FullBackupData["groups"]) : [],
77-
apps: appsFile ? (JSON.parse(await appsFile.async("string")) as FullBackupData["apps"]) : [],
78-
settings: settingsFile ? (JSON.parse(await settingsFile.async("string")) as FullBackupData["settings"]) : null,
104+
users: usersFile ? unwrapData<FullBackupData["users"]>(await usersFile.async("string")) : [],
105+
groups: groupsFile ? unwrapData<FullBackupData["groups"]>(await groupsFile.async("string")) : [],
106+
apps: appsFile ? unwrapData<FullBackupData["apps"]>(await appsFile.async("string")) : [],
107+
settings: settingsFile ? unwrapData<FullBackupData["settings"]>(await settingsFile.async("string")) : null,
79108
searchEngines: searchEnginesFile
80-
? (JSON.parse(await searchEnginesFile.async("string")) as FullBackupData["searchEngines"])
109+
? unwrapData<FullBackupData["searchEngines"]>(await searchEnginesFile.async("string"))
81110
: [],
82111
};
83112

packages/backup/src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,12 +149,17 @@ export interface BoardExportOptions {
149149
includeIntegrations?: boolean;
150150
}
151151

152+
/**
153+
* Wrapper for single entity export (board, integration)
154+
*/
152155
/**
153156
* Wrapper for single entity export (board, integration)
154157
*/
155158
export interface EntityExport<T> {
156159
/** Version of the export format */
157160
version: string;
161+
/** Version of Homarr that created the export */
162+
homarrVersion: string;
158163
/** ISO timestamp of when export was created */
159164
exportedAt: string;
160165
/** Type of entity exported */

0 commit comments

Comments
 (0)