Merge pull request #8373 from Budibase/feature/backups-ui

Backups UI
This commit is contained in:
Michael Drury 2022-10-24 18:18:12 +01:00 committed by GitHub
commit 2e3698a919
23 changed files with 797 additions and 29 deletions

View File

@ -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}

View File

@ -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>

View File

@ -56,6 +56,7 @@
{schema}
value={cellValue}
on:clickrelationship
on:buttonclick
>
<slot />
</svelte:component>

View File

@ -387,6 +387,7 @@
schema={schema[field]}
value={deepGet(row, field)}
on:clickrelationship
on:buttonclick
>
<slot />
</CellRenderer>

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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`)}

View File

@ -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>

View File

@ -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()

View File

@ -9,3 +9,4 @@ export { templates } from "./templates"
export { licensing } from "./licensing"
export { groups } from "./groups"
export { plugins } from "./plugins"
export { backups } from "./backups"

View File

@ -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,
}
})
},

View File

@ -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 },
})
},
})

View File

@ -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),
}
}

View File

@ -113,6 +113,7 @@ export const ApiVersion = "1"
export const Features = {
USER_GROUPS: "userGroups",
BACKUPS: "appBackups",
}
// Role IDs