commit
2e3698a919
23 changed files with 797 additions and 29 deletions
|
@ -17,7 +17,7 @@
|
||||||
export let timeOnly = false
|
export let timeOnly = false
|
||||||
export let ignoreTimezones = false
|
export let ignoreTimezones = false
|
||||||
export let time24hr = false
|
export let time24hr = false
|
||||||
|
export let range = false
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
const flatpickrId = `${uuid()}-wrapper`
|
const flatpickrId = `${uuid()}-wrapper`
|
||||||
let open = false
|
let open = false
|
||||||
|
@ -41,6 +41,7 @@
|
||||||
time_24hr: time24hr || false,
|
time_24hr: time24hr || false,
|
||||||
altFormat: timeOnly ? "H:i" : enableTime ? "F j Y, H:i" : "F j, Y",
|
altFormat: timeOnly ? "H:i" : enableTime ? "F j Y, H:i" : "F j, Y",
|
||||||
wrap: true,
|
wrap: true,
|
||||||
|
mode: range ? "range" : null,
|
||||||
appendTo,
|
appendTo,
|
||||||
disableMobile: "true",
|
disableMobile: "true",
|
||||||
onReady: () => {
|
onReady: () => {
|
||||||
|
@ -64,9 +65,8 @@
|
||||||
if (newValue) {
|
if (newValue) {
|
||||||
newValue = newValue.toISOString()
|
newValue = newValue.toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
// If time only set date component to 2000-01-01
|
// If time only set date component to 2000-01-01
|
||||||
if (timeOnly) {
|
else if (timeOnly) {
|
||||||
// Classic flackpickr causing issues.
|
// Classic flackpickr causing issues.
|
||||||
// When selecting a value for the first time for a "time only" field,
|
// 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
|
// the time is always offset by 1 hour for some reason (regardless of time
|
||||||
|
@ -95,7 +95,11 @@
|
||||||
.slice(0, -1)
|
.slice(0, -1)
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch("change", newValue)
|
if (range) {
|
||||||
|
dispatch("change", event.detail)
|
||||||
|
} else {
|
||||||
|
dispatch("change", newValue)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearDateOnBackspace = event => {
|
const clearDateOnBackspace = event => {
|
||||||
|
@ -160,7 +164,7 @@
|
||||||
{#key redrawOptions}
|
{#key redrawOptions}
|
||||||
<Flatpickr
|
<Flatpickr
|
||||||
bind:flatpickr
|
bind:flatpickr
|
||||||
value={parseDate(value)}
|
value={range ? value : parseDate(value)}
|
||||||
on:open={onOpen}
|
on:open={onOpen}
|
||||||
on:close={onClose}
|
on:close={onClose}
|
||||||
options={flatpickrOptions}
|
options={flatpickrOptions}
|
||||||
|
|
|
@ -14,11 +14,17 @@
|
||||||
export let placeholder = null
|
export let placeholder = null
|
||||||
export let appendTo = undefined
|
export let appendTo = undefined
|
||||||
export let ignoreTimezones = false
|
export let ignoreTimezones = false
|
||||||
|
export let range = false
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
const onChange = e => {
|
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)
|
dispatch("change", e.detail)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -34,6 +40,7 @@
|
||||||
{time24hr}
|
{time24hr}
|
||||||
{appendTo}
|
{appendTo}
|
||||||
{ignoreTimezones}
|
{ignoreTimezones}
|
||||||
|
{range}
|
||||||
on:change={onChange}
|
on:change={onChange}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
|
@ -56,6 +56,7 @@
|
||||||
{schema}
|
{schema}
|
||||||
value={cellValue}
|
value={cellValue}
|
||||||
on:clickrelationship
|
on:clickrelationship
|
||||||
|
on:buttonclick
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</svelte:component>
|
</svelte:component>
|
||||||
|
|
|
@ -387,6 +387,7 @@
|
||||||
schema={schema[field]}
|
schema={schema[field]}
|
||||||
value={deepGet(row, field)}
|
value={deepGet(row, field)}
|
||||||
on:clickrelationship
|
on:clickrelationship
|
||||||
|
on:buttonclick
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</CellRenderer>
|
</CellRenderer>
|
||||||
|
|
BIN
packages/builder/assets/backups-default.png
Normal file
BIN
packages/builder/assets/backups-default.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 314 KiB |
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -113,18 +113,23 @@
|
||||||
>
|
>
|
||||||
Access
|
Access
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
{#if isPublished}
|
<MenuItem
|
||||||
<MenuItem
|
on:click={() =>
|
||||||
on:click={() =>
|
$goto(
|
||||||
$goto(
|
`../../portal/overview/${application}?tab=${encodeURIComponent(
|
||||||
`../../portal/overview/${application}?tab=${encodeURIComponent(
|
"Automation History"
|
||||||
"Automation History"
|
)}`
|
||||||
)}`
|
)}
|
||||||
)}
|
>
|
||||||
>
|
Automation history
|
||||||
Automation history
|
</MenuItem>
|
||||||
</MenuItem>
|
<MenuItem
|
||||||
{/if}
|
on:click={() =>
|
||||||
|
$goto(`../../portal/overview/${application}?tab=Backups`)}
|
||||||
|
>
|
||||||
|
Backups
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
on:click={() =>
|
on:click={() =>
|
||||||
$goto(`../../portal/overview/${application}?tab=Settings`)}
|
$goto(`../../portal/overview/${application}?tab=Settings`)}
|
||||||
|
|
|
@ -33,6 +33,7 @@
|
||||||
import ExportAppModal from "components/start/ExportAppModal.svelte"
|
import ExportAppModal from "components/start/ExportAppModal.svelte"
|
||||||
import { checkIncomingDeploymentStatus } from "components/deploy/utils"
|
import { checkIncomingDeploymentStatus } from "components/deploy/utils"
|
||||||
import { onDestroy, onMount } from "svelte"
|
import { onDestroy, onMount } from "svelte"
|
||||||
|
import BackupsTab from "components/portal/overview/backups/BackupsTab.svelte"
|
||||||
|
|
||||||
export let application
|
export let application
|
||||||
|
|
||||||
|
@ -318,16 +319,12 @@
|
||||||
<Tab title="Access">
|
<Tab title="Access">
|
||||||
<AccessTab app={selectedApp} />
|
<AccessTab app={selectedApp} />
|
||||||
</Tab>
|
</Tab>
|
||||||
{#if isPublished}
|
<Tab title="Automation History">
|
||||||
<Tab title="Automation History">
|
<HistoryTab app={selectedApp} />
|
||||||
<HistoryTab app={selectedApp} />
|
</Tab>
|
||||||
</Tab>
|
<Tab title="Backups">
|
||||||
{/if}
|
<BackupsTab app={selectedApp} />
|
||||||
{#if false}
|
</Tab>
|
||||||
<Tab title="Backups">
|
|
||||||
<div class="container">Backups contents</div>
|
|
||||||
</Tab>
|
|
||||||
{/if}
|
|
||||||
<Tab title="Settings">
|
<Tab title="Settings">
|
||||||
<SettingsTab app={selectedApp} />
|
<SettingsTab app={selectedApp} />
|
||||||
</Tab>
|
</Tab>
|
||||||
|
|
52
packages/builder/src/stores/portal/backups.js
Normal file
52
packages/builder/src/stores/portal/backups.js
Normal 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()
|
|
@ -9,3 +9,4 @@ export { templates } from "./templates"
|
||||||
export { licensing } from "./licensing"
|
export { licensing } from "./licensing"
|
||||||
export { groups } from "./groups"
|
export { groups } from "./groups"
|
||||||
export { plugins } from "./plugins"
|
export { plugins } from "./plugins"
|
||||||
|
export { backups } from "./backups"
|
||||||
|
|
|
@ -14,6 +14,7 @@ export const createLicensingStore = () => {
|
||||||
isFreePlan: true,
|
isFreePlan: true,
|
||||||
// features
|
// features
|
||||||
groupsEnabled: false,
|
groupsEnabled: false,
|
||||||
|
backupsEnabled: false,
|
||||||
// the currently used quotas from the db
|
// the currently used quotas from the db
|
||||||
quotaUsage: undefined,
|
quotaUsage: undefined,
|
||||||
// derived quota metrics for percentages used
|
// derived quota metrics for percentages used
|
||||||
|
@ -56,12 +57,17 @@ export const createLicensingStore = () => {
|
||||||
const groupsEnabled = license.features.includes(
|
const groupsEnabled = license.features.includes(
|
||||||
Constants.Features.USER_GROUPS
|
Constants.Features.USER_GROUPS
|
||||||
)
|
)
|
||||||
|
const backupsEnabled = license.features.includes(
|
||||||
|
Constants.Features.BACKUPS
|
||||||
|
)
|
||||||
|
|
||||||
store.update(state => {
|
store.update(state => {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
license,
|
license,
|
||||||
isFreePlan,
|
isFreePlan,
|
||||||
groupsEnabled,
|
groupsEnabled,
|
||||||
|
backupsEnabled,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
50
packages/frontend-core/src/api/backups.js
Normal file
50
packages/frontend-core/src/api/backups.js
Normal 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 },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
|
@ -25,6 +25,7 @@ import { buildViewEndpoints } from "./views"
|
||||||
import { buildLicensingEndpoints } from "./licensing"
|
import { buildLicensingEndpoints } from "./licensing"
|
||||||
import { buildGroupsEndpoints } from "./groups"
|
import { buildGroupsEndpoints } from "./groups"
|
||||||
import { buildPluginEndpoints } from "./plugins"
|
import { buildPluginEndpoints } from "./plugins"
|
||||||
|
import { buildBackupsEndpoints } from "./backups"
|
||||||
|
|
||||||
const defaultAPIClientConfig = {
|
const defaultAPIClientConfig = {
|
||||||
/**
|
/**
|
||||||
|
@ -245,5 +246,6 @@ export const createAPIClient = config => {
|
||||||
...buildLicensingEndpoints(API),
|
...buildLicensingEndpoints(API),
|
||||||
...buildGroupsEndpoints(API),
|
...buildGroupsEndpoints(API),
|
||||||
...buildPluginEndpoints(API),
|
...buildPluginEndpoints(API),
|
||||||
|
...buildBackupsEndpoints(API),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -113,6 +113,7 @@ export const ApiVersion = "1"
|
||||||
|
|
||||||
export const Features = {
|
export const Features = {
|
||||||
USER_GROUPS: "userGroups",
|
USER_GROUPS: "userGroups",
|
||||||
|
BACKUPS: "appBackups",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Role IDs
|
// Role IDs
|
||||||
|
|
Loading…
Reference in a new issue