diff --git a/packages/bbui/src/Form/Core/DatePicker.svelte b/packages/bbui/src/Form/Core/DatePicker.svelte index 1a7ab59818..15200e111e 100644 --- a/packages/bbui/src/Form/Core/DatePicker.svelte +++ b/packages/bbui/src/Form/Core/DatePicker.svelte @@ -17,7 +17,7 @@ export let timeOnly = false export let ignoreTimezones = false export let time24hr = false - + export let range = false const dispatch = createEventDispatcher() const flatpickrId = `${uuid()}-wrapper` let open = false @@ -41,6 +41,7 @@ time_24hr: time24hr || false, altFormat: timeOnly ? "H:i" : enableTime ? "F j Y, H:i" : "F j, Y", wrap: true, + mode: range ? "range" : null, appendTo, disableMobile: "true", onReady: () => { @@ -64,9 +65,8 @@ if (newValue) { newValue = newValue.toISOString() } - // If time only set date component to 2000-01-01 - if (timeOnly) { + else if (timeOnly) { // Classic flackpickr causing issues. // When selecting a value for the first time for a "time only" field, // the time is always offset by 1 hour for some reason (regardless of time @@ -95,7 +95,11 @@ .slice(0, -1) } - dispatch("change", newValue) + if (range) { + dispatch("change", event.detail) + } else { + dispatch("change", newValue) + } } const clearDateOnBackspace = event => { @@ -160,7 +164,7 @@ {#key redrawOptions} <Flatpickr bind:flatpickr - value={parseDate(value)} + value={range ? value : parseDate(value)} on:open={onOpen} on:close={onClose} options={flatpickrOptions} diff --git a/packages/bbui/src/Form/DatePicker.svelte b/packages/bbui/src/Form/DatePicker.svelte index a0b102dbe8..04ce8b5467 100644 --- a/packages/bbui/src/Form/DatePicker.svelte +++ b/packages/bbui/src/Form/DatePicker.svelte @@ -14,11 +14,17 @@ export let placeholder = null export let appendTo = undefined export let ignoreTimezones = false - + export let range = false const dispatch = createEventDispatcher() const onChange = e => { - value = e.detail + if (range) { + // Flatpickr cant take two dates and work out what to display, needs to be provided a string. + // Like - "Date1 to Date2". Hence passing in that specifically from the array + value = e?.detail[1] + } else { + value = e.detail + } dispatch("change", e.detail) } </script> @@ -34,6 +40,7 @@ {time24hr} {appendTo} {ignoreTimezones} + {range} on:change={onChange} /> </Field> diff --git a/packages/bbui/src/Table/CellRenderer.svelte b/packages/bbui/src/Table/CellRenderer.svelte index 246323244a..5004401d91 100644 --- a/packages/bbui/src/Table/CellRenderer.svelte +++ b/packages/bbui/src/Table/CellRenderer.svelte @@ -56,6 +56,7 @@ {schema} value={cellValue} on:clickrelationship + on:buttonclick > <slot /> </svelte:component> diff --git a/packages/bbui/src/Table/Table.svelte b/packages/bbui/src/Table/Table.svelte index 01a2ca4835..7745c3c407 100644 --- a/packages/bbui/src/Table/Table.svelte +++ b/packages/bbui/src/Table/Table.svelte @@ -387,6 +387,7 @@ schema={schema[field]} value={deepGet(row, field)} on:clickrelationship + on:buttonclick > <slot /> </CellRenderer> diff --git a/packages/builder/assets/backups-default.png b/packages/builder/assets/backups-default.png new file mode 100644 index 0000000000..6e37cbb6c7 Binary files /dev/null and b/packages/builder/assets/backups-default.png differ diff --git a/packages/builder/src/components/portal/overview/backups/ActionsRenderer.svelte b/packages/builder/src/components/portal/overview/backups/ActionsRenderer.svelte new file mode 100644 index 0000000000..b9ca38cf72 --- /dev/null +++ b/packages/builder/src/components/portal/overview/backups/ActionsRenderer.svelte @@ -0,0 +1,114 @@ +<script> + import { + ActionMenu, + MenuItem, + Icon, + Input, + Heading, + Body, + Modal, + } from "@budibase/bbui" + import ConfirmDialog from "components/common/ConfirmDialog.svelte" + import CreateRestoreModal from "./CreateRestoreModal.svelte" + import { createEventDispatcher } from "svelte" + + export let row + + let deleteDialog + let restoreDialog + let updateDialog + let name + let restoreBackupModal + + const dispatch = createEventDispatcher() + + const onClickRestore = name => { + dispatch("buttonclick", { + type: "backupRestore", + name, + backupId: row._id, + restoreBackupName: name, + }) + } + + const onClickDelete = () => { + dispatch("buttonclick", { + type: "backupDelete", + backupId: row._id, + }) + } + + const onClickUpdate = () => { + dispatch("buttonclick", { + type: "backupUpdate", + backupId: row._id, + name, + }) + } + + async function downloadExport() { + window.open(`/api/apps/${row.appId}/backups/${row._id}/file`, "_blank") + } +</script> + +<div class="cell"> + <ActionMenu align="right"> + <div slot="control"> + <Icon size="M" hoverable name="MoreSmallList" /> + </div> + + {#if row.type !== "restore"} + <MenuItem on:click={restoreDialog.show} icon="Revert">Restore</MenuItem> + <MenuItem on:click={deleteDialog.show} icon="Delete">Delete</MenuItem> + <MenuItem on:click={downloadExport} icon="Download">Download</MenuItem> + {/if} + <MenuItem on:click={updateDialog.show} icon="Edit">Update</MenuItem> + </ActionMenu> +</div> + +<Modal bind:this={restoreBackupModal}> + <CreateRestoreModal confirm={name => onClickRestore(name)} /> +</Modal> + +<ConfirmDialog + bind:this={deleteDialog} + okText="Delete Backup" + onOk={onClickDelete} + title="Confirm Deletion" +> + Are you sure you wish to delete the backup + <i>{row.name}</i> + This action cannot be undone. +</ConfirmDialog> + +<ConfirmDialog + bind:this={restoreDialog} + okText="Continue" + onOk={restoreBackupModal?.show} + title="Confirm restore" + warning={false} +> + <Heading size="S">{row.name || "Backup"}</Heading> + <Body size="S">{new Date(row.timestamp).toLocaleString()}</Body> +</ConfirmDialog> + +<ConfirmDialog + bind:this={updateDialog} + disabled={!name} + okText="Confirm" + onOk={onClickUpdate} + title="Update Backup" + warning={false} +> + <Input onlabel="Backup name" placeholder={row.name} bind:value={name} /> +</ConfirmDialog> + +<style> + .cell { + display: flex; + flex-direction: row; + gap: var(--spacing-m); + align-items: center; + margin-left: auto; + } +</style> diff --git a/packages/builder/src/components/portal/overview/backups/AppSizeRenderer.svelte b/packages/builder/src/components/portal/overview/backups/AppSizeRenderer.svelte new file mode 100644 index 0000000000..c103399f5b --- /dev/null +++ b/packages/builder/src/components/portal/overview/backups/AppSizeRenderer.svelte @@ -0,0 +1,41 @@ +<script> + import { Icon } from "@budibase/bbui" + + export let row + + $: automations = row?.automations + $: datasources = row?.datasources + $: screens = row?.screens +</script> + +<div class="cell"> + {#if automations != null && screens != null && datasources != null} + <div class="item"> + <Icon name="Data" /> + <div>{datasources || 0}</div> + </div> + <div class="item"> + <Icon name="WebPage" /> + <div>{screens || 0}</div> + </div> + <div class="item"> + <Icon name="JourneyVoyager" /> + <div>{automations || 0}</div> + </div> + {/if} +</div> + +<style> + .cell { + display: flex; + flex-direction: row; + gap: calc(var(--spacing-xl) * 2); + align-items: center; + } + + .item { + display: flex; + gap: var(--spacing-s); + flex-direction: row; + } +</style> diff --git a/packages/builder/src/components/portal/overview/backups/BackupsTab.svelte b/packages/builder/src/components/portal/overview/backups/BackupsTab.svelte new file mode 100644 index 0000000000..958ee995c7 --- /dev/null +++ b/packages/builder/src/components/portal/overview/backups/BackupsTab.svelte @@ -0,0 +1,336 @@ +<script> + import { + ActionButton, + Button, + DatePicker, + Divider, + Layout, + Modal, + notifications, + Pagination, + Select, + Heading, + Body, + Tags, + Tag, + Table, + Page, + } from "@budibase/bbui" + import { backups, licensing, auth, admin } from "stores/portal" + import { createPaginationStore } from "helpers/pagination" + import AppSizeRenderer from "./AppSizeRenderer.svelte" + import CreateBackupModal from "./CreateBackupModal.svelte" + import ActionsRenderer from "./ActionsRenderer.svelte" + import DateRenderer from "./DateRenderer.svelte" + import UserRenderer from "./UserRenderer.svelte" + import StatusRenderer from "./StatusRenderer.svelte" + import TypeRenderer from "./TypeRenderer.svelte" + import BackupsDefault from "assets/backups-default.png" + import { onMount } from "svelte" + + export let app + + let backupData = null + let modal + let pageInfo = createPaginationStore() + let filterOpt = null + let startDate = null + let endDate = null + let filters = getFilters() + + $: page = $pageInfo.page + $: fetchBackups(filterOpt, page, startDate, endDate) + + function getFilters() { + const options = [] + let types = ["backup"] + let triggers = ["manual", "publish", "scheduled", "restoring"] + for (let type of types) { + for (let trigger of triggers) { + let label = `${trigger} ${type}` + label = label.charAt(0).toUpperCase() + label?.slice(1) + options.push({ label, value: { type, trigger } }) + } + } + options.push({ + label: `Manual restore`, + value: { type: "restore", trigger: "manual" }, + }) + return options + } + + const schema = { + type: { + displayName: "Type", + }, + createdAt: { + displayName: "Date", + }, + name: { + displayName: "Name", + }, + appSize: { + displayName: "App size", + }, + createdBy: { + displayName: "User", + }, + status: { + displayName: "Status", + }, + actions: { + displayName: null, + }, + } + + const customRenderers = [ + { column: "appSize", component: AppSizeRenderer }, + { column: "actions", component: ActionsRenderer }, + { column: "createdAt", component: DateRenderer }, + { column: "createdBy", component: UserRenderer }, + { column: "status", component: StatusRenderer }, + { column: "type", component: TypeRenderer }, + ] + + function flattenBackups(backups) { + return backups.map(backup => { + return { + ...backup, + ...backup?.contents, + } + }) + } + + async function fetchBackups(filters, page, startDate, endDate) { + const response = await backups.searchBackups({ + appId: app.instance._id, + ...filters, + page, + startDate, + endDate, + }) + pageInfo.fetched(response.hasNextPage, response.nextPage) + + // flatten so we have an easier structure to use for the table schema + backupData = flattenBackups(response.data) + } + + async function createManualBackup(name) { + try { + let response = await backups.createManualBackup({ + appId: app.instance._id, + name, + }) + await fetchBackups(filterOpt, page) + notifications.success(response.message) + } catch { + notifications.error("Unable to create backup") + } + } + + async function handleButtonClick({ detail }) { + if (detail.type === "backupDelete") { + await backups.deleteBackup({ + appId: app.instance._id, + backupId: detail.backupId, + }) + await fetchBackups(filterOpt, page) + } else if (detail.type === "backupRestore") { + await backups.restoreBackup({ + appId: app.instance._id, + backupId: detail.backupId, + name: detail.restoreBackupName, + }) + await fetchBackups(filterOpt, page) + } else if (detail.type === "backupUpdate") { + await backups.updateBackup({ + appId: app.instance._id, + backupId: detail.backupId, + name: detail.name, + }) + await fetchBackups(filterOpt, page) + } + } + + onMount(() => { + fetchBackups(filterOpt, page, startDate, endDate) + }) +</script> + +<div class="root"> + {#if !$licensing.backupsEnabled} + <Page wide={false}> + <Layout gap="XS" noPadding> + <div class="title"> + <Heading size="M">Backups</Heading> + <Tags> + <Tag icon="LockClosed">Pro plan</Tag> + </Tags> + </div> + <div> + <Body> + Backup your apps and restore them to their previous state. + {#if !$auth.accountPortalAccess && !$licensing.groupsEnabled && $admin.cloud} + Contact your account holder to upgrade your plan. + {/if} + </Body> + </div> + <Divider /> + <div class="pro-buttons"> + {#if $auth.accountPortalAccess} + <Button + newStyles + primary + disabled={!$auth.accountPortalAccess && $admin.cloud} + on:click={$licensing.goToUpgradePage()} + > + Upgrade + </Button> + {/if} + <!--Show the view plans button--> + <Button + newStyles + secondary + on:click={() => { + window.open("https://budibase.com/pricing/", "_blank") + }} + > + View Plans + </Button> + </div> + </Layout> + </Page> + {:else if backupData?.length > 0} + <Layout noPadding gap="M" alignContent="start"> + <div class="search"> + <div class="select"> + <Select + placeholder="All" + label="Type" + options={filters} + getOptionValue={filter => filter.value} + getOptionLabel={filter => filter.label} + bind:value={filterOpt} + /> + </div> + <div> + <DatePicker + range={true} + label={"Filter Range"} + on:change={e => { + if (e.detail[0].length > 1) { + startDate = e.detail[0][0].toISOString() + endDate = e.detail[0][1].toISOString() + } + }} + /> + </div> + + <div class="split-buttons"> + <ActionButton on:click={modal.show} icon="SaveAsFloppy" + >Create new backup</ActionButton + > + </div> + </div> + <div> + <Table + {schema} + allowSelectRows={false} + allowEditColumns={false} + allowEditRows={false} + data={backupData} + {customRenderers} + placeholderText="No backups found" + border={false} + on:buttonclick={handleButtonClick} + /> + <div class="pagination"> + <Pagination + page={$pageInfo.pageNumber} + hasPrevPage={$pageInfo.loading ? false : $pageInfo.hasPrevPage} + hasNextPage={$pageInfo.loading ? false : $pageInfo.hasNextPage} + goToPrevPage={pageInfo.prevPage} + goToNextPage={pageInfo.nextPage} + /> + </div> + </div> + </Layout> + {:else if backupData?.length === 0} + <Page wide={false}> + <div class="align"> + <img + width="200px" + height="120px" + src={BackupsDefault} + alt="BackupsDefault" + /> + <Layout gap="S"> + <Heading>You have no backups yet</Heading> + <div class="opacity"> + <Body size="S">You can manually backup your app any time</Body> + </div> + <div class="padding"> + <Button on:click={modal.show} cta>Create Backup</Button> + </div> + </Layout> + </div> + </Page> + {/if} +</div> + +<Modal bind:this={modal}> + <CreateBackupModal {createManualBackup} /> +</Modal> + +<style> + .root { + display: grid; + grid-template-columns: 1fr; + height: 100%; + padding: var(--spectrum-alias-grid-gutter-medium) + var(--spectrum-alias-grid-gutter-large); + } + + .search { + display: flex; + gap: var(--spacing-xl); + width: 100%; + align-items: flex-end; + } + + .select { + flex-basis: 150px; + } + + .pagination { + display: flex; + flex-direction: row; + justify-content: flex-end; + margin-top: var(--spacing-xl); + } + + .split-buttons { + display: flex; + align-items: center; + justify-content: flex-end; + flex: 1; + gap: var(--spacing-xl); + } + + .title { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--spacing-m); + } + + .align { + margin-top: 5%; + text-align: center; + } + + .pro-buttons { + display: flex; + gap: var(--spacing-m); + } +</style> diff --git a/packages/builder/src/components/portal/overview/backups/CreateBackupModal.svelte b/packages/builder/src/components/portal/overview/backups/CreateBackupModal.svelte new file mode 100644 index 0000000000..d7a511890e --- /dev/null +++ b/packages/builder/src/components/portal/overview/backups/CreateBackupModal.svelte @@ -0,0 +1,21 @@ +<script> + import { ModalContent, Input } from "@budibase/bbui" + import { auth } from "stores/portal" + + export let createManualBackup + + let templateName = $auth.user.firstName + ? `${$auth.user.firstName}'s Backup` + : "New Backup" + let name = templateName +</script> + +<ModalContent + onConfirm={() => createManualBackup(name)} + title="Create new backup" + confirmText="Create" + ><Input label="Backup name" bind:value={name} /></ModalContent +> + +<style> +</style> diff --git a/packages/builder/src/components/portal/overview/backups/CreateRestoreModal.svelte b/packages/builder/src/components/portal/overview/backups/CreateRestoreModal.svelte new file mode 100644 index 0000000000..4ffb105772 --- /dev/null +++ b/packages/builder/src/components/portal/overview/backups/CreateRestoreModal.svelte @@ -0,0 +1,27 @@ +<script> + import { ModalContent, Input, Body } from "@budibase/bbui" + import { auth } from "stores/portal" + + export let confirm + + let templateName = $auth.user.firstName + ? `${$auth.user.firstName}'s Backup` + : "Restore Backup" + let name = templateName +</script> + +<ModalContent + onConfirm={() => confirm(name)} + title="Backup your current version" + confirmText="Confirm Restore" + disabled={!name} +> + <Body size="S" + >Create a backup of your current app to allow you to roll back after + restoring this backup</Body + > + <Input label="Backup name" bind:value={name} /> +</ModalContent> + +<style> +</style> diff --git a/packages/builder/src/components/portal/overview/backups/DatasourceRenderer.svelte b/packages/builder/src/components/portal/overview/backups/DatasourceRenderer.svelte new file mode 100644 index 0000000000..198339dae9 --- /dev/null +++ b/packages/builder/src/components/portal/overview/backups/DatasourceRenderer.svelte @@ -0,0 +1,21 @@ +<script> + import { Icon } from "@budibase/bbui" + + export let value +</script> + +<div class="cell"> + {#if value != null} + <Icon name="Data" /> + <div>{value || 0}</div> + {/if} +</div> + +<style> + .cell { + display: flex; + flex-direction: row; + gap: var(--spacing-m); + align-items: center; + } +</style> diff --git a/packages/builder/src/components/portal/overview/backups/DateRenderer.svelte b/packages/builder/src/components/portal/overview/backups/DateRenderer.svelte new file mode 100644 index 0000000000..ec58c9ea07 --- /dev/null +++ b/packages/builder/src/components/portal/overview/backups/DateRenderer.svelte @@ -0,0 +1,22 @@ +<script> + import DateTimeRenderer from "components/common/renderers/DateTimeRenderer.svelte" + import dayjs from "dayjs" + import relativeTime from "dayjs/plugin/relativeTime" + dayjs.extend(relativeTime) + + export let value + $: timeSince = dayjs(value).fromNow() +</script> + +<div class="cell"> + {timeSince} - <DateTimeRenderer {value} /> +</div> + +<style> + .cell { + display: flex; + flex-direction: row; + gap: var(--spacing-m); + align-items: center; + } +</style> diff --git a/packages/builder/src/components/portal/overview/backups/StatusRenderer.svelte b/packages/builder/src/components/portal/overview/backups/StatusRenderer.svelte new file mode 100644 index 0000000000..3b3cce731d --- /dev/null +++ b/packages/builder/src/components/portal/overview/backups/StatusRenderer.svelte @@ -0,0 +1,15 @@ +<script> + import { Badge } from "@budibase/bbui" + + export let value = "started" + $: status = value[0].toUpperCase() + value?.slice(1) +</script> + +<Badge + grey={value === "started" || value === "pending"} + green={value === "complete"} + red={value === "failed"} + size="S" +> + {status} +</Badge> diff --git a/packages/builder/src/components/portal/overview/backups/TypeRenderer.svelte b/packages/builder/src/components/portal/overview/backups/TypeRenderer.svelte new file mode 100644 index 0000000000..9057a2adee --- /dev/null +++ b/packages/builder/src/components/portal/overview/backups/TypeRenderer.svelte @@ -0,0 +1,20 @@ +<script> + export let row + + $: baseTrig = row?.trigger || "manual" + $: type = row?.type || "backup" + $: trigger = baseTrig.charAt(0).toUpperCase() + baseTrig.slice(1) +</script> + +<div class="cell"> + {trigger} + {type} +</div> + +<style> + .cell { + display: flex; + flex-direction: row; + align-items: center; + } +</style> diff --git a/packages/builder/src/components/portal/overview/backups/UserRenderer.svelte b/packages/builder/src/components/portal/overview/backups/UserRenderer.svelte new file mode 100644 index 0000000000..abab314d05 --- /dev/null +++ b/packages/builder/src/components/portal/overview/backups/UserRenderer.svelte @@ -0,0 +1,24 @@ +<script> + export let value + + let firstName = value?.firstName + let lastName = value?.lastName || "" + + $: username = + firstName && lastName ? `${firstName} ${lastName}` : value?.email +</script> + +<div class="cell"> + {#if value != null} + <div>{username}</div> + {/if} +</div> + +<style> + .cell { + display: flex; + flex-direction: row; + gap: var(--spacing-m); + align-items: center; + } +</style> diff --git a/packages/builder/src/pages/builder/app/[application]/_layout.svelte b/packages/builder/src/pages/builder/app/[application]/_layout.svelte index 5d25bba0fc..a854d09304 100644 --- a/packages/builder/src/pages/builder/app/[application]/_layout.svelte +++ b/packages/builder/src/pages/builder/app/[application]/_layout.svelte @@ -113,18 +113,23 @@ > Access </MenuItem> - {#if isPublished} - <MenuItem - on:click={() => - $goto( - `../../portal/overview/${application}?tab=${encodeURIComponent( - "Automation History" - )}` - )} - > - Automation history - </MenuItem> - {/if} + <MenuItem + on:click={() => + $goto( + `../../portal/overview/${application}?tab=${encodeURIComponent( + "Automation History" + )}` + )} + > + Automation history + </MenuItem> + <MenuItem + on:click={() => + $goto(`../../portal/overview/${application}?tab=Backups`)} + > + Backups + </MenuItem> + <MenuItem on:click={() => $goto(`../../portal/overview/${application}?tab=Settings`)} diff --git a/packages/builder/src/pages/builder/portal/overview/[application]/index.svelte b/packages/builder/src/pages/builder/portal/overview/[application]/index.svelte index fe0e2443a2..cc31eec348 100644 --- a/packages/builder/src/pages/builder/portal/overview/[application]/index.svelte +++ b/packages/builder/src/pages/builder/portal/overview/[application]/index.svelte @@ -33,6 +33,7 @@ import ExportAppModal from "components/start/ExportAppModal.svelte" import { checkIncomingDeploymentStatus } from "components/deploy/utils" import { onDestroy, onMount } from "svelte" + import BackupsTab from "components/portal/overview/backups/BackupsTab.svelte" export let application @@ -318,16 +319,12 @@ <Tab title="Access"> <AccessTab app={selectedApp} /> </Tab> - {#if isPublished} - <Tab title="Automation History"> - <HistoryTab app={selectedApp} /> - </Tab> - {/if} - {#if false} - <Tab title="Backups"> - <div class="container">Backups contents</div> - </Tab> - {/if} + <Tab title="Automation History"> + <HistoryTab app={selectedApp} /> + </Tab> + <Tab title="Backups"> + <BackupsTab app={selectedApp} /> + </Tab> <Tab title="Settings"> <SettingsTab app={selectedApp} /> </Tab> diff --git a/packages/builder/src/stores/portal/backups.js b/packages/builder/src/stores/portal/backups.js new file mode 100644 index 0000000000..328a9d37cc --- /dev/null +++ b/packages/builder/src/stores/portal/backups.js @@ -0,0 +1,52 @@ +import { writable } from "svelte/store" +import { API } from "api" + +export function createBackupsStore() { + const store = writable({}) + + function selectBackup(backupId) { + store.update(state => { + state.selectedBackup = backupId + return state + }) + } + + async function searchBackups({ + appId, + trigger, + type, + page, + startDate, + endDate, + }) { + return API.searchBackups({ appId, trigger, type, page, startDate, endDate }) + } + + async function restoreBackup({ appId, backupId, name }) { + return API.restoreBackup({ appId, backupId, name }) + } + + async function deleteBackup({ appId, backupId }) { + return API.deleteBackup({ appId, backupId }) + } + + async function createManualBackup(appId, name) { + return API.createManualBackup(appId, name) + } + + async function updateBackup({ appId, backupId, name }) { + return API.updateBackup({ appId, backupId, name }) + } + + return { + createManualBackup, + searchBackups, + selectBackup, + deleteBackup, + restoreBackup, + updateBackup, + subscribe: store.subscribe, + } +} + +export const backups = createBackupsStore() diff --git a/packages/builder/src/stores/portal/index.js b/packages/builder/src/stores/portal/index.js index fa7aa7e3cf..5406ddc3dc 100644 --- a/packages/builder/src/stores/portal/index.js +++ b/packages/builder/src/stores/portal/index.js @@ -9,3 +9,4 @@ export { templates } from "./templates" export { licensing } from "./licensing" export { groups } from "./groups" export { plugins } from "./plugins" +export { backups } from "./backups" diff --git a/packages/builder/src/stores/portal/licensing.js b/packages/builder/src/stores/portal/licensing.js index 179dac9689..59a1622c9f 100644 --- a/packages/builder/src/stores/portal/licensing.js +++ b/packages/builder/src/stores/portal/licensing.js @@ -14,6 +14,7 @@ export const createLicensingStore = () => { isFreePlan: true, // features groupsEnabled: false, + backupsEnabled: false, // the currently used quotas from the db quotaUsage: undefined, // derived quota metrics for percentages used @@ -56,12 +57,17 @@ export const createLicensingStore = () => { const groupsEnabled = license.features.includes( Constants.Features.USER_GROUPS ) + const backupsEnabled = license.features.includes( + Constants.Features.BACKUPS + ) + store.update(state => { return { ...state, license, isFreePlan, groupsEnabled, + backupsEnabled, } }) }, diff --git a/packages/frontend-core/src/api/backups.js b/packages/frontend-core/src/api/backups.js new file mode 100644 index 0000000000..8658815b15 --- /dev/null +++ b/packages/frontend-core/src/api/backups.js @@ -0,0 +1,50 @@ +export const buildBackupsEndpoints = API => ({ + /** + * Gets a list of users in the current tenant. + */ + searchBackups: async ({ appId, trigger, type, page, startDate, endDate }) => { + const opts = {} + if (page) { + opts.page = page + } + if (trigger && type) { + opts.trigger = trigger.toLowerCase() + opts.type = type.toLowerCase() + } + if (startDate && endDate) { + opts.startDate = startDate + opts.endDate = endDate + } + return await API.post({ + url: `/api/apps/${appId}/backups/search`, + body: opts, + }) + }, + + createManualBackup: async ({ appId, name }) => { + return await API.post({ + url: `/api/apps/${appId}/backups`, + body: { name }, + }) + }, + + deleteBackup: async ({ appId, backupId }) => { + return await API.delete({ + url: `/api/apps/${appId}/backups/${backupId}`, + }) + }, + + updateBackup: async ({ appId, backupId, name }) => { + return await API.patch({ + url: `/api/apps/${appId}/backups/${backupId}`, + body: { name }, + }) + }, + + restoreBackup: async ({ appId, backupId, name }) => { + return await API.post({ + url: `/api/apps/${appId}/backups/${backupId}/import`, + body: { name }, + }) + }, +}) diff --git a/packages/frontend-core/src/api/index.js b/packages/frontend-core/src/api/index.js index 3b9bb5b57e..9a40b21351 100644 --- a/packages/frontend-core/src/api/index.js +++ b/packages/frontend-core/src/api/index.js @@ -25,6 +25,7 @@ import { buildViewEndpoints } from "./views" import { buildLicensingEndpoints } from "./licensing" import { buildGroupsEndpoints } from "./groups" import { buildPluginEndpoints } from "./plugins" +import { buildBackupsEndpoints } from "./backups" const defaultAPIClientConfig = { /** @@ -245,5 +246,6 @@ export const createAPIClient = config => { ...buildLicensingEndpoints(API), ...buildGroupsEndpoints(API), ...buildPluginEndpoints(API), + ...buildBackupsEndpoints(API), } } diff --git a/packages/frontend-core/src/constants.js b/packages/frontend-core/src/constants.js index 9a5acf8a9b..1eed492c25 100644 --- a/packages/frontend-core/src/constants.js +++ b/packages/frontend-core/src/constants.js @@ -113,6 +113,7 @@ export const ApiVersion = "1" export const Features = { USER_GROUPS: "userGroups", + BACKUPS: "appBackups", } // Role IDs