Merge pull request #1335 from ItzNotABug/feat-database-backups

Feat: Database Backups
This commit is contained in:
Jake Barnby
2024-10-10 17:15:05 +13:00
committed by GitHub
57 changed files with 3637 additions and 55 deletions
@@ -0,0 +1,46 @@
<script lang="ts">
import { page } from '$app/stores';
import { BillingPlan } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { organization } from '$lib/stores/organization';
import { HeaderAlert } from '$lib/layout';
import { isCloud } from '$lib/system';
import { upgradeURL } from '$lib/stores/billing';
import { hideNotification } from '$lib/helpers/notifications';
import { backupsBannerId, showPolicyAlert } from '$lib/stores/database';
function handleClose() {
showPolicyAlert.set(false);
hideNotification(backupsBannerId);
}
</script>
{#if $showPolicyAlert && isCloud && $organization?.$id && $page.url.pathname.match(/\/databases\/database-[^/]+$/)}
{@const isFreePlan = $organization?.billingPlan === BillingPlan.FREE}
{@const subtitle = isFreePlan
? 'Upgrade your plan to ensure your data stays safe and backed up'
: 'Protect your data by quickly adding a backup policy'}
{@const ctaText = isFreePlan ? 'Upgrade plan' : 'Add backup'}
{@const ctaURL = isFreePlan ? $upgradeURL : `${$page.url.pathname}/backups`}
<HeaderAlert type="warning" title="Your database has no backup policy">
<svelte:fragment>{subtitle}</svelte:fragment>
<svelte:fragment slot="buttons">
<div class="u-flex u-gap-16">
<Button
href={ctaURL}
secondary
fullWidthMobile
event={isFreePlan ? 'backup_banner_upgrade' : 'backup_banner_add'}>
<span class="text">{ctaText}</span>
</Button>
<Button text on:click={handleClose} event="backup_banner_close">
<span class="icon-x" aria-hidden="true"></span>
</Button>
</div>
</svelte:fragment>
</HeaderAlert>
{/if}
+257
View File
@@ -0,0 +1,257 @@
<script lang="ts">
import { sdk } from '$lib/stores/sdk';
import { type Payload, Query } from '@appwrite.io/console';
import { onMount } from 'svelte';
import { isCloud, isSelfHosted } from '$lib/system';
import { organization } from '$lib/stores/organization';
import { BillingPlan, Dependencies } from '$lib/constants';
import type { BackupArchive, BackupRestoration } from '$lib/sdk/backups';
import { goto, invalidate } from '$app/navigation';
import { page } from '$app/stores';
import { addNotification } from '$lib/stores/notifications';
import { base } from '$app/paths';
import { getProjectId } from '$lib/helpers/project';
let backupRestoreItems: {
archives: Map<string, BackupArchive>;
restorations: Map<string, BackupRestoration>;
} = {
archives: new Map(),
restorations: new Map()
};
let openStates = {
archives: true,
restorations: true
};
$: showBackupRestoreBox =
backupRestoreItems.archives.size > 0 || backupRestoreItems.restorations.size > 0;
let lastDatabaseRestorationId = null;
const showRestoreNotification = (newDatabaseId: string, newDatabaseName: string) => {
if (newDatabaseId && newDatabaseName && lastDatabaseRestorationId !== newDatabaseId) {
const project = $page.params.project;
lastDatabaseRestorationId = newDatabaseId;
addNotification({
type: 'success',
isHtml: true,
message: `Restoration complete. <b>${newDatabaseName}</b> has been created.`,
buttons: [
{
name: 'View restored data',
method: () => {
goto(`${base}/project-${project}/databases/database-${newDatabaseId}`);
}
}
]
});
}
};
const fetchBackupRestores = async () => {
try {
const query = [
Query.equal('status', 'pending'),
Query.equal('status', 'uploading'),
Query.equal('status', 'processing')
];
const [archivesResponse, restorationsResponse] = await Promise.all([
sdk.forProject.backups.listArchives([
...query,
// only manual backups
Query.isNull('policyId')
]),
sdk.forProject.backups.listRestorations(query)
]);
// this is a one time op.
backupRestoreItems.archives = new Map(
archivesResponse.archives.map((item) => [item.$id, item])
);
backupRestoreItems.restorations = new Map(
restorationsResponse.restorations.map((item) => [item.$id, item])
);
} catch (e) {
// ignore?
}
};
// fresh fetch.
fetchBackupRestores();
const updateOrAddItem = (payload: Payload) => {
const { $id, status, $collection, policyId } = payload;
if ($collection === 'archives' && policyId !== null) {
return;
}
if ($collection in backupRestoreItems) {
const collectionMap = backupRestoreItems[$collection];
if (collectionMap.has($id)) {
collectionMap.get($id).status = status;
if (status === 'completed') {
invalidate(Dependencies.BACKUPS);
if ($collection === 'restorations') {
const { newId, newName } =
collectionMap.get($id).options?.['databases']?.['database'][0] || {};
showRestoreNotification(newId, newName);
}
}
} else if (status === 'pending' || status === 'processing' || status === 'uploading') {
collectionMap.set($id, payload);
}
backupRestoreItems[$collection] = collectionMap;
}
};
const graphSize = (status: string) => {
switch (status) {
case 'pending':
return 10;
case 'processing':
return 30;
case 'uploading':
return 60;
case 'completed':
case 'failed':
return 100;
default:
return 0;
}
};
const text = (status: string, key: string) => {
const service = key === 'archives' ? 'backup' : 'restore';
if (status === 'completed') {
return `Database ${service} complete`;
} else if (status === 'failed') {
return `Database ${service} failed`;
} else {
return 'Preparing database...';
}
};
const handleClose = (which: string) => {
backupRestoreItems[which] = new Map();
if (which === 'restorations') lastDatabaseRestorationId = null;
};
// TODO: `startedAt` is probably not correct here. need more info.
const backupName = (item: BackupArchive | BackupRestoration, key: string) => {
const attribute = key === 'archives' ? '$createdAt' : 'startedAt';
const date = new Date(item[attribute]);
return `${date.toDateString().slice(4, 10)}, ${date.toTimeString().slice(0, 5)}`;
};
onMount(() => {
// fast path: don't subscribe if org is on a free plan or is self-hosted.
if (isSelfHosted || (isCloud && $organization.billingPlan === BillingPlan.FREE)) return;
sdk.forConsole.client.subscribe('console', (response) => {
// nice!
if (!response.channels.includes(`projects.${getProjectId()}`)) return;
if (
response.events.includes('archives.*') ||
response.events.includes('restorations.*')
) {
updateOrAddItem(response.payload);
}
});
});
</script>
{#if showBackupRestoreBox}
<div class="box-holder u-flex u-flex-vertical u-gap-16" style="align-items: end">
{#each Object.keys(backupRestoreItems) as key}
{@const isBackup = key === 'archives'}
{@const items = backupRestoreItems[key]}
{@const titleText = isBackup ? 'Backup status' : 'Restoration status'}
{#if items.size > 0}
<section class="upload-box">
<header class="upload-box-header">
<h4 class="upload-box-title">
<span class="text">{titleText} ({items.size})</span>
</h4>
<button
class="upload-box-button"
class:is-open={openStates[key]}
aria-label="toggle upload box"
on:click={() => {
openStates[key] = !openStates[key];
}}>
<span class="icon-cheveron-up" aria-hidden="true" />
</button>
<button
class="upload-box-button"
aria-label="close backup restore box"
on:click={() => handleClose(key)}>
<span class="icon-x" aria-hidden="true" />
</button>
</header>
<div class="upload-box-content" class:is-open={openStates[key]}>
<ul class="upload-box-list">
{#each [...items.values()] as item (item.$id)}
<li class="upload-box-item">
<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">
{text(item.status, key)}
</span>
<span class="backup-name">
{backupName(item, key)}
</span>
</div>
<div
class="progress-bar-container"
class:is-danger={item.status === 'failed'}
style="--graph-size:{graphSize(item.status)}%" />
</section>
</li>
{/each}
</ul>
</div>
</section>
{/if}
{/each}
</div>
{/if}
<style>
.upload-box-title {
font-size: 11px;
}
.upload-box-content {
min-width: 400px;
max-width: 100vw;
}
.upload-box-button {
display: flex;
align-items: center;
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);
}
</style>
+4 -1
View File
@@ -42,7 +42,7 @@
{
name: 'offset',
options: {
offset: [noArrow ? 0 : -arrowSize, noArrow ? 0 : arrowSize / 1.5]
offset: [noArrow ? 0 : arrowSize * 1.75, noArrow ? 0 : arrowSize / 1.5]
}
},
{
@@ -135,10 +135,12 @@
<style global lang="scss">
.drop-arrow.is-popover {
--drop-arrow-pop-over-bg-color: var(--color-neutral-90);
body.theme-light & {
--drop-arrow-pop-over-bg-color: var(--color-neutral-0);
}
}
.drop-arrow,
.drop-arrow::before {
position: absolute;
@@ -148,6 +150,7 @@
--drop-arrow-border: 1px solid hsl(var(--color-neutral-85));
--drop-arrow-bg-color: hsl(var(--drop-arrow-pop-over-bg-color, var(--color-neutral-105)));
body.theme-light & {
--drop-arrow-border: 1px solid hsl(var(--color-neutral-10));
--drop-arrow-bg-color: hsl(var(--drop-arrow-pop-over-bg-color, var(--color-neutral-0)));
+11 -7
View File
@@ -8,6 +8,7 @@
import { createEventDispatcher } from 'svelte';
export let single = false;
export let isCard = true;
export let noMedia = false;
export let target: string = null;
export let href: string = null;
@@ -36,7 +37,7 @@
</script>
{#if single}
<article class="card u-grid u-cross-center u-width-full-line common-section">
<article class:card={isCard} 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}
@@ -45,11 +46,13 @@
on:click={track}
on:click={onClick}
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>
@@ -83,7 +86,8 @@
on:click={track}
aria-label="create"
type="button"
class="card u-grid u-cross-center u-width-full-line dashed"
class:card={isCard}
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
View File
@@ -12,6 +12,7 @@ export { default as Tiles } from './tiles.svelte';
export { default as Copy } from './copy.svelte';
export { default as CopyInput } from './copyInput.svelte';
export { default as UploadBox } from './uploadBox.svelte';
export { default as BackupRestoreBox } from './backupRestoreBox.svelte';
export { default as List } from './list.svelte';
export { default as ListItem } from './listItem.svelte';
export { default as Empty } from './empty.svelte';
+11 -2
View File
@@ -66,7 +66,7 @@
</script>
{#if $showMigrationBox && migration}
<section class="upload-box is-float">
<section class="upload-box">
<header class="upload-box-header">
<h4 class="upload-box-title">
<span class="text">Importing Data</span>
@@ -93,9 +93,18 @@
{/if}
<style>
.upload-box-title {
font-size: 11px;
}
.upload-box-content {
padding: 1.5rem;
min-width: 400px;
max-width: 100vw;
}
.upload-box-button {
display: flex;
align-items: center;
justify-content: center;
}
</style>
+1 -1
View File
@@ -70,7 +70,7 @@
{/if}
</div>
{#if description.length > 0}
<p class="u-margin-block-start-4">
<p class="modal-description u-margin-block-start-4">
<slot name="description">
{description}
</slot>
+22 -1
View File
@@ -13,7 +13,7 @@
</script>
{#if $uploader?.isOpen}
<section class="upload-box is-float">
<section class="upload-box">
<header class="upload-box-header">
<h4 class="upload-box-title">
<span class="text">Uploading files</span>
@@ -101,3 +101,24 @@
</div>
</section>
{/if}
<style>
.upload-box-title {
font-size: 11px;
}
.upload-box-button {
display: flex;
align-items: center;
justify-content: center;
}
.upload-box-content {
min-width: 400px;
max-width: 100vw;
}
.file-name {
max-width: 24ch;
}
</style>
+1
View File
@@ -50,6 +50,7 @@ export enum Dependencies {
WEBHOOKS = 'dependency:webhooks',
MIGRATIONS = 'dependency:migrations',
COLLECTIONS = 'dependency:collections',
BACKUPS = 'dependency:backups',
RUNTIMES = 'dependency:runtimes',
CONSOLE_VARIABLES = 'dependency:console_variables',
MESSAGING_PROVIDERS = 'dependency:messaging_providers',
@@ -68,7 +68,7 @@
<span
class:icon-cheveron-up={show}
class:icon-cheveron-down={!show}
class="u-position-absolute u-inset-block-start-4 u-inset-inline-end-12"
class="chevron-icon u-position-absolute u-inset-inline-end-12"
aria-hidden="true"></span>
</button>
@@ -86,3 +86,15 @@
{/each}
</svelte:fragment>
</DropList>
<style>
@media (max-width: 768px) {
.chevron-icon {
inset-block-start: 0.25rem !important;
}
.tags-input {
padding-right: 2rem;
}
}
</style>
+165
View File
@@ -0,0 +1,165 @@
export type UserBackupPolicy = {
id?: string;
label: string;
retained: number;
default: boolean;
description: string;
schedule?: string;
checked?: boolean;
selectedTime?: string;
plainTextFrequency?: string;
weeklySelectedDays?: string[];
monthlyBackupFrequency?: string;
};
export const cronExpression = (policy: UserBackupPolicy) => {
const now = new Date();
if (policy.plainTextFrequency === 'hourly') {
const utcMinute = now.getUTCMinutes();
if (!policy.default) {
policy.schedule = `${utcMinute} * * * *`;
}
return;
}
let cronExpression = '';
if (policy.default) {
// default should use utc.
cronExpression = policy.schedule;
} else {
const [localHour, localMinute] = policy.selectedTime.split(':');
now.setHours(parseInt(localHour), parseInt(localMinute), 0);
const utcHour = now.getUTCHours();
const utcMinute = now.getUTCMinutes();
if (policy.plainTextFrequency === 'daily') {
cronExpression = `${utcMinute} ${utcHour} * * *`;
} else if (policy.plainTextFrequency === 'weekly') {
const selectedDays = policy.weeklySelectedDays
?.map(
(dayLabel) =>
backupFrequencies.weekly.find((option) => option.label === dayLabel)?.index
)
.filter((index) => index !== undefined)
.join(',');
// Default to Monday (1)
cronExpression = `${utcMinute} ${utcHour} * * ${selectedDays || '1'}`;
} else if (policy.plainTextFrequency === 'monthly') {
cronExpression = `${utcMinute} ${utcHour} 28 * *`;
}
}
policy.schedule = cronExpression;
};
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
})
);
export 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)
};
export const backupPolicyDescription = (
frequency: string,
time: string | null = null,
retained: number | null = null,
monthlyBackupFrequency: string,
weeklySelectedDays: string[] | null = null
) => {
let retainedText = '';
const timeFormatted = time ?? '';
if (retained !== null) {
if (retained === 365 * 100) {
retainedText = 'forever';
} else if (retained >= 365) {
const years = Math.floor(retained / 365);
retainedText = `${years} year${years > 1 ? 's' : ''}`;
} else if (retained >= 30) {
const months = Math.floor(retained / 30);
retainedText = `${months} month${months > 1 ? 's' : ''}`;
} else if (retained >= 7) {
const weeks = Math.floor(retained / 7);
retainedText = `${weeks} week${weeks > 1 ? 's' : ''}`;
} else {
retainedText = `${retained} day${retained > 1 ? 's' : ''}`;
}
}
switch (frequency) {
case 'hourly':
return retained !== null
? `Runs every hour and is retained for ${retainedText}.`
: 'A backup will run every hour.';
case 'daily':
return retained !== null
? `Runs every day and is retained for ${retainedText}.`
: `A backup will run daily at ${timeFormatted}.`;
case 'weekly': {
const daysArray = weeklySelectedDays.length ? weeklySelectedDays : ['Monday'];
const dayString =
daysArray.length > 1
? daysArray.slice(0, -1).join(', ') + ' and ' + daysArray.slice(-1)
: daysArray[0];
return retained !== null
? `Runs every ${dayString} and is retained for ${retainedText}.`
: `A backup will run weekly on ${dayString} at ${timeFormatted}.`;
}
case 'monthly': {
const monthDay =
backupFrequencies[frequency]
.find((option) => option.value === monthlyBackupFrequency)
?.label.toLowerCase() || '28th';
let actualDay: string;
switch (monthlyBackupFrequency) {
case 'first':
actualDay = '1st';
break;
case 'middle':
actualDay = '15th';
break;
case 'end':
default:
actualDay = '28th';
break;
}
return retained !== null
? `Runs every ${actualDay} of the month and is retained for ${retainedText}.`
: `A backup will run every month on the ${monthDay} at ${timeFormatted}.`;
}
default:
return 'A backup schedule is not set.';
}
};
+2 -1
View File
@@ -18,7 +18,8 @@ const userPreferences = () => get(user).prefs;
const notificationPrefs = (): Record<string, NotificationPrefItem> => {
const prefs = userPreferences();
return prefs.notificationPrefs ? prefs.notificationPrefs : {};
// for some reason, the prefs become array as default or on all clear. let's reset.
return Array.isArray(prefs.notificationPrefs) ? {} : prefs.notificationPrefs || {};
};
function updateNotificationPrefs(parsedPrefs: Record<string, NotificationPrefItem>) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

+17 -1
View File
@@ -10,7 +10,13 @@
class:is-danger={type === 'error'}
class:is-info={type === 'info'}>
<div class="alert-grid">
<span class="icon-info" aria-hidden="true" />
<span
aria-hidden="true"
class:icon-check-circle={type === 'success'}
class:icon-exclamation={type === 'warning'}
class:icon-exclamation-circle={type === 'error'}
class:icon-info={type === 'info' || type === 'default'} />
<div class="alert-content">
{#if title || $$slots.title}
<h6 class="alert-title">
@@ -30,3 +36,13 @@
{/if}
</div>
</section>
<style>
.alert {
padding: 1rem 1rem 0.75rem 1rem;
}
.alert-content {
gap: 0.25rem;
}
</style>
+332
View File
@@ -0,0 +1,332 @@
import { AppwriteException, Client, type Payload } from '@appwrite.io/console';
export type BackupPolicyList = {
total: number;
policies: BackupPolicy[];
};
export type BackupArchiveList = {
total: number;
archives: BackupArchive[];
};
export type BackupRestorationList = {
total: number;
restorations: BackupRestoration[];
};
export type BackupPolicy = {
$id: string;
name: string;
$createdAt: string;
$updatedAt: string;
services: string[];
resources: string[];
resourceId?: string;
resourceType?: string;
retention: number;
schedule: string;
enabled: boolean;
};
export type BackupArchive = {
$id: string;
$createdAt: string;
$updatedAt: string;
policyId: string;
size: number;
status: string;
startedAt: string;
migrationId: string;
services: string[];
resources: string[];
resourceId?: string;
resourceType?: string;
};
export type BackupRestoration = {
$id: string;
$createdAt: string;
$updatedAt: string;
archiveId: string;
policyId: string;
status: string;
startedAt: string;
migrationId: string;
services: string[];
resources: string[];
options: string;
};
export class Backups {
client: Client;
constructor(client: Client) {
this.client = client;
}
async createArchive(services: string[], resourceId?: string): Promise<BackupArchive> {
if (typeof services === 'undefined') {
throw new AppwriteException('Missing required parameter: "services"');
}
const apiPath = '/backups/archives';
const payload: Payload = {};
if (typeof services !== 'undefined') {
payload['services'] = services;
}
if (typeof resourceId !== 'undefined') {
payload['resourceId'] = resourceId;
}
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('post', uri, apiHeaders, payload);
}
async deleteArchive(archiveId: string): Promise<object> {
if (typeof archiveId === 'undefined') {
throw new AppwriteException('Missing required parameter: "archiveId"');
}
const apiPath = '/backups/archives/{archiveId}'.replace('{archiveId}', archiveId);
const payload: Payload = {};
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('delete', uri, apiHeaders, payload);
}
async listArchives(queries?: string[]): Promise<BackupArchiveList> {
const apiPath = '/backups/archives';
const payload: Payload = {};
if (typeof queries !== 'undefined') {
payload['queries'] = queries;
}
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('get', uri, apiHeaders, payload);
}
async getArchive(archiveId: string): Promise<BackupArchive> {
if (typeof archiveId === 'undefined') {
throw new AppwriteException('Missing required parameter: "archiveId"');
}
const apiPath = '/backups/archives/{archiveId}'.replace('{archiveId}', archiveId);
const payload: Payload = {};
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('get', uri, apiHeaders, payload);
}
async listPolicies(queries?: string[]): Promise<BackupPolicyList> {
const apiPath = '/backups/policies';
const payload: Payload = {};
if (typeof queries !== 'undefined') {
payload['queries'] = queries;
}
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('get', uri, apiHeaders, payload);
}
async createPolicy(
policyId: string,
services: string[],
retention: number,
schedule: string,
name?: string,
resourceId?: string,
enabled?: boolean
): Promise<BackupPolicy> {
if (typeof policyId === 'undefined') {
throw new AppwriteException('Missing required parameter: "policyId"');
}
if (typeof services === 'undefined') {
throw new AppwriteException('Missing required parameter: "services"');
}
if (typeof retention === 'undefined') {
throw new AppwriteException('Missing required parameter: "retention"');
}
if (typeof schedule === 'undefined') {
throw new AppwriteException('Missing required parameter: "schedule"');
}
const apiPath = '/backups/policies';
const payload: Payload = {};
if (typeof policyId !== 'undefined') {
payload['policyId'] = policyId;
}
if (typeof name !== 'undefined') {
payload['name'] = name;
}
if (typeof services !== 'undefined') {
payload['services'] = services;
}
if (typeof resourceId !== 'undefined') {
payload['resourceId'] = resourceId;
}
if (typeof enabled !== 'undefined') {
payload['enabled'] = enabled;
}
if (typeof retention !== 'undefined') {
payload['retention'] = retention;
}
if (typeof schedule !== 'undefined') {
payload['schedule'] = schedule;
}
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('post', uri, apiHeaders, payload);
}
async getPolicy(policyId: string): Promise<BackupPolicy> {
if (typeof policyId === 'undefined') {
throw new AppwriteException('Missing required parameter: "policyId"');
}
const apiPath = '/backups/policies/{policyId}'.replace('{policyId}', policyId);
const payload: Payload = {};
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('get', uri, apiHeaders, payload);
}
async updatePolicy(
policyId: string,
name?: string,
retention?: number,
schedule?: string,
enabled?: boolean
): Promise<BackupPolicy> {
if (typeof policyId === 'undefined') {
throw new AppwriteException('Missing required parameter: "policyId"');
}
const apiPath = '/backups/policies/{policyId}'.replace('{policyId}', policyId);
const payload: Payload = {};
if (typeof name !== 'undefined') {
payload['name'] = name;
}
if (typeof retention !== 'undefined') {
payload['retention'] = retention;
}
if (typeof schedule !== 'undefined') {
payload['schedule'] = schedule;
}
if (typeof enabled !== 'undefined') {
payload['enabled'] = enabled;
}
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('patch', uri, apiHeaders, payload);
}
async deletePolicy(policyId: string): Promise<object> {
if (typeof policyId === 'undefined') {
throw new AppwriteException('Missing required parameter: "policyId"');
}
const apiPath = '/backups/policies/{policyId}'.replace('{policyId}', policyId);
const payload: Payload = {};
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('delete', uri, apiHeaders, payload);
}
async createRestoration(
archiveId: string,
services: string[],
newResourceId?: string,
newResourceName?: string
): Promise<BackupRestoration> {
if (typeof archiveId === 'undefined') {
throw new AppwriteException('Missing required parameter: "archiveId"');
}
if (typeof services === 'undefined') {
throw new AppwriteException('Missing required parameter: "services"');
}
const apiPath = '/backups/restoration';
const payload: Payload = {};
if (typeof archiveId !== 'undefined') {
payload['archiveId'] = archiveId;
}
if (typeof services !== 'undefined') {
payload['services'] = services;
}
if (typeof newResourceId !== 'undefined') {
payload['newResourceId'] = newResourceId;
}
if (typeof newResourceName !== 'undefined') {
payload['newResourceName'] = newResourceName;
}
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('post', uri, apiHeaders, payload);
}
async listRestorations(queries?: string[]): Promise<BackupRestorationList> {
const apiPath = '/backups/restorations';
const payload: Payload = {};
if (typeof queries !== 'undefined') {
payload['queries'] = queries;
}
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('get', uri, apiHeaders, payload);
}
async getRestoration(restorationId: string): Promise<BackupArchive> {
if (typeof restorationId === 'undefined') {
throw new AppwriteException('Missing required parameter: "restorationId"');
}
const apiPath = '/backups/restorations/{restorationId}'.replace(
'{restorationId}',
restorationId
);
const payload: Payload = {};
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('get', uri, apiHeaders, payload);
}
}
+42
View File
@@ -0,0 +1,42 @@
import { derived, writable } from 'svelte/store';
import { page } from '$app/stores';
import { type Models, Query } from '@appwrite.io/console';
import { sdk } from '$lib/stores/sdk';
import { headerAlert } from '$lib/stores/headerAlert';
import BackupDatabase from '$lib/components/backupDatabaseAlert.svelte';
import { shouldShowNotification } from '$lib/helpers/notifications';
export const database = derived(page, ($page) => $page.data?.database as Models.Database);
export const backupsBannerId = 'banner:databaseBackups';
export const showPolicyAlert = writable<boolean>(false);
export async function checkForDatabaseBackupPolicies(database: Models.Database) {
// fast path: return if user dismissed the banner
if (!shouldShowNotification(backupsBannerId)) return;
let total = 0;
try {
const policies = await sdk.forProject.backups.listPolicies([
Query.limit(1),
Query.equal('resourceId', database.$id)
]);
total = policies.total;
} catch (e) {
// ignore, backups not allowed on free plan error.
}
showPolicyAlert.set(total <= 0);
if (!total) {
headerAlert.add({
id: backupsBannerId,
component: BackupDatabase,
show: true,
importance: 1
});
}
}
+17 -5
View File
@@ -1,6 +1,6 @@
import { browser } from '$app/environment';
import { VARS } from '$lib/system';
import { writable } from 'svelte/store';
import { get, writable } from 'svelte/store';
import type { SvelteComponent } from 'svelte';
import FeedbackGeneral from '$lib/components/feedback/feedbackGeneral.svelte';
import FeedbackNps from '$lib/components/feedback/feedbackNPS.svelte';
@@ -11,6 +11,7 @@ export type Feedback = {
notification: boolean;
type: 'nps' | 'general';
show: boolean;
source: string;
};
export type FeedbackData = {
@@ -69,13 +70,15 @@ function createFeedbackStore() {
visualized: browser ? (parseInt(localStorage.getItem('feedbackVisualized')) ?? 0) : 0,
notification: false,
type: 'general',
show: false
show: false,
source: 'n/a'
});
return {
subscribe,
update,
toggleFeedback: () => {
toggleFeedback: (source: string = 'n/a') => {
update((feedback) => {
feedback.source = source;
feedback.show = !feedback.show;
return feedback;
});
@@ -104,7 +107,8 @@ function createFeedbackStore() {
return feedback;
});
},
// TODO: update growth server to accept `billingPlan` and other keys.
// TODO: update growth server to accept `billingPlan`.
// TODO: update growth server to accept `source` key to know the feedback source area.
submitFeedback: async (
subject: string,
message: string,
@@ -131,13 +135,21 @@ function createFeedbackStore() {
customFields: [
{ id: '47364', currentPage },
...(value ? [{ id: '40655', value }] : [])
]
],
metaFields: {
source: get(feedback).source
}
})
});
// reset the state
get(feedback).source = 'n/a';
if (response.status >= 400) {
throw new Error('Failed to submit feedback');
}
}
};
}
export const feedback = createFeedbackStore();
+2
View File
@@ -22,6 +22,7 @@ import {
Vcs
} from '@appwrite.io/console';
import { Billing } from '../sdk/billing';
import { Backups } from '../sdk/backups';
import { Sources } from '$lib/sdk/sources';
export function getApiEndpoint(): string {
@@ -41,6 +42,7 @@ const sdkForProject = {
client: clientProject,
account: new Account(clientProject),
avatars: new Avatars(clientProject),
backups: new Backups(clientProject),
databases: new Databases(clientProject),
functions: new Functions(clientProject),
health: new Health(clientProject),
+7
View File
@@ -9,6 +9,7 @@
import { app } from '$lib/stores/app';
import { log } from '$lib/stores/logs';
import { newOrgModal, organization } from '$lib/stores/organization';
import { database, checkForDatabaseBackupPolicies } from '$lib/stores/database';
import { wizard } from '$lib/stores/wizard';
import { afterUpdate, onMount } from 'svelte';
import { loading } from '$routes/store';
@@ -268,6 +269,12 @@
}
}
database.subscribe(async (database) => {
if (!database) return;
// the component checks `isCloud` internally.
await checkForDatabaseBackupPolicies(database);
});
organization.subscribe(async (org) => {
if (!org) return;
if (isCloud) {
+24 -1
View File
@@ -1,11 +1,14 @@
import { base } from '$app/paths';
import RolesDark from '$lib/images/roles-dark.png';
import RolesLight from '$lib/images/roles-light.png';
import BackupsDark from '$lib/images/backups/promo/backups-dark.png';
import BackupsLight from '$lib/images/backups/promo/backups-light.png';
import { type BottomModalAlertItem, showBottomModalAlert } from '$lib/stores/bottom-alerts';
const listOfPromotions: BottomModalAlertItem[] = [
{
id: 'memberRoles',
id: 'modal:memberRoles',
src: {
dark: RolesDark,
light: RolesLight
@@ -24,6 +27,26 @@ const listOfPromotions: BottomModalAlertItem[] = [
text: 'Learn more',
link: () => 'https://appwrite.io/docs/advanced/platform/roles'
}
},
{
id: 'modal:databaseBackups',
src: {
dark: BackupsDark,
light: BackupsLight
},
title: 'Database Backups are available now',
message: 'Protect your data and ensure quick recovery with our new backups',
plan: 'pro',
scope: 'project',
importance: 8,
cta: {
text: 'Try now',
link: ({ project }) => `${base}/project-${project.$id}/databases`
},
learnMore: {
text: 'Learn more',
link: () => 'http://appwrite.io/docs/products/databases/backups'
}
}
];
@@ -1,5 +1,5 @@
<script lang="ts">
import { MigrationBox, UploadBox } from '$lib/components';
import { BackupRestoreBox, MigrationBox, UploadBox } from '$lib/components';
import { sdk } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import { project, stats } from './store';
@@ -103,5 +103,29 @@
<slot />
<UploadBox />
<MigrationBox />
<div class="layout-level-progress-bars">
<UploadBox />
<MigrationBox />
<BackupRestoreBox />
</div>
<style>
.layout-level-progress-bars {
gap: 1rem;
display: flex;
flex-direction: column;
right: 0;
bottom: 0;
position: fixed;
padding: 1.5rem;
}
@media (max-width: 768px) {
.layout-level-progress-bars {
position: relative;
align-items: center;
}
}
</style>
@@ -1,24 +1,93 @@
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';
import type { BackupPolicy } from '$lib/sdk/backups';
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('$createdAt')
]);
const [policies, lastBackups] = await Promise.all([
await fetchPolicies(databases),
await fetchLastBackups(databases)
]);
return { databases, policies, lastBackups };
}
async function fetchPolicies(databases: Models.DatabaseList) {
const databasePolicies: Record<string, BackupPolicy[]> = {};
await Promise.all(
databases.databases.map(async (database) => {
try {
const { policies } = await sdk.forProject.backups.listPolicies([
// TODO: are all needed!?
// Query.limit(3),
Query.equal('resourceType', 'database'),
Query.equal('resourceId', database.$id)
]);
if (policies.length > 0) {
databasePolicies[database.$id] = policies;
}
} 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('resourceType', 'database'),
Query.equal('resourceId', database.$id)
]);
if (archives.length > 0) {
lastBackups[database.$id] = timeFromNow(archives[0].$createdAt);
}
} catch (e) {
// ignore
}
})
);
return lastBackups;
}
@@ -1,24 +1,84 @@
<script lang="ts">
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { Modal, CustomId } from '$lib/components';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Alert, CustomId, Modal } from '$lib/components';
import { Pill } from '$lib/elements';
import { Button, InputText, FormList } from '$lib/elements/forms';
import { Button, FormList, InputText } 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';
import CreatePolicy from './database-[database]/backups/createPolicy.svelte';
import { cronExpression, type UserBackupPolicy } from '$lib/helpers/backups';
export let showCreate = false;
let totalPolicies: UserBackupPolicy[] = [];
const dispatch = createEventDispatcher();
let name = '';
let id: string = null;
let showCustomId = true;
let showCustomId = false;
let showPlanUpgradeAlert = true;
const trackEvents = (policies) => {
policies.forEach((policy) => {
let actualDay = null;
const monthlyBackupFrequency = policy.monthlyBackupFrequency;
switch (monthlyBackupFrequency) {
case 'first':
actualDay = '1st';
break;
case 'middle':
actualDay = '15th';
break;
case 'end':
default:
actualDay = '28th';
break;
}
const message = {
keepFor: `${policy.retained} days`,
frequency: policy.plainTextFrequency,
policy: policy.default ? 'preset' : 'custom'
};
if (actualDay) message['monthlyInterval'] = actualDay;
trackEvent('submit_policy_submit', message);
});
};
const createPolicies = async (resourceId: string) => {
if (!totalPolicies.length) return;
const totalPoliciesPromise = totalPolicies.map((policy) => {
cronExpression(policy);
return sdk.forProject.backups.createPolicy(
policy.id,
['databases'],
policy.retained,
policy.schedule,
policy.label,
resourceId
);
});
await Promise.all(totalPoliciesPromise);
trackEvents(totalPolicies);
};
const create = async () => {
try {
const database = await sdk.forProject.databases.create(id ? id : ID.unique(), name);
const databaseId = id ? id : ID.unique();
const database = await sdk.forProject.databases.create(databaseId, name);
await createPolicies(databaseId);
showCreate = false;
dispatch('created', database);
addNotification({
@@ -37,10 +97,14 @@
trackError(error, Submit.DatabaseCreate);
}
};
$: if (!showCreate) {
showPlanUpgradeAlert = true;
}
</script>
<Modal title="Create database" size="big" onSubmit={create} bind:show={showCreate}>
<FormList>
<FormList gap={8}>
<InputText
id="name"
label="Name"
@@ -59,6 +123,33 @@
{:else}
<CustomId bind:show={showCustomId} name="Database" bind:id autofocus={false} />
{/if}
{#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}
</div>
{/if}
</FormList>
<svelte:fragment slot="footer">
<Button secondary on:click={() => (showCreate = false)}>Cancel</Button>
@@ -15,6 +15,7 @@
import { showCreate } from './store';
import { CollectionsPanel } from '$lib/commandCenter/panels';
import { canWriteCollections, canWriteDatabases } from '$lib/stores/roles';
import { showCreateBackup, showCreatePolicy } from './backups/store';
const project = $page.params.project;
const databaseId = $page.params.database;
@@ -38,9 +39,35 @@
},
keys: $page.url.pathname.endsWith(databaseId) ? ['c'] : ['c', 'c'],
disabled: $page.url.pathname.includes('collection-') || !$canWriteCollections,
group: 'collections',
group: 'databases',
icon: 'plus'
},
{
label: 'Create backup policy',
callback: async () => {
if (!$page.url.pathname.endsWith('backups')) {
goto(`${base}/project-${project}/databases/database-${databaseId}/backups`);
}
showCreatePolicy.set(true);
},
keys: $page.url.pathname.endsWith('backups') ? ['c'] : ['c', 'p'],
group: 'databases',
icon: 'plus',
rank: $page.url.pathname.endsWith('backups') ? 10 : 0
},
{
label: 'Create manual backup',
callback: async () => {
if (!$page.url.pathname.endsWith('backups')) {
goto(`${base}/project-${project}/databases/database-${databaseId}/backups`);
}
showCreateBackup.set(true);
},
keys: $page.url.pathname.endsWith('backups') ? ['c'] : ['c', 'b'],
group: 'databases',
icon: 'plus',
rank: $page.url.pathname.endsWith('backups') ? 10 : 0
},
{
label: 'Go to collections',
callback() {
@@ -50,7 +77,7 @@
$page.url.pathname.endsWith(databaseId) ||
$page.url.pathname.includes('collection-'),
keys: ['g', 'c'],
group: 'collections'
group: 'databases'
},
{
label: 'Go to usage',
@@ -60,7 +87,18 @@
disabled:
$page.url.pathname.includes('/usage') || $page.url.pathname.includes('collection-'),
keys: ['g', 'u'],
group: 'collections'
group: 'databases'
},
{
label: 'Go to backups',
callback() {
goto(`${base}/project-${project}/databases/database-${databaseId}/backups`);
},
disabled:
$page.url.pathname.includes('/backups') ||
$page.url.pathname.includes('collection-'),
keys: ['g', 'b'],
group: 'databases'
},
{
label: 'Go to settings',
@@ -72,14 +110,14 @@
$page.url.pathname.includes('collection-') ||
!$canWriteDatabases,
keys: ['g', 's'],
group: 'collections'
group: 'databases'
},
{
label: 'Find collections',
callback: () => {
addSubPanel(CollectionsPanel);
},
group: 'collections',
group: 'databases',
rank: -1
}
]);
@@ -0,0 +1,286 @@
<script lang="ts">
import { Alert, Modal, PaginationWithLimit } from '$lib/components';
import { Container } from '$lib/layout';
import ContainerHeader from './containerHeader.svelte';
import BackupPolicy from './policy.svelte';
import LockedCard from './locked.svelte';
import Table from './table.svelte';
import type { PageData } from './$types';
import CreatePolicy from './createPolicy.svelte';
import { Button } from '$lib/elements/forms';
import { addNotification, dismissAllNotifications } 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 { onMount } from 'svelte';
import { feedback } from '$lib/stores/feedback';
import { cronExpression, type UserBackupPolicy } from '$lib/helpers/backups';
import { ID } from '@appwrite.io/console';
import { showCreateBackup, showCreatePolicy } from './store';
import { getProjectId } from '$lib/helpers/project';
import { trackEvent } from '$lib/actions/analytics';
let policyCreateError: string;
let totalPolicies: UserBackupPolicy[] = [];
let isDisabled = isSelfHosted || (isCloud && $organization?.billingPlan === BillingPlan.FREE);
export let data: PageData;
$: hasPolicyCreationLimitations = () => {
// allow when on Pro and no policy exists
if ($organization?.billingPlan === BillingPlan.PRO) {
return data.policies.total > 0;
} else if ($organization?.billingPlan === BillingPlan.SCALE) {
return false;
}
};
const showFeedbackNotification = () => {
let counter = localStorage.getItem('createBackupsCounter');
const parsedCounter = counter ? parseInt(counter, 10) : 0;
// Exponential growth: Show after 1, 2, 4, 8, 16 uses
const showOnCount = Math.pow(2, Math.floor(Math.log2(parsedCounter)) || 0);
if (parsedCounter === showOnCount || !counter) {
addNotification({
type: 'info',
icon: 'question-mark-circle',
message:
'How was your experience with our new Backups feature? Give us your feedback and help us improve!',
timeout: 15000,
buttons: [
{
name: 'Leave feedback',
method: () => {
dismissAllNotifications();
feedback.toggleFeedback('backups');
}
},
{
name: 'Ask me later',
method: () => dismissAllNotifications()
}
]
});
}
localStorage.setItem('createBackupsCounter', ((parsedCounter ?? 0) + 1).toString());
};
const createManualBackup = async () => {
try {
await sdk.forProject.backups.createArchive(['databases'], data.database.$id);
addNotification({
type: 'success',
message: 'Database backup has started'
});
invalidate(Dependencies.BACKUPS);
trackEvent('click_manual_submit');
showFeedbackNotification();
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
} finally {
$showCreateBackup = false;
}
};
const trackEvents = (policies) => {
policies.forEach((policy) => {
let actualDay = null;
const monthlyBackupFrequency = policy.monthlyBackupFrequency;
switch (monthlyBackupFrequency) {
case 'first':
actualDay = '1st';
break;
case 'middle':
actualDay = '15th';
break;
case 'end':
default:
actualDay = '28th';
break;
}
const message = {
keepFor: `${policy.retained} days`,
frequency: policy.plainTextFrequency,
policy: policy.default ? 'preset' : 'custom'
};
if (actualDay) message['monthlyInterval'] = actualDay;
trackEvent('submit_policy_submit', message);
});
};
const createPolicies = async () => {
const totalPoliciesPromise = totalPolicies.map((policy) => {
cronExpression(policy);
return sdk.forProject.backups.createPolicy(
ID.unique(),
['databases'],
policy.retained,
policy.schedule,
policy.label,
data.database.$id
);
});
try {
await Promise.all(totalPoliciesPromise);
const message =
totalPolicies.length > 1
? `Backup policies have been created`
: `<b>${totalPolicies[0].label}</b> policy has been created`;
addNotification({
isHtml: true,
type: 'success',
message
});
trackEvents(totalPolicies);
invalidate(Dependencies.BACKUPS);
showFeedbackNotification();
} catch (err) {
addNotification({
type: 'error',
message: err.message
});
} finally {
totalPolicies = [];
$showCreatePolicy = false;
}
};
onMount(() => {
return sdk.forConsole.client.subscribe('console', (response) => {
// fast path return.
if (!response.channels.includes(`projects.${getProjectId()}`)) return;
if (response.events.includes('archives.*') || response.events.includes('policies.*')) {
invalidate(Dependencies.BACKUPS);
}
});
});
</script>
<Container size="xxl">
<div class="u-flex u-gap-32 u-flex-vertical-mobile">
{#if !isDisabled}
<div class="u-flex-vertical policies-holder-card">
<ContainerHeader
title="Policies"
buttonText="Create policy"
buttonEvent="create_backup"
buttonType="secondary"
buttonDisabled={isDisabled}
hasLimitations={hasPolicyCreationLimitations()}
buttonMethod={() => {
$showCreatePolicy = true;
trackEvent('click_policy_create');
}} />
<BackupPolicy
bind:showCreatePolicy={$showCreatePolicy}
policies={data.policies}
lastBackupDates={data.lastBackupDates} />
</div>
<div class="u-flex-vertical u-width-full-line u-overflow-x-auto">
<ContainerHeader
title="Backups"
buttonText="Manual backup"
buttonEvent="create_backup"
buttonType="secondary"
hasLimitations={false}
buttonDisabled={isDisabled}
buttonMethod={() => {
$showCreateBackup = true;
trackEvent('click_manual_create');
}} />
{#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}
<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">
No backups yet
</article>
</div>
{/if}
</div>
{:else}
<div class="u-flex-vertical u-gap-32">
<LockedCard />
</div>
{/if}
</div>
</Container>
<Modal
title="Create backup policy"
size="big"
onSubmit={createPolicies}
bind:show={$showCreatePolicy}
bind:error={policyCreateError}>
<Alert type="info">
Backups do not currently support backing up relationships between data
</Alert>
<CreatePolicy bind:totalPolicies isShowing={$showCreatePolicy} isFromBackupsTab />
<svelte:fragment slot="footer">
<Button secondary on:click={() => ($showCreatePolicy = false)}>Cancel</Button>
<Button submit disabled={!totalPolicies.length}>Create</Button>
</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.
<b>Depending on the size of your data, this may take a while.</b>
</p>
<Alert type="info"
>Backups do not currently support backing up relationships between data.
</Alert>
<svelte:fragment slot="footer">
<Button text on:click={() => ($showCreateBackup = false)}>Cancel</Button>
<Button submit>Create</Button>
</svelte:fragment>
</Modal>
<style>
.empty {
block-size: 365px;
text-align: center;
align-content: center;
}
@media (min-width: 768px) {
.policies-holder-card {
max-width: 21.5rem;
}
}
</style>
@@ -0,0 +1,71 @@
import { getLimit, getPage, getView, pageToOffset, View } from '$lib/helpers/load';
import { CARD_LIMIT, Dependencies } from '$lib/constants';
import { sdk } from '$lib/stores/sdk';
import { Query } from '@appwrite.io/console';
import type { BackupArchive, BackupArchiveList, BackupPolicyList } from '$lib/sdk/backups';
export const load = 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: BackupArchiveList = { total: 0, archives: [] };
let policies: BackupPolicyList = { total: 0, policies: [] };
try {
[backups, policies] = await Promise.all([
sdk.forProject.backups.listArchives([
Query.limit(limit),
Query.offset(offset),
Query.orderDesc('$createdAt'),
Query.equal('resourceType', 'database'),
Query.equal('resourceId', params.database)
]),
sdk.forProject.backups.listPolicies([
Query.orderDesc('$createdAt'),
Query.equal('resourceType', 'database'),
Query.equal('resourceId', params.database)
])
]);
} catch (e) {
// ignore
}
const archivesByPolicy = groupArchivesByPolicy(backups.archives);
const lastBackupDates = Object.fromEntries(getLatestBackupForPolicies(archivesByPolicy));
return {
offset,
limit,
view,
backups,
policies,
lastBackupDates
};
};
const groupArchivesByPolicy = (archives: BackupArchive[]) => {
return archives.reduce((acc, archive) => {
if (!acc.has(archive.policyId)) {
acc.set(archive.policyId, []);
}
acc.get(archive.policyId)!.push(archive);
return acc;
}, new Map<string, BackupArchive[]>());
};
const getLatestBackupForPolicies = (policyIdMap: Map<string, BackupArchive[]>) => {
const latestBackups = new Map<string, string | null>();
for (const [policyId, archives] of policyIdMap) {
const latestBackup = archives.sort(
(a, b) => new Date(b.$createdAt).getTime() - new Date(a.$createdAt).getTime()
)[0];
if (latestBackup && new Date(latestBackup.$createdAt).getTime() < Date.now()) {
latestBackups.set(policyId, latestBackup.$createdAt);
}
}
return latestBackups;
};
@@ -0,0 +1,89 @@
<script lang="ts">
import { Button } from '$lib/elements/forms';
import { DropList } from '$lib/components';
import { Pill } from '$lib/elements';
import { wizard } from '$lib/stores/wizard';
import SupportWizard from '$routes/(console)/supportWizard.svelte';
import { BillingPlan } from '$lib/constants';
import { organization } from '$lib/stores/organization';
export let isFlex = true;
export let title: string;
export let buttonText: string = null;
export let hasLimitations: boolean = true;
export let buttonMethod: () => void = null;
export let buttonEvent: string = buttonText?.toLocaleLowerCase();
export let buttonDisabled = false;
export let buttonType: 'primary' | 'secondary' | 'text' = 'primary';
let showDropdown = false;
</script>
<header
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>
{#if hasLimitations && $organization.billingPlan === BillingPlan.PRO}
<div style="height: 40px; padding-block-start: 4px">
<DropList bind:show={showDropdown} width="16">
<Pill button on:click={() => (showDropdown = true)}>
<span class="icon-info" />1/1 created
</Pill>
<svelte:fragment slot="list">
<slot name="tooltip">
<span>
You are limited to one policy on Pro plan.
<button
class="u-underline"
on:click={() => {
showDropdown = !showDropdown;
wizard.start(SupportWizard);
}}>Contact support</button> to upgrade your plan and add customized
backup policies.
</span>
</slot>
</svelte:fragment>
</DropList>
</div>
{/if}
</div>
{#if !hasLimitations}
<Button
event={buttonEvent}
on:click={hasLimitations
? () => {
showDropdown = true;
}
: buttonMethod}
disabled={buttonDisabled}
text={buttonType === 'text'}
secondary={buttonType === 'secondary'}>
<span class="icon-plus" aria-hidden="true" />
{buttonText}
</Button>
{/if}
</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>
@@ -0,0 +1,537 @@
<script lang="ts">
import {
Button,
FormList,
Helper,
InputCheckbox,
InputSelect,
InputSelectCheckbox,
InputSwitch,
InputText,
InputTime,
Label
} from '$lib/elements/forms';
import { ID } from '@appwrite.io/console';
import { capitalize } from '$lib/helpers/string';
import { backupRetainingOptions, customRetainingOptions } from '../store';
import { presetPolicies, showCreatePolicy } from './store';
import {
backupFrequencies,
backupPolicyDescription,
type UserBackupPolicy
} from '$lib/helpers/backups';
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';
export let isShowing: boolean;
export let isFromBackupsTab: boolean = false;
export let title: string | undefined = undefined;
export let subtitle: string | undefined = undefined;
export let totalPolicies: UserBackupPolicy[] = [];
let showCustomPolicy = false;
let customPolicySection: HTMLElement;
let listOfCustomPolicies: UserBackupPolicy[] = [];
$: totalPolicies = [
...listOfCustomPolicies,
...$presetPolicies.filter((policy) => policy.checked)
].map((policy) => {
if (!policy.id) policy.id = ID.unique();
return policy;
});
let policyInEdit = null;
let policyRetention = 30;
let selectedTime = '00:00';
let policyNameError: boolean;
let policyFrequency = 'monthly';
let monthlyBackupFrequency = 'end';
$: daysSelectionArray = [];
$: backupPolicyName = `${capitalize(policyFrequency)} backup`;
$: customRetentionEnabled = policyRetention === -1;
let customRetention = { ...customRetainingOptions[2], number: null };
const resetFormVariables = () => {
policyInEdit = null;
policyRetention = 30;
selectedTime = '00:00';
daysSelectionArray = [];
policyFrequency = 'monthly';
monthlyBackupFrequency = 'end';
customRetentionEnabled = false;
};
const handleSavePolicy = () => {
if (customRetentionEnabled) {
policyRetention =
customRetention.number *
customRetainingOptions.find((option) => option.value === customRetention.value)
.value;
}
const userBackupPolicy = {
default: false,
selectedTime,
monthlyBackupFrequency,
label: backupPolicyName,
retained: policyRetention,
id: policyInEdit ?? ID.unique(),
plainTextFrequency: policyFrequency,
weeklySelectedDays: daysSelectionArray,
// placeholder description.
description: `Runs every ${policyFrequency} and is retained for ${policyRetention}`
};
userBackupPolicy.description = customPolicyDescription(userBackupPolicy);
listOfCustomPolicies = [...listOfCustomPolicies, userBackupPolicy];
resetFormVariables();
showCustomPolicy = false;
};
const markPolicyChecked = (event: Event, policy: UserBackupPolicy) => {
const isChecked = (event.target as HTMLInputElement).checked;
presetPolicies.update((all) => {
return all.map((p) => {
if (p.label === policy.label) {
return { ...p, checked: isChecked };
}
return p;
});
});
};
$: customPolicyDescription = (policy: UserBackupPolicy) => {
return backupPolicyDescription(
policy.plainTextFrequency,
null,
policy.retained,
policy.monthlyBackupFrequency,
policy.weeklySelectedDays
);
};
$: formPolicyDescription = () => {
return backupPolicyDescription(
policyFrequency,
selectedTime,
null,
monthlyBackupFrequency,
daysSelectionArray
);
};
$: if (showCustomPolicy) {
customPolicySection?.scrollIntoView({ behavior: 'auto' });
}
$: if (isShowing) {
resetFormVariables();
showCustomPolicy = false;
listOfCustomPolicies = [];
presetPolicies.update((all) =>
all.map((policy) => {
policy.checked = false;
return policy;
})
);
// pre-check the hourly if on pro plan
if ($organization.billingPlan === BillingPlan.PRO && isFromBackupsTab) {
presetPolicies.update((all) =>
all.map((policy) => {
policy.id = ID.unique();
policy.checked = policy.label === 'Daily';
return policy;
})
);
}
}
$: {
const selectedOption = customRetainingOptions.find(
(option) => option.value === customRetention.value
);
if (selectedOption) {
customRetention = { ...selectedOption, number: customRetention.number };
}
}
</script>
<div class="u-flex-vertical u-gap-16">
{#if $organization.billingPlan === BillingPlan.SCALE}
{#if title || subtitle}
<div class="body-text-2">
{#if title}
<h3 class="u-bold">{title}</h3>
{/if}
{#if subtitle}
<span>{subtitle}</span>
{/if}
</div>
{/if}
{/if}
<FormList>
<!-- because we show a set of pre-defined ones -->
{#if $organization.billingPlan === BillingPlan.PRO}
{@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"
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
policies.
</span>
</div>
{:else}
<div class="u-flex u-gap-8 body-text-2">
<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>
</InputSwitch>
</div>
{/if}
{:else}
<!-- show 2 preset and create custom policy button on Scale & up -->
<div class="u-flex-vertical u-gap-12">
<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">
<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>
{policy.description}
</div>
</div>
</label>
{/each}
</div>
{#if listOfCustomPolicies.length}
<div class="u-flex-vertical u-gap-8">
{#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>
<div class="u-flex u-gap-8">
<Button
text
noMargin
class="icon-pencil height-fit-content"
on:click={() => {
policyInEdit = policy.id;
backupPolicyName = policy.label;
policyRetention = policy.retained;
selectedTime = policy.selectedTime;
policyFrequency = policy.plainTextFrequency;
monthlyBackupFrequency =
policy.monthlyBackupFrequency;
daysSelectionArray = policy.weeklySelectedDays;
// do not show in the list can cause confusion.
listOfCustomPolicies = [
...listOfCustomPolicies.filter(
(p) => policy.id !== p.id
)
];
}} />
<Button
text
noMargin
class="icon-trash height-fit-content"
on:click={() => {
listOfCustomPolicies = [
...listOfCustomPolicies.filter(
(p) => policy.id !== p.id
)
];
}} />
</div>
</div>
<span>{customPolicyDescription(policy)}</span>
</div>
</div>
{/each}
</div>
{/if}
{#if showCustomPolicy || policyInEdit}
<section
bind:this={customPolicySection}
class="modal is-inner-modal u-width-full-line">
<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}
</div>
{#if policyFrequency !== 'hourly'}
<div class="u-flex-vertical u-gap-8">
<div class="time-holder">
{#if policyFrequency === 'monthly'}
<InputSelect
id="monthly"
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>
<InputSelectCheckbox
name="Timing"
bind:tags={daysSelectionArray}
placeholder="Select weekdays"
options={backupFrequencies[
policyFrequency
].map((option) => ({
...option,
checked: daysSelectionArray.includes(
option.value
)
}))} />
</div>
{/if}
<div
class="input-time"
class:hide={policyFrequency === 'monthly' ||
policyFrequency === 'weekly'}
class:u-margin-block-start-4={policyFrequency ===
'monthly' || policyFrequency === 'weekly'}>
<InputTime
id="time"
bind:value={selectedTime}
label={['daily'].includes(policyFrequency)
? 'Timing'
: ''} />
</div>
</div>
<span>{formPolicyDescription()}</span>
</div>
{/if}
<div class="u-flex-vertical u-gap-8">
<InputSelect
fullWidth
id="retention"
label="Keep for"
placeholder="3 months"
bind:value={policyRetention}
options={backupRetainingOptions} />
<span class="u-flex u-flex-vertical u-gap-8">
{#if customRetentionEnabled}
<div class="u-flex u-gap-8 u-padding-block-start-12">
<div class="u-width-150">
<InputNumber
min={1}
id="number"
placeholder="11"
max={customRetention.max}
bind:value={customRetention.number} />
</div>
<InputSelect
fullWidth
id="retention"
placeholder="Months"
options={customRetainingOptions}
bind:value={customRetention.value} />
</div>
{/if}
<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>
</span>
</div>
<div>
<InputText
id="name"
label="Policy name"
placeholder="{capitalize(policyFrequency)} backup"
autofocus
bind:value={backupPolicyName}
required />
{#if policyNameError}
<Helper type="warning">This field is required</Helper>
{/if}
</div>
<div class="u-main-end u-flex u-gap-8">
<Button
text
on:click={() => {
showCustomPolicy = false;
}}
>Cancel
</Button>
<Button
secondary
on:click={() => {
if (!backupPolicyName) {
policyNameError = true;
return;
}
policyNameError = false;
handleSavePolicy();
}}>
{policyInEdit ? 'Update' : 'Save'}
</Button>
</div>
</div>
</div>
</section>
{:else}
<div class="custom-policy-wrapper u-padding-inline-4 u-width-full-line">
<button
class="custom-policy-text"
on:click={() => (showCustomPolicy = true)}
>Create custom policy
</button>
</div>
{/if}
</div>
{/if}
</FormList>
</div>
<style>
.card {
padding: 1rem;
border-radius: 0.5rem;
}
.time-holder {
gap: 8px;
display: flex;
}
.preset-label-card {
border: solid 0.0625rem #eeeef1;
}
.preset-label-card:hover {
border: solid 0.0625rem #d7d7da;
}
.custom-policy-text {
color: #19191c;
text-decoration: underline;
}
.custom-policy-card {
background-color: #f9f9fa !important;
}
:global(.theme-dark) .preset-label-card {
border: solid 0.0625rem #2c2c30;
}
:global(.theme-dark) .preset-label-card:hover {
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;
}
@media (max-width: 767.99px) {
.time-holder {
gap: 0;
flex-direction: column;
}
:global(.time-holder .input-time.hide > li label) {
display: none;
}
}
</style>
@@ -0,0 +1,124 @@
<script lang="ts">
import { app } from '$lib/stores/app';
import UpgradeCard from './upgradeCard.svelte';
import ContainerHeader from './containerHeader.svelte';
import LockedBackupsDarkDesktop from '$lib/images/backups/empty/backups-dark.png';
import LockedBackupsLightDesktop from '$lib/images/backups/empty/backups-light.png';
import LockedBackupsDarkMobile from '$lib/images/backups/empty/backups-mobile-dark.png';
import LockedBackupsLightMobile from '$lib/images/backups/empty/backups-mobile-light.png';
import LockedBackupsDarkTablet from '$lib/images/backups/empty/backups-tablet-dark.png';
import LockedBackupsLightTablet from '$lib/images/backups/empty/backups-tablet-light.png';
</script>
<div class="u-flex-vertical u-gap-32">
<UpgradeCard />
<div>
<!-- mobile, only policies are shown -->
<div class="is-only-mobile u-flex-vertical u-gap-16">
<ContainerHeader
title="Policies"
buttonText="Create policy"
buttonType="secondary"
buttonDisabled={true} />
<img
src={$app.themeInUse === 'dark'
? LockedBackupsDarkMobile
: LockedBackupsLightMobile}
alt="create"
aria-hidden="true" />
</div>
<!-- tablet, only policies are shown but bigger table-->
<div class="is-tablet u-flex-vertical u-gap-16 u-width-full-line">
<ContainerHeader
title="Policies"
buttonText="Create policy"
buttonType="secondary"
buttonDisabled={true} />
<img
src={$app.themeInUse === 'dark'
? LockedBackupsDarkTablet
: LockedBackupsLightTablet}
alt="create"
aria-hidden="true" />
</div>
<div class="is-desktop u-flex-vertical u-gap-16">
<div class="desktop-locked-card-buttons u-flex u-gap-24">
<div style="width: 31%">
<ContainerHeader
title="Policies"
buttonText="Create policy"
buttonType="secondary"
buttonDisabled={true} />
</div>
<div style="width: 69%; padding-inline-start: 1.5rem;">
<ContainerHeader
title="Backups"
buttonText="Manual backup"
buttonType="secondary"
buttonDisabled={true} />
</div>
</div>
<img
src={$app.themeInUse === 'dark'
? LockedBackupsDarkDesktop
: LockedBackupsLightDesktop}
alt="create"
aria-hidden="true" />
</div>
</div>
</div>
<style>
.is-tablet {
display: none;
}
:global(.desktop-locked-card-buttons .common-section) {
margin-block-start: unset !important;
}
@media (max-width: 524px) {
.is-tablet,
.is-desktop {
height: 0;
visibility: hidden;
display: none !important;
}
}
@media (min-width: 525px) and (max-width: 799.99px) {
.is-tablet {
display: block;
}
.is-desktop,
.is-only-mobile {
height: 0;
visibility: hidden;
display: none !important;
}
}
@media (min-width: 800px) {
.is-desktop {
display: block !important;
}
.is-tablet,
.is-only-mobile {
height: 0;
visibility: hidden;
display: none !important;
}
}
</style>
@@ -0,0 +1,481 @@
<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 { app } from '$lib/stores/app';
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';
import { tooltip } from '$lib/actions/tooltip';
import EmptyDark from '$lib/images/backups/backups-dark.png';
import EmptyLight from '$lib/images/backups/backups-light.png';
import type { BackupPolicy, BackupPolicyList } from '$lib/sdk/backups';
import { backupFrequencies } from '$lib/helpers/backups';
import { trackEvent } from '$lib/actions/analytics';
let showDropdown = [];
let showDelete = false;
let selectedPolicy: BackupPolicy = null;
let showEveryPolicy = false;
let confirmedDeletion = false;
export let showCreatePolicy = false;
export let policies: BackupPolicyList;
export let lastBackupDates: Record<string, string>;
async function deletePolicy() {
try {
await sdk.forProject.backups.deletePolicy(selectedPolicy.$id);
addNotification({
type: 'success',
message: 'Backup policy has been deleted'
});
invalidate(Dependencies.BACKUPS);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
} finally {
showDelete = false;
selectedPolicy = null;
confirmedDeletion = false;
}
}
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' : ''}`;
};
const getPolicyDescription = (cron: string): string => {
const cronParts = cron.split(' ');
const [minute, hour, dayOfMonth, month, dayOfWeek] = cronParts;
if (dayOfMonth !== '*' && month === '*' && dayOfWeek === '*') {
return 'Runs monthly';
}
if (dayOfMonth === '*' && month === '*' && dayOfWeek !== '*') {
const days = dayOfWeek
.split(',')
.map((day) => backupFrequencies.weekly[parseInt(day, 10)].label);
const dayString =
days.length > 1 ? days.slice(0, -1).join(', ') + ' and ' + days.slice(-1) : days[0];
return `Runs weekly on ${dayString}`;
}
if (
dayOfMonth === '*' &&
month === '*' &&
dayOfWeek === '*' &&
hour === '*' &&
minute !== '*'
) {
return 'Runs hourly';
}
if (
dayOfMonth === '*' &&
month === '*' &&
dayOfWeek === '*' &&
hour !== '*' &&
minute !== '*'
) {
return 'Runs daily';
}
};
const getTruncatedPolicyDescription = (policyDescription: string): string => {
let firstDayIndex = -1;
let secondDayIndex = -1;
for (const day of backupFrequencies.weekly.map((item) => item.label)) {
const dayIndex = policyDescription.indexOf(day);
if (dayIndex !== -1) {
if (firstDayIndex === -1) {
firstDayIndex = dayIndex;
} else {
secondDayIndex = dayIndex;
break;
}
}
}
if (firstDayIndex === -1 || secondDayIndex === -1) {
return policyDescription;
}
return `${policyDescription.substring(0, secondDayIndex).trim().replace(',', '')}...`;
};
</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">
{#each policies.policies as policy, index (policy.$id)}
{@const policyDescription = getPolicyDescription(policy.schedule)}
{@const policyDescriptionShort = getTruncatedPolicyDescription(policyDescription)}
{@const shouldUseTooltip = policyDescription.length > policyDescriptionShort.length}
<div
class="policy-card-item-padding u-flex-vertical u-gap-10"
data-show-every={showEveryPolicy}
data-visible={index < 3 || showEveryPolicy}
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>
<svelte:fragment slot="list">
<DropListItem
on:click={() => {
showDelete = true;
selectedPolicy = policy;
showDropdown[index] = false;
trackEvent('click_policy_delete');
}}>
Delete
</DropListItem>
</svelte:fragment>
</DropList>
</div>
<div
class="policy-item-subtitles u-flex u-gap-6"
style="width: fit-content;">
{#if shouldUseTooltip}
<span use:tooltip={{ content: policyDescription }}>
{policyDescriptionShort}
</span>
{:else}
{policyDescription}
{/if}
<span class="small-ellipse"></span>
{formatRetentionMessage(policy.retention)}
</div>
</div>
<div
class="policy-cycles u-flex u-main-space-between u-padding-block-2 policy-item-subtitles">
<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">
{#if lastBackupDates[policy.$id]}
{toLocaleDateTime(lastBackupDates[policy.$id])}
{:else}
No backups yet
{/if}
</span>
</div>
</div>
<div class="u-border-vertical" />
<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">
{toLocaleDateTime(
parseExpression(policy.schedule, {
utc: true
})
.next()
.toString()
)}
</div>
</div>
</div>
</div>
{:else}
<div class="u-padding-24 u-flex-vertical u-gap-16 u-cross-center">
{#if $app.themeInUse === 'dark'}
<img
src={EmptyDark}
alt="create"
aria-hidden="true"
height="152"
width="280" />
{:else}
<img
src={EmptyLight}
alt="create"
aria-hidden="true"
height="152"
width="280" />
{/if}
<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-padding-block-start-4 policy-item-caption">
Create a backup policy to automate regular and secure data protection.
</p>
</div>
<div class="u-flex u-main-center u-padding-block-end-8">
<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>
</div>
{/each}
</div>
{#if !showEveryPolicy && policies.policies.length >= 3}
<div class="is-only-mobile show-more-policy-wrapper">
<Button
secondary
fullWidthMobile
class="show-more-policy-button"
on:click={() => (showEveryPolicy = !showEveryPolicy)}>
Show more
</Button>
</div>
{/if}
</Card>
</div>
<Modal
title="Delete policy"
icon="exclamation"
state="warning"
size="small"
bind:show={showDelete}
headerDivider={false}
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>
<p class="text" data-private>
<b
>This will also delete all backups associated with this policy. This action is
irreversible.</b>
</p>
<div class="input-check-box-friction">
<InputCheckbox
required
size="small"
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>
.inner-card {
margin: 0 -1px;
padding: 0.5rem;
}
.u-border-vertical {
width: 1px;
height: 34px;
background-color: hsl(var(--color-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));
}
:global(.u-gap-6) {
gap: 0.375rem;
}
.u-gap-10 {
gap: 0.625rem;
}
.policy-card-item-padding {
padding: var(--space-3, 6px) var(--space-4, 8px);
border-block-end: solid 0.0625rem hsl(var(--color-border));
}
.policy-card-item-padding:last-child {
border-block-end: none;
}
.u-padding-block-start-10 {
padding-block-start: 10px;
}
.u-padding-block-end-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(--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);
}
:global(.show-more-policy-button) {
border-radius: 1rem;
background: transparent;
}
.show-more-policy-wrapper {
padding-inline: 13px;
padding-block-end: 0.875rem;
}
@media (max-width: 768px) {
:global(.backups-policy-list-card) {
min-width: unset !important;
}
.policy-card-item-padding {
display: none;
visibility: hidden;
}
.policy-card-item-padding[data-visible='true'] {
display: block;
visibility: visible;
}
.policy-card-item-padding[data-visible='true']:nth-child(3) {
opacity: 0.25;
border-block-end: none;
}
.policy-card-item-padding[data-visible='true']:nth-child(3) .policy-cycles {
height: 0;
margin: unset;
padding: unset;
visibility: hidden;
}
.policy-card-item-padding[data-visible='false']:nth-child(n + 4) {
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(
:last-child
) {
border-block-end: solid 0.0625rem hsl(var(--color-border));
}
.policy-card-item-padding[data-visible='true'][data-show-every='true']:nth-child(3)
.policy-cycles,
.policy-card-item-padding[data-visible='true'][data-show-every='true']:nth-child(3),
.policy-cycles {
opacity: 1;
height: auto;
visibility: visible;
}
.opacity-gradient-bottom {
overflow: hidden;
position: relative;
}
.opacity-gradient-bottom[data-visible='true']::after {
content: ''; /* blocks the dropdown click */
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 100%;
background: linear-gradient(to top, rgba(255, 255, 255, 1), transparent);
}
:global(.theme-dark) .opacity-gradient-bottom[data-visible='true']::after {
background: linear-gradient(to top, rgba(28, 28, 33, 1), transparent);
}
.opacity-gradient-bottom[data-visible='true'][data-show-every='true']::after,
:global(.theme-dark)
.opacity-gradient-bottom[data-visible='true'][data-show-every='true']::after {
content: revert;
background: transparent;
}
}
</style>
@@ -0,0 +1,95 @@
<script lang="ts">
import { onMount } from 'svelte';
import { trackEvent } from '$lib/actions/analytics';
import { InnerModal } from '$lib/components';
import { FormItem } from '$lib/elements/forms';
import TextCounter from '$lib/elements/forms/textCounter.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;
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_show_custom_id');
}
$: if (id === databaseId) {
icon = 'exclamation';
element?.setCustomValidity('Database ID must be different from the one being restored.');
} else {
icon = 'info';
element?.setCustomValidity('');
}
$: if (id?.length) {
icon = 'info';
} else {
id = 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">
<FormItem>
<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>
</FormItem>
<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>
</div>
</svelte:fragment>
</InnerModal>
@@ -0,0 +1,29 @@
import { writable } from 'svelte/store';
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);
export const presetPolicies = writable<UserBackupPolicy[]>([
{
label: 'Hourly',
retained: 1,
default: true,
checked: false,
schedule: '0 * * * *',
selectedTime: '00:00',
plainTextFrequency: 'hourly',
description: 'Runs every hour and is retained for 24 hours'
},
{
label: 'Daily',
retained: 7,
default: true,
checked: false,
schedule: '0 0 * * *',
selectedTime: '00:00',
plainTextFrequency: 'daily',
description: 'Runs every day and is retained for 7 days'
}
]);
@@ -0,0 +1,426 @@
<script lang="ts">
import { Card, DropList, DropListItem, FloatingActionBar, 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 { tooltip } from '$lib/actions/tooltip';
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 { database } from '../store';
import type { BackupArchive } from '$lib/sdk/backups';
import { trackEvent } from '$lib/actions/analytics';
import { copy } from '$lib/helpers/copy';
import { LabelCard } from '$lib/components/index.js';
import { Dependencies } from '$lib/constants';
export let data: PageData;
let showDelete = false;
let selectedBackup: BackupArchive = null;
let showDropdown = [];
let selectedBackups: string[] = [];
let showRestore = false;
let showCustomId = false;
let newDatabaseInfo: { name: string; id: string } = { name: null, id: null };
let confirmSameDbRestore = false;
let selectedRestoreOption = 'new';
let restoreOptions = [
{
id: 'new',
title: 'Restore in new database',
description:
'Duplicate the database from the selected backup version into a new database.'
},
{
id: 'same',
title: 'Restore in current database',
description: 'Overwrite the current database with the selected backup version.'
}
];
const deleteBackups = async () => {
if (!selectedBackups.length && selectedBackup) {
selectedBackups.push(selectedBackup.$id);
}
const message = `${selectedBackups.length} backup${selectedBackups.length > 1 ? 's have been' : ''} deleted`;
const promises = selectedBackups.map((archiveId) =>
sdk.forProject.backups.deleteArchive(archiveId)
);
try {
await Promise.all(promises);
addNotification({
message,
type: 'success'
});
invalidate(Dependencies.BACKUPS);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
} finally {
showDelete = false;
selectedBackup = null;
selectedBackups = [];
}
};
const restoreBackup = async () => {
if (selectedRestoreOption === 'same') {
newDatabaseInfo.id = $database.$id;
newDatabaseInfo.name = $database.name;
}
try {
await sdk.forProject.backups.createRestoration(
selectedBackup.$id,
['databases'],
newDatabaseInfo.id ?? ID.unique(),
newDatabaseInfo.name
);
addNotification({
type: 'success',
message: 'Database restore initiated'
});
invalidate(Dependencies.BACKUPS);
trackEvent('backup_restore_submit', {
newDatabaseName: newDatabaseInfo.name
});
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
} finally {
showRestore = false;
}
};
const policyDetails = (policyId: string | null) =>
data.policies.policies.find((policy) => policy.$id === policyId);
const cleanBackupName = (backup: BackupArchive) =>
toLocaleDateTime(backup.$createdAt).replaceAll(',', '');
$: if (!showRestore && !showDelete) {
showCustomId = false;
selectedBackup = null;
confirmSameDbRestore = false;
selectedRestoreOption = 'new';
newDatabaseInfo = { name: null, id: null };
}
</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={80}>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}>
<span
use:tooltip={{
content: timeFromNow(backup.$createdAt)
}}>
{cleanBackupName(backup)}
</span>
</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">
<span
use:tooltip={{
content: policy
? `Retained until: ${formattedRetainedUntil}`
: `Retained forever`
}}>
{policy?.name || 'Manual'}
</span>
</div>
</TableCell>
<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>
<svelte:fragment slot="list">
{#if backup.status === 'completed'}
<DropListItem
icon="refresh"
on:click={() => {
showRestore = true;
selectedBackup = backup;
showDropdown[index] = false;
trackEvent('click_backup_restore');
}}>
Restore
</DropListItem>
{/if}
<DropListItem
icon="trash"
on:click={() => {
showDelete = true;
selectedBackup = backup;
showDropdown[index] = false;
trackEvent('click_backup_delete');
}}>
Delete
</DropListItem>
<DropListItem
icon="duplicate"
on:click={() => {
copy(backup.$id);
showDropdown[index] = false;
}}>
Copy ID
</DropListItem>
</svelte:fragment>
</DropList>
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableScroll>
<FloatingActionBar show={selectedBackups.length > 0}>
<div class="u-flex u-cross-center u-main-space-between actions">
<div class="u-flex u-cross-center u-gap-8">
<span class="indicator body-text-2 u-bold">{selectedBackups.length}</span>
<p>
<span class="is-only-desktop">
{selectedBackups.length > 1 ? 'backups' : 'backup'}
</span>
selected
</p>
</div>
<div class="u-flex u-cross-center u-gap-8">
<Button text on:click={() => (selectedBackups = [])}>Cancel</Button>
<Button secondary on:click={() => (showDelete = true)}>
<p>Delete</p>
</Button>
</div>
</div>
</FloatingActionBar>
<Modal
title="Delete {selectedBackups.length ? 'backups' : 'backup'}"
icon="exclamation"
state="warning"
bind:show={showDelete}
headerDivider={false}
onSubmit={deleteBackups}>
<p class="text" data-private>
Are you sure you want to delete
{#if selectedBackups.length}
<b>{selectedBackups.length}</b> {selectedBackups.length > 1 ? 'backups' : 'backup'}?
{:else}
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>
<Modal headerDivider={true} 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">
{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>
<!-- TODO: ellipsis-->
{timeFromNow(selectedBackup.$createdAt)}
</div>
</div>
</Card>
<FormList>
<div class="u-flex u-flex-vertical-mobile u-gap-16">
{#each restoreOptions as restoreOption}
<div class="u-width-full-line">
<LabelCard
padding={1}
name="restore"
value={restoreOption.id}
bind:group={selectedRestoreOption}>
<svelte:fragment slot="custom">
<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>
</LabelCard>
</div>
{/each}
</div>
{#if selectedRestoreOption === 'new'}
<div class="u-flex-vertical u-gap-8">
<InputText
id="name"
label="Database 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">
<RestoreModal
autofocus={false}
name="Database"
bind:show={showCustomId}
databaseId={$database.$id}
bind:id={newDatabaseInfo.id} />
</div>
{/if}
</div>
{:else}
<div class="input-check-box-friction">
<InputCheckbox
required
size="small"
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>
{/if}
</FormList>
<svelte:fragment slot="footer">
<Button text on:click={() => (showRestore = false)}>Cancel</Button>
<Button submit>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>
@@ -0,0 +1,146 @@
<script>
import { Button } from '$lib/elements/forms';
import { app } from '$lib/stores/app';
import EmptyDark from '$lib/images/backups/upgrade/backups-dark.png';
import EmptyLight from '$lib/images/backups/upgrade/backups-light.png';
import EmptyDarkMobile from '$lib/images/backups/upgrade/backups-mobile-dark.png';
import EmptyLightMobile from '$lib/images/backups/upgrade/backups-mobile-light.png';
import EmptyDarkTablet from '$lib/images/backups/upgrade/backups-tablet-dark.png';
import EmptyLightTablet from '$lib/images/backups/upgrade/backups-tablet-light.png';
import { upgradeURL } from '$lib/stores/billing';
import { Card } from '$lib/components';
import { isCloud } from '$lib/system';
const title = isCloud
? 'Backups are available on paid plans'
: 'Database Backups are available on Appwrite Cloud';
const message = isCloud
? "Upgrade now to unlock Appwrite's backups."
: "Sign up now to access Appwrite's backups.";
</script>
<div>
<Card style="--card-padding: 1rem; --card-padding-mobile: 1rem">
<div class="u-flex u-gap-24 u-flex-vertical-mobile u-cross-center">
<div
style:--p-file-preview-border-color="transparent"
class="is-full-cover-image is-full-width-mobile u-height-100-percent">
<div class="is-only-mobile u-width-full-line u-height-100-percent">
{#if $app.themeInUse === 'dark'}
<img
height="137px"
src={EmptyDarkMobile}
class="placeholder u-image-object-fit-contain u-only-dark u-width-full-line u-height-100-percent"
alt="Mock Numbers Example" />
{:else}
<img
height="137px"
src={EmptyLightMobile}
class="placeholder u-image-object-fit-contain u-only-light u-width-full-line u-height-100-percent"
alt="Mock Numbers Example" />
{/if}
</div>
<div class="is-not-mobile">
{#if $app.themeInUse === 'dark'}
<img
src={EmptyDark}
height="102px"
class="u-image-object-fit-contain u-block u-only-dark"
alt="Backups Example" />
{:else}
<img
src={EmptyLight}
height="102px"
class="u-image-object-fit-contain u-only-light"
alt="Backups Example" />
{/if}
</div>
<div class="is-tablet">
{#if $app.themeInUse === 'dark'}
<img
src={EmptyDarkTablet}
height="102px"
class="u-image-object-fit-contain u-block u-only-dark"
alt="Backups Example" />
{:else}
<img
src={EmptyLightTablet}
height="102px"
class="u-image-object-fit-contain u-only-light"
alt="Backups Example" />
{/if}
</div>
</div>
<div class="u-flex u-flex-vertical-mobile u-gap-mobile-6">
<div class="u-flex-vertical u-gap-8">
<h3 class="body-text-2 u-bold">
{title}
</h3>
<span class="upgrade-description">
{message} Schedule automatic or manual backups to protect your data and ensure
quick recovery.
</span>
</div>
<Button
external={!isCloud}
class="is-not-mobile"
href={isCloud ? $upgradeURL : 'https://cloud.appwrite.io/register'}>
{isCloud ? 'Upgrade plan' : 'Sign up'}
</Button>
<Button
fullWidthMobile
external={!isCloud}
class="is-only-mobile-button u-margin-block-start-32"
href={isCloud ? $upgradeURL : 'https://cloud.appwrite.io/register'}>
{isCloud ? 'Upgrade plan' : 'Sign up'}
</Button>
</div>
</div>
</Card>
</div>
<style>
.is-tablet {
display: none;
}
:global(.is-only-mobile-button) {
display: none;
}
@media (min-width: 768px) {
.upgrade-description {
padding-inline-end: 16ch;
}
}
@media (max-width: 767.99px) {
:global(.is-only-mobile-button) {
display: flex;
}
}
@media (min-width: 525px) and (max-width: 767.99px) {
.is-tablet {
display: block;
}
.is-tablet img {
height: 100%;
}
.is-only-mobile {
display: none !important;
}
}
</style>
@@ -4,13 +4,16 @@
import { page } from '$app/stores';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Modal } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { Button, InputCheckbox } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { database } from './store';
import { FormList } from '$lib/elements/forms/index.js';
const databaseId = $page.params.database;
export let showDelete = false;
let confirmedDeletion = false;
const handleDelete = async () => {
try {
@@ -36,14 +39,31 @@
title="Delete database"
icon="exclamation"
state="warning"
size="small"
bind:show={showDelete}
onSubmit={handleDelete}
headerDivider={false}>
<p class="text" data-private>
Are you sure you want to delete <b>{$database.name}</b>?
</p>
<FormList>
<p class="text" data-private>
Are you sure you want to delete <b>{$database.name}</b>?
</p>
<p class="text" data-private>
<b>Once deleted, this database cannot be restored. This action is irreversible.</b>
</p>
<div class="input-check-box-friction">
<InputCheckbox
required
size="small"
id="delete_policy"
bind:checked={confirmedDeletion}
label="I understand and confirm" />
</div>
</FormList>
<svelte:fragment slot="footer">
<Button text on:click={() => (showDelete = false)}>Cancel</Button>
<Button secondary submit>Delete</Button>
<Button secondary submit disabled={!confirmedDeletion}>Delete</Button>
</svelte:fragment>
</Modal>
@@ -17,6 +17,12 @@
event: 'collections',
hasChildren: true
},
{
href: `${path}/backups`,
title: 'Backups',
event: 'backups',
hasChildren: true
},
{
href: `${path}/usage`,
title: 'Usage',
@@ -12,3 +12,20 @@ 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 },
{ label: 'Custom', value: -1 }
];
export const customRetainingOptions = [
{ label: 'Days', value: 1, max: 30 },
{ label: 'Weeks', value: 7, max: 4 },
{ label: 'Months', value: 30, max: 12 }
];
@@ -18,6 +18,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}
@@ -25,3 +34,9 @@
<p>Create a database</p>
</svelte:fragment>
</CardContainer>
<style>
.icon-exclamation {
color: hsl(var(--color-warning-100)) !important;
}
</style>
@@ -4,6 +4,7 @@ import { writable } from 'svelte/store';
export const columns = writable<Column[]>([
{ id: '$id', title: 'Database ID', type: 'string', show: true, width: 150 },
{ id: 'name', title: 'Name', type: 'string', show: true, width: 120 },
{ id: 'backup', title: 'Backups', type: 'string', show: true, width: 120 },
{ id: '$createdAt', title: 'Created', type: 'datetime', show: true, width: 120 },
{ id: '$updatedAt', title: 'Updated', type: 'datetime', show: true, width: 120 }
]);
@@ -2,21 +2,22 @@
import { invalidate } from '$app/navigation';
import { base } from '$app/paths';
import { page } from '$app/stores';
import { tooltip } from '$lib/actions/tooltip';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Id, Modal } from '$lib/components';
import FloatingActionBar from '$lib/components/floatingActionBar.svelte';
import { Dependencies } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { Button, FormList, InputCheckbox } from '$lib/elements/forms';
import {
TableBody,
TableCell,
TableCellCheck,
TableCellHead,
TableCellHeadCheck,
TableCellText,
TableHeader,
TableRowLink,
TableScroll,
TableCellCheck
TableScroll
} from '$lib/elements/table';
import { toLocaleDateTime } from '$lib/helpers/date';
import { addNotification } from '$lib/stores/notifications';
@@ -24,6 +25,7 @@
import { sdk } from '$lib/stores/sdk';
import type { PageData } from './$types';
import { columns } from './store';
import Cell from '$lib/elements/table/cell.svelte';
export let data: PageData;
const projectId = $page.params.project;
@@ -31,6 +33,7 @@
let selected: string[] = [];
let showDelete = false;
let deleting = false;
let confirmedDeletion = false;
async function handleDelete() {
showDelete = false;
@@ -53,8 +56,18 @@
} finally {
selected = [];
showDelete = false;
confirmedDeletion = false;
}
}
function getPolicyDescription(cron: string): string {
const [minute, hour, dayOfMonth, , dayOfWeek] = cron.split(' ');
if (dayOfMonth !== '*') return 'Monthly';
if (dayOfWeek !== '*') return 'Weekly on Mondays';
if (minute !== '*' && hour === '*') return 'Hourly';
if (hour !== '*') return 'Daily';
}
</script>
<TableScroll>
@@ -90,6 +103,28 @@
<TableCellText width={column.width} title={column.title}>
{database.name}
</TableCellText>
{:else if column.id === 'backup'}
{@const policies = data.policies?.[database.$id] ?? null}
{@const lastBackup = data.lastBackups?.[database.$id] ?? null}
{@const description = policies
?.map((policy) => getPolicyDescription(policy.schedule))
.join(', ')}
<Cell title={column.title} width={column.width}>
<span
class="u-trim"
use:tooltip={{
placement: 'bottom',
disabled: !policies || !lastBackup,
content: `Last backup: ${lastBackup}`
}}>
{#if !policies}
<span class="icon-exclamation" /> No backup policies
{:else}
{description}
{/if}
</span>
</Cell>
{:else}
<TableCellText width={column.width} title={column.title}>
{toLocaleDateTime(database[column.id])}
@@ -127,17 +162,35 @@
title="Delete Database"
icon="exclamation"
state="warning"
size="small"
bind:show={showDelete}
onSubmit={handleDelete}
headerDivider={false}
closable={!deleting}>
<p class="text" data-private>
Are you sure you want to delete <b>{selected.length}</b>
{selected.length > 1 ? 'databases' : 'database'}?
</p>
<FormList>
<p class="text" data-private>
Are you sure you want to delete <b>{selected.length}</b>
{selected.length > 1 ? 'databases' : 'database'}?
</p>
<p class="text" data-private>
<b
>Once deleted, {selected.length > 1 ? 'these databases' : 'this database'} cannot be
restored. This action is irreversible.</b>
</p>
<div class="input-check-box-friction">
<InputCheckbox
required
size="small"
id="delete_policy"
bind:checked={confirmedDeletion}
label="I understand and confirm" />
</div>
</FormList>
<svelte:fragment slot="footer">
<Button text on:click={() => (showDelete = false)} disabled={deleting}>Cancel</Button>
<Button secondary submit disabled={deleting}>Delete</Button>
<Button secondary submit disabled={deleting || !confirmedDeletion}>Delete</Button>
</svelte:fragment>
</Modal>
@@ -152,4 +205,8 @@
display: inline-block;
}
}
.icon-exclamation {
color: hsl(var(--color-warning-100)) !important;
}
</style>
@@ -1,11 +1,15 @@
import { Dependencies } from '$lib/constants.js';
import { sdk } from '$lib/stores/sdk';
import { Query } from '@appwrite.io/console';
export async function load({ depends }) {
depends(Dependencies.MIGRATIONS);
try {
const { migrations } = await sdk.forProject.migrations.list();
const { migrations } = await sdk.forProject.migrations.list([
// hides backups/restorations from migrations page.
Query.equal('source', ['Firebase', 'NHost', 'Supabase'])
]);
return {
migrations