Skip to content

Commit e3225fe

Browse files
committed
feat: implement backup management features and export functionality
1 parent 1f1a5f2 commit e3225fe

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

88 files changed

+11151
-40
lines changed

.env.example

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,12 @@ TURBO_TELEMETRY_DISABLED=1
4949
# ENABLE_KUBERNETES=true
5050

5151
# Enable mock integration
52-
UNSAFE_ENABLE_MOCK_INTEGRATION=true
52+
UNSAFE_ENABLE_MOCK_INTEGRATION=true
53+
54+
# Backup Configuration
55+
# Path to store backup files (default: ./backups-archive in development, /appdata/backups-archive in production)
56+
# BACKUP_STORAGE_PATH='./backups-archive'
57+
# Maximum backup file size in MB (default: 100)
58+
# BACKUP_MAX_SIZE_MB=100
59+
# Number of backups to retain (oldest will be deleted when limit exceeded, default: 10)
60+
# BACKUP_RETENTION_COUNT=10

.gitignore

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ yarn-error.log*
5050
# database
5151
*.sqlite
5252

53+
# backups archive
54+
backups-archive/
55+
5356
# logs
5457
*.log
5558

@@ -67,4 +70,8 @@ e2e/shared/tmp
6770
apps/nextjs/public/images/background.png
6871

6972
# next-intl
70-
en.d.json.ts
73+
en.d.json.ts
74+
75+
# ai
76+
.claude/
77+
.serena/

apps/nextjs/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"@homarr/analytics": "workspace:^0.1.0",
2323
"@homarr/api": "workspace:^0.1.0",
2424
"@homarr/auth": "workspace:^0.1.0",
25+
"@homarr/backup": "workspace:^0.1.0",
2526
"@homarr/boards": "workspace:^0.1.0",
2627
"@homarr/common": "workspace:^0.1.0",
2728
"@homarr/core": "workspace:^0.1.0",

apps/nextjs/src/app/[locale]/init/_steps/start/init-start.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getMantineColor } from "@homarr/common";
55
import { getScopedI18n } from "@homarr/translation/server";
66

77
import { InitStartButton } from "./next-button";
8+
import { RestoreBackupButton } from "./restore-backup-button";
89

910
export const InitStart = async () => {
1011
const tStart = await getScopedI18n("init.step.start");
@@ -26,6 +27,7 @@ export const InitStart = async () => {
2627
>
2728
{tStart("action.importOldmarr")}
2829
</InitStartButton>
30+
<RestoreBackupButton>{tStart("action.restoreBackup")}</RestoreBackupButton>
2931
</Stack>
3032
</Card>
3133
);
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
"use client";
2+
3+
import { useCallback, useState } from "react";
4+
import { Alert, Badge, Button, Group, List, Modal, Stack, Stepper, Text } from "@mantine/core";
5+
import { Dropzone } from "@mantine/dropzone";
6+
import { useDisclosure } from "@mantine/hooks";
7+
import { IconAlertTriangle, IconCheck, IconDatabaseImport, IconUpload, IconX } from "@tabler/icons-react";
8+
9+
import type { RouterOutputs } from "@homarr/api";
10+
import { clientApi } from "@homarr/api/client";
11+
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
12+
import { useScopedI18n } from "@homarr/translation/client";
13+
14+
type ValidationResult = RouterOutputs["backup"]["validate"];
15+
16+
interface RestoreBackupButtonProps {
17+
children: React.ReactNode;
18+
}
19+
20+
export const RestoreBackupButton = ({ children }: RestoreBackupButtonProps) => {
21+
const t = useScopedI18n("init.step.start.restore");
22+
const tBackup = useScopedI18n("backup");
23+
const [opened, { open, close }] = useDisclosure(false);
24+
const [step, setStep] = useState(0);
25+
const [fileContent, setFileContent] = useState<string | null>(null);
26+
const [validation, setValidation] = useState<ValidationResult | null>(null);
27+
28+
const validateMutation = clientApi.backup.validateOnboarding.useMutation();
29+
const restoreMutation = clientApi.backup.restoreOnboarding.useMutation();
30+
31+
const handleClose = useCallback(() => {
32+
close();
33+
setStep(0);
34+
setFileContent(null);
35+
setValidation(null);
36+
}, [close]);
37+
38+
const handleFileDrop = useCallback(
39+
(files: File[]) => {
40+
const file = files[0];
41+
if (!file) return;
42+
43+
const reader = new FileReader();
44+
reader.onload = async (event) => {
45+
const base64 = (event.target?.result as string).split(",")[1];
46+
if (!base64) return;
47+
48+
setFileContent(base64);
49+
50+
try {
51+
const result = await validateMutation.mutateAsync({ fileContent: base64 });
52+
setValidation(result);
53+
setStep(1);
54+
} catch {
55+
showErrorNotification({
56+
title: tBackup("action.restore.validation.error.title"),
57+
message: tBackup("action.restore.validation.error.message"),
58+
});
59+
}
60+
};
61+
reader.readAsDataURL(file);
62+
},
63+
[validateMutation, tBackup],
64+
);
65+
66+
const handleRestore = useCallback(async () => {
67+
if (!fileContent) return;
68+
69+
try {
70+
await restoreMutation.mutateAsync({
71+
fileContent,
72+
});
73+
74+
showSuccessNotification({
75+
title: t("success.title"),
76+
message: t("success.message"),
77+
});
78+
79+
// Hard redirect to login after successful restore
80+
// Using window.location for full page reload since server state changed
81+
setTimeout(() => {
82+
window.location.href = "/auth/login";
83+
}, 1500);
84+
} catch {
85+
showErrorNotification({
86+
title: tBackup("action.restore.error.title"),
87+
message: tBackup("action.restore.error.message"),
88+
});
89+
}
90+
}, [fileContent, restoreMutation, t, tBackup]);
91+
92+
return (
93+
<>
94+
<Button onClick={open} variant="default" leftSection={<IconDatabaseImport size={16} stroke={1.5} />}>
95+
{children}
96+
</Button>
97+
98+
<Modal opened={opened} onClose={handleClose} title={t("title")} size="lg">
99+
<Stack>
100+
<Stepper active={step} size="sm">
101+
<Stepper.Step label={tBackup("action.restore.step.upload")} />
102+
<Stepper.Step label={tBackup("action.restore.step.validate")} />
103+
</Stepper>
104+
105+
{step === 0 && (
106+
<Stack>
107+
<Alert color="blue" icon={<IconAlertTriangle />}>
108+
{t("description")}
109+
</Alert>
110+
111+
<Dropzone
112+
onDrop={handleFileDrop}
113+
accept={["application/zip"]}
114+
maxFiles={1}
115+
loading={validateMutation.isPending}
116+
>
117+
<Stack align="center" gap="sm" py="xl">
118+
<Dropzone.Accept>
119+
<IconCheck size={48} color="green" />
120+
</Dropzone.Accept>
121+
<Dropzone.Reject>
122+
<IconX size={48} color="red" />
123+
</Dropzone.Reject>
124+
<Dropzone.Idle>
125+
<IconUpload size={48} opacity={0.5} />
126+
</Dropzone.Idle>
127+
<Text size="lg" fw={500}>
128+
{tBackup("action.restore.dropzone.title")}
129+
</Text>
130+
<Text size="sm" c="dimmed">
131+
{tBackup("action.restore.dropzone.description")}
132+
</Text>
133+
</Stack>
134+
</Dropzone>
135+
</Stack>
136+
)}
137+
138+
{step === 1 && validation && (
139+
<Stack>
140+
{validation.valid ? (
141+
<Alert color="green" icon={<IconCheck />}>
142+
{tBackup("action.restore.validation.valid")}
143+
</Alert>
144+
) : (
145+
<Alert color="red" icon={<IconX />}>
146+
{tBackup("action.restore.validation.invalid")}
147+
</Alert>
148+
)}
149+
150+
{validation.errors.length > 0 && (
151+
<Stack gap="xs">
152+
<Text fw={500} c="red">
153+
{tBackup("action.restore.validation.errors")}
154+
</Text>
155+
<List size="sm">
156+
{validation.errors.map((error) => (
157+
<List.Item key={error} c="red">
158+
{error}
159+
</List.Item>
160+
))}
161+
</List>
162+
</Stack>
163+
)}
164+
165+
{validation.warnings.length > 0 && (
166+
<Stack gap="xs">
167+
<Text fw={500} c="yellow">
168+
{tBackup("action.restore.validation.warnings")}
169+
</Text>
170+
<List size="sm">
171+
{validation.warnings.map((warning) => (
172+
<List.Item key={warning} c="yellow">
173+
{warning}
174+
</List.Item>
175+
))}
176+
</List>
177+
</Stack>
178+
)}
179+
180+
<Stack gap="xs">
181+
<Text fw={500}>{tBackup("action.restore.validation.summary")}</Text>
182+
<Group gap="xs">
183+
<Badge>{validation.summary.boards} boards</Badge>
184+
<Badge>{validation.summary.integrations} integrations</Badge>
185+
<Badge>{validation.summary.users} users</Badge>
186+
<Badge>{validation.summary.groups} groups</Badge>
187+
<Badge>{validation.summary.apps} apps</Badge>
188+
<Badge>{validation.summary.searchEngines} search engines</Badge>
189+
<Badge>{validation.summary.mediaFiles} media files</Badge>
190+
</Group>
191+
</Stack>
192+
193+
{validation.valid && (
194+
<Button onClick={handleRestore} loading={restoreMutation.isPending} color="blue">
195+
{t("confirm")}
196+
</Button>
197+
)}
198+
</Stack>
199+
)}
200+
</Stack>
201+
</Modal>
202+
</>
203+
);
204+
};

apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22

33
import { useCallback } from "react";
44
import { Menu } from "@mantine/core";
5-
import { IconCopy, IconDeviceMobile, IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
5+
import { IconCopy, IconDeviceMobile, IconDownload, IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
66

77
import type { RouterOutputs } from "@homarr/api";
88
import { clientApi } from "@homarr/api/client";
99
import { useSession } from "@homarr/auth/client";
1010
import { revalidatePathActionAsync } from "@homarr/common/client";
1111
import { useConfirmModal, useModalAction } from "@homarr/modals";
1212
import { DuplicateBoardModal } from "@homarr/modals-collection";
13+
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
1314
import { useScopedI18n } from "@homarr/translation/client";
1415
import { Link } from "@homarr/ui";
1516

@@ -28,7 +29,8 @@ interface BoardCardMenuDropdownProps {
2829
}
2930

3031
export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => {
31-
const t = useScopedI18n("management.page.board.action");
32+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
33+
const t = useScopedI18n("management.page.board.action") as any;
3234
const tCommon = useScopedI18n("common");
3335

3436
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
@@ -55,6 +57,36 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
5557
},
5658
});
5759

60+
const exportBoardMutation = clientApi.backup.exportBoard.useMutation();
61+
62+
const handleExportBoard = useCallback(async () => {
63+
try {
64+
const result = await exportBoardMutation.mutateAsync({
65+
boardId: board.id,
66+
includeIntegrations: false,
67+
});
68+
// Create a blob and trigger download
69+
const blob = new Blob([result.data], { type: "application/json" });
70+
const url = URL.createObjectURL(blob);
71+
const link = document.createElement("a");
72+
link.href = url;
73+
link.download = result.fileName;
74+
document.body.appendChild(link);
75+
link.click();
76+
document.body.removeChild(link);
77+
URL.revokeObjectURL(url);
78+
showSuccessNotification({
79+
title: t("export.success.title"),
80+
message: t("export.success.message"),
81+
});
82+
} catch {
83+
showErrorNotification({
84+
title: t("export.error.title"),
85+
message: t("export.error.message"),
86+
});
87+
}
88+
}, [board.id, exportBoardMutation, t]);
89+
5890
const handleDeletion = useCallback(() => {
5991
openConfirmModal({
6092
title: t("delete.confirm.title"),
@@ -112,6 +144,15 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
112144
</Menu.Item>
113145
</>
114146
)}
147+
{hasFullAccess && (
148+
<Menu.Item
149+
onClick={handleExportBoard}
150+
leftSection={<IconDownload {...iconProps} />}
151+
disabled={exportBoardMutation.isPending}
152+
>
153+
{t("export.label")}
154+
</Menu.Item>
155+
)}
115156
{hasFullAccess && (
116157
<>
117158
<Menu.Divider />

apps/nextjs/src/app/[locale]/manage/layout.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
IconBrandTablerFilled,
1111
IconCertificate,
1212
IconClipboardListFilled,
13+
IconDatabase,
1314
IconDirectionsFilled,
1415
IconGitFork,
1516
IconHelpSquareRoundedFilled,
@@ -38,7 +39,8 @@ import { MainNavigation } from "~/components/layout/navigation";
3839
import { ClientShell } from "~/components/layout/shell";
3940

4041
export default async function ManageLayout({ children }: PropsWithChildren) {
41-
const t = await getScopedI18n("management.navbar");
42+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
43+
const t = (await getScopedI18n("management.navbar")) as any;
4244
const session = await auth();
4345
const navigationLinks: NavigationLink[] = [
4446
{
@@ -146,6 +148,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
146148
href: "/manage/tools/tasks",
147149
hidden: !session?.user.permissions.includes("admin"),
148150
},
151+
{
152+
label: t("items.tools.items.backups"),
153+
icon: IconDatabase,
154+
href: "/manage/tools/backups",
155+
hidden: !session?.user.permissions.includes("admin"),
156+
},
149157
],
150158
},
151159
{

0 commit comments

Comments
 (0)