diff --git a/src/lib/components/backupDatabaseAlert.svelte b/src/lib/components/backupDatabaseAlert.svelte new file mode 100644 index 000000000..d6fa60365 --- /dev/null +++ b/src/lib/components/backupDatabaseAlert.svelte @@ -0,0 +1,46 @@ + + +{#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`} + + + {subtitle} + +
+ + + +
+
+
+{/if} diff --git a/src/lib/components/backupRestoreBox.svelte b/src/lib/components/backupRestoreBox.svelte new file mode 100644 index 000000000..842c15e19 --- /dev/null +++ b/src/lib/components/backupRestoreBox.svelte @@ -0,0 +1,257 @@ + + +{#if showBackupRestoreBox} +
+ {#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} +
+
+

+ {titleText} ({items.size}) +

+ + +
+ +
+
    + {#each [...items.values()] as item (item.$id)} +
  • +
    +
    + + {text(item.status, key)} + + + + {backupName(item, key)} + +
    +
    +
    +
  • + {/each} +
+
+
+ {/if} + {/each} +
+{/if} + + diff --git a/src/lib/components/drop.svelte b/src/lib/components/drop.svelte index 2d4ee2b73..db5eac60e 100644 --- a/src/lib/components/drop.svelte +++ b/src/lib/components/drop.svelte @@ -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 @@ diff --git a/src/lib/components/modal.svelte b/src/lib/components/modal.svelte index 410116586..c043fc1dc 100644 --- a/src/lib/components/modal.svelte +++ b/src/lib/components/modal.svelte @@ -70,7 +70,7 @@ {/if} {#if description.length > 0} -

+

+

Uploading files @@ -101,3 +101,24 @@

{/if} + + diff --git a/src/lib/constants.ts b/src/lib/constants.ts index e73ba5854..ffa1bda99 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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', diff --git a/src/lib/elements/forms/inputSelectCheckbox.svelte b/src/lib/elements/forms/inputSelectCheckbox.svelte index b4f735b32..57c0fd440 100644 --- a/src/lib/elements/forms/inputSelectCheckbox.svelte +++ b/src/lib/elements/forms/inputSelectCheckbox.svelte @@ -68,7 +68,7 @@ @@ -86,3 +86,15 @@ {/each} + + diff --git a/src/lib/helpers/backups.ts b/src/lib/helpers/backups.ts new file mode 100644 index 000000000..0921b15a4 --- /dev/null +++ b/src/lib/helpers/backups.ts @@ -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.'; + } +}; diff --git a/src/lib/helpers/notifications.ts b/src/lib/helpers/notifications.ts index 306a80995..e5f9ae143 100644 --- a/src/lib/helpers/notifications.ts +++ b/src/lib/helpers/notifications.ts @@ -18,7 +18,8 @@ const userPreferences = () => get(user).prefs; const notificationPrefs = (): Record => { 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) { diff --git a/src/lib/images/backups/backups-dark.png b/src/lib/images/backups/backups-dark.png new file mode 100644 index 000000000..fcf87eff8 Binary files /dev/null and b/src/lib/images/backups/backups-dark.png differ diff --git a/src/lib/images/backups/backups-light.png b/src/lib/images/backups/backups-light.png new file mode 100644 index 000000000..220d6c391 Binary files /dev/null and b/src/lib/images/backups/backups-light.png differ diff --git a/src/lib/images/backups/empty/backups-dark.png b/src/lib/images/backups/empty/backups-dark.png new file mode 100644 index 000000000..333651538 Binary files /dev/null and b/src/lib/images/backups/empty/backups-dark.png differ diff --git a/src/lib/images/backups/empty/backups-light.png b/src/lib/images/backups/empty/backups-light.png new file mode 100644 index 000000000..888e662ad Binary files /dev/null and b/src/lib/images/backups/empty/backups-light.png differ diff --git a/src/lib/images/backups/empty/backups-mobile-dark.png b/src/lib/images/backups/empty/backups-mobile-dark.png new file mode 100644 index 000000000..7c9000e8a Binary files /dev/null and b/src/lib/images/backups/empty/backups-mobile-dark.png differ diff --git a/src/lib/images/backups/empty/backups-mobile-light.png b/src/lib/images/backups/empty/backups-mobile-light.png new file mode 100644 index 000000000..1d0255f45 Binary files /dev/null and b/src/lib/images/backups/empty/backups-mobile-light.png differ diff --git a/src/lib/images/backups/empty/backups-tablet-dark.png b/src/lib/images/backups/empty/backups-tablet-dark.png new file mode 100644 index 000000000..45d4dc86f Binary files /dev/null and b/src/lib/images/backups/empty/backups-tablet-dark.png differ diff --git a/src/lib/images/backups/empty/backups-tablet-light.png b/src/lib/images/backups/empty/backups-tablet-light.png new file mode 100644 index 000000000..5a9141f74 Binary files /dev/null and b/src/lib/images/backups/empty/backups-tablet-light.png differ diff --git a/src/lib/images/backups/promo/backups-dark.png b/src/lib/images/backups/promo/backups-dark.png new file mode 100644 index 000000000..6b31eab88 Binary files /dev/null and b/src/lib/images/backups/promo/backups-dark.png differ diff --git a/src/lib/images/backups/promo/backups-light.png b/src/lib/images/backups/promo/backups-light.png new file mode 100644 index 000000000..aa675d86b Binary files /dev/null and b/src/lib/images/backups/promo/backups-light.png differ diff --git a/src/lib/images/backups/upgrade/backups-dark.png b/src/lib/images/backups/upgrade/backups-dark.png new file mode 100644 index 000000000..2ce08b804 Binary files /dev/null and b/src/lib/images/backups/upgrade/backups-dark.png differ diff --git a/src/lib/images/backups/upgrade/backups-light.png b/src/lib/images/backups/upgrade/backups-light.png new file mode 100644 index 000000000..ba64a7877 Binary files /dev/null and b/src/lib/images/backups/upgrade/backups-light.png differ diff --git a/src/lib/images/backups/upgrade/backups-mobile-dark.png b/src/lib/images/backups/upgrade/backups-mobile-dark.png new file mode 100644 index 000000000..1e3046557 Binary files /dev/null and b/src/lib/images/backups/upgrade/backups-mobile-dark.png differ diff --git a/src/lib/images/backups/upgrade/backups-mobile-light.png b/src/lib/images/backups/upgrade/backups-mobile-light.png new file mode 100644 index 000000000..bb476939b Binary files /dev/null and b/src/lib/images/backups/upgrade/backups-mobile-light.png differ diff --git a/src/lib/images/backups/upgrade/backups-tablet-dark.png b/src/lib/images/backups/upgrade/backups-tablet-dark.png new file mode 100644 index 000000000..2ad9c5a57 Binary files /dev/null and b/src/lib/images/backups/upgrade/backups-tablet-dark.png differ diff --git a/src/lib/images/backups/upgrade/backups-tablet-light.png b/src/lib/images/backups/upgrade/backups-tablet-light.png new file mode 100644 index 000000000..34fb4ea5f Binary files /dev/null and b/src/lib/images/backups/upgrade/backups-tablet-light.png differ diff --git a/src/lib/layout/headerAlert.svelte b/src/lib/layout/headerAlert.svelte index f99449110..7ab5a58af 100644 --- a/src/lib/layout/headerAlert.svelte +++ b/src/lib/layout/headerAlert.svelte @@ -10,7 +10,13 @@ class:is-danger={type === 'error'} class:is-info={type === 'info'}>
-
+ + diff --git a/src/lib/sdk/backups.ts b/src/lib/sdk/backups.ts new file mode 100644 index 000000000..43cb6eb8b --- /dev/null +++ b/src/lib/sdk/backups.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/src/lib/stores/database.ts b/src/lib/stores/database.ts new file mode 100644 index 000000000..efec8fed1 --- /dev/null +++ b/src/lib/stores/database.ts @@ -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(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 + }); + } +} diff --git a/src/lib/stores/feedback.ts b/src/lib/stores/feedback.ts index f586df983..cf5c2093f 100644 --- a/src/lib/stores/feedback.ts +++ b/src/lib/stores/feedback.ts @@ -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(); diff --git a/src/lib/stores/sdk.ts b/src/lib/stores/sdk.ts index 33db722d3..c4b3189dd 100644 --- a/src/lib/stores/sdk.ts +++ b/src/lib/stores/sdk.ts @@ -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), diff --git a/src/routes/(console)/+layout.svelte b/src/routes/(console)/+layout.svelte index d584b1f25..c0ba2b343 100644 --- a/src/routes/(console)/+layout.svelte +++ b/src/routes/(console)/+layout.svelte @@ -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) { diff --git a/src/routes/(console)/bottomAlerts.ts b/src/routes/(console)/bottomAlerts.ts index e4ee69014..b9e842543 100644 --- a/src/routes/(console)/bottomAlerts.ts +++ b/src/routes/(console)/bottomAlerts.ts @@ -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' + } } ]; diff --git a/src/routes/(console)/project-[project]/+layout.svelte b/src/routes/(console)/project-[project]/+layout.svelte index d7c8ef963..e50716318 100644 --- a/src/routes/(console)/project-[project]/+layout.svelte +++ b/src/routes/(console)/project-[project]/+layout.svelte @@ -1,5 +1,5 @@ - + {/if} + + {#if isCloud} +
+ {#if $organization?.billingPlan === BillingPlan.FREE} + {#if showPlanUpgradeAlert} + (showPlanUpgradeAlert = false)}> + This database won't be backed up + + Upgrade your plan to ensure your data stays safe and backed up. + + + + + {/if} + {:else} + + {/if} +
+ {/if}
diff --git a/src/routes/(console)/project-[project]/databases/database-[database]/+layout.svelte b/src/routes/(console)/project-[project]/databases/database-[database]/+layout.svelte index 50273e999..ecf3610ee 100644 --- a/src/routes/(console)/project-[project]/databases/database-[database]/+layout.svelte +++ b/src/routes/(console)/project-[project]/databases/database-[database]/+layout.svelte @@ -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 } ]); diff --git a/src/routes/(console)/project-[project]/databases/database-[database]/backups/+page.svelte b/src/routes/(console)/project-[project]/databases/database-[database]/backups/+page.svelte new file mode 100644 index 000000000..801dec03c --- /dev/null +++ b/src/routes/(console)/project-[project]/databases/database-[database]/backups/+page.svelte @@ -0,0 +1,286 @@ + + + +
+ {#if !isDisabled} +
+ { + $showCreatePolicy = true; + trackEvent('click_policy_create'); + }} /> + + +
+ +
+ { + $showCreateBackup = true; + trackEvent('click_manual_create'); + }} /> + + {#if data.backups.total} +
+ + + {#if data.backups.total > 6} + + {/if} + + {:else} +
+
+ No backups yet +
+
+ {/if} + + {:else} +
+ +
+ {/if} + + + + + + Backups do not currently support backing up relationships between data + + + + + + + + + + + +

+ Manual backups are retained forever unless manually deleted. Use for major data + changes or rollback safeguards. + Depending on the size of your data, this may take a while. +

+ Backups do not currently support backing up relationships between data. + + + + + +
+ + diff --git a/src/routes/(console)/project-[project]/databases/database-[database]/backups/+page.ts b/src/routes/(console)/project-[project]/databases/database-[database]/backups/+page.ts new file mode 100644 index 000000000..6014bbc28 --- /dev/null +++ b/src/routes/(console)/project-[project]/databases/database-[database]/backups/+page.ts @@ -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()); +}; + +const getLatestBackupForPolicies = (policyIdMap: Map) => { + const latestBackups = new Map(); + 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; +}; diff --git a/src/routes/(console)/project-[project]/databases/database-[database]/backups/containerHeader.svelte b/src/routes/(console)/project-[project]/databases/database-[database]/backups/containerHeader.svelte new file mode 100644 index 000000000..a01b1c581 --- /dev/null +++ b/src/routes/(console)/project-[project]/databases/database-[database]/backups/containerHeader.svelte @@ -0,0 +1,89 @@ + + +
+
+
{title}
+ + {#if hasLimitations && $organization.billingPlan === BillingPlan.PRO} +
+ + (showDropdown = true)}> + 1/1 created + + + + + You are limited to one policy on Pro plan. + to upgrade your plan and add customized + backup policies. + + + + +
+ {/if} +
+ + {#if !hasLimitations} + + {/if} +
+ + diff --git a/src/routes/(console)/project-[project]/databases/database-[database]/backups/createPolicy.svelte b/src/routes/(console)/project-[project]/databases/database-[database]/backups/createPolicy.svelte new file mode 100644 index 000000000..c7c8abebd --- /dev/null +++ b/src/routes/(console)/project-[project]/databases/database-[database]/backups/createPolicy.svelte @@ -0,0 +1,537 @@ + + +
+ {#if $organization.billingPlan === BillingPlan.SCALE} + {#if title || subtitle} +
+ {#if title} +

{title}

+ {/if} + + {#if subtitle} + {subtitle} + {/if} +
+ {/if} + {/if} + + + + {#if $organization.billingPlan === BillingPlan.PRO} + {@const dailyPolicy = $presetPolicies[1]} + + {#if isFromBackupsTab} +
+ +
+ + Daily backup + + Runs every day and is retained for 7 days +
+
+ + to upgrade your plan and add customized backup + policies. + +
+ {:else} +
+ markPolicyChecked(event, dailyPolicy)}> + + + Daily backups are retained for 7 days. + + to upgrade your plan and add customized backup policies. + + + +
+ {/if} + {:else} + +
+
+ {#each $presetPolicies as policy, index (index)} + + {/each} +
+ + {#if listOfCustomPolicies.length} +
+ {#each listOfCustomPolicies as policy} +
+
+
+

{policy.label}

+ +
+
+
+ + {customPolicyDescription(policy)} +
+
+ {/each} +
+ {/if} + + {#if showCustomPolicy || policyInEdit} + + {:else} +
+ +
+ {/if} +
+ {/if} +
+
+ + diff --git a/src/routes/(console)/project-[project]/databases/database-[database]/backups/locked.svelte b/src/routes/(console)/project-[project]/databases/database-[database]/backups/locked.svelte new file mode 100644 index 000000000..c131b9ff9 --- /dev/null +++ b/src/routes/(console)/project-[project]/databases/database-[database]/backups/locked.svelte @@ -0,0 +1,124 @@ + + +
+ + +
+ +
+ + + +
+ + +
+ + + +
+ +
+
+
+ +
+ +
+ +
+
+ + +
+
+
+ + diff --git a/src/routes/(console)/project-[project]/databases/database-[database]/backups/policy.svelte b/src/routes/(console)/project-[project]/databases/database-[database]/backups/policy.svelte new file mode 100644 index 000000000..a4f905710 --- /dev/null +++ b/src/routes/(console)/project-[project]/databases/database-[database]/backups/policy.svelte @@ -0,0 +1,481 @@ + + +
+ +
+ {#each policies.policies as policy, index (policy.$id)} + {@const policyDescription = getPolicyDescription(policy.schedule)} + {@const policyDescriptionShort = getTruncatedPolicyDescription(policyDescription)} + {@const shouldUseTooltip = policyDescription.length > policyDescriptionShort.length} + +
1}> +
+
+

{policy.name}

+ + + + + { + showDelete = true; + selectedPolicy = policy; + showDropdown[index] = false; + trackEvent('click_policy_delete'); + }}> + Delete + + + +
+ +
+ {#if shouldUseTooltip} + + {policyDescriptionShort} + + {:else} + {policyDescription} + {/if} + + + + {formatRetentionMessage(policy.retention)} +
+
+ +
+
+ Previous +
+ + + {#if lastBackupDates[policy.$id]} + {toLocaleDateTime(lastBackupDates[policy.$id])} + {:else} + No backups yet + {/if} + +
+
+ +
+ +
+ Next +
+ {toLocaleDateTime( + parseExpression(policy.schedule, { + utc: true + }) + .next() + .toString() + )} +
+
+
+
+ {:else} +
+ {#if $app.themeInUse === 'dark'} + + {:else} + + {/if} + +
+
+ Ensure your data stays safe +
+

+ Create a backup policy to automate regular and secure data protection. +

+
+ +
+ +
+
+ {/each} +
+ + {#if !showEveryPolicy && policies.policies.length >= 3} +
+ +
+ {/if} + +
+ + + +
+

+ Are you sure you want to delete the {selectedPolicy.name} policy? +

+ +

+ This will also delete all backups associated with this policy. This action is + irreversible. +

+ +
+ +
+
+
+ + + + +
+ + diff --git a/src/routes/(console)/project-[project]/databases/database-[database]/backups/restoreModal.svelte b/src/routes/(console)/project-[project]/databases/database-[database]/backups/restoreModal.svelte new file mode 100644 index 000000000..df8d8e0ab --- /dev/null +++ b/src/routes/(console)/project-[project]/databases/database-[database]/backups/restoreModal.svelte @@ -0,0 +1,95 @@ + + + + {name} ID + + Enter a custom {name} ID. Leave blank for a randomly generated one. + + +
+ +
+ + +
+
+
+
+
+
+
diff --git a/src/routes/(console)/project-[project]/databases/database-[database]/backups/store.ts b/src/routes/(console)/project-[project]/databases/database-[database]/backups/store.ts new file mode 100644 index 000000000..2036a8e93 --- /dev/null +++ b/src/routes/(console)/project-[project]/databases/database-[database]/backups/store.ts @@ -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([ + { + 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' + } +]); diff --git a/src/routes/(console)/project-[project]/databases/database-[database]/backups/table.svelte b/src/routes/(console)/project-[project]/databases/database-[database]/backups/table.svelte new file mode 100644 index 000000000..d2a0feaac --- /dev/null +++ b/src/routes/(console)/project-[project]/databases/database-[database]/backups/table.svelte @@ -0,0 +1,426 @@ + + + + + b.$id)} /> + Backups + Size + Status + Policy + + + + {#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 })}`} + + + + + {cleanBackupName(backup)} + + + + {#if backup.status === 'completed'} + {calculateSize(backup.size)} + {:else} + - + {/if} + + +
+ + {backup.status.toLowerCase()} + + + + +
+
+ +
+ + {policy?.name || 'Manual'} + +
+
+ + + + + + + {#if backup.status === 'completed'} + { + showRestore = true; + selectedBackup = backup; + showDropdown[index] = false; + trackEvent('click_backup_restore'); + }}> + Restore + + {/if} + { + showDelete = true; + selectedBackup = backup; + showDropdown[index] = false; + trackEvent('click_backup_delete'); + }}> + Delete + + + { + copy(backup.$id); + showDropdown[index] = false; + }}> + Copy ID + + + + +
+ {/each} +
+
+ + 0}> +
+
+ {selectedBackups.length} +

+ + {selectedBackups.length > 1 ? 'backups' : 'backup'} + + selected +

+
+ +
+ + +
+
+
+ + +

+ Are you sure you want to delete + {#if selectedBackups.length} + {selectedBackups.length} {selectedBackups.length > 1 ? 'backups' : 'backup'}? + {:else} + the {cleanBackupName(selectedBackup)} backup? + {/if} +
This action is irreversible. +

+ + + + + +
+ + + +
+ + {cleanBackupName(selectedBackup)} + +
+ + Completed + + + {calculateSize(selectedBackup.size)} + + + + {timeFromNow(selectedBackup.$createdAt)} +
+
+
+ + +
+ {#each restoreOptions as restoreOption} +
+ + +
+

+ {restoreOption.title} +

+

+ {restoreOption.description} +

+
+
+
+
+ {/each} +
+ + {#if selectedRestoreOption === 'new'} +
+ + + {#if !showCustomId} +
+ (showCustomId = !showCustomId)} + > +
+ {:else} +
+ +
+ {/if} +
+ {:else} +
+ + + + Overwrite {$database.name} with the selected backup version + + + +
+ {/if} +
+ + + + + +
+ + diff --git a/src/routes/(console)/project-[project]/databases/database-[database]/backups/upgradeCard.svelte b/src/routes/(console)/project-[project]/databases/database-[database]/backups/upgradeCard.svelte new file mode 100644 index 000000000..b54c3313c --- /dev/null +++ b/src/routes/(console)/project-[project]/databases/database-[database]/backups/upgradeCard.svelte @@ -0,0 +1,146 @@ + + +
+ +
+
+
+ {#if $app.themeInUse === 'dark'} + Mock Numbers Example + {:else} + Mock Numbers Example + {/if} +
+ +
+ {#if $app.themeInUse === 'dark'} + Backups Example + {:else} + Backups Example + {/if} +
+ +
+ {#if $app.themeInUse === 'dark'} + Backups Example + {:else} + Backups Example + {/if} +
+
+
+
+

+ {title} +

+ + + {message} Schedule automatic or manual backups to protect your data and ensure + quick recovery. + +
+ + + + +
+
+
+
+ + diff --git a/src/routes/(console)/project-[project]/databases/database-[database]/delete.svelte b/src/routes/(console)/project-[project]/databases/database-[database]/delete.svelte index 7cd9419bc..247ee2902 100644 --- a/src/routes/(console)/project-[project]/databases/database-[database]/delete.svelte +++ b/src/routes/(console)/project-[project]/databases/database-[database]/delete.svelte @@ -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}> -

- Are you sure you want to delete {$database.name}? -

+ +

+ Are you sure you want to delete {$database.name}? +

+ +

+ Once deleted, this database cannot be restored. This action is irreversible. +

+ +
+ +
+
+ - + diff --git a/src/routes/(console)/project-[project]/databases/database-[database]/header.svelte b/src/routes/(console)/project-[project]/databases/database-[database]/header.svelte index 512afbd59..f06be0138 100644 --- a/src/routes/(console)/project-[project]/databases/database-[database]/header.svelte +++ b/src/routes/(console)/project-[project]/databases/database-[database]/header.svelte @@ -17,6 +17,12 @@ event: 'collections', hasChildren: true }, + { + href: `${path}/backups`, + title: 'Backups', + event: 'backups', + hasChildren: true + }, { href: `${path}/usage`, title: 'Usage', diff --git a/src/routes/(console)/project-[project]/databases/database-[database]/store.ts b/src/routes/(console)/project-[project]/databases/database-[database]/store.ts index 35f8a9c59..f7844f092 100644 --- a/src/routes/(console)/project-[project]/databases/database-[database]/store.ts +++ b/src/routes/(console)/project-[project]/databases/database-[database]/store.ts @@ -12,3 +12,20 @@ export const columns = writable([ { 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 } +]; diff --git a/src/routes/(console)/project-[project]/databases/grid.svelte b/src/routes/(console)/project-[project]/databases/grid.svelte index 20de14275..25a687959 100644 --- a/src/routes/(console)/project-[project]/databases/grid.svelte +++ b/src/routes/(console)/project-[project]/databases/grid.svelte @@ -18,6 +18,15 @@ {#each data.databases.databases as database} {database.name} + + {#if data.lastBackups && data.lastBackups[database.$id]} + Last backup: {data.lastBackups[database.$id]} + {:else if !data.policies || !data.policies[database.$id]} + No backup policies + {:else} + Last backup: No backups yet + {/if} + {database.$id} {/each} @@ -25,3 +34,9 @@

Create a database

+ + diff --git a/src/routes/(console)/project-[project]/databases/store.ts b/src/routes/(console)/project-[project]/databases/store.ts index ff4697d56..d3fa4c538 100644 --- a/src/routes/(console)/project-[project]/databases/store.ts +++ b/src/routes/(console)/project-[project]/databases/store.ts @@ -4,6 +4,7 @@ import { writable } from 'svelte/store'; export const columns = writable([ { 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 } ]); diff --git a/src/routes/(console)/project-[project]/databases/table.svelte b/src/routes/(console)/project-[project]/databases/table.svelte index aa447973b..036059ffc 100644 --- a/src/routes/(console)/project-[project]/databases/table.svelte +++ b/src/routes/(console)/project-[project]/databases/table.svelte @@ -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'; + } @@ -90,6 +103,28 @@ {database.name} + {: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(', ')} + + + + {#if !policies} + No backup policies + {:else} + {description} + {/if} + + {:else} {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}> -

- Are you sure you want to delete {selected.length} - {selected.length > 1 ? 'databases' : 'database'}? -

+ +

+ Are you sure you want to delete {selected.length} + {selected.length > 1 ? 'databases' : 'database'}? +

+ +

+ Once deleted, {selected.length > 1 ? 'these databases' : 'this database'} cannot be + restored. This action is irreversible. +

+ +
+ +
+
- + @@ -152,4 +205,8 @@ display: inline-block; } } + + .icon-exclamation { + color: hsl(var(--color-warning-100)) !important; + } diff --git a/src/routes/(console)/project-[project]/settings/migrations/+page.ts b/src/routes/(console)/project-[project]/settings/migrations/+page.ts index 1fa49df7c..ee6220c65 100644 --- a/src/routes/(console)/project-[project]/settings/migrations/+page.ts +++ b/src/routes/(console)/project-[project]/settings/migrations/+page.ts @@ -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