Merge pull request #1335 from ItzNotABug/feat-database-backups
Feat: Database Backups
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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)));
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.';
|
||||
}
|
||||
};
|
||||
@@ -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>) {
|
||||
|
||||
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 474 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 118 KiB |
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||