Skip to content

Commit 4a0ef50

Browse files
committed
feat: implement backup management features and export functionality
1 parent 1842e5f commit 4a0ef50

File tree

97 files changed

+12726
-47
lines changed

Some content is hidden

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

97 files changed

+12726
-47
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]/boards/(content)/_header-actions.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,5 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
264264
window.removeEventListener("popstate", handlePopState);
265265
window.removeEventListener("beforeunload", handleBeforeUnload);
266266
};
267-
// eslint-disable-next-line react-hooks/exhaustive-deps
268267
}, [isDirty]);
269268
};

apps/nextjs/src/app/[locale]/boards/(content)/_ready-context.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ export const BoardReadyProvider = ({ children }: PropsWithChildren) => {
2727

2828
useEffect(() => {
2929
setReadySections((previous) => previous.filter((id) => board.sections.some((section) => section.id === id)));
30-
// eslint-disable-next-line react-hooks/exhaustive-deps
3130
}, [board.sections.length, setReadySections]);
3231

3332
const markAsReady = useCallback((id: string) => {

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: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,23 @@
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 {
6+
IconCopy,
7+
IconDeviceMobile,
8+
IconDownload,
9+
IconHome,
10+
IconSettings,
11+
IconTrash,
12+
IconUpload,
13+
} from "@tabler/icons-react";
614

715
import type { RouterOutputs } from "@homarr/api";
816
import { clientApi } from "@homarr/api/client";
917
import { useSession } from "@homarr/auth/client";
1018
import { revalidatePathActionAsync } from "@homarr/common/client";
1119
import { useConfirmModal, useModalAction } from "@homarr/modals";
12-
import { DuplicateBoardModal } from "@homarr/modals-collection";
20+
import { DuplicateBoardModal, ImportBoardJsonModal } from "@homarr/modals-collection";
21+
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
1322
import { useScopedI18n } from "@homarr/translation/client";
1423
import { Link } from "@homarr/ui";
1524

@@ -36,6 +45,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
3645

3746
const { openConfirmModal } = useConfirmModal();
3847
const { openModal: openDuplicateModal } = useModalAction(DuplicateBoardModal);
48+
const { openModal: openImportJsonModal } = useModalAction(ImportBoardJsonModal);
3949

4050
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
4151
onSettled: async () => {
@@ -55,6 +65,36 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
5565
},
5666
});
5767

68+
const exportBoardMutation = clientApi.backup.exportBoard.useMutation();
69+
70+
const handleExportBoard = useCallback(async () => {
71+
try {
72+
const result = await exportBoardMutation.mutateAsync({
73+
boardId: board.id,
74+
includeIntegrations: false,
75+
});
76+
// Create a blob and trigger download
77+
const blob = new Blob([result.data], { type: "application/json" });
78+
const url = URL.createObjectURL(blob);
79+
const link = document.createElement("a");
80+
link.href = url;
81+
link.download = result.fileName;
82+
document.body.appendChild(link);
83+
link.click();
84+
document.body.removeChild(link);
85+
URL.revokeObjectURL(url);
86+
showSuccessNotification({
87+
title: t("export.success.title"),
88+
message: t("export.success.message"),
89+
});
90+
} catch {
91+
showErrorNotification({
92+
title: t("export.error.title"),
93+
message: t("export.error.message"),
94+
});
95+
}
96+
}, [board.id, exportBoardMutation, t]);
97+
5898
const handleDeletion = useCallback(() => {
5999
openConfirmModal({
60100
title: t("delete.confirm.title"),
@@ -112,6 +152,20 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
112152
</Menu.Item>
113153
</>
114154
)}
155+
{hasFullAccess && (
156+
<Menu.Item
157+
onClick={handleExportBoard}
158+
leftSection={<IconDownload {...iconProps} />}
159+
disabled={exportBoardMutation.isPending}
160+
>
161+
{t("export.label")}
162+
</Menu.Item>
163+
)}
164+
{session?.user.permissions.includes("board-create") && (
165+
<Menu.Item onClick={openImportJsonModal} leftSection={<IconUpload {...iconProps} />}>
166+
{t("importJson.label")}
167+
</Menu.Item>
168+
)}
115169
{hasFullAccess && (
116170
<>
117171
<Menu.Divider />

0 commit comments

Comments
 (0)