add: backup route, page and components.

This commit is contained in:
ItzNotABug committed 2024-09-05 17:57:08 +05:30
1 parent 3ebde29722
commit ad2f006822
12 files changed
+1114 -16

No files matched your search

+12 -8
View File
@@ -7,6 +7,7 @@
import { trackEvent } from '$lib/actions/analytics';
export let single = false;
export let card = true;
export let noMedia = false;
export let target: string = null;
export let href: string = null;
@@ -22,7 +23,7 @@
</script>
{#if single}
<article class="card u-grid u-cross-center u-width-full-line common-section">
<article class:card class="u-grid u-cross-center u-width-full-line common-section">
<div
class="u-flex u-flex-vertical u-cross-center u-gap-24 u-width-full-line u-overflow-hidden u-padding-block-8">
{#if !noMedia}
@@ -31,11 +32,13 @@
on:click|preventDefault
on:click={track}
aria-label="create {target}">
{#if $app.themeInUse === 'dark'}
<img src={EmptyDark} alt="create" aria-hidden="true" height="242" />
{:else}
<img src={EmptyLight} alt="create" aria-hidden="true" height="242" />
{/if}
<slot name="empty-media">
{#if $app.themeInUse === 'dark'}
<img src={EmptyDark} alt="create" aria-hidden="true" height="242" />
{:else}
<img src={EmptyLight} alt="create" aria-hidden="true" height="242" />
{/if}
</slot>
</button>
{/if}
<slot>
@@ -54,7 +57,7 @@
text
event="empty_documentation"
ariaLabel="create {target}">Documentation</Button>
<Button secondary on:click on:click={track}>
<Button secondary on:click={track}>
Create {target}
</Button>
</div>
@@ -67,7 +70,8 @@
on:click={track}
aria-label="create"
type="button"
class="card u-grid u-cross-center u-width-full-line dashed"
class:card
class="u-grid u-cross-center u-width-full-line dashed"
class:common-section={marginTop}>
<div class="u-flex u-cross-center u-flex-vertical u-main-center u-flex">
<div class="common-section">
+2
View File
@@ -8,7 +8,9 @@
<div class="grid-item-1-start-start">
<div class="eyebrow-heading-3"><slot name="eyebrow" /></div>
<Heading tag="h2" size="7"><slot name="title" /></Heading>
<div class="u-padding-block-start-4"><slot name="subtitle" /></div>
</div>
<div class="grid-item-1-start-end">
<slot name="status" />
</div>
@@ -1,24 +1,87 @@
import { CARD_LIMIT, Dependencies } from '$lib/constants';
import { getLimit, getPage, getView, pageToOffset, View } from '$lib/helpers/load';
import { sdk } from '$lib/stores/sdk';
import { Query } from '@appwrite.io/console';
import { type Models, Query } from '@appwrite.io/console';
import { timeFromNow } from '$lib/helpers/date';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ url, route, depends }) => {
depends(Dependencies.DATABASES);
const page = getPage(url);
const limit = getLimit(url, route, CARD_LIMIT);
const view = getView(url, route, View.Grid);
const offset = pageToOffset(page, limit);
const { databases, policies, lastBackups } = await fetchDatabasesAndBackups(limit, offset);
return {
offset,
limit,
view,
databases: await sdk.forProject.databases.list([
Query.limit(limit),
Query.offset(offset),
Query.orderDesc('')
])
policies,
databases,
lastBackups
};
};
// TODO: @itznotabug we should improve this!
async function fetchDatabasesAndBackups(limit: number, offset: number) {
const databases = await sdk.forProject.databases.list([
Query.limit(limit),
Query.offset(offset),
Query.orderDesc('')
]);
const policies = await fetchPolicies(databases);
const lastBackups = await fetchLastBackups(databases);
return { databases, policies, lastBackups };
}
async function fetchPolicies(databases: Models.DatabaseList) {
const databasePolicies: Record<string, boolean> = {};
await Promise.all(
databases.databases.map(async (database) => {
try {
const { policies } = await sdk.forProject.backups.listPolicies([
Query.limit(1),
Query.equal('resourceId', database.$id)
]);
if (policies.length > 0) {
databasePolicies[database.$id] = true;
}
} catch (e) {
// ignore
}
})
);
return databasePolicies;
}
async function fetchLastBackups(databases: Models.DatabaseList) {
const lastBackups: Record<string, string> = {};
await Promise.all(
databases.databases.map(async (database) => {
try {
const { archives } = await sdk.forProject.backups.listArchives([
Query.limit(1),
Query.orderDesc('$createdAt'),
Query.equal('resourceId', database.$id)
]);
if (archives.length > 0) {
lastBackups[database.$id] = timeFromNow(archives[0].$createdAt);
}
} catch (e) {
// ignore
}
})
);
return lastBackups;
}
@@ -1,12 +1,16 @@
<script lang="ts">
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { Modal, CustomId } from '$lib/components';
import { Modal, CustomId, Alert } from '$lib/components';
import { Pill } from '$lib/elements';
import { Button, InputText, FormList } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { ID } from '@appwrite.io/console';
import { createEventDispatcher } from 'svelte';
import { isCloud } from '$lib/system';
import { BillingPlan } from '$lib/constants';
import { organization } from '$lib/stores/organization';
import { upgradeURL } from '$lib/stores/billing';
export let showCreate = false;
@@ -14,7 +18,8 @@
let name = '';
let id: string = null;
let showCustomId = true;
let showCustomId = false;
let showPlanUpgradeAlert = true;
const create = async () => {
try {
@@ -37,6 +42,10 @@
trackError(error, Submit.DatabaseCreate);
}
};
$: if (!showCreate) {
showPlanUpgradeAlert = true;
}
</script>
<Modal title="Create database" size="big" onSubmit={create} bind:show={showCreate}>
@@ -59,6 +68,20 @@
{:else}
<CustomId bind:show={showCustomId} name="Database" bind:id autofocus={false} />
{/if}
{#if isCloud && $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 manage database backup policies
<svelte:fragment slot="buttons">
<Button href={$upgradeURL} text>Upgrade plan</Button>
</svelte:fragment>
</Alert>
{/if}
{:else}
Backup Policies Tabs here
{/if}
</FormList>
<svelte:fragment slot="footer">
<Button secondary on:click={() => (showCreate = false)}>Cancel</Button>
@@ -0,0 +1,159 @@
<script lang="ts">
import { Alert, Empty, Modal, PaginationWithLimit } from '$lib/components';
import { Container } from '$lib/layout';
import ContainerHeader from './containerHeader.svelte';
import BackupPolicy from './policy.svelte';
import UpgradeCard from './upgradeCard.svelte';
import Table from './table.svelte';
import type { PageData } from './$types';
import CreatePolicy from './createPolicy.svelte';
import { Button } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { invalidate } from '$app/navigation';
import { BillingPlan, Dependencies } from '$lib/constants';
import { isCloud, isSelfHosted } from '$lib/system';
import { organization } from '$lib/stores/organization';
import LockedBackupsDarkDesktop from '$lib/images/backups-locked-dark.svg';
import LockedBackupsLightDesktop from '$lib/images/backups-locked-light.svg';
import LockedBackupsDarkMobile from '$lib/images/backups-locked-mobile-dark.svg';
import LockedBackupsLightMobile from '$lib/images/backups-locked-mobile-light.svg';
import { app } from '$lib/stores/app';
let showCreatePolicy = false;
let showCreateManualBackup = false;
let isDisabled = isSelfHosted || (isCloud && $organization?.billingPlan === BillingPlan.FREE);
export let data: PageData;
const createManualBackup = async () => {
try {
await sdk.forProject.backups.createArchive(['databases'], data.database.$id);
addNotification({
type: 'success',
message: 'Backup successfully created'
});
invalidate(Dependencies.BACKUPS);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
} finally {
showCreateManualBackup = false;
}
};
</script>
<Container size="xxl">
<!-- TODO: fix/implement layout for mobile -->
<div class="u-flex u-gap-32">
{#if !isDisabled}
<div class="u-flex-vertical">
<ContainerHeader
title="Policies"
buttonText="Create policy"
buttonEvent="create_backup"
buttonType="secondary"
buttonDisabled={isDisabled}
buttonMethod={() => (showCreatePolicy = true)} />
<BackupPolicy policies={data.policies} bind:showCreatePolicy />
</div>
<div class="u-flex-vertical u-width-full-line">
<ContainerHeader
title="Backups"
buttonText="Manual Backup"
buttonEvent="create_backup"
buttonType="secondary"
buttonDisabled={isDisabled}
buttonMethod={() => (showCreateManualBackup = true)} />
{#if data.backups.total}
<div class="u-padding-block-start-8">
<Table {data} />
{#if data.backups.total > 6}
<PaginationWithLimit
name="Backups"
limit={data.limit}
offset={data.offset}
total={data.backups.total} />
{/if}
</div>
{:else}
<Empty single on:click={() => (showCreateManualBackup = true)}>
<div class="u-text-center">
<div class="body-text-2 u-bold darker-neutral-color">
Create a backup to get started.
</div>
<p class="body-text-2 u-margin-block-start-4 policy-item-caption">
Need a hand? Learn more in our documentation.
</p>
</div>
<div class="u-flex u-gap-16 u-main-center">
<Button
text
external
event="empty_documentation"
href="https://appwrite.io/docs/products/databases/backups"
ariaLabel="create backup"
>Documentation
</Button>
<Button
event="create_policy"
class="small-radius-border-button"
on:click={() => (showCreateManualBackup = true)}>
<span class="icon-plus" aria-hidden="true" />
<span class="text">Create Backup</span>
</Button>
</div>
</Empty>
{/if}
</div>
{:else}
<div class="u-flex-vertical u-gap-32">
<UpgradeCard />
<img
class="is-not-mobile"
src={$app.themeInUse === 'dark'
? LockedBackupsDarkDesktop
: LockedBackupsLightDesktop}
alt="create"
aria-hidden="true"
style="opacity: 0.5" />
<img
class="is-only-mobile"
src={$app.themeInUse === 'dark'
? LockedBackupsDarkMobile
: LockedBackupsLightMobile}
alt="create"
aria-hidden="true"
style="opacity: 0.5" />
</div>
{/if}
</div>
</Container>
<CreatePolicy bind:showCreate={showCreatePolicy} />
<Modal title="Create manual backup" bind:show={showCreateManualBackup}>
<p class="text" data-private>
Manual backups are <b>retained forever</b> unless manually deleted. Use them when making significant
changes to your data structure or as a safeguard for future rollbacks.
</p>
<Alert type="info">
<svelte:fragment slot="title">
Depending on the size of your data, this may take a while.
</svelte:fragment>
</Alert>
<svelte:fragment slot="footer">
<Button text on:click={() => (showCreateManualBackup = false)}>Cancel</Button>
<Button submit on:click={createManualBackup}>Create</Button>
</svelte:fragment>
</Modal>
@@ -0,0 +1,47 @@
import { getLimit, getPage, getView, pageToOffset, View } from '$lib/helpers/load';
import type { PageLoad } from './$types';
import { CARD_LIMIT, Dependencies } from '$lib/constants';
import { sdk } from '$lib/stores/sdk';
import { type Models, Query } from '@appwrite.io/console';
export const load: PageLoad = async ({ params, url, route, depends }) => {
depends(Dependencies.BACKUPS);
const page = getPage(url);
const limit = getLimit(url, route, CARD_LIMIT);
const view = getView(url, route, View.Grid);
const offset = pageToOffset(page, limit);
let backups: Models.BackupArchiveList = { total: 0, archives: [] };
let policies: Models.BackupPolicyList = { total: 0, policies: [] };
try {
[backups, policies] = await Promise.all([
await sdk.forProject.backups.listArchives([
Query.limit(limit),
Query.offset(offset),
Query.orderDesc(''),
Query.equal('resourceType', 'database'),
Query.equal('resourceId', params.database)
]),
await sdk.forProject.backups.listPolicies([
Query.limit(limit),
Query.offset(offset),
Query.orderDesc('')
// TODO: add queries once supported.
// Query.equal('resourceType', 'database'),
// Query.equal('resourceId', params.database),
])
]);
} catch (e) {
// ignore
}
return {
offset,
limit,
view,
backups,
policies
};
};
@@ -0,0 +1,44 @@
<script lang="ts">
import { Button } from '$lib/elements/forms';
export let isFlex = true;
export let title: string;
export let buttonText: string = null;
export let buttonMethod: () => void = null;
export let buttonEvent: string = buttonText?.toLocaleLowerCase();
export let buttonDisabled = false;
export let buttonType: 'primary' | 'secondary' | 'text' = 'primary';
</script>
<header 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-16">
<div class="body-text-1 u-bold backups-title">{title}</div>
</div>
<Button
event={buttonEvent}
on:click={buttonMethod}
disabled={buttonDisabled}
text={buttonType === 'text'}
class="small-radius-border-button"
secondary={buttonType === 'secondary'}>
<span class="icon-plus" aria-hidden="true" />
<span class="body-text-1">{buttonText}</span>
</Button>
</header>
<style>
: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>
@@ -0,0 +1,254 @@
<script lang="ts">
import { Modal } from '$lib/components';
import {
Button,
FormList,
InputRadio,
InputSelect,
InputSelectCheckbox,
InputText,
InputTime,
Label
} from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { capitalize } from '$lib/helpers/string';
import { backupRetainingOptions, database } from '../store';
import { sdk } from '$lib/stores/sdk';
import { ID } from '@appwrite.io/console';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
export let showCreate = false;
// TODO: Layout design for modal has changed, make changes.
// TODO: improve options below.
const generateHourlyOptions = (start: number, end: number) =>
Array.from({ length: end - start + 1 }, (_, i) => ({
value: `${i + start}`,
label: `${i + start}`
}));
const generateWeeklyOptions = () =>
['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'].map(
(day, index) => ({
label: day,
value: day,
index: index,
checked: false
})
);
const backupFrequencies = {
monthly: [
{ value: 'first', label: 'First day of month', day: '1' },
{ value: 'middle', label: 'Middle of month (15th)', day: '15' },
{ value: 'end', label: 'End of month (28th)', day: '28' }
],
weekly: generateWeeklyOptions(),
hourly: generateHourlyOptions(1, 12)
};
let error: string;
let selectedTime: string = '00:00';
let backupPolicyName: string = null;
let policyFrequency: string = 'monthly';
let daysSelectionArray: string[] = [];
let policyRetention: number = 30;
let backupFrequency: string = 'end';
const generateCronExpression = () => {
const [hour, minute] = selectedTime.split(':');
let dayOfWeek = '*';
let dayOfMonth = '*';
if (policyFrequency === 'monthly') {
// Default to 28th of every month.
const selectedDay = backupFrequencies.monthly.find(
(option) => option.value === backupFrequency
)?.day;
dayOfMonth = selectedDay || '28';
} else if (policyFrequency === 'weekly') {
const selectedDays = daysSelectionArray
.map(dayLabel => backupFrequencies.weekly.find(option => option.label === dayLabel)?.index)
.filter(index => index !== undefined)
.join(',');
// Default to Monday (1)
dayOfWeek = selectedDays || '1';
} else if (policyFrequency === 'hourly') {
return `0 * * * *`;
}
return `${minute} ${hour} ${dayOfMonth} * ${dayOfWeek}`;
};
// TODO: reset values on modal close.
const createPolicy = async () => {
try {
const cronExpression = generateCronExpression();
console.log(cronExpression);
await sdk.forProject.backups.createPolicy(
ID.unique(),
['databases'],
policyRetention,
cronExpression,
backupPolicyName,
$database.$id,
true
);
error = null;
showCreate = false;
addNotification({
type: 'success',
message: `${backupPolicyName} backup policy has been created`
});
invalidate(Dependencies.BACKUPS);
} catch (err) {
addNotification({
type: 'error',
message: err.message
});
}
};
$: backupScheduleText = () => {
const timeFormatted = formatTime24to12(selectedTime);
switch (policyFrequency) {
case 'hourly':
return 'A backup will run every hour.';
case 'daily':
return `A backup will run daily at ${timeFormatted}.`;
case 'weekly': {
const days = daysSelectionArray.join(', ');
return `A backup will run weekly on ${days} at ${timeFormatted}.`;
}
case 'monthly': {
const monthDay = backupFrequencies[policyFrequency].find(option => option.value === backupFrequency).label.toLowerCase();
return `A backup will run monthly on the ${monthDay} at ${timeFormatted}.`;
}
default:
return 'A backup schedule is not set.';
}
};
function formatTime24to12(time24: string) {
const [hour, minute] = time24.split(':');
const hourInt = parseInt(hour, 10);
const suffix = hourInt >= 12 ? 'PM' : 'AM';
const hour12 = ((hourInt + 11) % 12 + 1);
return `${hour12}:${minute} ${suffix}`;
}
</script>
<Modal
title="Create backup policy"
size="big"
bind:show={showCreate}
onSubmit={createPolicy}
bind:error>
<FormList>
<div class="u-flex-vertical u-gap-8">
<Label>Frequency</Label>
<div class="u-flex u-gap-16 input-radio-group">
{#each ['hourly', 'daily', 'weekly', 'monthly'] as freq}
<InputRadio
label={freq.charAt(0).toUpperCase() + freq.slice(1)}
id={freq}
bind:group={policyFrequency}
value={freq}
name="radio" />
{/each}
</div>
</div>
<div class="u-flex-vertical u-gap-8">
<div class="u-flex u-gap-8 time-holder">
{#if policyFrequency === 'monthly'}
<InputSelect
id="monthly"
label="Monthly Timing"
bind:value={backupFrequency}
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>
<InputSelectCheckbox
name="Timing"
bind:tags={daysSelectionArray}
placeholder="Select the weekdays for backup"
options={backupFrequencies[policyFrequency].map((option) => ({
...option,
checked: daysSelectionArray.includes(option.value)
}))} />
</div>
{/if}
{#if policyFrequency !== 'hourly'}
<div class:u-margin-block-start-4={policyFrequency !== 'daily'}>
<InputTime
id="time"
bind:value={selectedTime}
label={policyFrequency === 'daily' ? 'Timing' : ''} />
</div>
{/if}
</div>
<span>{backupScheduleText()}</span>
</div>
<div class="u-flex-vertical u-gap-8">
<InputSelect
fullWidth
id="retention"
label="Keep for"
placeholder="3 months"
bind:value={policyRetention}
options={backupRetainingOptions} />
<span>
{#if policyRetention === 365 * 100}
Every backup created under this policy will be retained <b>forever</b>.
{:else}
Every backup created under this policy will be retained for <b
>{backupRetainingOptions.find((option) => option.value === policyRetention)
?.label ?? '3 months'}</b> before being automatically deleted.
{/if}
</span>
</div>
<InputText
id="name"
label="Policy name"
placeholder="{capitalize(policyFrequency)} backup"
autofocus
bind:value={backupPolicyName}
required />
</FormList>
<svelte:fragment slot="footer">
<Button secondary on:click={() => (showCreate = false)}>Cancel</Button>
<Button submit>Create</Button>
</svelte:fragment>
</Modal>
<style>
.input-radio-group :global(.input-text-wrapper) {
gap: 0.5rem;
display: flex;
}
</style>
@@ -0,0 +1,237 @@
<script lang="ts">
import Card from '$lib/components/card.svelte';
import { DropList, DropListItem, Empty, Modal } from '$lib/components';
import { Button } from '$lib/elements/forms/index';
import { type Models } from '@appwrite.io/console';
import { app } from '$lib/stores/app';
import EmptyDark from '$lib/images/backups-empty-dark.svg';
import EmptyLight from '$lib/images/backups-empty-light.svg';
import { sdk } from '$lib/stores/sdk';
import { addNotification } from '$lib/stores/notifications';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { parseExpression } from 'cron-parser';
import { toLocaleDateTime } from '$lib/helpers/date';
let showDropdown = [];
let showDelete = false;
let selectedPolicy: Models.BackupPolicy = null;
export let showCreatePolicy = false;
export let policies: Models.BackupPolicyList;
async function deletePolicy() {
try {
await sdk.forProject.backups.deletePolicy(selectedPolicy.$id);
addNotification({
type: 'success',
message: 'Backup policy deleted'
});
invalidate(Dependencies.BACKUPS);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
} finally {
showDelete = false;
selectedPolicy = null;
}
}
const formatRetentionMessage = (retention: number): string => {
const foreverValue = 36500; // 365 * 100
if (retention === foreverValue) return 'Retained forever';
if (retention % 365 === 0)
return `Retained for ${retention / 365} year${retention > 365 ? 's' : ''}`;
if (retention % 30 === 0)
return `Retained for ${retention / 30} month${retention > 30 ? 's' : ''}`;
if (retention % 7 === 0)
return `Retained for ${retention / 7} week${retention > 7 ? 's' : ''}`;
return `Retained for ${retention} day${retention > 1 ? 's' : ''}`;
};
</script>
<Card
class="u-margin-block-start-24"
style="--card-padding: 1rem; --card-padding-mobile: 2rem; min-width: 21rem;">
<div class="u-flex-vertical-mobile">
{#each policies.policies as policy, index}
<!-- TODO: check card paddings later -->
<div
class="policy-card-item-padding u-flex-vertical u-gap-10"
class:u-padding-block-start-10={index !== 0}
class:u-sep-block-end={index !== policies.total - 1}
style:margin-block={index !== 0 ? '6px' : ''}>
<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 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>
<svelte:fragment slot="list">
<DropListItem
on:click={() => {
showDelete = true;
selectedPolicy = policy;
showDropdown[index] = false;
}}>
Delete
</DropListItem>
</svelte:fragment>
</DropList>
</div>
<div class="policy-item-subtitles u-flex u-gap-6">
<!-- -->
<span class="small-ellipse">●</span>
{formatRetentionMessage(policy.retention)}
</div>
</div>
<!-- Previous and Next section -->
<div
class="policy-cycles u-flex u-cross-start u-padding-block-2 policy-item-subtitles">
<!-- TODO: Need to get previous backup date from API -->
<!-- <div 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 style="font-size: 1.25rem; color: hsl(var(&#45;&#45;color-success-100));">-->
<!-- ●-->
<!-- </span>-->
<!-- &lt;!&ndash; TODO: get previous backup from this policy if exists! &ndash;&gt;-->
<!-- &lt;!&ndash;{toLocaleDateTime(parseExpression(policy.schedule, { utc: true }).prev().toString())}&ndash;&gt;-->
<!-- </div>-->
<!-- </div>-->
<!-- <div class="u-border-vertical" />-->
<div 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">
<span style="font-size: 1.25rem; color: hsl(var(--color-success-100));">
●
</span>
{toLocaleDateTime(
parseExpression(policy.schedule, { utc: false }).next().toString()
)}
</div>
</div>
</div>
</div>
{:else}
<Empty single card={false} target="backup" on:click={() => (showCreatePolicy = true)}>
<div slot="empty-media">
{#if $app.themeInUse === 'dark'}
<img src={EmptyDark} alt="create" aria-hidden="true" height="242" />
{:else}
<img src={EmptyLight} alt="create" aria-hidden="true" height="242" />
{/if}
</div>
<div class="u-text-center">
<div class="body-text-2 u-bold darker-neutral-color">
Ensure your data stays safe
</div>
<p class="body-text-2 u-margin-block-start-4 policy-item-caption">
Create a backup policy to automate regular and secure data protection.
</p>
</div>
<div class="u-flex u-gap-16 u-main-center">
<Button
event="create_policy"
class="small-radius-border-button"
on:click={() => (showCreatePolicy = true)}>
<span class="icon-plus" aria-hidden="true" />
<span class="text">Create Policy</span>
</Button>
</div>
</Empty>
{/each}
</div>
</Card>
<Modal
title="Delete policy"
icon="exclamation"
state="warning"
bind:show={showDelete}
headerDivider={false}>
<p class="text" data-private>
Are you sure you want to delete <b>{selectedPolicy.name}</b> policy?
<br />This action is irreversible.
</p>
<svelte:fragment slot="footer">
<Button text on:click={() => (showDelete = false)}>Cancel</Button>
<Button secondary submit on:click={deletePolicy}>Delete</Button>
</svelte:fragment>
</Modal>
<style>
.u-border-vertical {
width: 1px;
height: 34px;
margin: 0 1.5rem;
background-color: hsl(var(--color-border));
}
:global(.small-ellipse) {
font-size: 0.5rem !important;
}
:global(.u-gap-6) {
gap: 0.375rem !important;
}
.u-gap-10 {
gap: 0.625rem !important;
}
.policy-card-item-padding {
/*padding-inline: var(--space-3, 6px) var(--space-4, 8px);*/
}
.u-padding-block-start-10 {
padding-block-start: 10px !important;
}
.policy-item-subtitles {
font-size: 12px;
font-weight: 400;
line-height: 150%;
font-style: normal;
font-family: Inter;
}
.policy-cycles {
margin-block: 0.625rem !important;
}
:global(.theme-light .policy-item-subtitles) {
color: var(--color-fgColor-neutral-secondary, #56565c);
}
:global(.theme-light .policy-item-caption) {
color: var(--color-neutral-50, #818186);
}
:global(.theme-light .darker-neutral-color) {
color: var(--color-neutral-80, #414146);
}
</style>
@@ -0,0 +1,240 @@
<script lang="ts">
import { Card, CustomId, DropList, DropListItem, Modal } from '$lib/components';
import { Button, FormList, Helper, InputText } from '$lib/elements/forms';
import {
TableBody,
TableCell,
TableCellHead,
TableHeader,
TableRow,
TableScroll
} from '$lib/elements/table';
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 { Dependencies } from '$lib/constants';
import { calculateSize } from '$lib/helpers/sizeConvertion';
import { ID, type Models } from '@appwrite.io/console';
import { page } from '$app/stores';
export let data: PageData;
const databaseId = $page.params.database;
let showDelete = false;
let selectedBackup: Models.BackupArchive = null;
let showDropdown = [];
let error = null;
let showRestore = false;
let showCustomId = false;
let newDatabaseInfo: { name: string; id: string } = { name: null, id: null };
const deleteBackup = async () => {
try {
await sdk.forProject.backups.deleteArchive(selectedBackup.$id);
addNotification({
type: 'success',
message: 'Backup delete'
});
invalidate(Dependencies.BACKUPS);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
} finally {
showDelete = false;
}
};
const restoreBackup = async () => {
if (newDatabaseInfo.id === databaseId) {
error = 'Database ID must be different from the one being restored';
return;
}
try {
await sdk.forProject.backups.createRestoration(
selectedBackup.$id,
['databases'],
newDatabaseInfo.id ?? ID.unique(),
newDatabaseInfo.name
);
addNotification({
type: 'success',
message: 'Backup restore initiated'
});
invalidate(Dependencies.BACKUPS);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
} finally {
showRestore = false;
}
};
$: cleanBackupName = (backup: Models.BackupArchive) =>
toLocaleDateTime(backup.$createdAt).replaceAll(',', '');
</script>
<TableScroll>
<TableHeader>
<TableCellHead width={180}>Backups</TableCellHead>
<TableCellHead width={176}>Size</TableCellHead>
<TableCellHead width={178}>Size</TableCellHead>
<TableCellHead width={176}>Policy</TableCellHead>
</TableHeader>
<TableBody>
{#each data.backups.archives as backup, index}
<TableRow>
<TableCell width={180} title={backup.$createdAt}>
{cleanBackupName(backup)}
</TableCell>
<TableCell width={164} title="Backup Size">
{#if backup.status === 'completed'}
{calculateSize(backup.size)}
{:else}
-
{/if}
</TableCell>
<TableCell width={163} 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 width={163} title="Backup Policy">
<div class="u-flex u-main-space-between u-cross-baseline">
{backup.policyId ? backup.policyId : 'Manual'}
<DropList 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>
<svelte:fragment slot="list">
<DropListItem
on:click={() => {
showRestore = true;
selectedBackup = backup;
showDropdown[index] = false;
}}>
Restore
</DropListItem>
<DropListItem
on:click={() => {
showDelete = true;
selectedBackup = backup;
showDropdown[index] = false;
}}>
Delete
</DropListItem>
</svelte:fragment>
</DropList>
</div>
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableScroll>
<Modal
title="Delete backup"
icon="exclamation"
state="warning"
bind:show={showDelete}
headerDivider={false}>
<p class="text" data-private>
Are you sure you want to delete <b>{cleanBackupName(selectedBackup)}</b>?
<br />This action is irreversible.
</p>
<svelte:fragment slot="footer">
<Button text on:click={() => (showDelete = false)}>Cancel</Button>
<Button secondary on:click={deleteBackup}>Delete</Button>
</svelte:fragment>
</Modal>
<Modal title="Restore backup" bind:show={showRestore}>
<p class="text" data-private>
Restoring this backup will duplicate the database from the selected backup version. This
action may take a while.
</p>
<Card isTile 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">
{cleanBackupName(selectedBackup)}
</span>
<div class="u-flex u-cross-center u-gap-6">
<span>
<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>
Created {timeFromNow(selectedBackup.$createdAt)}
</div>
</div>
</Card>
<FormList>
<InputText
id="name"
label="Name"
placeholder="Enter database name"
bind:value={newDatabaseInfo.name}
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>
{:else}
<div class="u-flex u-flex-vertical u-gap-8">
<CustomId
bind:show={showCustomId}
name="Database"
bind:id={newDatabaseInfo.id}
autofocus={false} />
{#if error}
<Helper type="warning">{error}</Helper>
{/if}
</div>
{/if}
</FormList>
<svelte:fragment slot="footer">
<Button text on:click={() => (showRestore = false)}>Cancel</Button>
<Button submit on:click={restoreBackup}>Restore</Button>
</svelte:fragment>
</Modal>
<style lang="scss">
.icon-dots-horizontal {
font-size: 1.5rem;
}
</style>
@@ -12,3 +12,13 @@ export const columns = writable<Column[]>([
{ id: '$createdAt', title: 'Created', type: 'datetime', show: true, width: 120 },
{ id: '$updatedAt', title: 'Updated', type: 'datetime', show: true, width: 120 }
]);
export const backupRetainingOptions = [
{ label: '3 Days', value: 3 },
{ label: '1 Week', value: 7 },
{ label: '2 Weeks', value: 14 },
{ label: '1 Month', value: 30 },
{ label: '3 Months', value: 90 },
{ label: '1 Year', value: 365 },
{ label: 'Forever', value: 365 * 100 }
];
@@ -16,6 +16,15 @@
{#each data.databases.databases as database}
<GridItem1 href={`${base}/project-${project}/databases/database-${database.$id}`}>
<svelte:fragment slot="title">{database.name}</svelte:fragment>
<svelte:fragment slot="subtitle">
{#if data.lastBackups && data.lastBackups[database.$id]}
Last backup: {data.lastBackups[database.$id]}
{:else if !data.policies || !data.policies[database.$id]}
<span class="icon-exclamation" /> No backup policies
{:else}
Last backup: No backups yet
{/if}
</svelte:fragment>
<Id value={database.$id}>{database.$id}</Id>
</GridItem1>
{/each}
@@ -23,3 +32,9 @@
<p>Create a database</p>
</svelte:fragment>
</CardContainer>
<style>
.icon-exclamation {
color: hsl(var(--color-warning-100)) !important;
}
</style>