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