Skip to content

Commit da4713e

Browse files
reeceyangConvex, Inc.
authored andcommitted
dashboard: UI for configuring including storage in cloud backups (#42885)
GitOrigin-RevId: 06ce0815918a1f50773dd95db3ab85d6a29b38a5
1 parent f7dcdaa commit da4713e

File tree

4 files changed

+331
-46
lines changed

4 files changed

+331
-46
lines changed

npm-packages/dashboard/src/components/deploymentSettings/BackupListItem.test.tsx

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,17 @@ import {
66
TeamMemberResponse,
77
} from "generatedApi";
88
import userEvent from "@testing-library/user-event";
9-
import { BackupResponse, useRestoreFromCloudBackup } from "api/backups";
9+
import {
10+
BackupResponse,
11+
useRestoreFromCloudBackup,
12+
useRequestCloudBackup,
13+
} from "api/backups";
1014
import { Doc, Id } from "system-udfs/convex/_generated/dataModel";
11-
import { BackupListItem, progressMessageForBackup } from "./BackupListItem";
15+
import {
16+
BackupListItem,
17+
progressMessageForBackup,
18+
BackupNowButton,
19+
} from "./BackupListItem";
1220

1321
const now = new Date();
1422

@@ -31,6 +39,8 @@ const backup: BackupResponse = {
3139

3240
const backupInProgress: BackupResponse = { ...backup, state: "inProgress" };
3341

42+
const backupNoStorage: BackupResponse = { ...backup, includeStorage: false };
43+
3444
const existingCloudBackupRequested: Doc<"_exports"> = {
3545
_id: "yo" as Id<"_exports">,
3646
_creationTime: 2,
@@ -272,4 +282,97 @@ describe("BackupListItem", () => {
272282
progressMessageForBackup(badIdBackup, existingCloudBackupInProgress),
273283
).toEqual(null);
274284
});
285+
286+
it("indicates backup includes storage", async () => {
287+
const { findByText } = render(
288+
<BackupListItem
289+
backup={backup}
290+
restoring={false}
291+
someBackupInProgress={false}
292+
someRestoreInProgress={false}
293+
latestBackupInTargetDeployment={null}
294+
targetDeployment={{ ...targetDeployment, deploymentType: "dev" }}
295+
team={team}
296+
getZipExportUrl={getZipExportUrl}
297+
canPerformActions
298+
maxCloudBackups={2}
299+
progressMessage={null}
300+
/>,
301+
);
302+
303+
expect(await findByText("Includes file storage")).toBeInTheDocument();
304+
});
305+
306+
it("indicates backup does not include storage", async () => {
307+
const { findByText } = render(
308+
<BackupListItem
309+
backup={backupNoStorage}
310+
restoring={false}
311+
someBackupInProgress={false}
312+
someRestoreInProgress={false}
313+
latestBackupInTargetDeployment={null}
314+
targetDeployment={{ ...targetDeployment, deploymentType: "dev" }}
315+
team={team}
316+
getZipExportUrl={getZipExportUrl}
317+
canPerformActions
318+
maxCloudBackups={2}
319+
progressMessage={null}
320+
/>,
321+
);
322+
323+
expect(await findByText("Tables only")).toBeInTheDocument();
324+
});
325+
});
326+
327+
describe("BackupNowButton", () => {
328+
afterEach(cleanup);
329+
330+
it("shows modal when clicked", async () => {
331+
const user = userEvent.setup({ delay: null });
332+
const { getByText, findByText } = render(
333+
<BackupNowButton
334+
deployment={targetDeployment}
335+
team={team}
336+
maxCloudBackups={10}
337+
canPerformActions
338+
/>,
339+
);
340+
341+
await user.click(getByText("Backup Now"));
342+
343+
expect(await findByText("Request an immediate backup")).toBeInTheDocument();
344+
expect(await findByText("Include file storage")).toBeInTheDocument();
345+
});
346+
347+
it.each([[true], [false]])(
348+
"calls useRequestCloudBackup with includeStorage=%s when checkbox is toggled",
349+
async (includeStorage) => {
350+
const user = userEvent.setup({ delay: null });
351+
352+
const { getByText, getByLabelText } = render(
353+
<BackupNowButton
354+
deployment={targetDeployment}
355+
team={team}
356+
maxCloudBackups={10}
357+
canPerformActions
358+
/>,
359+
);
360+
361+
// Open modal
362+
await user.click(getByText("Backup Now"));
363+
364+
// Toggle checkbox to match desired state
365+
const checkbox = getByLabelText(/Include file storage/i);
366+
if (includeStorage) {
367+
await user.click(checkbox);
368+
}
369+
370+
// Submit
371+
await user.click(getByText("Create Backup"));
372+
373+
expect(useRequestCloudBackup()).toHaveBeenCalledWith({
374+
includeStorage,
375+
});
376+
},
377+
);
275378
});

npm-packages/dashboard/src/components/deploymentSettings/BackupListItem.tsx

Lines changed: 82 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -117,13 +117,18 @@ export function BackupListItem({
117117
</div>
118118
)}
119119
</div>
120-
{backup.expirationTime !== null && (
121-
<TimestampDistance
122-
prefix="Expires "
123-
date={new Date(backup.expirationTime)}
124-
className="text-left text-content-errorSecondary"
125-
/>
126-
)}
120+
<div className="flex flex-col items-end gap-1">
121+
<span className="text-xs text-content-secondary">
122+
{backup.includeStorage ? "Includes file storage" : "Tables only"}
123+
</span>
124+
{backup.expirationTime !== null && (
125+
<TimestampDistance
126+
prefix="Expires "
127+
date={new Date(backup.expirationTime)}
128+
className="text-left text-content-errorSecondary"
129+
/>
130+
)}
131+
</div>
127132
{backup.state === "failed" && (
128133
<Tooltip
129134
tip="This backup couldn’t be completed. Contact [email protected] for help."
@@ -374,13 +379,15 @@ function RestoreConfirmation({
374379
/>
375380

376381
<p className="my-2 text-sm">
377-
The data (tables and files) in <code>{targetDeployment.name}</code> will
378-
be replaced by the contents of the backup.
382+
The tables in <code>{targetDeployment.name}</code> will be replaced by
383+
the contents of the backup.{" "}
384+
{backup.includeStorage ??
385+
"Any files in the backup that do not already exist will be uploaded."}
379386
</p>
380387

381388
<p className="text-sm text-content-secondary">
382389
The rest of your deployment configuration (code, environment variables,
383-
scheduled functions, etc.) will not be changed.
390+
scheduled functions, existing files, etc.) will not be changed.
384391
</p>
385392

386393
{needsCheckboxConfirmation && (
@@ -522,6 +529,9 @@ function BackupSummary({
522529
<p className="text-xs text-content-secondary">
523530
(<TimestampDistance date={new Date(backup.requestedTime)} />)
524531
</p>
532+
<p className="text-xs text-content-secondary">
533+
{backup.includeStorage ? "Includes file storage" : "Tables only"}
534+
</p>
525535
</>
526536
) : (
527537
<em>Unknown backup</em>
@@ -704,46 +714,79 @@ export function BackupNowButton({
704714

705715
const requestBackup = useRequestCloudBackup(deployment.id, team.id);
706716
const [isOngoing, setIsOngoing] = useState(false);
717+
const [showModal, setShowModal] = useState(false);
718+
const [includeStorage, setIncludeStorage] = useState(false);
719+
const includeStorageCheckboxId = useId();
707720

708721
const doBackup = async () => {
709722
setIsOngoing(true);
710723
try {
711-
await requestBackup();
724+
await requestBackup({ includeStorage });
712725
} finally {
713726
setIsOngoing(false);
714727
}
728+
setShowModal(false);
729+
if (onBackupRequested) {
730+
onBackupRequested();
731+
}
715732
};
716733

717734
return (
718-
<Button
719-
variant="neutral"
720-
className="w-fit"
721-
loading={isOngoing}
722-
icon={<ArchiveIcon />}
723-
onClick={async () => {
724-
await doBackup();
725-
if (onBackupRequested) {
726-
onBackupRequested();
735+
<>
736+
<Button
737+
variant="neutral"
738+
className="w-fit"
739+
loading={isOngoing}
740+
icon={<ArchiveIcon />}
741+
onClick={() => setShowModal(true)}
742+
disabled={
743+
nonFailedBackupsForDeployment === undefined ||
744+
nonFailedBackupsForDeployment.length >= maxCloudBackups ||
745+
!canPerformActions
727746
}
728-
}}
729-
disabled={
730-
nonFailedBackupsForDeployment === undefined ||
731-
nonFailedBackupsForDeployment.length >= maxCloudBackups ||
732-
!canPerformActions
733-
}
734-
tip={
735-
isOngoing
736-
? "A backup is currently in progress."
737-
: nonFailedBackupsForDeployment &&
738-
nonFailedBackupsForDeployment.length >= maxCloudBackups
739-
? `You can only have up to ${maxCloudBackups} backups on your current plan. Delete some of your existing backups in this deployment to create a new one.`
740-
: !canPerformActions
741-
? "You do not have permission to create backups in production."
742-
: undefined
743-
}
744-
>
745-
Backup Now
746-
</Button>
747+
tip={
748+
isOngoing
749+
? "A backup is currently in progress."
750+
: nonFailedBackupsForDeployment &&
751+
nonFailedBackupsForDeployment.length >= maxCloudBackups
752+
? `You can only have up to ${maxCloudBackups} backups on your current plan. Delete some of your existing backups in this deployment to create a new one.`
753+
: !canPerformActions
754+
? "You do not have permission to create backups in production."
755+
: undefined
756+
}
757+
>
758+
Backup Now
759+
</Button>
760+
761+
{showModal && (
762+
<Modal
763+
onClose={() => setShowModal(false)}
764+
title="Request an immediate backup"
765+
size="sm"
766+
>
767+
<label
768+
className="ml-px flex items-center gap-2 text-sm"
769+
htmlFor={includeStorageCheckboxId}
770+
>
771+
<Checkbox
772+
id={includeStorageCheckboxId}
773+
checked={includeStorage}
774+
onChange={() => setIncludeStorage(!includeStorage)}
775+
/>
776+
Include file storage
777+
</label>
778+
779+
<Button
780+
className="mt-4 ml-auto flex gap-2"
781+
variant="primary"
782+
onClick={doBackup}
783+
loading={isOngoing}
784+
>
785+
Create Backup
786+
</Button>
787+
</Modal>
788+
)}
789+
</>
747790
);
748791
}
749792

npm-packages/dashboard/src/components/deploymentSettings/BackupScheduleSelector.test.tsx

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import { render } from "@testing-library/react";
22
import { DeploymentResponse } from "generatedApi";
33
import userEvent from "@testing-library/user-event";
44
import { useConfigurePeriodicBackup } from "api/backups";
5-
import { BackupScheduleSelector, BackupScheduleSelectorInner } from "./Backups";
5+
import {
6+
BackupScheduleSelector,
7+
BackupScheduleSelectorInner,
8+
BackupIncludeStorageSelector,
9+
AutomaticBackupSelector,
10+
} from "./Backups";
611

712
const deployment: DeploymentResponse = {
813
kind: "cloud",
@@ -18,6 +23,8 @@ const deployment: DeploymentResponse = {
1823
jest.mock("api/profile", () => {});
1924
jest.mock("api/backups", () => ({
2025
useConfigurePeriodicBackup: jest.fn().mockReturnValue(jest.fn()),
26+
useGetPeriodicBackupConfig: jest.fn().mockReturnValue(null),
27+
useDisablePeriodicBackup: jest.fn().mockReturnValue(jest.fn()),
2128
}));
2229
jest.mock("api/vanityDomains", () => ({}));
2330
jest.mock("api/usage", () => ({}));
@@ -212,3 +219,60 @@ describe("BackupScheduleSelectorInner", () => {
212219
});
213220
});
214221
});
222+
223+
describe("BackupIncludeStorageSelector", () => {
224+
it.each([
225+
[true, false],
226+
[false, true],
227+
])(
228+
"toggles includeStorage from %s to %s when checkbox is clicked",
229+
async (initialValue, expectedValue) => {
230+
const user = userEvent.setup({ delay: null });
231+
232+
const periodicBackup = {
233+
sourceDeploymentId: 1,
234+
cronspec: "0 0 * * *",
235+
expirationDeltaSecs: 604800,
236+
nextRun: Date.now() + 86400000,
237+
includeStorage: initialValue,
238+
};
239+
240+
const { getByLabelText } = render(
241+
<BackupIncludeStorageSelector
242+
periodicBackup={periodicBackup}
243+
deployment={deployment}
244+
disabled={false}
245+
/>,
246+
);
247+
248+
const checkbox = getByLabelText(/Include file storage/i);
249+
expect(checkbox).toBeInTheDocument();
250+
await user.click(checkbox);
251+
252+
expect(useConfigurePeriodicBackup()).toHaveBeenCalledWith(
253+
expect.objectContaining({
254+
includeStorage: expectedValue,
255+
}),
256+
);
257+
},
258+
);
259+
});
260+
261+
describe("AutomaticBackupSelector", () => {
262+
it("defaults includeStorage to false when enabling automatic backups", async () => {
263+
const user = userEvent.setup({ delay: null });
264+
265+
const { getByLabelText } = render(
266+
<AutomaticBackupSelector deployment={deployment} canPerformActions />,
267+
);
268+
269+
const checkbox = getByLabelText(/Backup automatically/i);
270+
await user.click(checkbox);
271+
272+
expect(useConfigurePeriodicBackup()).toHaveBeenCalledWith(
273+
expect.objectContaining({
274+
includeStorage: false,
275+
}),
276+
);
277+
});
278+
});

0 commit comments

Comments
 (0)