Merge pull request #1740 from appwrite/backups-to-pink2

Backups to pink2
This commit is contained in:
Torsten Dittmann
2025-03-19 15:25:05 +01:00
committed by GitHub
24 changed files with 679 additions and 650 deletions
+21 -14
View File
@@ -12,6 +12,7 @@
import { base } from '$app/paths';
import { getProjectId } from '$lib/helpers/project';
import { toLocaleDate } from '$lib/helpers/date';
import { Typography } from '@appwrite.io/pink-svelte';
const backupRestoreItems: {
archives: Map<string, BackupArchive>;
@@ -146,7 +147,9 @@
<section class="upload-box">
<header class="upload-box-header">
<h4 class="upload-box-title">
<span class="text">{titleText} ({items.size})</span>
<Typography.Text variant="m-500">
{titleText} ({items.size})
</Typography.Text>
</h4>
<button
class="upload-box-button"
@@ -172,13 +175,13 @@
<section class="progress-bar u-width-full-line">
<div
class="progress-bar-top-line u-flex u-gap-8 u-main-space-between">
<span class="body-text-2">
<Typography.Text>
{text(item.status, key)}
</span>
</Typography.Text>
<span class="backup-name">
<Typography.Caption variant="400">
{backupName(item, key)}
</span>
</Typography.Caption>
</div>
<div
class="progress-bar-container"
@@ -195,7 +198,7 @@
</div>
{/if}
<style>
<style lang="scss">
.upload-box-title {
font-size: 11px;
}
@@ -211,13 +214,17 @@
justify-content: center;
}
.backup-name {
font-size: 12px;
font-weight: 400;
line-height: 130%;
font-style: normal;
letter-spacing: -0.12px;
color: var(--mid-neutrals-50, #818186);
font-family: var(--font-family-sansSerif, Inter);
.progress-bar-container {
height: 4px;
&::before {
height: 4px;
background-color: var(--bgcolor-neutral-invert);
}
&.is-danger::before {
height: 4px;
background-color: var(--bgcolor-error);
}
}
</style>
@@ -77,7 +77,9 @@
<div class="aw-stripe-container" data-private>
{#if isLoading}
<Spinner />
<div class="loader-element">
<Spinner />
</div>
{/if}
<div class="stripe-element" bind:this={element}>
@@ -99,5 +101,11 @@
.stripe-element {
width: 100%;
}
.loader-element {
width: 100%;
align-self: center;
justify-items: end;
}
}
</style>
@@ -1,5 +1,5 @@
<script lang="ts">
import { Button, InputChoice, InputText } from '$lib/elements/forms';
import { Button, InputText } from '$lib/elements/forms';
import type { PaymentList, PaymentMethodData } from '$lib/sdk/billing';
import { hasStripePublicKey, isCloud } from '$lib/system';
import { onMount } from 'svelte';
+5
View File
@@ -15,6 +15,11 @@
let confirm = false;
let checkboxId = `delete_${title.replaceAll(' ', '_').toLowerCase()}`;
// reset checkbox status
$: if (open && confirmDeletion) {
confirm = false;
}
</script>
<Form isModal {onSubmit}>
+9 -1
View File
@@ -7,6 +7,7 @@
export let show = false;
export let error: string = null;
export let dismissible = true;
export let size: 's' | 'm' | 'l' = 'm';
export let onSubmit: (e: SubmitEvent) => Promise<void> | void = function () {
return;
};
@@ -28,7 +29,7 @@
</script>
<Form isModal {onSubmit} bind:this={formComponent}>
<Modal {title} bind:open={show} {hideFooter} {dismissible}>
<Modal {title} bind:open={show} {hideFooter} {dismissible} {size}>
<slot slot="description" name="description" />
{#if error}
<div bind:this={alert}>
@@ -50,3 +51,10 @@
</svelte:fragment>
</Modal>
</Form>
<style>
/* temporary fix to modal width */
:global(dialog section) {
max-width: 100% !important;
}
</style>
@@ -1,6 +1,8 @@
<script lang="ts">
import { DropList } from '$lib/components';
import { SelectSearchCheckbox } from '..';
import { Icon } from '@appwrite.io/pink-svelte';
import { IconChevronDown, IconChevronUp } from '@appwrite.io/pink-icons-svelte';
type Option = {
value: string;
@@ -60,16 +62,11 @@
</ul>
</div>
<input
class="tags-input-text u-cursor-text"
{placeholder}
bind:value={search}
bind:this={input} />
<span
class:icon-cheveron-up={show}
class:icon-cheveron-down={!show}
class="chevron-icon u-position-absolute u-inset-inline-end-12"
aria-hidden="true"></span>
<div class="input">
<input {placeholder} bind:value={search} bind:this={input} />
<Icon size="m" icon={show ? IconChevronUp : IconChevronDown} />
</div>
</button>
<svelte:fragment slot="list">
@@ -88,13 +85,41 @@
</DropList>
<style>
@media (max-width: 768px) {
.chevron-icon {
inset-block-start: 0.25rem !important;
}
.tags-input {
width: 100%;
}
@media (max-width: 768px) {
.tags-input {
padding-right: 2rem;
}
}
.input {
width: 100%;
display: flex;
align-items: center;
transition: all 0.15s ease-in-out;
border: var(--border-width-s) solid var(--border-neutral);
border-radius: var(--border-radius-s);
background-color: var(--p-input-background-color);
padding-inline: var(--space-6);
outline-offset: calc(var(--border-width-s) * -1);
--p-input-background-color: var(--input-background-color, var(--bgcolor-neutral-default));
}
.input input {
inline-size: 100%;
padding-block: var(--space-3);
padding-inline: 0;
border: none;
display: block;
line-height: 140%;
background: none;
}
.input input::placeholder {
color: var(--fgcolor-neutral-tertiary);
}
.input:focus-within {
outline: var(--border-width-l) solid var(--border-focus);
}
</style>
+3 -1
View File
@@ -8,4 +8,6 @@
export let description: string = undefined;
</script>
<Selector.Switch {id} {description} {label} {disabled} bind:checked={value} on:invalid on:change />
<Selector.Switch {id} {description} {label} {disabled} bind:checked={value} on:invalid on:change>
<slot name="description" slot="description" />
</Selector.Switch>
+1 -1
View File
@@ -19,7 +19,7 @@ const userPreferences = () => get(user)?.prefs;
const notificationPrefs = (): Record<string, NotificationPrefItem> => {
const prefs = userPreferences();
// due to php backend, empty object can be returnd as an empty array
// due to php backend, empty object can be returned as an empty array.
if (!prefs?.notificationPrefs || Array.isArray(prefs.notificationPrefs)) {
return {};
}
+7 -1
View File
@@ -5,18 +5,23 @@
export let title: string;
export let type: 'info' | 'success' | 'warning' | 'error' | 'default' = 'info';
let container;
let container: HTMLElement | null = null;
function setNavigationHeight() {
const alertHeight = container ? container.getBoundingClientRect().height : 0;
const header: HTMLHeadingElement = document.querySelector('main > header');
const sidebar: HTMLElement = document.querySelector('main > div > nav');
const contentSection: HTMLElement = document.querySelector('main > div > section');
if (header) {
header.style.top = `${alertHeight}px`;
}
if (sidebar) {
sidebar.style.top = `${alertHeight + ($isTabletViewport ? 0 : header.getBoundingClientRect().height)}px`;
}
if (contentSection) {
contentSection.style.paddingBlockStart = `${alertHeight}px`;
}
}
onMount(() => {
@@ -30,6 +35,7 @@
</script>
<svelte:window on:resize={setNavigationHeight} />
<section
bind:this={container}
class="alert is-action is-action-and-top-sticky u-sep-block-end"
@@ -107,7 +107,7 @@
</svelte:fragment>
</CardContainer>
{:else}
<Empty single on:click={createOrg}>
<Empty single on:click={createOrg} target="organization">
<p>Create a new organization</p>
</Empty>
{/if}
+1 -1
View File
@@ -25,7 +25,7 @@ if (isCloud) {
},
learnMore: {
text: 'Learn more',
link: () => 'http://appwrite.io/docs/products/databases/backups'
link: () => 'https://appwrite.io/docs/products/databases/backups'
}
});
}
@@ -122,6 +122,7 @@
<style>
.layout-level-progress-bars {
gap: 1rem;
z-index: 1;
display: flex;
flex-direction: column;
@@ -1,6 +1,6 @@
<script lang="ts">
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Alert, CustomId, Modal } from '$lib/components';
import { CustomId, Modal } from '$lib/components';
import { Button, InputText } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
@@ -12,7 +12,7 @@
import { upgradeURL } from '$lib/stores/billing';
import CreatePolicy from './database-[database]/backups/createPolicy.svelte';
import { cronExpression, type UserBackupPolicy } from '$lib/helpers/backups';
import { Icon, Tag } from '@appwrite.io/pink-svelte';
import { Alert, Icon, Tag } from '@appwrite.io/pink-svelte';
import { IconPencil } from '@appwrite.io/pink-icons-svelte';
export let showCreate = false;
@@ -122,33 +122,30 @@
}}><Icon icon={IconPencil} /> Database ID</Tag>
</div>
{/if}
<CustomId bind:show={showCustomId} name="Database" bind:id autofocus={false} />
{#if isCloud}
<div class="u-flex-vertical u-gap-24 u-padding-block-start-24">
{#if $organization?.billingPlan === BillingPlan.FREE}
{#if showPlanUpgradeAlert}
<Alert
type="warning"
dismissible
on:dismiss={() => (showPlanUpgradeAlert = false)}>
<svelte:fragment slot="title">
This database won't be backed up
</svelte:fragment>
Upgrade your plan to ensure your data stays safe and backed up.
<svelte:fragment slot="buttons">
<Button href={$upgradeURL} text>Upgrade plan</Button>
</svelte:fragment>
</Alert>
{/if}
{:else}
<CreatePolicy
bind:totalPolicies
bind:isShowing={showCreate}
title="Backup policies"
subtitle="Protect your data and ensure quick recovery by adding backup policies." />
{#if $organization?.billingPlan === BillingPlan.FREE}
{#if showPlanUpgradeAlert}
<Alert.Inline
dismissible
title="This database won't be backed up"
status="warning"
on:dismiss={() => (showPlanUpgradeAlert = false)}>
Upgrade your plan to ensure your data stays safe and backed up.
<svelte:fragment slot="actions">
<Button compact href={$upgradeURL}>Upgrade plan</Button>
</svelte:fragment>
</Alert.Inline>
{/if}
</div>
{:else}
<CreatePolicy
bind:totalPolicies
bind:isShowing={showCreate}
title="Backup policies"
subtitle="Protect your data and ensure quick recovery by adding backup policies." />
{/if}
{/if}
<svelte:fragment slot="footer">
<Button secondary on:click={() => (showCreate = false)}>Cancel</Button>
@@ -21,6 +21,7 @@
import { showCreateBackup, showCreatePolicy } from './store';
import { getProjectId } from '$lib/helpers/project';
import { trackEvent } from '$lib/actions/analytics';
import { Layout, Typography } from '@appwrite.io/pink-svelte';
let policyCreateError: string;
let totalPolicies: UserBackupPolicy[] = [];
@@ -132,6 +133,7 @@
? `Backup policies have been created`
: `<b>${totalPolicies[0].label}</b> policy has been created`;
// TODO: html isn't yet supported on Toast.
addNotification({
isHtml: true,
type: 'success',
@@ -168,15 +170,15 @@
<Container size="xxl">
<div class="u-flex u-gap-32 u-flex-vertical-mobile">
{#if !isDisabled}
<div class="u-flex-vertical policies-holder-card">
<div class="u-flex-vertical u-gap-16 policies-holder-card">
<ContainerHeader
title="Policies"
buttonText="Create policy"
buttonEvent="create_backup"
buttonType="secondary"
buttonDisabled={isDisabled}
maxPolicies={$currentPlan.backupPolicies}
policiesCreated={data.policies.total}
maxPolicies={$currentPlan.backupPolicies}
buttonMethod={() => {
$showCreatePolicy = true;
trackEvent('click_policy_create');
@@ -188,7 +190,7 @@
lastBackupDates={data.lastBackupDates} />
</div>
<div class="u-flex-vertical u-width-full-line u-overflow-x-auto">
<div class="u-flex-vertical u-gap-16 u-width-full-line u-overflow-x-auto">
<ContainerHeader
title="Backups"
buttonText="Manual backup"
@@ -201,7 +203,7 @@
}} />
{#if data.backups.total}
<div class="u-padding-block-start-8">
<Layout.Stack gap="xxl">
<Table {data} />
{#if data.backups.total > 6}
@@ -211,11 +213,10 @@
offset={data.offset}
total={data.backups.total} />
{/if}
</div>
</Layout.Stack>
{:else}
<div class="u-flex u-flex-vertical u-gap-16">
<article
class="empty card u-width-full-line common-section u-margin-block-start-24">
<article class="empty card u-width-full-line common-section">
No backups yet
</article>
</div>
@@ -242,12 +243,19 @@
</svelte:fragment>
</Modal>
<Modal title="Create manual backup" bind:show={$showCreateBackup} onSubmit={createManualBackup}>
<p class="text" data-private>
Manual backups are <b>retained forever</b> unless manually deleted. Use for major data
changes or rollback safeguards.
<Modal
size="s"
title="Create manual backup"
bind:show={$showCreateBackup}
onSubmit={createManualBackup}>
<Typography.Text variant="m-400">
Manual backups are <b>retained forever</b> unless manually deleted. Use for major data changes
or rollback safeguards.
</Typography.Text>
<Typography.Text variant="m-500">
<b>Depending on the size of your data, this may take a while.</b>
</p>
</Typography.Text>
<svelte:fragment slot="footer">
<Button text on:click={() => ($showCreateBackup = false)}>Cancel</Button>
@@ -264,7 +272,7 @@
@media (min-width: 768px) {
.policies-holder-card {
max-width: 21.5rem;
min-width: 330px;
}
}
</style>
@@ -1,5 +1,5 @@
import { getLimit, getPage, getView, pageToOffset, View } from '$lib/helpers/load';
import { CARD_LIMIT, Dependencies } from '$lib/constants';
import { Dependencies, PAGE_LIMIT } from '$lib/constants';
import { sdk } from '$lib/stores/sdk';
import { Query } from '@appwrite.io/console';
import type { BackupArchive, BackupArchiveList, BackupPolicyList } from '$lib/sdk/backups';
@@ -7,8 +7,9 @@ import { isCloud } from '$lib/system';
export const load = async ({ params, url, route, depends }) => {
depends(Dependencies.BACKUPS);
const page = getPage(url);
const limit = getLimit(url, route, CARD_LIMIT);
const limit = getLimit(url, route, PAGE_LIMIT);
const view = getView(url, route, View.Grid);
const offset = pageToOffset(page, limit);
@@ -0,0 +1,42 @@
<script lang="ts">
export let size: 'm' | 's' = 'm';
export let color: string | undefined = 'currentColor';
const sizes = {
m: { width: 8, height: 8, cx: 4, cy: 4, r: 4 },
s: { width: 4, height: 4, cx: 2, cy: 2, r: 2 }
};
const { width, height, cx, cy, r } = sizes[size] || sizes.m;
</script>
<span class="ellipse" class:m={size === 'm'} class:s={size === 's'}>
<span class="ellipse-wrapper">
<svg
xmlns="http://www.w3.org/2000/svg"
{width}
{height}
viewBox={`0 0 ${width} ${height}`}
fill="none">
<circle {cx} {cy} {r} fill={color ?? 'currentColor'} />
</svg>
</span>
</span>
<style>
.ellipse {
display: inline-flex;
align-items: center;
}
.ellipse-wrapper {
display: flex;
align-items: center;
justify-content: center;
}
.ellipse svg {
display: inline;
vertical-align: middle;
}
</style>
@@ -1,11 +1,11 @@
<script lang="ts">
import { Button } from '$lib/elements/forms';
import { DropList } from '$lib/components';
import { Pill } from '$lib/elements';
import { DropList } from '$lib/components';
import { wizard } from '$lib/stores/wizard';
import { Button } from '$lib/elements/forms';
import SupportWizard from '$routes/(console)/supportWizard.svelte';
import { Icon } from '@appwrite.io/pink-svelte';
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
import { IconInfo, IconPlus } from '@appwrite.io/pink-icons-svelte';
import { Badge, Icon, Layout, Typography } from '@appwrite.io/pink-svelte';
export let isFlex = true;
export let title: string;
@@ -25,14 +25,27 @@
class:is-disabled={buttonDisabled}
class:u-flex={isFlex}
class="u-gap-12 common-section u-main-space-between u-flex-wrap">
<div class="u-flex u-cross-child-center u-cross-center u-gap-12">
<div class="body-text-1 u-bold backups-title">{title}</div>
<Layout.Stack
direction="row"
gap="m"
alignContent="center"
alignItems="center"
justifyContent="space-between">
<Layout.Stack direction="row" gap="xs">
<Typography.Text variant="m-500">{title}</Typography.Text>
{#if title === 'Policies'}
<Badge size="xs" variant="secondary" content={policiesCreated.toString()} />
{/if}
</Layout.Stack>
{#if title === 'Policies' && policiesCreated >= maxPolicies}
<div style="height: 40px; padding-block-start: 4px">
<div style:height="40px;" style:padding-block-start="4px">
<DropList bind:show={showDropdown} width="16">
<Pill button on:click={() => (showDropdown = true)}>
<span class="icon-info" />{policiesCreated}/{maxPolicies} created
<Pill disabled={buttonDisabled} button on:click={() => (showDropdown = true)}>
<Layout.Stack direction="row" gap="xs" alignItems="center" inline>
<Icon icon={IconInfo} size="s" />
{policiesCreated}/{maxPolicies} created
</Layout.Stack>
</Pill>
<svelte:fragment slot="list">
<slot name="tooltip">
@@ -51,36 +64,23 @@
</DropList>
</div>
{/if}
</div>
{#if title === 'Backups' || policiesCreated < maxPolicies}
<Button
event={buttonEvent}
on:click={buttonMethod}
disabled={buttonDisabled}
text={buttonType === 'text'}
secondary={buttonType === 'secondary'}>
<Icon icon={IconPlus} slot="start" size="s" />
{buttonText}
</Button>
{/if}
{#if title === 'Backups' || policiesCreated < maxPolicies}
<Button
event={buttonEvent}
on:click={buttonMethod}
disabled={buttonDisabled}
text={buttonType === 'text'}
secondary={buttonType === 'secondary'}>
<Icon icon={IconPlus} slot="start" size="s" />
{buttonText}
</Button>
{/if}
</Layout.Stack>
</header>
<style>
.is-disabled {
opacity: 0.5;
}
:global(.theme-light) .backups-title {
--p-body-text-color: #373b4d;
color: var(--p-body-text-color);
}
:global(.theme-dark) .backups-title {
color: hsl(var(--color-neutral-5));
}
:global(.small-radius-border-button) {
border-radius: var(--border-radius-small) !important;
}
</style>
@@ -23,10 +23,13 @@
import { InputNumber } from '$lib/elements/forms/index.js';
import { organization } from '$lib/stores/organization';
import { BillingPlan } from '$lib/constants';
import { Card } from '$lib/components';
import { wizard } from '$lib/stores/wizard';
import SupportWizard from '$routes/(console)/supportWizard.svelte';
import { Card, Icon, Layout, Link, Tag, Typography } from '@appwrite.io/pink-svelte';
import { IconPencil, IconTrash } from '@appwrite.io/pink-icons-svelte';
import { isSmallViewport } from '$lib/stores/viewport';
export let isShowing: boolean;
export let isFromBackupsTab: boolean = false;
export let title: string | undefined = undefined;
@@ -100,9 +103,8 @@
showCustomPolicy = false;
};
const markPolicyChecked = (event: Event, policy: UserBackupPolicy) => {
const isChecked = (event.target as HTMLInputElement).checked;
const markPolicyChecked = (event: CustomEvent, policy: UserBackupPolicy) => {
const isChecked = event.detail as boolean;
presetPolicies.update((all) => {
return all.map((p) => {
if (p.label === policy.label) {
@@ -168,6 +170,11 @@
customRetention = { ...selectedOption, number: customRetention.number };
}
}
let frequencyOptions = ['hourly', 'daily', 'weekly', 'monthly'].map((freq) => ({
value: freq,
label: freq.charAt(0).toUpperCase() + freq.slice(1)
}));
</script>
<div class="u-flex-vertical u-gap-16">
@@ -191,85 +198,82 @@
{@const dailyPolicy = $presetPolicies[1]}
{#if isFromBackupsTab}
<div class="u-flex-vertical u-gap-8">
<Card
isTile
class="restore-modal-inner-card"
style="border-radius: var(--border-radius-small, 8px); padding: 1rem;">
<div class="u-flex u-flex-vertical u-gap-4">
<span class="body-text-2 u-bold darker-neutral-color">
Daily backup
</span>
<span>Runs every day and is retained for 7 days</span>
</div>
</Card>
<span>
<button
type="button"
<Layout.Stack gap="m">
<Card.Base padding="s" radius="s">
<Layout.Stack gap="xs">
<Typography.Text variant="m-600">Daily backup</Typography.Text>
Runs every day and is retained for 7 days
</Layout.Stack>
</Card.Base>
<Layout.Stack gap="xxs" direction="row" alignItems="center">
<Button
extraCompact
class="u-underline cursor-pointer"
on:click={() => {
isShowing = false;
$showCreatePolicy = false;
wizard.start(SupportWizard);
}}>Contact support</button> to upgrade your plan and add customized backup
}}>Contact support</Button> to upgrade your plan and add customized backup
policies.
</span>
</div>
</Layout.Stack>
</Layout.Stack>
{:else}
<div class="u-flex u-gap-8 body-text-2">
<Layout.Stack gap="m">
<InputSwitch
id="daily_backup"
label="Daily backups"
on:change={(event) => markPolicyChecked(event, dailyPolicy)}>
<svelte:fragment slot="description">
<span>
Daily backups are retained for 7 days.
<button
type="button"
class="u-underline cursor-pointer"
on:click={() => {
isShowing = false;
wizard.start(SupportWizard);
}}>Contact support</button>
to upgrade your plan and add customized backup policies.
</span>
</svelte:fragment>
<Typography.Text variant="m-400" slot="description">
Daily backups are retained for 7 days.
<Link.Button
on:click={() => {
isShowing = false;
wizard.start(SupportWizard);
}}>Contact support</Link.Button>
to upgrade your plan and add customized backup policies.
</Typography.Text>
</InputSwitch>
</div>
</Layout.Stack>
{/if}
{:else}
<!-- show 2 preset and create custom policy button on Scale & up -->
<div class="u-flex-vertical u-gap-12">
<Layout.Stack gap="m">
<div class="grid-1-1 u-gap-12">
{#each $presetPolicies as policy, index (index)}
<label for={index.toString()} class="card preset-label-card is-allow-focus">
<div class="u-flex u-gap-8 body-text-2">
<Layout.Stack gap="s" direction="row">
<InputCheckbox
id={index.toString()}
on:change={(event) => markPolicyChecked(event, policy)} />
<div class="u-flex-vertical u-gap-4">
<h3 class="u-bold">{policy.label}</h3>
<Layout.Stack gap="xxs">
<Typography.Text variant="m-600"
>{policy.label}</Typography.Text>
{policy.description}
</div>
</div>
</Layout.Stack>
</Layout.Stack>
</label>
{/each}
</div>
{#if listOfCustomPolicies.length}
<div class="u-flex-vertical u-gap-8">
<Layout.Stack gap="s">
{#each listOfCustomPolicies as policy}
<div class="card custom-policy-card">
<div class="u-flex-vertical u-gap-4 body-text-2">
<div class="u-flex u-main-space-between">
<h3 class="u-bold">{policy.label}</h3>
<Card.Base padding="s">
<Layout.Stack gap="xs">
<Layout.Stack direction="row" justifyContent="space-between">
<Typography.Text style="width: inherit" variant="m-600"
>{policy.label}</Typography.Text>
<div class="u-flex u-gap-8">
<Layout.Stack
direction="row"
gap="s"
justifyContent="flex-end">
<Button
text
noMargin
class="icon-pencil height-fit-content"
extraCompact
class="height-fit-content"
on:click={() => {
policyInEdit = policy.id;
backupPolicyName = policy.label;
@@ -293,55 +297,56 @@
(p) => policy.id !== p.id
)
];
}} />
}}>
<Icon icon={IconPencil} size="s" />
</Button>
<Button
text
noMargin
class="icon-trash height-fit-content"
extraCompact
class="height-fit-content"
on:click={() => {
listOfCustomPolicies = [
...listOfCustomPolicies.filter(
(p) => policy.id !== p.id
)
];
}} />
</div>
</div>
}}>
<Icon icon={IconTrash} size="s" />
</Button>
</Layout.Stack>
</Layout.Stack>
<span>{customPolicyDescription(policy)}</span>
</div>
</div>
</Layout.Stack>
</Card.Base>
{/each}
</div>
</Layout.Stack>
{/if}
{#if showCustomPolicy || policyInEdit}
<section
bind:this={customPolicySection}
class="modal is-inner-modal u-width-full-line">
<Card.Base
variant="secondary"
padding="s"
--input-background-color="var(--bgcolor-neutral-primary)">
<div class="modal-form">
<div class="u-flex-vertical u-gap-24">
<div class="u-flex-vertical u-gap-4">
<InputSelect
label="Frequency"
id="policyFrequency"
placeholder="Select frequency"
bind:value={policyFrequency}
options={['hourly', 'daily', 'weekly', 'monthly'].map(
(freq) => ({
value: freq,
label: freq.charAt(0).toUpperCase() + freq.slice(1)
})
)}
required />
{#if policyFrequency === 'hourly'}
<span>{formPolicyDescription()}</span>
{/if}
<Layout.Grid columns={4}>
{#each frequencyOptions as frequency}
<Tag
size="s"
selected={frequency.value === policyFrequency}
on:click={() => {
policyFrequency = frequency.value;
}}>
{frequency.label}
</Tag>
{/each}
</Layout.Grid>
</div>
{#if policyFrequency !== 'hourly'}
<div class="u-flex-vertical u-gap-8">
<Layout.Stack gap="s">
<div class="time-holder">
{#if policyFrequency === 'monthly'}
<InputSelect
@@ -349,11 +354,11 @@
label="Monthly timing"
bind:value={monthlyBackupFrequency}
placeholder="End of month (28th)"
fullWidth
options={backupFrequencies[policyFrequency]} />
{:else if policyFrequency === 'weekly'}
<div class="u-flex-vertical u-width-full-line">
<Label>Timing</Label>
<Layout.Stack gap="s">
<Label class="timing-label">Timing</Label>
<InputSelectCheckbox
name="Timing"
bind:tags={daysSelectionArray}
@@ -366,17 +371,19 @@
option.value
)
}))} />
</div>
</Layout.Stack>
{/if}
<div
class="input-time"
class:hide={policyFrequency === 'monthly' ||
policyFrequency === 'weekly'}
class:u-margin-block-start-4={policyFrequency ===
'monthly' || policyFrequency === 'weekly'}>
class:u-margin-block-start-32={!$isSmallViewport &&
(policyFrequency === 'monthly' ||
policyFrequency === 'weekly')}>
<InputTime
id="time"
step={null}
bind:value={selectedTime}
label={['daily'].includes(policyFrequency)
? 'Timing'
@@ -384,13 +391,12 @@
</div>
</div>
<span>{formPolicyDescription()}</span>
</div>
<Typography.Text>{formPolicyDescription()}</Typography.Text>
</Layout.Stack>
{/if}
<div class="u-flex-vertical u-gap-8">
<InputSelect
fullWidth
id="retention"
label="Keep for"
placeholder="3 months"
@@ -410,7 +416,6 @@
</div>
<InputSelect
fullWidth
id="retention"
placeholder="Months"
options={customRetainingOptions}
@@ -472,6 +477,7 @@
<div class="button-container u-main-end u-flex u-gap-8">
<Button
text
size="xs"
on:click={() => {
policyInEdit = false;
showCustomPolicy = false;
@@ -487,6 +493,7 @@
</Button>
<Button
size="xs"
secondary
on:click={() => {
if (!backupPolicyName) {
@@ -506,18 +513,19 @@
</div>
</div>
</div>
</section>
</Card.Base>
{:else}
<div class="custom-policy-wrapper u-padding-inline-4 u-width-full-line">
<button
type="button"
class="custom-policy-text"
<Button
size="s"
extraCompact
class="u-underline"
on:click={() => (showCustomPolicy = true)}
>Create custom policy
</button>
</Button>
</div>
{/if}
</div>
</Layout.Stack>
{/if}
</FormList>
</div>
@@ -541,13 +549,14 @@
border: solid 0.0625rem #d7d7da;
}
.custom-policy-text {
color: #19191c;
text-decoration: underline;
}
.custom-policy-card {
background-color: #f9f9fa !important;
/* taken from pink 2 */
:global(.timing-label) {
font-style: normal;
font-weight: 500;
line-height: 140%;
display: flex;
align-items: center;
gap: var(--space-2);
}
:global(.theme-dark) .preset-label-card {
@@ -558,39 +567,27 @@
border: solid 0.0625rem #424248;
}
:global(.theme-dark) .custom-policy-text {
color: #fff;
}
:global(.theme-dark) .custom-policy-card {
background: #2c2c2f !important;
}
:global(.height-fit-content) {
height: fit-content;
}
:global(.time-holder .input-time input[type='time']) {
padding-block: 0.34rem !important;
}
@media (max-width: 767.99px) {
.time-holder {
gap: 0;
gap: 1rem;
flex-direction: column;
}
:global(.time-holder .input-time.hide > li label) {
display: none;
.input-time {
margin-block-start: 0 !important;
}
}
/** the modal, for some reason has issues with inner padding on smaller devices. */
@media (max-width: 409px) {
.modal {
--p-modal-padding: 0.95rem;
}
}
@media (max-width: 315px) {
.modal {
--p-modal-padding: 0.9rem;
/* week days selector */
:global(.tags-input) {
padding-right: unset !important;
}
}
</style>
@@ -1,7 +1,6 @@
<script lang="ts">
import Card from '$lib/components/card.svelte';
import { DropList, DropListItem, Modal } from '$lib/components';
import { Button, FormList, InputCheckbox } from '$lib/elements/forms/index';
import { Button } from '$lib/elements/forms/index';
import { app } from '$lib/stores/app';
import { sdk } from '$lib/stores/sdk';
@@ -16,15 +15,23 @@
import type { BackupPolicy, BackupPolicyList } from '$lib/sdk/backups';
import { backupFrequencies } from '$lib/helpers/backups';
import { Click, trackEvent } from '$lib/actions/analytics';
import { Icon, Tooltip } from '@appwrite.io/pink-svelte';
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
import {
ActionMenu,
Divider,
Icon,
Layout,
Popover,
Tooltip,
Typography
} from '@appwrite.io/pink-svelte';
import { IconDotsHorizontal, IconPlus, IconTrash } from '@appwrite.io/pink-icons-svelte';
import { Confirm } from '$lib/components/index.js';
import Ellipse from './components/Ellipse.svelte';
let showDropdown = [];
let showDelete = false;
let selectedPolicy: BackupPolicy = null;
let showEveryPolicy = false;
let confirmedDeletion = false;
export let showCreatePolicy = false;
export let policies: BackupPolicyList;
@@ -47,7 +54,6 @@
} finally {
showDelete = false;
selectedPolicy = null;
confirmedDeletion = false;
}
}
@@ -129,11 +135,9 @@
};
</script>
<div class="u-flex u-flex-vertical u-gap-16">
<Card
class="backups-policy-list-card u-margin-block-start-24"
style="padding: 0; min-width: 21.5rem;">
<div class="inner-card u-flex-vertical-mobile">
<Layout.Stack gap="l">
<Card class="backups-policy-list-card" style="padding: 0; min-width: 21.5rem;">
<div class="inner-card u-flex-vertical-mobile" class:empty={policies.total === 0}>
{#each policies.policies as policy, index (policy.$id)}
{@const policyDescription = getPolicyDescription(policy.schedule)}
{@const policyDescriptionShort = getTruncatedPolicyDescription(policyDescription)}
@@ -146,81 +150,78 @@
class:opacity-gradient-bottom={index === 2}
class:u-padding-block-start-10={index !== 0}
class:u-padding-block-end-10={index === 0 && policies.policies.length > 1}>
<div class="u-flex-vertical u-gap-2">
<div class="u-flex u-main-space-between">
<h3 class="body-text-2 u-bold darker-neutral-color">{policy.name}</h3>
<DropList
noArrow
bind:show={showDropdown[index]}
placement="bottom-end">
<button
class="is-only-icon is-text"
aria-label="More options"
on:click|preventDefault={() => {
showDropdown[index] = !showDropdown[index];
}}>
<span class="icon-dots-horizontal" aria-hidden="true" />
</button>
<Layout.Stack direction="column" gap="xxxs">
<Layout.Stack direction="row" justifyContent="space-between">
<Typography.Text variant="m-500">{policy.name}</Typography.Text>
<Popover let:toggle padding="none" placement="bottom-end">
<Button extraCompact on:click={toggle}>
<Icon icon={IconDotsHorizontal} />
</Button>
<svelte:fragment slot="list">
<DropListItem
on:click={() => {
showDelete = true;
selectedPolicy = policy;
showDropdown[index] = false;
trackEvent(Click.PolicyDeleteClick);
}}>
Delete
</DropListItem>
<svelte:fragment slot="tooltip" let:toggle>
<ActionMenu.Root width="180px">
<ActionMenu.Item.Button
status="danger"
trailingIcon={IconTrash}
on:click={(e) => {
toggle(e);
showDelete = true;
selectedPolicy = policy;
trackEvent(Click.PolicyDeleteClick);
}}>
Delete
</ActionMenu.Item.Button>
</ActionMenu.Root>
</svelte:fragment>
</DropList>
</div>
</Popover>
</Layout.Stack>
<div
class="policy-item-subtitles u-flex u-gap-6"
style="width: fit-content;">
{#if shouldUseTooltip}
<Tooltip>
<span>
<Typography.Caption variant="400">
<Layout.Stack gap="xs" direction="row" alignItems="baseline">
{#if shouldUseTooltip}
<Tooltip>
{policyDescriptionShort}
</span>
<span slot="tooltip">{policyDescription}</span>
</Tooltip>
{:else}
{policyDescription}
{/if}
<span slot="tooltip">{policyDescription}</span>
</Tooltip>
{:else}
{policyDescription}
{/if}
<span class="small-ellipse"></span>
<Ellipse size="s" />
{formatRetentionMessage(policy.retention)}
</div>
</div>
{formatRetentionMessage(policy.retention)}
</Layout.Stack>
</Typography.Caption>
</Layout.Stack>
<div
class="policy-cycles u-flex u-main-space-between u-padding-block-2 policy-item-subtitles">
<!-- Prev / Next section -->
<div class="policy-cycles u-flex u-gap-24 u-padding-block-2">
<div style="width: 128px" class="u-flex-vertical policy-item-caption">
<span style="color: #97979B">Previous</span>
<div
class="u-flex u-gap-4 u-cross-center policy-item-subtitles darker-neutral-color">
<span
class="medium-ellipse"
class:success={!!lastBackupDates[policy.$id]}>●</span>
<span class="policy-item-subtitles">
<div class="u-flex u-gap-4 u-cross-center darker-neutral-color">
<Ellipse
color={lastBackupDates[policy.$id]
? 'var(--bgcolor-success)'
: undefined} />
<Typography.Caption variant="400">
{#if lastBackupDates[policy.$id]}
{toLocaleDateTime(lastBackupDates[policy.$id])}
{:else}
No backups yet
{/if}
</span>
</Typography.Caption>
</div>
</div>
<div class="u-border-vertical" />
<div>
<Divider vertical />
</div>
<div style="width: 128px" class="u-flex-vertical policy-item-caption">
<span style="color: #97979B">Next</span>
<div
class="u-flex u-gap-4 u-cross-center policy-item-subtitles darker-neutral-color">
<Typography.Caption variant="400">
{toLocaleDateTime(
parseExpression(policy.schedule, {
utc: true
@@ -228,10 +229,14 @@
.next()
.toString()
)}
</div>
</Typography.Caption>
</div>
</div>
</div>
{#if index !== policies.total - 1}
<Divider class="item-divider" />
{/if}
{:else}
<div class="u-padding-24 u-flex-vertical u-gap-16 u-cross-center">
{#if $app.themeInUse === 'dark'}
@@ -284,59 +289,28 @@
</div>
{/if}
</Card>
</div>
</Layout.Stack>
<Modal title="Delete policy" bind:show={showDelete} onSubmit={deletePolicy}>
<FormList>
<div class="u-flex-vertical u-gap-16">
<p class="text" data-private>
Are you sure you want to delete the <b>{selectedPolicy.name}</b> policy?
</p>
<Confirm title="Delete policy" bind:open={showDelete} onSubmit={deletePolicy} confirmDeletion>
<Layout.Stack gap="l">
<Typography.Text variant="m-400">
Are you sure you want to delete the <b>{selectedPolicy.name}</b> policy?
</Typography.Text>
<p class="text" data-private>
<b
>This will also delete all backups associated with this policy. This action is
irreversible.</b>
</p>
<Typography.Text variant="m-600">
This will also delete all backups associated with this policy. This action is
irreversible.
</Typography.Text>
</Layout.Stack>
</Confirm>
<div class="input-check-box-friction">
<InputCheckbox
required
id="delete_policy"
bind:checked={confirmedDeletion}
label="I understand and confirm" />
</div>
</div>
</FormList>
<svelte:fragment slot="footer">
<Button text on:click={() => (showDelete = false)}>Cancel</Button>
<Button secondary submit disabled={!confirmedDeletion}>Delete</Button>
</svelte:fragment>
</Modal>
<style>
<style lang="scss">
.inner-card {
margin: 0 -1px;
padding: 0.5rem;
}
.u-border-vertical {
width: 1px;
height: 34px;
background-color: hsl(var(--border));
}
:global(.small-ellipse) {
font-size: 0.25rem;
}
:global(.medium-ellipse) {
font-size: 0.5rem;
color: hsl(var(--color-neutral-20));
}
:global(.medium-ellipse.success) {
color: hsl(var(--color-success-100));
&.empty {
block-size: 365px;
}
}
:global(.u-gap-6) {
@@ -350,10 +324,10 @@
.policy-card-item-padding {
padding: var(--space-3, 6px) var(--space-4, 8px);
border-block-end: solid 0.0625rem hsl(var(--border));
}
.policy-card-item-padding:last-child {
border-block-end: none;
&:last-child {
border-block-end: none;
}
}
.u-padding-block-start-10 {
@@ -364,22 +338,10 @@
padding-block-end: 10px;
}
.policy-item-subtitles {
font-size: 12px;
font-weight: 400;
line-height: 150%;
font-style: normal;
font-family: Inter;
}
:global(.input-check-box-friction .choice-item-title) {
margin-block-start: 1px;
}
:global(.theme-light .policy-item-subtitles) {
color: var(--fgcolor-neutral-secondary, #56565c);
}
:global(.theme-light .policy-item-caption) {
color: var(--color-neutral-50, #818186);
}
@@ -408,39 +370,48 @@
visibility: hidden;
}
.policy-cycles {
justify-content: space-between;
}
.policy-card-item-padding.opacity-gradient-bottom[data-show-every='false']
+ :global(.item-divider) {
display: none;
}
.policy-card-item-padding[data-visible='true'] {
display: block;
visibility: visible;
}
.policy-card-item-padding[data-visible='true']:nth-child(3) {
.policy-card-item-padding[data-visible='true']:nth-child(4) {
opacity: 0.25;
border-block-end: none;
}
.policy-card-item-padding[data-visible='true']:nth-child(3) .policy-cycles {
.policy-card-item-padding[data-visible='true']:nth-child(4) .policy-cycles {
height: 0;
margin: unset;
padding: unset;
visibility: hidden;
}
.policy-card-item-padding[data-visible='false']:nth-child(n + 4) {
.policy-card-item-padding[data-visible='false']:nth-child(n + 5) {
opacity: 0;
height: 0;
padding: unset;
border-block-end: none;
}
.policy-card-item-padding[data-visible='true'][data-show-every='true']:nth-child(3):not(
.policy-card-item-padding[data-visible='true'][data-show-every='true']:nth-child(4):not(
:last-child
) {
border-block-end: solid 0.0625rem hsl(var(--border));
}
.policy-card-item-padding[data-visible='true'][data-show-every='true']:nth-child(3)
.policy-card-item-padding[data-visible='true'][data-show-every='true']:nth-child(4)
.policy-cycles,
.policy-card-item-padding[data-visible='true'][data-show-every='true']:nth-child(3),
.policy-card-item-padding[data-visible='true'][data-show-every='true']:nth-child(4),
.policy-cycles {
opacity: 1;
height: auto;
@@ -1,92 +1,67 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Card, Icon, Input, Layout, Typography } from '@appwrite.io/pink-svelte';
import { Click, trackEvent } from '$lib/actions/analytics';
import { InnerModal } from '$lib/components';
import TextCounter from '$lib/elements/forms/textCounter.svelte';
import { IconX } from '@appwrite.io/pink-icons-svelte';
import Button from '$lib/elements/forms/button.svelte';
export let id: string;
export let show = false;
export let name: string;
export let autofocus = true;
export let fullWidth = false;
export let databaseId: string;
let icon = 'info';
let element: HTMLInputElement;
let error = null;
const pattern = String.raw`^[a-zA-Z0-9][a-zA-Z0-9._\-]*$`;
onMount(() => {
if (element && autofocus) {
element.focus();
}
});
$: if (!show) {
id = null;
}
const handleInvalid = (event: Event) => {
event.preventDefault();
if (element.validity.patternMismatch) {
icon = 'exclamation';
return;
}
};
$: if (show) {
trackEvent(Click.ShowCustomIdClick);
}
$: if (id === databaseId) {
icon = 'exclamation';
element?.setCustomValidity('Database ID must be different from the one being restored.');
error = 'Database ID must be different from the one being restored.';
} else {
icon = 'info';
element?.setCustomValidity('');
}
$: if (id?.length) {
icon = 'info';
} else {
id = null;
error = null;
}
</script>
<InnerModal bind:show {fullWidth}>
<svelte:fragment slot="title">{name} ID</svelte:fragment>
<svelte:fragment slot="subtitle">
Enter a custom {name} ID. Leave blank for a randomly generated one.
</svelte:fragment>
<svelte:fragment slot="content">
<div class="form u-gap-8">
<div class="input-text-wrapper">
<input
id="id"
placeholder="Enter ID"
maxlength={36}
{pattern}
autocomplete="off"
type="text"
class="input-text"
bind:value={id}
bind:this={element}
on:invalid={handleInvalid} />
<TextCounter count={id?.length ?? 0} max={36} />
</div>
<div
class="u-flex u-gap-4 u-margin-block-start-8 u-small"
class:u-color-text-warning={icon === 'exclamation'}>
<span
class:icon-info={icon === 'info'}
class:icon-exclamation={icon === 'exclamation'}
class="u-cross-center u-line-height-1 u-color-text-gray"
aria-hidden="true" />
<span class="text u-line-height-1-5">
Allowed characters: alphanumeric, non-leading hyphen, underscore, period.
Database ID must be different from the one being restored.
</span>
</div>
<Card.Base
variant="secondary"
padding="s"
--input-background-color="var(--bgcolor-neutral-primary)">
<Layout.Stack gap="xl">
<Layout.Stack gap="s">
<Layout.Stack direction="row" justifyContent="space-between" alignContent="center">
<Typography.Text variant="m-600">{name} ID</Typography.Text>
<Button extraCompact on:click={() => (show = false)}>
<Icon icon={IconX} size="s" />
</Button>
</Layout.Stack>
<Typography.Text>
Enter a custom {name} ID. Leave blank for a randomly generated one.
</Typography.Text>
</Layout.Stack>
<Input.Text
id="id"
placeholder="Enter ID"
maxlength={36}
{pattern}
{autofocus}
helper={error}
state={error ? 'warning' : 'default'}
autocomplete="off"
type="text"
class="input-text"
bind:value={id} />
<div class="u-flex u-gap-4 u-margin-block-start-8 u-small">
<span class="text u-line-height-1-5">
Allowed characters: alphanumeric, non-leading hyphen, underscore, period. Database
ID must be different from the one being restored.
</span>
</div>
</svelte:fragment>
</InnerModal>
</Layout.Stack>
</Card.Base>
@@ -1,7 +1,7 @@
import { writable } from 'svelte/store';
import type { Column } from '$lib/helpers/types';
import type { UserBackupPolicy } from '$lib/helpers/backups';
export const policyPricing = 20; //TODO: get this from the backend
export const showCreatePolicy = writable(false);
export const showCreateBackup = writable(false);
@@ -27,3 +27,11 @@ export const presetPolicies = writable<UserBackupPolicy[]>([
description: 'Runs every day and is retained for 7 days'
}
]);
export const columns = writable<Column[]>([
{ id: 'backups', title: 'Backups', type: 'string', width: { min: 180 } },
{ id: 'size', title: 'Size', type: 'integer', width: { min: 163 } },
{ id: 'status', title: 'Status', type: 'enum', width: { min: 163 } },
{ id: 'policy', title: 'Policy', type: 'string', width: { min: 163 } },
{ id: 'actions', title: '', type: 'string', width: 48 }
]);
@@ -1,34 +1,47 @@
<script lang="ts">
import { Card, DropList, DropListItem, Modal } from '$lib/components';
import { Card, Modal } from '$lib/components';
import { Button, FormList, InputCheckbox, InputText } from '$lib/elements/forms';
import {
TableBody,
TableCell,
TableCellCheck,
TableCellHead,
TableCellHeadCheck,
TableHeader,
TableRow,
TableScroll
} from '$lib/elements/table';
import RestoreModal from './restoreModal.svelte';
import type { PageData } from './$types';
import { timeFromNow, toLocaleDateTime } from '$lib/helpers/date';
import { Pill } from '$lib/elements';
import { sdk } from '$lib/stores/sdk';
import { addNotification } from '$lib/stores/notifications';
import { invalidate } from '$app/navigation';
import { calculateSize } from '$lib/helpers/sizeConvertion';
import { ID } from '@appwrite.io/console';
import { columns } from './store';
import { database } from '../store';
import type { BackupArchive } from '$lib/sdk/backups';
import { Click, trackEvent } from '$lib/actions/analytics';
import { copy } from '$lib/helpers/copy';
import { LabelCard } from '$lib/components/index.js';
import { Dependencies } from '$lib/constants';
import { Badge, FloatingActionBar, Tooltip } from '@appwrite.io/pink-svelte';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { Dependencies } from '$lib/constants';
import {
ActionMenu,
Badge,
FloatingActionBar,
Icon,
Layout,
Popover,
Status,
Table,
Tag,
Tooltip,
Typography
} from '@appwrite.io/pink-svelte';
import {
IconDotsHorizontal,
IconDuplicate,
IconPencil,
IconRefresh,
IconTrash
} from '@appwrite.io/pink-icons-svelte';
import { capitalize } from '$lib/helpers/string';
import Ellipse from './components/Ellipse.svelte';
import Confirm from '$lib/components/confirm.svelte';
export let data: PageData;
let showDelete = false;
@@ -47,8 +60,7 @@
{
id: 'new',
title: 'Restore in new database',
description:
'Duplicate the database from the selected backup version into a new database.'
description: 'Duplicate the database from the selected backup version to a new.'
},
{
id: 'same',
@@ -133,121 +145,124 @@
selectedRestoreOption = 'new';
newDatabaseInfo = { name: null, id: null };
}
$: disableButton =
(selectedRestoreOption === 'new' &&
(!newDatabaseInfo.name || $database.$id === newDatabaseInfo.id)) ||
(selectedRestoreOption === 'same' && !confirmSameDbRestore);
function getBackupStatus(backup: BackupArchive) {
switch (backup.status) {
case 'pending':
return 'pending';
case 'completed':
return 'complete';
case 'uploading':
case 'downloading':
return 'processing';
case 'failed':
return 'failed';
default:
return 'waiting';
}
}
</script>
<TableScroll class="custom-height-table-column">
<TableHeader>
<TableCellHeadCheck
bind:selected={selectedBackups}
pageItemsIds={data.backups.archives.map((b) => b.$id)} />
<TableCellHead width={192}>Backups</TableCellHead>
<TableCellHead width={80}>Size</TableCellHead>
<TableCellHead width={120}>Status</TableCellHead>
<TableCellHead width={120}>Policy</TableCellHead>
<TableCellHead width={48} />
</TableHeader>
<TableBody>
{#each data.backups.archives as backup, index}
{@const policy = policyDetails(backup.policyId)}
{@const retainedUntil = new Date(
new Date(policy?.$createdAt).getTime() + policy?.retention * 24 * 60 * 60 * 1000
)}
{@const formattedRetainedUntil = `${retainedUntil.getDate()} ${retainedUntil.toLocaleString('en-US', { month: 'short' })}, ${retainedUntil.getFullYear()} ${retainedUntil.toLocaleTimeString('en-US', { hour12: false })}`}
<TableRow>
<TableCellCheck id={backup.$id} bind:selectedIds={selectedBackups} />
<TableCell title={backup.$createdAt}>
<DualTimeView time={backup.$createdAt}>
{cleanBackupName(backup)}
</DualTimeView>
</TableCell>
<TableCell title="Backup Size">
{#if backup.status === 'completed'}
{calculateSize(backup.size)}
{:else}
-
{/if}
</TableCell>
<TableCell title="Backup Status">
<div class="u-flex u-gap-8 u-cross-baseline">
<Pill
warning={backup.status === 'pending'}
danger={backup.status === 'failed'}
success={backup.status === 'completed'}>
{backup.status.toLowerCase()}
</Pill>
<!--{#if backup.status === 'Failed'}-->
<!-- <span class="u-underline">Get support</span>-->
<!--{/if}-->
</div>
</TableCell>
<TableCell title="Backup Policy">
<div class="u-flex u-main-space-between u-cross-baseline">
<Tooltip>
<span>
{policy?.name || 'Manual'}
</span>
<span slot="tooltip"
>{policy
? `Retained until: ${formattedRetainedUntil}`
: `Retained forever`}</span>
</Tooltip>
</div>
</TableCell>
<Table.Root let:root allowSelection columns={$columns} bind:selectedRows={selectedBackups}>
<svelte:fragment slot="header" let:root>
{#each $columns as column}
<Table.Header.Cell column={column.id} {root}>{column.title}</Table.Header.Cell>
{/each}
</svelte:fragment>
<TableCell class="last-dropdown-item">
<DropList
class="drop-list-menu"
noArrow
bind:show={showDropdown[index]}
placement="bottom-end">
<button
class="button is-only-icon is-text"
aria-label="More options"
on:click|preventDefault={() => {
showDropdown[index] = !showDropdown[index];
}}>
<span class="icon-dots-horizontal" aria-hidden="true" />
</button>
{#each data.backups.archives as backup, index}
{@const policy = policyDetails(backup.policyId)}
{@const retainedUntil = new Date(
new Date(policy?.$createdAt).getTime() + policy?.retention * 24 * 60 * 60 * 1000
)}
{@const formattedRetainedUntil = `${retainedUntil.getDate()} ${retainedUntil.toLocaleString('en-US', { month: 'short' })}, ${retainedUntil.getFullYear()} ${retainedUntil.toLocaleTimeString('en-US', { hour12: false })}`}
<Table.Row.Base id={backup.$id} {root}>
<Table.Cell column="backups" {root}>
<DualTimeView time={backup.$createdAt}>
{cleanBackupName(backup)}
</DualTimeView>
</Table.Cell>
<Table.Cell column="size" {root}>
{#if backup.status === 'completed'}
{calculateSize(backup.size)}
{:else}
-
{/if}
</Table.Cell>
<Table.Cell column="status" {root}>
{@const backupStatus = getBackupStatus(backup)}
<Status status={backupStatus} label={capitalize(backupStatus)} />
<!--{#if backup.status === 'Failed'}-->
<!-- <span class="u-underline">Get support</span>-->
<!--{/if}-->
</Table.Cell>
<Table.Cell column="policy" {root}>
<div class="u-flex u-main-space-between u-cross-baseline">
<Tooltip maxWidth="fit-content">
<span>
{policy?.name || 'Manual'}
</span>
<span slot="tooltip"
>{policy
? `Retained until: ${formattedRetainedUntil}`
: `Retained forever`}</span>
</Tooltip>
</div>
</Table.Cell>
<Table.Cell column="action" {root}>
<Popover let:toggle padding="m" placement="bottom-end">
<Button extraCompact on:click={toggle}>
<Icon icon={IconDotsHorizontal} />
</Button>
<svelte:fragment slot="list">
<svelte:fragment slot="tooltip" let:toggle>
<ActionMenu.Root width="180px">
{#if backup.status === 'completed'}
<DropListItem
icon="refresh"
on:click={() => {
<ActionMenu.Item.Button
trailingIcon={IconRefresh}
on:click={(e) => {
toggle(e);
showRestore = true;
selectedBackup = backup;
showDropdown[index] = false;
trackEvent(Click.BackupRestoreClick);
}}>
Restore
</DropListItem>
</ActionMenu.Item.Button>
{/if}
<DropListItem
icon="trash"
on:click={() => {
<ActionMenu.Item.Button
status="danger"
trailingIcon={IconTrash}
on:click={(e) => {
toggle(e);
showDelete = true;
selectedBackup = backup;
showDropdown[index] = false;
trackEvent('click_backup_delete');
}}>
Delete
</DropListItem>
<DropListItem
icon="duplicate"
on:click={() => {
</ActionMenu.Item.Button>
<ActionMenu.Item.Button
trailingIcon={IconDuplicate}
on:click={(e) => {
toggle(e);
copy(backup.$id);
showDropdown[index] = false;
}}>
Copy ID
</DropListItem>
</svelte:fragment>
</DropList>
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableScroll>
</ActionMenu.Item.Button>
</ActionMenu.Root>
</svelte:fragment>
</Popover>
</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
{#if selectedBackups.length > 0}
<FloatingActionBar>
@@ -265,11 +280,11 @@
</FloatingActionBar>
{/if}
<Modal
<Confirm
title="Delete {selectedBackups.length ? 'backups' : 'backup'}"
bind:show={showDelete}
bind:open={showDelete}
onSubmit={deleteBackups}>
<p class="text" data-private>
<Typography.Text>
Are you sure you want to delete
{#if selectedBackups.length}
<b>{selectedBackups.length}</b> {selectedBackups.length > 1 ? 'backups' : 'backup'}?
@@ -277,63 +292,53 @@
the <b>{cleanBackupName(selectedBackup)}</b> backup?
{/if}
<br />This action is irreversible.
</p>
<svelte:fragment slot="footer">
<Button text on:click={() => (showDelete = false)}>Cancel</Button>
<Button secondary submit>Delete</Button>
</svelte:fragment>
</Modal>
</Typography.Text>
</Confirm>
<Modal title="Restore backup" bind:show={showRestore} onSubmit={restoreBackup}>
<Card
isTile
class="restore-modal-inner-card u-width-full-line"
style="border-radius: var(--border-radius-small, 8px); padding: 1rem;">
<div class="u-flex u-flex-vertical u-gap-4">
<span class="body-text-2 u-bold darker-neutral-color">
<Card radius="m" padding="s">
<Layout.Stack gap="xxs">
<Typography.Text variant="m-500">
{cleanBackupName(selectedBackup)}
</span>
<div class="u-flex u-cross-center u-gap-6 u-width-full-line">
<span class="u-flex u-cross-center u-gap-4">
<span class="u-color-text-success u-font-size-12"></span> Completed
</span>
<span class="small-ellipse"></span>
{calculateSize(selectedBackup.size)}
<span class="small-ellipse"></span>
</Typography.Text>
<!-- TODO: ellipsis-->
{timeFromNow(selectedBackup.$createdAt)}
</div>
</div>
<Typography.Caption variant="500">
<Layout.Stack direction="row" gap="xs">
<Ellipse color="var(--bgcolor-success)" /> Completed
<Ellipse size="s" />
{calculateSize(selectedBackup.size)}
<Ellipse size="s" />
{timeFromNow(selectedBackup.$createdAt)}
</Layout.Stack>
</Typography.Caption>
</Layout.Stack>
</Card>
<FormList>
<div class="u-flex u-flex-vertical-mobile u-gap-16">
<Layout.Stack direction="row" gap="l">
{#each restoreOptions as restoreOption}
<div class="u-width-full-line">
<LabelCard
padding="s"
name="restore"
value={restoreOption.id}
bind:group={selectedRestoreOption}>
<svelte:fragment slot="custom">
<svelte:fragment slot="title">
<div class="u-flex u-flex-vertical u-gap-4 u-width-full-line">
<h4 class="body-text-2 u-bold">
{restoreOption.title}
</h4>
<p class="u-color-text-offline u-small">
{restoreOption.description}
</p>
</div>
</svelte:fragment>
<p class="u-color-text-offline u-small">
{restoreOption.description}
</p>
</LabelCard>
</div>
{/each}
</div>
</Layout.Stack>
{#if selectedRestoreOption === 'new'}
<div class="u-flex-vertical u-gap-8">
<Layout.Stack gap="s" alignItems="flex-start">
<InputText
id="name"
label="Database name"
@@ -342,75 +347,33 @@
autofocus
required />
{#if !showCustomId}
<div>
<Pill button on:click={() => (showCustomId = !showCustomId)}
><span class="icon-pencil" aria-hidden="true" /><span class="text">
Database ID
</span></Pill>
</div>
<Tag
size="s"
on:click={() => {
showCustomId = true;
}}><Icon icon={IconPencil} /> Database ID</Tag>
{:else}
<div class="u-flex u-flex-vertical u-gap-8">
<RestoreModal
autofocus={false}
name="Database"
bind:show={showCustomId}
databaseId={$database.$id}
bind:id={newDatabaseInfo.id} />
</div>
<RestoreModal
autofocus={false}
name="Database"
bind:show={showCustomId}
databaseId={$database.$id}
bind:id={newDatabaseInfo.id} />
{/if}
</div>
</Layout.Stack>
{:else}
<div class="input-check-box-friction">
<InputCheckbox
required
size="s"
id="delete_policy"
bind:checked={confirmSameDbRestore}>
<svelte:fragment slot="description">
<span style="margin-block-start: 1px;">
Overwrite <b>{$database.name}</b> with the selected backup version
</span>
</svelte:fragment>
</InputCheckbox>
</div>
<InputCheckbox
required
size="s"
id="delete_policy"
bind:checked={confirmSameDbRestore}
label="Overwrite '{$database.name}' with the selected backup version">
</InputCheckbox>
{/if}
</FormList>
<svelte:fragment slot="footer">
<Button text on:click={() => (showRestore = false)}>Cancel</Button>
<Button submit>Restore</Button>
<Button submit disabled={disableButton}>Restore</Button>
</svelte:fragment>
</Modal>
<style lang="scss">
:global(.custom-height-table-column .table-col) {
height: 54px;
padding: 0 1rem; /* removes vertical padding for constrained height */
}
:global(.restore-modal-inner-card) {
background: hsl(var(--color-neutral-5));
border: 1px solid hsl(var(--color-neutral-10));
}
:global(.theme-dark .restore-modal-inner-card) {
background: hsl(var(--color-neutral-85));
border: 1px solid hsl(var(--color-neutral-80));
}
// centers item horizontally!
:global(.last-dropdown-item div) {
margin: auto;
}
.actions {
.indicator {
border-radius: 0.25rem;
background: hsl(var(--color-information-100));
color: hsl(var(--color-neutral-0));
padding: 0rem 0.375rem;
display: inline-block;
}
}
</style>
@@ -3,7 +3,7 @@
import { base } from '$app/paths';
import { page } from '$app/stores';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Id, Modal } from '$lib/components';
import { Id } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { toLocaleDateTime } from '$lib/helpers/date';
@@ -47,7 +47,10 @@
?.map((policy) => getPolicyDescription(policy.schedule))
.join(', ')}
<Tooltip placement="bottom" disabled={!policies || !lastBackup}>
<Tooltip
placement="bottom"
disabled={!policies || !lastBackup}
maxWidth="fit-content">
<span class="u-trim">
{#if !policies}
<span class="icon-exclamation" /> No backup policies
@@ -55,7 +58,9 @@
{description}
{/if}
</span>
<span slot="tooltip">{`Last backup: ${lastBackup}`}</span>
<span slot="tooltip">
{`Last backup: ${lastBackup}`}
</span>
</Tooltip>
{:else}
{toLocaleDateTime(database[column.id])}