Merge branch 'main' into 'billing-sdk-refactor'.

This commit is contained in:
Darshan
2026-01-12 13:21:04 +05:30
107 changed files with 1804 additions and 1435 deletions
+2 -2
View File
@@ -22,12 +22,13 @@
},
"dependencies": {
"@ai-sdk/svelte": "^1.1.24",
"@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@d7e354b",
"@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@9c55755",
"@appwrite.io/pink-icons": "0.25.0",
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@865e2fc",
"@appwrite.io/pink-legacy": "^1.0.3",
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@865e2fc",
"@faker-js/faker": "^9.9.0",
"@plausible-analytics/tracker": "^0.4.4",
"@popperjs/core": "^2.11.8",
"@sentry/sveltekit": "^8.38.0",
"@stripe/stripe-js": "^3.5.0",
@@ -42,7 +43,6 @@
"ignore": "^6.0.2",
"nanoid": "^5.1.5",
"nanotar": "^0.1.1",
"plausible-tracker": "^0.3.9",
"pretty-bytes": "^6.1.1",
"prismjs": "^1.30.0",
"remarkable": "^2.0.1",
+14 -15
View File
@@ -12,8 +12,8 @@ importers:
specifier: ^1.1.24
version: 1.1.24(svelte@5.25.3)(zod@3.24.3)
'@appwrite.io/console':
specifier: https://pkg.vc/-/@appwrite/@appwrite.io/console@d7e354b
version: https://pkg.vc/-/@appwrite/@appwrite.io/console@d7e354b
specifier: https://pkg.vc/-/@appwrite/@appwrite.io/console@9c55755
version: https://pkg.vc/-/@appwrite/@appwrite.io/console@9c55755
'@appwrite.io/pink-icons':
specifier: 0.25.0
version: 0.25.0
@@ -29,6 +29,9 @@ importers:
'@faker-js/faker':
specifier: ^9.9.0
version: 9.9.0
'@plausible-analytics/tracker':
specifier: ^0.4.4
version: 0.4.4
'@popperjs/core':
specifier: ^2.11.8
version: 2.11.8
@@ -71,9 +74,6 @@ importers:
nanotar:
specifier: ^0.1.1
version: 0.1.1
plausible-tracker:
specifier: ^0.3.9
version: 0.3.9
pretty-bytes:
specifier: ^6.1.1
version: 6.1.1
@@ -272,9 +272,9 @@ packages:
'@analytics/type-utils@0.6.2':
resolution: {integrity: sha512-TD+xbmsBLyYy/IxFimW/YL/9L2IEnM7/EoV9Aeh56U64Ify8o27HJcKjo38XY9Tcn0uOq1AX3thkKgvtWvwFQg==}
'@appwrite.io/console@https://pkg.vc/-/@appwrite/@appwrite.io/console@d7e354b':
resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/console@d7e354b}
version: 1.10.0
'@appwrite.io/console@https://pkg.vc/-/@appwrite/@appwrite.io/console@9c55755':
resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/console@9c55755}
version: 2.1.0
'@appwrite.io/pink-icons-svelte@2.0.0-RC.1':
resolution: {integrity: sha512-iLFlV55hj8mGuAbmxJGenxN5RaZMmVT4GJb9dv/MP1xBAtYibFq7JvBcxm18qV2KU8c31Rntf+Ub4GL7HwqTYg==}
@@ -1008,6 +1008,9 @@ packages:
resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==}
engines: {node: '>= 10.0.0'}
'@plausible-analytics/tracker@0.4.4':
resolution: {integrity: sha512-fz0NOYUEYXtg1TBaPEEvtcBq3FfmLFuTe1VZw4M8sTWX129br5dguu3M15+plOQnc181ShYe67RfwhKgK89VnA==}
'@playwright/test@1.56.1':
resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==}
engines: {node: '>=18'}
@@ -2900,10 +2903,6 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
plausible-tracker@0.3.9:
resolution: {integrity: sha512-hMhneYm3GCPyQon88SZrVJx+LlqhM1kZFQbuAgXPoh/Az2YvO1B6bitT9qlhpiTdJlsT5lsr3gPmzoVjb5CDXA==}
engines: {node: '>=10'}
playwright-core@1.56.1:
resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==}
engines: {node: '>=18'}
@@ -3823,7 +3822,7 @@ snapshots:
'@analytics/type-utils@0.6.2': {}
'@appwrite.io/console@https://pkg.vc/-/@appwrite/@appwrite.io/console@d7e354b': {}
'@appwrite.io/console@https://pkg.vc/-/@appwrite/@appwrite.io/console@9c55755': {}
'@appwrite.io/pink-icons-svelte@2.0.0-RC.1(svelte@5.25.3)':
dependencies:
@@ -4575,6 +4574,8 @@ snapshots:
'@parcel/watcher-win32-x64': 2.5.1
optional: true
'@plausible-analytics/tracker@0.4.4': {}
'@playwright/test@1.56.1':
dependencies:
playwright: 1.56.1
@@ -6675,8 +6676,6 @@ snapshots:
picomatch@4.0.3: {}
plausible-tracker@0.3.9: {}
playwright-core@1.56.1: {}
playwright@1.56.1:
+18 -16
View File
@@ -1,5 +1,5 @@
import Analytics, { type AnalyticsPlugin } from 'analytics';
import Plausible from 'plausible-tracker';
import { init, track } from '@plausible-analytics/tracker';
import { get } from 'svelte/store';
import { page } from '$app/state';
import { user } from '$lib/stores/user';
@@ -11,30 +11,30 @@ import { getReferrerAndUtmSource, getTrackedQueryParams } from '$lib/helpers/utm
function plausible(domain: string): AnalyticsPlugin {
if (!browser) return { name: 'analytics-plugin-plausible' };
const instance = Plausible({
domain
init({
domain,
autoCapturePageviews: false
});
return {
name: 'analytics-plugin-plausible',
page: ({ payload }) => {
instance.trackPageview({
track('pageview', {
url: payload.properties.path,
referrer: payload.properties.referrer,
deviceWidth: payload.properties.width
props: {
referrer: payload.properties.referrer,
deviceWidth: String(payload.properties.width)
}
});
},
track: ({ payload }) => {
instance.trackEvent(
payload.event,
{
props: payload.properties
},
{
url: payload.properties.path,
deviceWidth: payload.properties.width
track(payload.event, {
url: payload.properties.path,
props: {
...payload.properties,
deviceWidth: String(payload.properties.width)
}
);
});
},
loaded: () => true
};
@@ -247,6 +247,7 @@ export enum Submit {
ProjectDelete = 'submit_project_delete',
ProjectUpdateName = 'submit_project_update_name',
ProjectUpdateTeam = 'submit_project_update_team',
ProjectUpdateLabels = 'submit_project_update_labels',
ProjectService = 'submit_project_service',
ProjectUpdateSMTP = 'submit_project_update_smtp',
MemberCreate = 'submit_member_create',
@@ -268,7 +269,7 @@ export enum Submit {
AuthSessionAlertsUpdate = 'submit_auth_session_alerts_update',
AuthMembershipPrivacyUpdate = 'submit_auth_membership_privacy_update',
AuthMockNumbersUpdate = 'submit_auth_mock_numbers_update',
AuthInvalidateSesssion = 'submit_auth_invalidate_session',
AuthInvalidateSession = 'submit_auth_invalidate_session',
SessionsLengthUpdate = 'submit_sessions_length_update',
SessionsLimitUpdate = 'submit_sessions_limit_update',
SessionDelete = 'submit_session_delete',
@@ -277,6 +278,7 @@ export enum Submit {
DatabaseDelete = 'submit_database_delete',
DatabaseUpdateName = 'submit_database_update_name',
DatabaseImportCsv = 'submit_database_import_csv',
DatabaseBackupDelete = 'submit_database_backup_delete',
ColumnCreate = 'submit_column_create',
ColumnUpdate = 'submit_column_update',
+28 -5
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import PaginationWithLimit from './paginationWithLimit.svelte';
import { Button, InputText } from '$lib/elements/forms';
import { GridItem1, CardContainer, Modal } from '$lib/components';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
@@ -43,9 +44,19 @@
currentPlan: Models.BillingPlan;
organization: Models.Organization;
projectsToArchive: Models.Project[];
archivedTotalOverall: number;
archivedOffset: number;
limit: number;
}
let { projectsToArchive, organization, currentPlan }: Props = $props();
let {
currentPlan,
organization,
projectsToArchive,
archivedTotalOverall,
archivedOffset,
limit
}: Props = $props();
// Check if current plan order is less than Pro (order < 1 means FREE plan)
let isPlanBelowPro = $derived(currentPlan?.order < 1);
@@ -185,7 +196,7 @@
}
import { formatName as formatNameHelper } from '$lib/helpers/string';
function formatName(name: string, limit: number = 19) {
function formatName(name: string, limit: number = 16) {
return formatNameHelper(name, limit, $isSmallViewport);
}
</script>
@@ -194,7 +205,7 @@
<div class="archive-projects-margin-top">
<Accordion
title={isPlanBelowPro ? 'Archived projects' : 'Pending archive'}
badge={`${projectsToArchive.length}`}>
badge={`${archivedTotalOverall}`}>
<Typography.Text tag="p" size="s">
{#if isPlanBelowPro}
These projects are archived and require a plan upgrade to restore access.
@@ -204,7 +215,7 @@
</Typography.Text>
<div class="archive-projects-margin">
<CardContainer disableEmpty={true} total={projectsToArchive.length}>
<CardContainer disableEmpty={true} total={archivedTotalOverall}>
{#each projectsToArchive as project}
{@const platforms = filterPlatforms(
project.platforms.map((platform) => getPlatformInfo(platform.type))
@@ -264,7 +275,7 @@
</Badge>
{/each}
{#if platforms.length > 3}
{#if platforms.length > 2}
<Badge
variant="secondary"
content={`+${platforms.length - 2}`}
@@ -280,6 +291,15 @@
</GridItem1>
{/each}
</CardContainer>
<PaginationWithLimit
name="Archived Projects"
{limit}
offset={archivedOffset}
total={archivedTotalOverall}
pageParam="archivedPage"
removeOnFirstPage
class="pagination-container" />
</div>
</Accordion>
</div>
@@ -353,4 +373,7 @@
align-items: center;
gap: 8px;
}
:global(.pagination-container) {
margin-top: 16px;
}
</style>
+4 -4
View File
@@ -5,7 +5,6 @@
import { isCloud, isSelfHosted } from '$lib/system';
import { organization } from '$lib/stores/organization';
import { BillingPlan, Dependencies } from '$lib/constants';
import type { BackupArchive, BackupRestoration } from '$lib/sdk/backups';
import { goto, invalidate } from '$app/navigation';
import { page } from '$app/state';
import { addNotification } from '$lib/stores/notifications';
@@ -13,10 +12,11 @@
import { getProjectId } from '$lib/helpers/project';
import { toLocaleDate } from '$lib/helpers/date';
import { Typography } from '@appwrite.io/pink-svelte';
import { type Models } from '@appwrite.io/console';
const backupRestoreItems: {
archives: Map<string, BackupArchive>;
restorations: Map<string, BackupRestoration>;
archives: Map<string, Models.BackupArchive>;
restorations: Map<string, Models.BackupRestoration>;
} = {
archives: new Map(),
restorations: new Map()
@@ -117,7 +117,7 @@
if (which === 'restorations') lastDatabaseRestorationId = null;
}
function backupName(item: BackupArchive | BackupRestoration, key: string) {
function backupName(item: Models.BackupArchive | Models.BackupRestoration, key: string) {
const column = key === 'archives' ? '$createdAt' : 'startedAt';
return toLocaleDate(item[column]);
@@ -1,25 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import { Button } from '$lib/elements/forms';
import { HeaderAlert } from '$lib/layout';
import { hideBillingHeaderRoutes, paymentMissingMandate } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import { confirmSetup } from '$lib/stores/stripe';
async function verifyPaymentMethod() {
const method = await sdk.forConsole.account.updatePaymentMethodMandateOptions({
paymentMethodId: $paymentMissingMandate.$id
});
await confirmSetup(method.clientSecret, method.providerMethodId);
}
</script>
{#if $paymentMissingMandate && $paymentMissingMandate?.country?.toLowerCase() === 'in' && $paymentMissingMandate.mandateId === null && !hideBillingHeaderRoutes.includes(page.url.pathname)}
<HeaderAlert title="Authorization required" type="info">
The payment method for {$organization.name} needs to be verified.
<svelte:fragment slot="buttons">
<Button secondary on:click={verifyPaymentMethod}>Verify payment method</Button>
</svelte:fragment>
</HeaderAlert>
{/if}
+13 -7
View File
@@ -35,16 +35,22 @@
</Typography.Text>
</Layout.Stack>
<Table.Root columns={3} let:root>
<Table.Root
columns={[
{ id: 'type', width: { min: 150 } },
{ id: 'name', width: { min: 80 } },
{ id: 'value', width: { min: 100 } }
]}
let:root>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell {root}>Type</Table.Header.Cell>
<Table.Header.Cell {root}>Name</Table.Header.Cell>
<Table.Header.Cell {root}>Value</Table.Header.Cell>
<Table.Header.Cell column="type" {root}>Type</Table.Header.Cell>
<Table.Header.Cell column="name" {root}>Name</Table.Header.Cell>
<Table.Header.Cell column="value" {root}>Value</Table.Header.Cell>
</svelte:fragment>
<Table.Row.Base {root}>
<Table.Cell {root}>CNAME</Table.Cell>
<Table.Cell {root}>{subdomain}</Table.Cell>
<Table.Cell {root}>
<Table.Cell column="type" {root}>CNAME</Table.Cell>
<Table.Cell column="name" {root}>{subdomain}</Table.Cell>
<Table.Cell column="value" {root}>
<InteractiveText
variant="copy"
isVisible
+16 -10
View File
@@ -101,29 +101,35 @@
</Typography.Text>
</Layout.Stack>
<Table.Root columns={3} let:root>
<Table.Root
columns={[
{ id: 'type', width: { min: 150 } },
{ id: 'name', width: { min: 80 } },
{ id: 'value', width: { min: 100 } }
]}
let:root>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell {root}>Type</Table.Header.Cell>
<Table.Header.Cell {root}>Name</Table.Header.Cell>
<Table.Header.Cell {root}>Value</Table.Header.Cell>
<Table.Header.Cell column="type" {root}>Type</Table.Header.Cell>
<Table.Header.Cell column="name" {root}>Name</Table.Header.Cell>
<Table.Header.Cell column="value" {root}>Value</Table.Header.Cell>
</svelte:fragment>
<Table.Row.Base {root}>
<Table.Cell {root}>{variant.toUpperCase()}</Table.Cell>
<Table.Cell {root}>{subdomain || '@'}</Table.Cell>
<Table.Cell {root}>
<Table.Cell column="type" {root}>{variant.toUpperCase()}</Table.Cell>
<Table.Cell column="name" {root}>{subdomain || '@'}</Table.Cell>
<Table.Cell column="value" {root}>
<InteractiveText variant="copy" isVisible text={setTarget()} />
</Table.Cell>
</Table.Row.Base>
{#if $regionalConsoleVariables._APP_DOMAIN_TARGET_CAA}
<Table.Row.Base {root}>
<Table.Cell {root}>
<Table.Cell column="type" {root}>
<Layout.Stack gap="s" direction="row" alignItems="center">
<span>CAA</span>
<Badge variant="secondary" size="xs" content="Recommended" />
</Layout.Stack>
</Table.Cell>
<Table.Cell {root}>@</Table.Cell>
<Table.Cell {root}>
<Table.Cell column="name" {root}>@</Table.Cell>
<Table.Cell column="value" {root}>
<InteractiveText variant="copy" isVisible text={caaText} />
</Table.Cell>
</Table.Row.Base>
+13 -4
View File
@@ -8,6 +8,8 @@
export let sum: number;
export let limit: number;
export let name: string;
export let pageParam: string = 'page';
export let removeOnFirstPage: boolean = false;
const options = [
{ label: '6', value: 6 },
@@ -23,10 +25,17 @@
url.searchParams.set('limit', limit.toString());
await preferences.setLimit(limit);
if (url.searchParams.has('page')) {
const page = Number(url.searchParams.get('page'));
const newPage = Math.floor(((page - 1) * previousLimit) / limit);
url.searchParams.set('page', newPage.toString());
if (url.searchParams.has(pageParam)) {
const page = Number(url.searchParams.get(pageParam));
const prev =
Number.isFinite(previousLimit) && previousLimit > 0 ? previousLimit : limit;
const newPage = Math.floor(((page - 1) * prev) / limit) + 1;
const safePage = Math.max(1, Number.isFinite(newPage) ? newPage : 1);
if (removeOnFirstPage && safePage === 1) {
url.searchParams.delete(pageParam);
} else {
url.searchParams.set(pageParam, safePage.toString());
}
}
await goto(url.toString());
+93 -20
View File
@@ -1,5 +1,13 @@
<script lang="ts" module>
export type DeleteOperationState = Error | void;
export type DeleteOperationState = {
error?: Error;
deleted: string[];
} | void;
export type DeleteOperation = (
deleteFn: (id: string) => Promise<unknown>,
batchSize?: number
) => Promise<Exclude<DeleteOperationState, void>>;
</script>
<script lang="ts">
@@ -40,7 +48,14 @@
children: Snippet<[root: TableRootProps]>;
deleteContent?: Snippet<[count: number]>;
deleteContentNotice?: Snippet;
onDelete?: (selectedRows: string[]) => Promise<DeleteOperationState> | DeleteOperationState;
onDelete?: (
batchDelete: DeleteOperation,
/**
* this is useful when you have a custom deletion logic
* and the default `batchDelete` helper doesn't fit the use-case!
*/
selectedRows: string[]
) => Promise<DeleteOperationState> | DeleteOperationState;
onCancel?: () => Promise<void> | void;
} = $props();
@@ -49,10 +64,8 @@
let onDeleteError: string | null = $state(null);
let showConfirmDeletion: boolean = $state(false);
function notifySuccess() {
function notifySuccess(count: number) {
if (!showSuccessNotification) return;
const count = selectedRows.length;
if (count === 0) return;
const label = `${resource}${count > 1 ? 's' : ''}`;
@@ -71,6 +84,75 @@
return `${resource}s`;
}
async function batchDelete(
ids: string[],
deleteFn: (id: string) => Promise<unknown>,
batchSize: number | undefined = undefined
): Promise<Exclude<DeleteOperationState, void>> {
const deleted: string[] = [];
let firstError: Error | undefined;
// prevent infinite loop
if (batchSize !== undefined) {
batchSize = Math.max(1, Math.floor(Math.abs(batchSize)));
}
async function processBatch(batch: string[]) {
// build promises
const results = await Promise.allSettled(batch.map((id) => deleteFn(id)));
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
// success, log it!
deleted.push(batch[index]);
} else if (!firstError) {
// error
firstError =
result.reason instanceof Error
? result.reason
: new Error(String(result.reason));
}
});
}
// batch when needed.
// example: >= 100 items to delete!
if (batchSize && batchSize < ids.length) {
for (let i = 0; i < ids.length; i += batchSize) {
const batch = ids.slice(i, i + batchSize);
await processBatch(batch);
}
} else {
await processBatch(ids);
}
return {
deleted,
error: firstError
};
}
async function consumeDeleteOperation() {
const state = await onDelete?.((deleteFn, batchSize) => {
return batchDelete(selectedRows, deleteFn, batchSize);
}, selectedRows);
if (!state) {
return false;
}
const deletedCount = state.deleted.length;
selectedRows = selectedRows.filter((id) => !state.deleted.includes(id));
if (state.error) {
onDeleteError = `Some ${getPluralResource()} were not deleted. Error: ${state.error.message}`;
return false;
}
notifySuccess(deletedCount);
return true;
}
</script>
{#key computeKey}
@@ -104,13 +186,7 @@
if (confirmDeletion) {
showConfirmDeletion = true;
} else {
const state = await onDelete?.(selectedRows);
if (state instanceof Error) {
// user should handle error on their own!
} else {
notifySuccess();
selectedRows = [];
}
await consumeDeleteOperation();
}
}}>Delete</Button>
</svelte:fragment>
@@ -129,16 +205,13 @@
disableModal = true;
onDeleteError = null;
const state = await onDelete?.(selectedRows);
if (state instanceof Error) {
disableModal = false;
onDeleteError = state.message || `Failed to delete ${resource}s`;
} else {
notifySuccess();
selectedRows = [];
disableModal = false;
const allDeleted = await consumeDeleteOperation();
if (allDeleted) {
showConfirmDeletion = false;
}
disableModal = false;
}}>
<Typography.Text>
{@const selectionCount = selectedRows.length}
+8 -2
View File
@@ -6,15 +6,21 @@
export let limit: number;
export let offset: number;
export let useCreateLink = true;
export let pageParam: string = 'page';
export let removeOnFirstPage: boolean = false;
$: currentPage = Math.floor(offset / limit + 1);
function getLink(page: number): string {
const url = new URL(pageStore.url);
if (page === 1) {
url.searchParams.delete('page');
if (removeOnFirstPage) {
url.searchParams.delete(pageParam);
} else {
url.searchParams.set(pageParam, '1');
}
} else {
url.searchParams.set('page', page.toString());
url.searchParams.set(pageParam, page.toString());
}
return url.toString();
+17 -4
View File
@@ -8,13 +8,19 @@
offset,
total,
name,
useCreateLink = true
useCreateLink = true,
pageParam = 'page',
removeOnFirstPage = false,
...restProps
}: {
limit: number;
offset: number;
total: number;
name: string;
useCreateLink?: boolean;
pageParam?: string;
removeOnFirstPage?: boolean;
[key: string]: unknown;
} = $props();
const showLimit = $derived(!!useCreateLink);
@@ -22,10 +28,17 @@
const alignItems = $derived(showLimit ? 'center' : 'flex-end');
</script>
<Layout.Stack wrap="wrap" {direction} {alignItems} justifyContent="space-between">
<Layout.Stack wrap="wrap" {direction} {alignItems} justifyContent="space-between" {...restProps}>
{#if showLimit}
<Limit {limit} sum={total} {name} />
<Limit {limit} sum={total} {name} {pageParam} {removeOnFirstPage} />
{/if}
<Pagination on:page {limit} {offset} sum={total} {useCreateLink} />
<Pagination
on:page
{limit}
{offset}
sum={total}
{useCreateLink}
{pageParam}
{removeOnFirstPage} />
</Layout.Stack>
+4 -2
View File
@@ -13,7 +13,8 @@
name = 'items',
gap = 's',
offset = $bindable(0),
children
children,
...restProps
}: {
items: T[];
limit?: number;
@@ -26,6 +27,7 @@
| undefined;
offset?: number;
children: Snippet<[T[], number]>;
[key: string]: unknown;
} = $props();
let total = $derived(items.length);
@@ -33,7 +35,7 @@
let paginatedItems = $derived(items.slice(offset, offset + limit));
</script>
<Layout.Stack {gap}>
<Layout.Stack {gap} {...restProps}>
{@render children(paginatedItems, limit)}
{#if !hideFooter}
+23 -4
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import { onDestroy } from 'svelte';
import { onDestroy, untrack } from 'svelte';
import { goto } from '$app/navigation';
import { trackEvent } from '$lib/actions/analytics';
import { Icon, Input } from '@appwrite.io/pink-svelte';
@@ -23,7 +23,10 @@
autofocus = false
}: Props = $props();
let inputValue = $state(page.url.searchParams.get('search') ?? '');
const initialSearch = page.url.searchParams.get('search') ?? '';
let inputValue = $state(initialSearch);
let previousInputValue = initialSearch;
let previousUrlSearch = initialSearch;
const runSearch = createDebounce((value: string) => {
const trimmed = value.trim();
@@ -56,9 +59,25 @@
$effect(() => {
const urlSearch = page.url.searchParams.get('search') ?? '';
if (urlSearch !== previousUrlSearch) {
previousUrlSearch = urlSearch;
const currentInput = untrack(() => inputValue);
if (urlSearch !== currentInput) {
inputValue = urlSearch;
previousInputValue = urlSearch;
}
}
});
if (urlSearch !== inputValue) {
runSearch(inputValue);
$effect(() => {
if (inputValue !== previousInputValue) {
const urlSearch = untrack(() => page.url.searchParams.get('search') ?? '');
if (inputValue !== urlSearch) {
previousInputValue = inputValue;
runSearch(inputValue);
} else {
previousInputValue = inputValue;
}
}
});
</script>
+12 -2
View File
@@ -22,14 +22,24 @@ function isBuildTimedOut(createdAt: string, status: string, timeoutSeconds: numb
* Gets the effective status for a build, considering timeout
*/
export function getEffectiveBuildStatus(
originalStatus: string,
createdAt: string,
deployment: Models.Deployment,
consoleVariables: Models.ConsoleVariables | undefined
): string {
const originalStatus = deployment.status;
const createdAt = deployment.$createdAt;
const timeoutSeconds = getBuildTimeoutSeconds(consoleVariables);
if (isBuildTimedOut(createdAt, originalStatus, timeoutSeconds)) {
return 'failed';
}
const isReady = originalStatus === 'ready';
const hasScreenshot = deployment.screenshotLight && deployment.screenshotDark;
if (isReady && !hasScreenshot) {
return 'finalizing';
}
return originalStatus;
}
+12
View File
@@ -206,6 +206,18 @@ export function timeFromNow(datetime: string): string {
return dayjs().to(dayjs(datetime));
}
export function timeFromNowShort(datetime: string): string {
if (!datetime || !isValidDate(datetime)) {
return 'n/a';
}
const timeStr = dayjs().to(dayjs(datetime));
return timeStr
.replace('second', 'sec') // seconds > secs
.replace('minute', 'min') // minutes > mins
.replace('hour', 'hr'); // hours > hrs
}
export function hoursToDays(hours: number) {
if (hours > 24) {
return `${Math.floor(hours / 24)} days`;
+74 -52
View File
@@ -1,9 +1,9 @@
<script lang="ts">
import { AvatarInitials, PaginationWithLimit, Trim } from '$lib/components';
import { Container } from '$lib/layout';
import { toLocaleDateTime } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import type { Models } from '@appwrite.io/console';
import { Layout, Table, Card, Empty } from '@appwrite.io/pink-svelte';
import { Layout, Table, Card, Empty, InteractiveText } from '@appwrite.io/pink-svelte';
import Button from '$lib/elements/forms/button.svelte';
import type { PinkColumn } from '$lib/helpers/types';
@@ -14,12 +14,32 @@
export let databasesScreen = false;
export let useCreateLinkForPagination = true;
function getColumnWidth(columnId: string): Pick<PinkColumn, 'width'> {
const widthConfig: Record<
string,
{ insideSheet: number | { min: number }; default: number | { min: number } }
> = {
user: { insideSheet: 140, default: { min: 100 } },
event: { insideSheet: 125, default: { min: 160 } },
location: { insideSheet: 100, default: { min: 120 } },
ip: { insideSheet: { min: 150 }, default: { min: 250 } },
date: { insideSheet: { min: 200 }, default: { min: 180 } }
};
const config = widthConfig[columnId];
if (!config) {
return {};
}
return { width: insideSideSheet ? config.insideSheet : config.default };
}
const columns: PinkColumn[] = [
{ id: 'user', ...(insideSideSheet ? { width: 140 } : {}) },
{ id: 'event', ...(insideSideSheet ? { width: 125 } : {}) },
{ id: 'location', ...(insideSideSheet ? { width: 100 } : {}) },
{ id: 'ip', ...(insideSideSheet ? { width: { min: 150 } } : {}) },
{ id: 'date', ...(insideSideSheet ? { width: { min: 200 } } : {}) }
{ id: 'user', ...getColumnWidth('user') },
{ id: 'event', ...getColumnWidth('event') },
{ id: 'location', ...getColumnWidth('location') },
{ id: 'ip', ...getColumnWidth('ip') },
{ id: 'date', ...getColumnWidth('date') }
];
</script>
@@ -29,55 +49,57 @@
expanded={databasesScreen && !insideSideSheet}
slotSpacing={databasesScreen && !insideSideSheet}>
{#if logs.total}
<div>
<Table.Root {columns} let:root>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="user" {root}>User</Table.Header.Cell>
<Table.Header.Cell column="event" {root}>Event</Table.Header.Cell>
<Table.Header.Cell column="location" {root}>Location</Table.Header.Cell>
<Table.Header.Cell column="ip" {root}>IP</Table.Header.Cell>
<Table.Header.Cell column="date" {root}>Date</Table.Header.Cell>
</svelte:fragment>
{#each logs.logs as log}
<Table.Row.Base {root}>
<Table.Cell column="user" {root}>
<Layout.Stack direction="row" alignItems="center">
{#if log.userEmail}
{#if log.userName}
<AvatarInitials size="xs" name={log.userName} />
<Trim>{log.userName}</Trim>
{:else}
<AvatarInitials size="xs" name={log.userEmail} />
<Trim>{log.userEmail}</Trim>
{/if}
<Table.Root {columns} let:root>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="user" {root}>User</Table.Header.Cell>
<Table.Header.Cell column="event" {root}>Event</Table.Header.Cell>
<Table.Header.Cell column="location" {root}>Location</Table.Header.Cell>
<Table.Header.Cell column="ip" {root}>IP</Table.Header.Cell>
<Table.Header.Cell column="date" {root}>Date</Table.Header.Cell>
</svelte:fragment>
{#each logs.logs as log}
<Table.Row.Base {root}>
<Table.Cell column="user" {root}>
<Layout.Stack direction="row" alignItems="center">
{#if log.userEmail}
{#if log.userName}
<AvatarInitials size="xs" name={log.userName} />
<Trim>{log.userName}</Trim>
{:else}
<div class="avatar is-size-small">
<span class="icon-anonymous" aria-hidden="true"></span>
</div>
<span class="text u-trim">{log.userName ?? 'Anonymous'}</span>
<AvatarInitials size="xs" name={log.userEmail} />
<Trim>{log.userEmail}</Trim>
{/if}
</Layout.Stack>
</Table.Cell>
<Table.Cell column="event" {root}>
{log.event}
</Table.Cell>
<Table.Cell column="location" {root}>
{#if log.countryCode !== '--'}
{log.countryName}
{:else}
Unknown
<div class="avatar is-size-small">
<span class="icon-anonymous" aria-hidden="true"></span>
</div>
<span class="text u-trim">{log.userName ?? 'Anonymous'}</span>
{/if}
</Table.Cell>
<Table.Cell column="ip" {root}>
{log.ip}
</Table.Cell>
<Table.Cell column="date" {root}>
{toLocaleDateTime(log.time)}
</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
</div>
</Layout.Stack>
</Table.Cell>
<Table.Cell column="event" {root}>
{log.event}
</Table.Cell>
<Table.Cell column="location" {root}>
{#if log.countryCode !== '--'}
{log.countryName}
{:else}
Unknown
{/if}
</Table.Cell>
<Table.Cell column="ip" {root}>
<InteractiveText variant="copy" text={log.ip} isVisible />
</Table.Cell>
<Table.Cell column="date" {root}>
<DualTimeView time={log.time} showDatetime />
</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
<PaginationWithLimit
{limit}
-332
View File
@@ -1,332 +0,0 @@
import { AppwriteException, Client, type Payload } from '@appwrite.io/console';
export type BackupPolicyList = {
total: number;
policies: BackupPolicy[];
};
export type BackupArchiveList = {
total: number;
archives: BackupArchive[];
};
export type BackupRestorationList = {
total: number;
restorations: BackupRestoration[];
};
export type BackupPolicy = {
$id: string;
name: string;
$createdAt: string;
$updatedAt: string;
services: string[];
resources: string[];
resourceId?: string;
resourceType?: string;
retention: number;
schedule: string;
enabled: boolean;
};
export type BackupArchive = {
$id: string;
$createdAt: string;
$updatedAt: string;
policyId: string;
size: number;
status: string;
startedAt: string;
migrationId: string;
services: string[];
resources: string[];
resourceId?: string;
resourceType?: string;
};
export type BackupRestoration = {
$id: string;
$createdAt: string;
$updatedAt: string;
archiveId: string;
policyId: string;
status: string;
startedAt: string;
migrationId: string;
services: string[];
resources: string[];
options: string;
};
export class Backups {
client: Client;
constructor(client: Client) {
this.client = client;
}
async createArchive(services: string[], resourceId?: string): Promise<BackupArchive> {
if (typeof services === 'undefined') {
throw new AppwriteException('Missing required parameter: "services"');
}
const apiPath = '/backups/archives';
const payload: Payload = {};
if (typeof services !== 'undefined') {
payload['services'] = services;
}
if (typeof resourceId !== 'undefined') {
payload['resourceId'] = resourceId;
}
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('post', uri, apiHeaders, payload);
}
async deleteArchive(archiveId: string): Promise<object> {
if (typeof archiveId === 'undefined') {
throw new AppwriteException('Missing required parameter: "archiveId"');
}
const apiPath = '/backups/archives/{archiveId}'.replace('{archiveId}', archiveId);
const payload: Payload = {};
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('delete', uri, apiHeaders, payload);
}
async listArchives(queries?: string[]): Promise<BackupArchiveList> {
const apiPath = '/backups/archives';
const payload: Payload = {};
if (typeof queries !== 'undefined') {
payload['queries'] = queries;
}
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('get', uri, apiHeaders, payload);
}
async getArchive(archiveId: string): Promise<BackupArchive> {
if (typeof archiveId === 'undefined') {
throw new AppwriteException('Missing required parameter: "archiveId"');
}
const apiPath = '/backups/archives/{archiveId}'.replace('{archiveId}', archiveId);
const payload: Payload = {};
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('get', uri, apiHeaders, payload);
}
async listPolicies(queries?: string[]): Promise<BackupPolicyList> {
const apiPath = '/backups/policies';
const payload: Payload = {};
if (typeof queries !== 'undefined') {
payload['queries'] = queries;
}
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('get', uri, apiHeaders, payload);
}
async createPolicy(
policyId: string,
services: string[],
retention: number,
schedule: string,
name?: string,
resourceId?: string,
enabled?: boolean
): Promise<BackupPolicy> {
if (typeof policyId === 'undefined') {
throw new AppwriteException('Missing required parameter: "policyId"');
}
if (typeof services === 'undefined') {
throw new AppwriteException('Missing required parameter: "services"');
}
if (typeof retention === 'undefined') {
throw new AppwriteException('Missing required parameter: "retention"');
}
if (typeof schedule === 'undefined') {
throw new AppwriteException('Missing required parameter: "schedule"');
}
const apiPath = '/backups/policies';
const payload: Payload = {};
if (typeof policyId !== 'undefined') {
payload['policyId'] = policyId;
}
if (typeof name !== 'undefined') {
payload['name'] = name;
}
if (typeof services !== 'undefined') {
payload['services'] = services;
}
if (typeof resourceId !== 'undefined') {
payload['resourceId'] = resourceId;
}
if (typeof enabled !== 'undefined') {
payload['enabled'] = enabled;
}
if (typeof retention !== 'undefined') {
payload['retention'] = retention;
}
if (typeof schedule !== 'undefined') {
payload['schedule'] = schedule;
}
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('post', uri, apiHeaders, payload);
}
async getPolicy(policyId: string): Promise<BackupPolicy> {
if (typeof policyId === 'undefined') {
throw new AppwriteException('Missing required parameter: "policyId"');
}
const apiPath = '/backups/policies/{policyId}'.replace('{policyId}', policyId);
const payload: Payload = {};
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('get', uri, apiHeaders, payload);
}
async updatePolicy(
policyId: string,
name?: string,
retention?: number,
schedule?: string,
enabled?: boolean
): Promise<BackupPolicy> {
if (typeof policyId === 'undefined') {
throw new AppwriteException('Missing required parameter: "policyId"');
}
const apiPath = '/backups/policies/{policyId}'.replace('{policyId}', policyId);
const payload: Payload = {};
if (typeof name !== 'undefined') {
payload['name'] = name;
}
if (typeof retention !== 'undefined') {
payload['retention'] = retention;
}
if (typeof schedule !== 'undefined') {
payload['schedule'] = schedule;
}
if (typeof enabled !== 'undefined') {
payload['enabled'] = enabled;
}
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('patch', uri, apiHeaders, payload);
}
async deletePolicy(policyId: string): Promise<object> {
if (typeof policyId === 'undefined') {
throw new AppwriteException('Missing required parameter: "policyId"');
}
const apiPath = '/backups/policies/{policyId}'.replace('{policyId}', policyId);
const payload: Payload = {};
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('delete', uri, apiHeaders, payload);
}
async createRestoration(
archiveId: string,
services: string[],
newResourceId?: string,
newResourceName?: string
): Promise<BackupRestoration> {
if (typeof archiveId === 'undefined') {
throw new AppwriteException('Missing required parameter: "archiveId"');
}
if (typeof services === 'undefined') {
throw new AppwriteException('Missing required parameter: "services"');
}
const apiPath = '/backups/restoration';
const payload: Payload = {};
if (typeof archiveId !== 'undefined') {
payload['archiveId'] = archiveId;
}
if (typeof services !== 'undefined') {
payload['services'] = services;
}
if (typeof newResourceId !== 'undefined') {
payload['newResourceId'] = newResourceId;
}
if (typeof newResourceName !== 'undefined') {
payload['newResourceName'] = newResourceName;
}
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('post', uri, apiHeaders, payload);
}
async listRestorations(queries?: string[]): Promise<BackupRestorationList> {
const apiPath = '/backups/restorations';
const payload: Payload = {};
if (typeof queries !== 'undefined') {
payload['queries'] = queries;
}
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('get', uri, apiHeaders, payload);
}
async getRestoration(restorationId: string): Promise<BackupArchive> {
if (typeof restorationId === 'undefined') {
throw new AppwriteException('Missing required parameter: "restorationId"');
}
const apiPath = '/backups/restorations/{restorationId}'.replace(
'{restorationId}',
restorationId
);
const payload: Payload = {};
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('get', uri, apiHeaders, payload);
}
}
+1 -23
View File
@@ -8,7 +8,7 @@ import MarkedForDeletion from '$lib/components/billing/alerts/markedForDeletion.
import MissingPaymentMethod from '$lib/components/billing/alerts/missingPaymentMethod.svelte';
import newDevUpgradePro from '$lib/components/billing/alerts/newDevUpgradePro.svelte';
import PaymentAuthRequired from '$lib/components/billing/alerts/paymentAuthRequired.svelte';
import PaymentMandate from '$lib/components/billing/alerts/paymentMandate.svelte';
import { BillingPlan, NEW_DEV_PRO_UPGRADE_COUPON } from '$lib/constants';
import { cachedStore } from '$lib/helpers/cache';
import type { BillingPlansMap } from '$lib/sdk/billing';
@@ -529,28 +529,6 @@ export function checkForMarkedForDeletion(org: Models.Organization) {
}
}
export const paymentMissingMandate = writable<Models.PaymentMethod>(null);
export async function checkForMandate(org: Models.Organization) {
const paymentId = org.paymentMethodId ?? org.backupPaymentMethodId;
if (!paymentId) return;
const paymentMethod = await sdk.forConsole.account.getPaymentMethod({
paymentMethodId: paymentId
});
if (paymentMethod?.mandateId === null && paymentMethod?.country.toLowerCase() === 'in') {
headerAlert.add({
id: 'paymentMandate',
component: PaymentMandate,
show: true,
importance: 8
});
activeHeaderAlert.set(headerAlert.get());
paymentMissingMandate.set(paymentMethod);
}
}
export async function checkForMissingPaymentMethod() {
const orgs = await sdk.forConsole.organizations.list({
queries: [
+3 -3
View File
@@ -27,9 +27,9 @@ export async function checkForDatabaseBackupPolicies(
if (isCloud && backupsEnabled) {
try {
const policies = await sdk
.forProject(region, projectId)
.backups.listPolicies([Query.limit(1), Query.equal('resourceId', database.$id)]);
const policies = await sdk.forProject(region, projectId).backups.listPolicies({
queries: [Query.limit(1), Query.equal('resourceId', database.$id)]
});
total = policies.total;
} catch (e) {
+1 -1
View File
@@ -27,7 +27,7 @@ export const oAuthProviders: Record<string, Provider> = {
apple: {
name: 'Apple',
icon: 'apple',
docs: 'https://developer.apple.com/',
docs: 'https://developer.apple.com/sign-in-with-apple/',
component: Apple
},
auth0: {
+1 -1
View File
@@ -3,6 +3,7 @@ import {
Account,
Assistant,
Avatars,
Backups,
Client,
Console,
Functions,
@@ -25,7 +26,6 @@ import {
Realtime,
Organizations
} from '@appwrite.io/console';
import { Backups } from '../sdk/backups';
import { Sources } from '$lib/sdk/sources';
import {
REGION_FRA,
+64 -2
View File
@@ -1,4 +1,4 @@
import { Client, ID, type Models, Sites, Storage } from '@appwrite.io/console';
import { Client, Functions, ID, type Models, Sites, Storage } from '@appwrite.io/console';
import { writable } from 'svelte/store';
import { getApiEndpoint } from '$lib/stores/sdk';
import { page } from '$app/state';
@@ -32,6 +32,13 @@ const temporarySites = (region: string, projectId: string) => {
return new Sites(clientProject);
};
const temporaryFunctions = (region: string, projectId: string) => {
const clientProject = new Client().setMode('admin');
const endpoint = getApiEndpoint(region);
clientProject.setEndpoint(endpoint).setProject(projectId);
return new Functions(clientProject);
};
const createUploader = () => {
const { subscribe, set, update } = writable<Uploader>({
isOpen: false,
@@ -110,7 +117,19 @@ const createUploader = () => {
newFile.status = 'success';
updateFile(newFile.$id, newFile);
},
uploadSiteDeployment: async (siteId: string, code: File) => {
uploadSiteDeployment: async ({
siteId,
code,
buildCommand,
installCommand,
outputDirectory
}: {
siteId: string;
code: File;
buildCommand?: string;
installCommand?: string;
outputDirectory?: string;
}) => {
const newDeployment: UploaderFile = {
$id: '',
resourceId: siteId,
@@ -132,6 +151,49 @@ const createUploader = () => {
siteId,
code,
activate: true,
buildCommand,
installCommand,
outputDirectory,
onProgress: (progress) => {
newDeployment.$id = progress.$id;
newDeployment.progress = progress.progress;
newDeployment.status = progress.progress === 100 ? 'success' : 'pending';
updateFile(progress.$id, newDeployment);
}
});
newDeployment.$id = uploadedFile.$id;
newDeployment.progress = 100;
newDeployment.status = 'success';
updateFile(newDeployment.$id, newDeployment);
},
uploadFunctionDeployment: async ({
functionId,
code
}: {
functionId: string;
code: File;
}) => {
const newDeployment: UploaderFile = {
$id: '',
resourceId: functionId,
name: code.name,
size: code.size,
progress: 0,
status: 'pending'
};
update((n) => {
n.isOpen = true;
n.isCollapsed = false;
n.files.unshift(newDeployment);
return n;
});
const uploadedFile = await temporaryFunctions(
page.params.region,
page.params.project
).createDeployment({
functionId,
code,
activate: true,
onProgress: (progress) => {
newDeployment.$id = progress.$id;
newDeployment.progress = progress.progress;
-3
View File
@@ -15,7 +15,6 @@
import {
calculateTrialDay,
checkForEnterpriseTrial,
checkForMandate,
checkForMarkedForDeletion,
checkForMissingPaymentMethod,
checkForNewDevUpgradePro,
@@ -308,7 +307,6 @@
if (org?.billingPlan !== BillingPlan.FREE) {
await paymentExpired(org);
await checkPaymentAuthorizationRequired(org);
await checkForMandate(org);
if (org?.billingTrialDays) {
calculateTrialDay(org);
@@ -334,7 +332,6 @@
<CommandCenter />
<Shell
showSideNavigation={page.url.pathname !== '/' &&
!page.url.pathname.includes(base + '/account') &&
!page.url.pathname.includes(base + '/card') &&
!page.url.pathname.includes(base + '/onboarding')}
showHeader={!page.url.pathname.includes(base + '/onboarding/create-project')}
+1 -1
View File
@@ -20,7 +20,7 @@ export const load: LayoutLoad = async ({ depends, parent }) => {
})
: null,
fetch(`${endpoint}/health/version`, {
headers: { 'X-Appwrite-Project': project }
headers: { 'X-Appwrite-Project': project as string }
}).then((response) => response.json() as { version?: string }),
sdk.forConsole.console.variables()
]);
@@ -5,7 +5,6 @@
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { identities } from './store';
import { toLocaleDateTime } from '$lib/helpers/date';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { oAuthProviders } from '$lib/stores/oauth-providers';
@@ -82,7 +81,13 @@
<DualTimeView time={identity.$createdAt} />
</Table.Cell>
<Table.Cell column="expiryDate" {root}>
{toLocaleDateTime(identity.providerAccessTokenExpiry)}
{#if identity.providerAccessTokenExpiry}
<DualTimeView
time={identity.providerAccessTokenExpiry}
showDatetime />
{:else}
-
{/if}
</Table.Cell>
<Table.Cell column="actions" {root}>
<Button text on:click={() => deleteIdentity(identity.$id)}>
@@ -9,15 +9,28 @@
import { Alert } from '@appwrite.io/pink-svelte';
import type { Models } from '@appwrite.io/console';
export let show = false;
export let isLinked = false;
export let selectedPaymentMethod: Models.PaymentMethod;
let {
show = $bindable(false),
isLinked = false,
selectedPaymentMethod
}: {
show: boolean;
isLinked?: boolean;
selectedPaymentMethod: Models.PaymentMethod;
} = $props();
let year: number | null = $state(null);
let month: string | null = $state(null);
let error: string | null = $state(null);
const currentYear = new Date().getFullYear();
let error: string;
let month: string;
let year: number;
const months = Array.from({ length: 12 }, (_, i) => {
const value = String(i + 1).padStart(2, '0');
return { value, label: value };
});
const options = $derived(createMonthOptions(year));
async function handleSubmit() {
try {
@@ -30,6 +43,8 @@
trackEvent(Submit.PaymentMethodUpdate);
invalidate(Dependencies.PAYMENT_METHODS);
show = false;
trackEvent(Submit.PaymentMethodUpdate);
await invalidate(Dependencies.PAYMENT_METHODS);
addNotification({
type: 'success',
message: 'Your payment method has been updated'
@@ -41,20 +56,6 @@
}
function createMonthOptions(year: number) {
const months = [
{ value: '01', label: '01' },
{ value: '02', label: '02' },
{ value: '03', label: '03' },
{ value: '04', label: '04' },
{ value: '05', label: '05' },
{ value: '06', label: '06' },
{ value: '07', label: '07' },
{ value: '08', label: '08' },
{ value: '09', label: '09' },
{ value: '10', label: '10' },
{ value: '11', label: '11' },
{ value: '12', label: '12' }
];
if (!year) return months;
if (year === currentYear) {
const currentMonth = new Date().getMonth() + 1;
@@ -63,8 +64,6 @@
return months;
}
}
$: options = createMonthOptions(year);
</script>
<Modal bind:error onSubmit={handleSubmit} bind:show title="Update payment method">
@@ -13,7 +13,14 @@
import type { PageData } from './$types';
import { addNotification } from '$lib/stores/notifications';
import { onMount } from 'svelte';
import { Badge, Layout, Table, Typography, Icon } from '@appwrite.io/pink-svelte';
import {
Badge,
Layout,
Table,
Typography,
Icon,
InteractiveText
} from '@appwrite.io/pink-svelte';
import { IconGlobeAlt } from '@appwrite.io/pink-icons-svelte';
export let data: PageData;
@@ -92,9 +99,9 @@
<Table.Root
let:root
columns={[
{ id: 'client' },
{ id: 'location', width: 200 },
{ id: 'ip', width: 200 },
{ id: 'client', width: { min: 450 } },
{ id: 'location', width: { min: 200 } },
{ id: 'ip', width: { min: 330 } },
{ id: 'actions', width: 100 }
]}>
<svelte:fragment slot="header" let:root>
@@ -146,7 +153,7 @@
{/if}
</Table.Cell>
<Table.Cell column="ip" {root}>
{session.ip}
<InteractiveText variant="copy" text={session.ip} isVisible />
</Table.Cell>
<Table.Cell column="actions" {root}>
<Button size="xs" secondary on:click={() => logout(session)}>Sign out</Button>
@@ -22,7 +22,7 @@ export const load: LayoutLoad = async ({ params, depends, parent }) => {
depends(Dependencies.MEMBERS);
depends(Dependencies.PAYMENT_METHODS);
const requestedOrg = await checkPlatformAndRedirect(params, organizations, prefs);
await checkPlatformAndRedirect(params, organizations, prefs);
let roles = isCloud ? [] : defaultRoles;
let scopes = isCloud ? [] : defaultScopes;
@@ -49,15 +49,10 @@ export const load: LayoutLoad = async ({ params, depends, parent }) => {
sdk.forConsole.account.updatePrefs({ prefs: newPrefs });
}
// fetch org only if we haven't already fetched it for platform check
const orgPromise: Promise<Models.Organization> = requestedOrg
? Promise.resolve(requestedOrg)
: (sdk.forConsole.teams.get({
teamId: params.organization
}) as Promise<Models.Organization>);
const [org, members, countryList, locale] = await Promise.all([
orgPromise,
sdk.forConsole.teams.get({
teamId: params.organization
}) as Promise<Models.Organization>,
sdk.forConsole.teams.listMemberships({ teamId: params.organization }),
sdk.forConsole.locale.listCountries(),
sdk.forConsole.locale.get(),
@@ -101,7 +96,7 @@ async function checkPlatformAndRedirect(
params: { organization: string },
organizations: { teams: Array<{ $id: string; platform?: string }> },
prefs: Record<string, string>
): Promise<Models.Organization | null> {
) {
// check if preloaded
let requestedOrg = organizations.teams.find((team) => team.$id === params.organization) as
| Models.Organization
@@ -159,8 +154,4 @@ async function checkPlatformAndRedirect(
redirect(303, resolve('/(console)'));
}
}
// send the org,
// if already in the full list so we don't have to make another API request.
return requestedOrg;
}
@@ -71,12 +71,6 @@
return getServiceLimit('projects', null, data.currentPlan);
});
const projectsToArchive = $derived.by(() => {
return isCloud
? data.projects.projects.filter((project) => project.status === 'archived')
: [];
});
function filterPlatforms(platforms: { name: string; icon: string }[]) {
return platforms.filter(
(value, index, self) => index === self.findIndex((t) => t.name === value.name)
@@ -136,6 +130,19 @@
return project.status === 'archived';
}
const projectsToArchive = $derived(
(data.archivedProjectsPage ?? data.projects.projects).filter(
(project) => project.status === 'archived'
)
);
const activeTotalOverall = $derived(
data?.activeTotalOverall ??
data?.organization?.projects?.length ??
data?.projects?.total ??
0
);
function clearSearch() {
searchQuery?.clearInput();
}
@@ -163,7 +170,7 @@
<Container>
<Layout.Stack direction="row" justifyContent="space-between" class="common-section">
<SearchQuery bind:this={searchQuery} placeholder="Search by name or ID" />
<SearchQuery bind:this={searchQuery} placeholder="Search by name, label, or ID" />
{#if $canWriteProjects}
{#if projectCreationDisabled && reachedProjectLimit}
@@ -238,7 +245,7 @@
{#if data.projects.total > 0}
<CardContainer
disableEmpty={!$canWriteProjects}
total={data.projects.total}
total={activeTotalOverall}
offset={data.offset}
on:click={handleCreateProject}>
{#each data.projects.projects as project}
@@ -323,13 +330,16 @@
name="Projects"
limit={data.limit}
offset={data.offset}
total={data.projects.total} />
total={activeTotalOverall} />
<!-- Archived Projects Section -->
<ArchiveProject
{projectsToArchive}
organization={data.organization}
currentPlan={$currentPlan} />
currentPlan={$currentPlan}
archivedTotalOverall={data.archivedTotalOverall}
archivedOffset={data.archivedOffset}
limit={data.limit} />
</Container>
<CreateOrganization bind:show={addOrganization} />
<CreateProject bind:show={showCreate} teamId={page.params.organization} />
@@ -1,11 +1,11 @@
import { Query } from '@appwrite.io/console';
import { isCloud } from '$lib/system';
import { sdk } from '$lib/stores/sdk';
import { getLimit, getPage, getSearch, pageToOffset } from '$lib/helpers/load';
import { CARD_LIMIT, Dependencies } from '$lib/constants';
import type { PageLoad } from './$types';
import { redirect } from '@sveltejs/kit';
import { base } from '$app/paths';
import { isCloud } from '$lib/system';
export const load: PageLoad = async ({ params, url, route, depends, parent }) => {
const { scopes } = await parent();
@@ -20,26 +20,76 @@ export const load: PageLoad = async ({ params, url, route, depends, parent }) =>
const offset = pageToOffset(page, limit);
const search = getSearch(url);
const projects = await sdk.forConsole.projects.list({
queries: [
Query.offset(offset),
Query.equal('teamId', params.organization),
Query.limit(limit),
Query.orderDesc(''),
Query.select(['$id', 'name', 'platforms', 'region', ...(isCloud ? ['status'] : [])])
],
search: search || undefined
});
const archivedPageRaw = parseInt(url.searchParams.get('archivedPage') || '1', 10);
const archivedPage =
Number.isFinite(archivedPageRaw) && archivedPageRaw > 0 ? archivedPageRaw : 1;
const archivedOffset = pageToOffset(archivedPage, limit);
const searchQueries = search
? [Query.or([Query.search('search', search), Query.contains('labels', search)])]
: [];
const commonQueries = [Query.equal('teamId', params.organization)];
const activeQueries = isCloud
? [Query.or([Query.equal('status', 'active'), Query.isNull('status')])]
: [];
const [activeProjects, archivedProjects, activeTotal, archivedTotal] = await Promise.all([
sdk.forConsole.projects.list({
queries: [
Query.offset(offset),
Query.limit(limit),
Query.orderDesc(''),
...commonQueries,
...searchQueries,
...activeQueries
]
}),
isCloud
? sdk.forConsole.projects.list({
queries: [
Query.offset(archivedOffset),
Query.limit(limit),
Query.orderDesc(''),
...commonQueries,
...searchQueries,
Query.equal('status', 'archived')
]
})
: Promise.resolve({ projects: [], total: 0 }),
sdk.forConsole.projects.list({
queries: [...commonQueries, ...activeQueries, ...searchQueries]
}),
isCloud
? sdk.forConsole.projects.list({
queries: [...commonQueries, ...searchQueries, Query.equal('status', 'archived')]
})
: Promise.resolve({ projects: [], total: 0 })
]);
// set `default` if no region!
for (const project of projects.projects) {
for (const project of activeProjects.projects) {
project.region ??= 'default';
}
if (isCloud) {
for (const project of archivedProjects.projects) {
project.region ??= 'default';
}
}
return {
offset,
limit,
projects,
projects: {
...activeProjects,
projects: activeProjects.projects,
total: activeTotal.total
},
activeProjectsPage: activeProjects.projects,
archivedProjectsPage: archivedProjects.projects,
activeTotalOverall: activeTotal.total,
archivedTotalOverall: archivedTotal.total,
archivedOffset,
archivedPage,
search
};
};
@@ -25,22 +25,15 @@
import { Alert } from '@appwrite.io/pink-svelte';
import { goto, invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { base } from '$app/paths';
import type { PageData } from './$types';
import { resolve } from '$app/paths';
export let data: PageData;
// Reactive statement to update organization when data changes
$: organization = data.organization;
// why are these reactive?
$: defaultPaymentMethod = data?.paymentMethods?.paymentMethods?.find(
(method) => method.$id === organization?.paymentMethodId
);
$: backupPaymentMethod = data?.paymentMethods?.paymentMethods?.find(
(method) => method.$id === organization?.backupPaymentMethodId
);
$: baseUrl = resolve('/(console)/organization-[organization]/billing', {
organization: organization.$id
});
onMount(async () => {
if (page.url.searchParams.has('type')) {
@@ -62,7 +55,7 @@
organization.$id,
invoice.clientSecret,
organization.paymentMethodId,
`${base}/organization-${organization.$id}/billing?type=validate-invoice&invoice=${invoice.$id}`
`${baseUrl}?type=validate-invoice&invoice=${invoice.$id}`
);
}
@@ -122,7 +115,7 @@
</Alert.Inline>
{/if}
{/if}
{#if defaultPaymentMethod?.failed && !backupPaymentMethod}
{#if data.primaryPaymentMethod?.failed && !data.backupPaymentMethod}
<Alert.Inline
status="error"
title={`The default payment method for ${organization.name} has expired`}>
@@ -156,7 +149,13 @@
currentInvoice={data?.billingInvoice} />
{/if}
<PaymentHistory />
<PaymentMethods organization={data?.organization} methods={data?.paymentMethods} />
<PaymentMethods
methods={data?.paymentMethods}
organization={data?.organization}
backupMethod={data.backupPaymentMethod}
primaryMethod={data.primaryPaymentMethod} />
<BillingAddress
organization={data?.organization}
billingAddress={data?.billingAddress}
@@ -25,7 +25,7 @@ export const load: PageLoad = async ({ parent, depends, url, route }) => {
depends(Dependencies.CREDIT);
depends(Dependencies.INVOICES);
depends(Dependencies.ADDRESS);
//aggregation reloads on page param changes
// aggregation reloads on page param changes
depends(Dependencies.BILLING_AGGREGATION);
const billingAddressId = (organization as Models.Organization)?.billingAddressId;
@@ -93,6 +93,7 @@ export const load: PageLoad = async ({ parent, depends, url, route }) => {
// make number
const credits = availableCredit ? availableCredit.available : null;
const { backup, primary } = getOrganizationPaymentMethods(organization, paymentMethods);
return {
paymentMethods,
@@ -109,6 +110,32 @@ export const load: PageLoad = async ({ parent, depends, url, route }) => {
offset: pageToOffset(
getPage(url) || 1,
getLimit(url, route, DEFAULT_BILLING_PROJECTS_LIMIT)
)
),
backupPaymentMethod: backup,
primaryPaymentMethod: primary
};
};
function getOrganizationPaymentMethods(
organization: Models.Organization,
paymentMethods: Models.PaymentMethodList
): {
backup: Models.PaymentMethod | null;
primary: Models.PaymentMethod | null;
} {
let backup: Models.PaymentMethod | null = null;
let primary: Models.PaymentMethod | null = null;
for (const paymentMethod of paymentMethods.paymentMethods) {
if (paymentMethod.$id === organization.paymentMethodId) {
primary = paymentMethod;
} else if (paymentMethod.$id === organization.backupPaymentMethodId) {
backup = paymentMethod;
}
if (primary && backup) break;
}
return { primary, backup };
}
@@ -27,7 +27,7 @@
message: `The payment method has been removed from ${$organization.name}`
});
trackEvent(Submit.OrganizationPaymentDelete);
invalidate(Dependencies.ORGANIZATION);
await invalidate(Dependencies.PAYMENT_METHODS);
showDelete = false;
} catch (e) {
error = e.message;
@@ -50,7 +50,7 @@
message: `The payment method has been removed from ${$organization.name}`
});
trackEvent(Submit.OrganizationBackupPaymentDelete);
await invalidate(Dependencies.ORGANIZATION);
await invalidate(Dependencies.PAYMENT_METHODS);
showDelete = false;
} catch (e) {
error = e.message;
@@ -70,10 +70,10 @@
</Confirm>
{:else}
<Confirm
onSubmit={isBackup ? removeBackupMethod : removeDefaultMethod}
title="Remove payment method"
bind:error
bind:open={showDelete}
bind:error>
title="Remove payment method"
onSubmit={isBackup ? removeBackupMethod : removeDefaultMethod}>
<Typography.Text>
Are you sure you want to remove the payment method from <b>{$organization?.name}</b>?
</Typography.Text>
@@ -35,14 +35,14 @@
export let organization: Models.Organization;
export let methods: Models.PaymentMethodList;
export let backupMethod: Models.PaymentMethod;
export let primaryMethod: Models.PaymentMethod;
let showEdit = false;
let showDelete = false;
let showPayment = false;
let showReplace = false;
let isSelectedBackup = false;
let backupPaymentMethod: Models.PaymentMethod;
let defaultPaymentMethod: Models.PaymentMethod;
async function addPaymentMethod(paymentMethodId: string) {
try {
@@ -55,7 +55,7 @@
message: `A new payment method has been added to ${organization.name}`
});
trackEvent(Submit.OrganizationPaymentUpdate);
invalidate(Dependencies.ORGANIZATION);
await invalidate(Dependencies.PAYMENT_METHODS);
} catch (error) {
addNotification({
type: 'error',
@@ -75,7 +75,7 @@
type: 'success',
message: `A new payment method has been added to ${organization.name}`
});
invalidate(Dependencies.ORGANIZATION);
await invalidate(Dependencies.PAYMENT_METHODS);
} catch (error) {
addNotification({
type: 'error',
@@ -85,33 +85,15 @@
}
}
$: if (organization?.backupPaymentMethodId) {
sdk.forConsole.organizations
.getPaymentMethod({
organizationId: organization.$id,
paymentMethodId: organization.backupPaymentMethodId
})
.then((res) => (backupPaymentMethod = res));
}
$: if (organization?.paymentMethodId) {
sdk.forConsole.organizations
.getPaymentMethod({
organizationId: organization.$id,
paymentMethodId: organization.paymentMethodId
})
.then((res) => (defaultPaymentMethod = res));
}
$: if (!showReplace) {
isSelectedBackup = false;
}
$: hasPaymentError =
defaultPaymentMethod?.lastError ||
defaultPaymentMethod?.expired ||
backupPaymentMethod?.lastError ||
backupPaymentMethod?.expired;
primaryMethod?.lastError ||
primaryMethod?.expired ||
backupMethod?.lastError ||
backupMethod?.expired;
</script>
<CardGrid overflow={false}>
@@ -137,14 +119,14 @@
</svelte:fragment>
<Table.Row.Base {root}>
<CreditCardInfo {root} paymentMethod={defaultPaymentMethod} />
<CreditCardInfo {root} paymentMethod={primaryMethod} />
<Table.Cell column="actions" {root}>
<Popover let:toggle placement="bottom-start" padding="none">
<Button text icon ariaLabel="more options" on:click={toggle}>
<Icon icon={IconDotsHorizontal} size="s" />
</Button>
<ActionMenu.Root slot="tooltip">
{#if defaultPaymentMethod?.userId === $user?.$id}
{#if primaryMethod?.userId === $user?.$id}
<ActionMenu.Item.Button
leadingIcon={IconPencil}
on:click={() => {
@@ -176,14 +158,14 @@
</Table.Row.Base>
{#if organization?.backupPaymentMethodId}
<Table.Row.Base {root}>
<CreditCardInfo {root} isBackup paymentMethod={backupPaymentMethod} />
<CreditCardInfo {root} isBackup paymentMethod={backupMethod} />
<Table.Cell column="actions" {root}>
<Popover let:toggle placement="bottom-start" padding="none">
<Button text icon ariaLabel="more options" on:click={toggle}>
<Icon icon={IconDotsHorizontal} size="s" />
</Button>
<ActionMenu.Root slot="tooltip">
{#if backupPaymentMethod?.userId === $user?.$id}
{#if backupMethod?.userId === $user?.$id}
<ActionMenu.Item.Button
leadingIcon={IconPencil}
on:click={() => {
@@ -323,8 +305,8 @@
{/if}
{#if showEdit && isCloud && hasStripePublicKey}
<EditPaymentModal
selectedPaymentMethod={isSelectedBackup ? backupPaymentMethod : defaultPaymentMethod}
bind:show={showEdit} />
bind:show={showEdit}
selectedPaymentMethod={isSelectedBackup ? backupMethod : primaryMethod} />
{/if}
{#if isCloud && hasStripePublicKey}
<ReplaceCard {organization} {methods} bind:show={showReplace} isBackup={isSelectedBackup} />
@@ -12,17 +12,29 @@
import type { PaymentMethod as StripePaymentMethod } from '@stripe/stripe-js';
import type { Models } from '@appwrite.io/console';
export let show = false;
export let isBackup = false;
export let organization: Models.Organization;
export let methods: Models.PaymentMethodList;
let {
show = $bindable(false),
isBackup = false,
methods,
organization
}: {
show?: boolean;
isBackup?: boolean;
methods: Models.PaymentMethodList;
organization: Models.Organization;
} = $props();
let name: string;
let error: string = null;
let selectedPaymentMethodId: string;
let showState: boolean = false;
let state: string = '';
let paymentMethod: StripePaymentMethod | null = null;
let name: string | null = $state(null);
let error: string | null = $state(null);
let showState: boolean = $state(false);
let countryState: string | null = $state(null);
let paymentMethod: StripePaymentMethod | null = $state(null);
let selectedPaymentMethodId: string | null = $state(null);
const filteredMethods = $derived(methods?.paymentMethods.filter((method) => !!method?.last4));
const submitEvent = $derived(
isBackup ? Submit.OrganizationBackupPaymentUpdate : Submit.OrganizationPaymentUpdate
);
onMount(async () => {
if (!organization.paymentMethodId && !organization.backupPaymentMethodId) {
@@ -44,12 +56,12 @@
async function handleSubmit() {
try {
if (selectedPaymentMethodId === '$new') {
if (showState && !state) {
if (showState && !countryState) {
throw Error('Please select a state');
}
let method: Models.PaymentMethod;
if (showState) {
method = await setPaymentMethod(paymentMethod.id, name, state);
method = await setPaymentMethod(paymentMethod.id, name, countryState);
} else {
const card = await submitStripeCard(name, organization.$id);
if (card && Object.hasOwn(card, 'id')) {
@@ -68,21 +80,17 @@
? await addBackupPaymentMethod(selectedPaymentMethodId)
: await addPaymentMethod(selectedPaymentMethodId);
await invalidate(Dependencies.PAYMENT_METHODS);
addNotification({
type: 'success',
message: `Your ${isBackup ? 'backup' : 'default'} payment method has been updated`
});
invalidate(Dependencies.ORGANIZATION);
trackEvent(
isBackup ? Submit.OrganizationBackupPaymentDelete : Submit.OrganizationPaymentDelete
);
trackEvent(submitEvent);
show = false;
} catch (e) {
error = e.message;
trackError(
e,
isBackup ? Submit.OrganizationBackupPaymentDelete : Submit.OrganizationPaymentDelete
);
} catch (err) {
error = err.message;
trackError(err, submitEvent);
}
}
@@ -92,8 +100,8 @@
organizationId: organization.$id,
paymentMethodId
});
} catch (e) {
error = e.message;
} catch (err) {
error = err.message;
}
}
@@ -103,23 +111,21 @@
organizationId: organization.$id,
paymentMethodId
});
} catch (e) {
error = e.message;
} catch (err) {
error = err.message;
}
}
$: filteredMethods = methods?.paymentMethods.filter((method) => !!method?.last4);
</script>
<FakeModal bind:show bind:error onSubmit={handleSubmit} size="big" title="Replace payment method">
<p class="text">Replace the existing payment method for your organization.</p>
<PaymentBoxes
methods={filteredMethods}
bind:name
bind:paymentMethod
bind:showState
bind:state
bind:paymentMethod
methods={filteredMethods}
bind:state={countryState}
bind:group={selectedPaymentMethodId}
defaultMethod={organization?.paymentMethodId}
backupMethod={organization?.backupPaymentMethodId}
@@ -124,7 +124,7 @@
<style>
.layout-level-progress-bars {
gap: 1rem;
z-index: 2;
z-index: 100;
display: flex;
flex-direction: column;
@@ -9,6 +9,7 @@
import {
AvatarInitials,
Copy,
type DeleteOperation,
type DeleteOperationState,
Empty,
EmptySearch,
@@ -60,20 +61,22 @@
);
}
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((userId) => {
return sdk.forProject(page.params.region, page.params.project).users.delete({ userId });
});
async function handleDelete(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
const result = await batchDelete((userId) =>
sdk.forProject(page.params.region, page.params.project).users.delete({ userId })
);
try {
await Promise.all(promises);
trackEvent(Submit.UserDelete, { total: selectedRows.length });
} catch (error) {
trackError(error, Submit.UserDelete);
return error;
if (result.error) {
trackError(result.error, Submit.UserDelete);
} else {
trackEvent(Submit.UserDelete, { total: result.deleted.length });
}
} finally {
await invalidate(Dependencies.USERS);
}
return result;
}
</script>
@@ -1,26 +1,23 @@
<script lang="ts">
import { Container } from '$lib/layout';
import type { PageProps } from './$types';
import UpdateMockNumbers from './updateMockNumbers.svelte';
import UpdatePasswordDictionary from './updatePasswordDictionary.svelte';
import UpdatePasswordHistory from './updatePasswordHistory.svelte';
import UpdatePersonalDataCheck from './updatePersonalDataCheck.svelte';
import UpdateSessionAlerts from './updateSessionAlerts.svelte';
import UpdateSessionLength from './updateSessionLength.svelte';
import UpdateSessionsLimit from './updateSessionsLimit.svelte';
import UpdateMembershipPrivacy from './updateMembershipPrivacy.svelte';
import UpdateUsersLimit from './updateUsersLimit.svelte';
import UpdateSessionInvalidation from './updateSessionInvalidation.svelte';
import UpdateSessionLength from './updateSessionLength.svelte';
import UpdateSessionsLimit from './updateSessionsLimit.svelte';
import PasswordPolicies from './passwordPolicies.svelte';
import SessionSecurity from './sessionSecurity.svelte';
let { data }: PageProps = $props();
</script>
<Container>
<UpdateUsersLimit />
<UpdateSessionLength />
<UpdateSessionsLimit />
<UpdatePasswordHistory />
<UpdatePasswordDictionary />
<UpdatePersonalDataCheck />
<UpdateSessionAlerts />
<UpdateSessionInvalidation />
<PasswordPolicies project={data.project} />
<SessionSecurity project={data.project} />
<UpdateMockNumbers />
<UpdateMembershipPrivacy />
</Container>
@@ -0,0 +1,159 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { CardGrid } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button, Form, InputNumber, InputSwitch } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { Typography, Link, Layout } from '@appwrite.io/pink-svelte';
import type { Models } from '@appwrite.io/console';
import { onMount } from 'svelte';
let {
project
}: {
project: Models.Project;
} = $props();
let lastValidLimit = $state(5);
let passwordHistory = $state(5);
let passwordDictionary = $state(false);
let passwordHistoryEnabled = $state(false);
let authPersonalDataCheck = $state(false);
onMount(() => {
// update initial states here in onMount.
const historyValue = project.authPasswordHistory;
if (historyValue && historyValue > 0) {
passwordHistory = historyValue;
lastValidLimit = historyValue;
}
passwordHistoryEnabled = (historyValue ?? 0) !== 0;
passwordDictionary = project.authPasswordDictionary ?? false;
authPersonalDataCheck = project.authPersonalDataCheck ?? false;
});
$effect(() => {
// restore last valid limit when enabling
if (passwordHistoryEnabled && passwordHistory < 1) {
passwordHistory = lastValidLimit;
}
});
const hasChanges = $derived.by(() => {
const dictChanged = passwordDictionary !== (project.authPasswordDictionary ?? false);
const dataCheckChanged = authPersonalDataCheck !== (project.authPersonalDataCheck ?? false);
const historyChanged =
passwordHistoryEnabled !== ((project.authPasswordHistory ?? 0) !== 0);
const limitChanged =
passwordHistoryEnabled &&
Number(passwordHistory) !== (project.authPasswordHistory ?? 0);
return historyChanged || dictChanged || dataCheckChanged || limitChanged;
});
async function updatePasswordPolicies() {
try {
const projectSdk = sdk.forConsole.projects;
await projectSdk.updateAuthPasswordHistory({
projectId: project.$id,
limit: passwordHistoryEnabled ? passwordHistory : 0
});
await projectSdk.updateAuthPasswordDictionary({
projectId: project.$id,
enabled: passwordDictionary
});
await projectSdk.updatePersonalDataCheck({
projectId: project.$id,
enabled: authPersonalDataCheck
});
await invalidate(Dependencies.PROJECT);
addNotification({
type: 'success',
message: 'Updated password policies.'
});
trackEvent(Submit.AuthPasswordHistoryUpdate);
trackEvent(Submit.AuthPasswordDictionaryUpdate);
trackEvent(Submit.AuthPersonalDataCheckUpdate);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.AuthPasswordHistoryUpdate);
}
}
</script>
<Form onSubmit={updatePasswordPolicies}>
<CardGrid gap="xxl">
<svelte:fragment slot="title">Password policies</svelte:fragment>
<svelte:fragment slot="aside">
<InputSwitch
bind:value={passwordHistoryEnabled}
id="passwordHistoryEnabled"
label="Password history">
<svelte:fragment slot="description">
<Layout.Stack gap="m">
<Typography.Text>
Enabling this option prevents users from reusing recent passwords by
comparing the new password with their password history.
</Typography.Text>
{#if passwordHistoryEnabled}
<InputNumber
required
max={20}
min={1}
autofocus
label="Limit"
id="password-history"
bind:value={passwordHistory}
helper="Maximum 20 passwords." />
{/if}
</Layout.Stack>
</svelte:fragment>
</InputSwitch>
<InputSwitch
bind:value={passwordDictionary}
id="passwordDictionary"
label="Password dictionary">
<svelte:fragment slot="description">
<Typography.Text>
Enabling this option prevents users from setting insecure passwords by
comparing the user's password with the <Link.Anchor
target="_blank"
rel="noopener noreferrer"
class="link"
href="https://github.com/danielmiessler/SecLists/blob/master/Passwords/Common-Credentials/10k-most-common.txt"
>10k most commonly used passwords.</Link.Anchor>
</Typography.Text>
</svelte:fragment>
</InputSwitch>
<InputSwitch
bind:value={authPersonalDataCheck}
id="personalDataCheck"
label="Disallow personal data">
<svelte:fragment slot="description">
<Typography.Text>
Do not allow passwords that contain any part of the user's personal data.
This includes the user's <Typography.Code>name</Typography.Code>, <Typography.Code
>email</Typography.Code
>, or <Typography.Code>phone</Typography.Code>.
</Typography.Text>
</svelte:fragment>
</InputSwitch>
</svelte:fragment>
<svelte:fragment slot="actions">
<Button disabled={!hasChanges} submit>Update</Button>
</svelte:fragment>
</CardGrid>
</Form>
@@ -0,0 +1,92 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { CardGrid } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button, Form, InputSwitch } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { Typography } from '@appwrite.io/pink-svelte';
import type { Models } from '@appwrite.io/console';
import { onMount } from 'svelte';
let { project }: { project: Models.Project } = $props();
let authSessionAlerts = $state(false);
let sessionInvalidation = $state(false);
onMount(() => {
authSessionAlerts = project?.authSessionAlerts ?? false;
sessionInvalidation = project?.authInvalidateSessions ?? false;
});
const hasChanges = $derived.by(() => {
const alertsChanged = authSessionAlerts !== (project?.authSessionAlerts ?? false);
const invalidationChanged =
sessionInvalidation !== (project?.authInvalidateSessions ?? false);
return alertsChanged || invalidationChanged;
});
async function updateSessionSecurity() {
try {
await sdk.forConsole.projects.updateSessionAlerts({
projectId: project.$id,
alerts: authSessionAlerts
});
await sdk.forConsole.projects.updateSessionInvalidation({
projectId: project.$id,
enabled: sessionInvalidation
});
await invalidate(Dependencies.PROJECT);
addNotification({
type: 'success',
message: 'Updated session security settings.'
});
trackEvent(Submit.AuthSessionAlertsUpdate);
trackEvent(Submit.AuthInvalidateSession);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.AuthSessionAlertsUpdate);
}
}
</script>
<Form onSubmit={updateSessionSecurity}>
<CardGrid gap="xxl">
<svelte:fragment slot="title">Session security</svelte:fragment>
<svelte:fragment slot="aside">
<InputSwitch
bind:value={authSessionAlerts}
id="authSessionAlerts"
label="Session alerts">
<svelte:fragment slot="description">
<Typography.Text>
Enabling this option will send an email to the users when a new session is
created.
</Typography.Text>
</svelte:fragment>
</InputSwitch>
<InputSwitch
bind:value={sessionInvalidation}
id="invalidateSessions"
label="Invalidate sessions">
<svelte:fragment slot="description">
<Typography.Text>
Enabling this option will clear all existing sessions when the user changes
their password.
</Typography.Text>
</svelte:fragment>
</InputSwitch>
</svelte:fragment>
<svelte:fragment slot="actions">
<Button disabled={!hasChanges} submit>Update</Button>
</svelte:fragment>
</CardGrid>
</Form>
@@ -1,61 +0,0 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { CardGrid } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button, Form, InputSwitch } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { Typography, Link } from '@appwrite.io/pink-svelte';
import { project } from '../../store';
let passwordDictionary = $project?.authPasswordDictionary ?? false;
async function updatePasswordDictionary() {
try {
await sdk.forConsole.projects.updateAuthPasswordDictionary({
projectId: $project.$id,
enabled: passwordDictionary
});
await invalidate(Dependencies.PROJECT);
addNotification({
type: 'success',
message: 'Updated password dictionary check.'
});
trackEvent(Submit.AuthPasswordDictionaryUpdate);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.AuthPasswordDictionaryUpdate);
}
}
</script>
<Form onSubmit={updatePasswordDictionary}>
<CardGrid>
<svelte:fragment slot="title">Password dictionary</svelte:fragment>
<svelte:fragment slot="aside">
<InputSwitch
bind:value={passwordDictionary}
id="passwordDictionary"
label="Password dictionary" />
<Typography.Text>
Enabling this option prevent users from setting insecure passwords by comparing the
user's password with the <Link.Anchor
target="_blank"
rel="noopener noreferrer"
class="link"
href="https://github.com/danielmiessler/SecLists/blob/master/Passwords/Common-Credentials/10k-most-common.txt"
>10k most commonly used passwords.</Link.Anchor>
</Typography.Text>
</svelte:fragment>
<svelte:fragment slot="actions">
<Button disabled={passwordDictionary === $project?.authPasswordDictionary} submit>
Update
</Button>
</svelte:fragment>
</CardGrid>
</Form>
@@ -1,80 +0,0 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { CardGrid } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button, Form, InputNumber, InputSwitch } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { Typography } from '@appwrite.io/pink-svelte';
import { project } from '../../store';
import { tick } from 'svelte';
let passwordHistory = $project?.authPasswordHistory < 1 ? 5 : $project?.authPasswordHistory;
let passwordHistoryEnabled = ($project?.authPasswordHistory ?? 0) !== 0;
let initialPasswordHistoryEnabled = passwordHistoryEnabled;
async function updatePasswordHistoryLimit() {
try {
await sdk.forConsole.projects.updateAuthPasswordHistory({
projectId: $project.$id,
limit: passwordHistoryEnabled ? passwordHistory : 0
});
await invalidate(Dependencies.PROJECT);
initialPasswordHistoryEnabled = passwordHistoryEnabled;
addNotification({
type: 'success',
message: 'Updated password history limit.'
});
trackEvent(Submit.AuthPasswordHistoryUpdate);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.AuthPasswordHistoryUpdate);
}
}
let maxPasswordInputField: InputNumber | null = null;
$: if (passwordHistoryEnabled && maxPasswordInputField) {
tick().then(() => {
maxPasswordInputField.addInputFocus();
});
}
</script>
<Form onSubmit={updatePasswordHistoryLimit}>
<CardGrid>
<svelte:fragment slot="title">Password history</svelte:fragment>
Set the maximum number of passwords saved per user.
<svelte:fragment slot="aside">
<InputSwitch
bind:value={passwordHistoryEnabled}
id="passwordHistoryEnabled"
label="Password history" />
<Typography.Text>
Enabling this option prevents users from reusing recent passwords by comparing the
new password with their password history.
</Typography.Text>
<InputNumber
required
max={20}
min={1}
id="password-history"
label="Limit"
disabled={!passwordHistoryEnabled}
bind:value={passwordHistory}
helper="Maximum 20 passwords." />
</svelte:fragment>
<svelte:fragment slot="actions">
<Button
disabled={(passwordHistory === $project?.authPasswordHistory ||
$project?.authPasswordHistory === 0) &&
initialPasswordHistoryEnabled === passwordHistoryEnabled}
submit>Update</Button>
</svelte:fragment>
</CardGrid>
</Form>
@@ -1,56 +0,0 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { CardGrid } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button, Form, InputSwitch } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { Typography } from '@appwrite.io/pink-svelte';
import { project } from '../../store';
let authPersonalDataCheck = $project?.authPersonalDataCheck ?? false;
async function updatePersonalDataCheck() {
try {
await sdk.forConsole.projects.updatePersonalDataCheck({
projectId: $project.$id,
enabled: authPersonalDataCheck
});
await invalidate(Dependencies.PROJECT);
addNotification({
type: 'success',
message: 'Toggled personal data checks for passwords'
});
trackEvent(Submit.AuthPersonalDataCheckUpdate);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.AuthPersonalDataCheckUpdate);
}
}
</script>
<Form onSubmit={updatePersonalDataCheck}>
<CardGrid>
<svelte:fragment slot="title">Personal data</svelte:fragment>
<svelte:fragment slot="aside">
<InputSwitch
bind:value={authPersonalDataCheck}
id="personalDataCheck"
label="Disallow personal data" />
<Typography.Text>
Do not allow passwords that contain any part of the user's personal data. This
includes the user's <Typography.Code>name</Typography.Code>, <Typography.Code
>email</Typography.Code
>, or <Typography.Code>phone</Typography.Code>.
</Typography.Text>
</svelte:fragment>
<svelte:fragment slot="actions">
<Button disabled={authPersonalDataCheck === $project?.authPersonalDataCheck} submit
>Update</Button>
</svelte:fragment>
</CardGrid>
</Form>
@@ -1,54 +0,0 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { CardGrid } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button, Form, InputSwitch } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { Typography } from '@appwrite.io/pink-svelte';
import { project } from '../../store';
let authSessionAlerts = $project?.authSessionAlerts ?? false;
async function updateSessionAlerts() {
try {
await sdk.forConsole.projects.updateSessionAlerts({
projectId: $project.$id,
alerts: authSessionAlerts
});
await invalidate(Dependencies.PROJECT);
addNotification({
type: 'success',
message: 'Updated session alerts.'
});
trackEvent(Submit.AuthSessionAlertsUpdate);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.AuthSessionAlertsUpdate);
}
}
</script>
<Form onSubmit={updateSessionAlerts}>
<CardGrid>
<svelte:fragment slot="title">Session alerts</svelte:fragment>
<svelte:fragment slot="aside">
<InputSwitch
bind:value={authSessionAlerts}
id="authSessionAlerts"
label="Session alerts" />
<Typography.Text>
Enabling this option will send an email to the users when a new session is created.
</Typography.Text>
</svelte:fragment>
<svelte:fragment slot="actions">
<Button disabled={authSessionAlerts === $project?.authSessionAlerts} submit>
Update
</Button>
</svelte:fragment>
</CardGrid>
</Form>
@@ -1,56 +0,0 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { CardGrid } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button, Form, InputSwitch } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { Typography } from '@appwrite.io/pink-svelte';
import { project } from '../../store';
let sessionInvalidation = $project?.authInvalidateSessions ?? false;
async function updateSessionInvalidation() {
try {
await sdk.forConsole.projects.updateSessionInvalidation({
projectId: $project.$id,
enabled: sessionInvalidation
});
await invalidate(Dependencies.PROJECT);
addNotification({
type: 'success',
message: 'Updated session invalidation check.'
});
trackEvent(Submit.AuthInvalidateSesssion);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.AuthInvalidateSesssion);
}
}
</script>
<Form onSubmit={updateSessionInvalidation}>
<CardGrid>
<svelte:fragment slot="title">Invalidate sessions</svelte:fragment>
<svelte:fragment slot="aside">
<InputSwitch
bind:value={sessionInvalidation}
id="invalidateSessions"
label="Invalidate sessions" />
<Typography.Text>
Enabling this option will clear all existing sessions when the user changes their
password.
</Typography.Text>
</svelte:fragment>
<svelte:fragment slot="actions">
<Button disabled={sessionInvalidation === $project?.authInvalidateSessions} submit>
Update
</Button>
</svelte:fragment>
</CardGrid>
</Form>
@@ -12,6 +12,7 @@
SearchQuery,
PaginationWithLimit,
type DeleteOperationState,
type DeleteOperation,
MultiSelectionTable
} from '$lib/components';
import Create from '../createTeam.svelte';
@@ -44,20 +45,22 @@
);
};
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((teamId) => {
return sdk.forProject(page.params.region, page.params.project).teams.delete({ teamId });
});
async function handleDelete(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
const result = await batchDelete((teamId) =>
sdk.forProject(page.params.region, page.params.project).teams.delete({ teamId })
);
try {
await Promise.all(promises);
trackEvent(Submit.TeamDelete, { total: selectedRows.length });
} catch (error) {
trackError(error, Submit.TeamDelete);
return error;
if (result.error) {
trackError(result.error, Submit.TeamDelete);
} else {
trackEvent(Submit.TeamDelete, { total: result.deleted.length });
}
} finally {
await invalidate(Dependencies.TEAMS);
}
return result;
}
</script>
@@ -6,6 +6,7 @@
AvatarInitials,
PaginationWithLimit,
MultiSelectionTable,
type DeleteOperation,
type DeleteOperationState
} from '$lib/components';
import { Button } from '$lib/elements/forms';
@@ -29,23 +30,25 @@
let showDelete = $state(false);
let selectedMembership: Models.Membership | null = $state(null);
async function handleBulkDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((membershipId) => {
return sdk.forProject(page.params.region, page.params.project).teams.deleteMembership({
async function handleBulkDelete(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
const result = await batchDelete((membershipId) =>
sdk.forProject(page.params.region, page.params.project).teams.deleteMembership({
teamId: page.params.team,
membershipId
});
});
})
);
try {
await Promise.all(promises);
trackEvent(Submit.MembershipUpdate, { total: selectedRows.length });
} catch (error) {
trackError(error, Submit.MembershipUpdate);
return error;
if (result.error) {
trackError(result.error, Submit.MembershipUpdate);
} else {
trackEvent(Submit.MembershipUpdate, { total: result.deleted.length });
}
} finally {
await invalidate(Dependencies.MEMBERSHIPS);
}
return result;
}
</script>
@@ -1,5 +1,10 @@
<script lang="ts">
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import {
type DeleteOperationState,
type DeleteOperation,
Id,
MultiSelectionTable
} from '$lib/components';
import type { PageData } from './$types';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { sdk } from '$lib/stores/sdk';
@@ -21,22 +26,24 @@
columns: Column[];
} = $props();
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((id) => {
return sdk
async function handleDelete(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
const result = await batchDelete((id) =>
sdk
.forProject(page.params.region, page.params.project)
.users.deleteIdentity({ identityId: id });
});
.users.deleteIdentity({ identityId: id })
);
try {
await Promise.all(promises);
trackEvent(Submit.UserIdentityDelete, { total: selectedRows.length });
} catch (error) {
trackError(error, Submit.UserIdentityDelete);
return error;
if (result.error) {
trackError(result.error, Submit.UserIdentityDelete);
} else {
trackEvent(Submit.UserIdentityDelete, { total: result.deleted.length });
}
} finally {
await invalidate(Dependencies.USER_IDENTITIES);
}
return result;
}
</script>
@@ -4,6 +4,7 @@
import {
AvatarInitials,
type DeleteOperationState,
type DeleteOperation,
MultiSelectionTable
} from '$lib/components';
import { Button } from '$lib/elements/forms';
@@ -23,14 +24,14 @@
let showDelete = $state(false);
let selectedMembership: Models.Membership | null = $state(null);
async function handleBulkDelete(selectedRows: string[]): Promise<DeleteOperationState> {
async function handleBulkDelete(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
// Precompute a lookup map from membershipId to teamId for efficient access
const membershipIdToTeamId: Record<string, string> = {};
for (const membership of data.memberships.memberships) {
membershipIdToTeamId[membership.$id] = membership.teamId;
}
const promises = selectedRows.map((membershipId) =>
const result = await batchDelete((membershipId) =>
sdk.forProject(page.params.region, page.params.project).teams.deleteMembership({
teamId: membershipIdToTeamId[membershipId] || '',
membershipId
@@ -38,14 +39,16 @@
);
try {
await Promise.all(promises);
trackEvent(Submit.MembershipUpdate, { total: selectedRows.length });
} catch (error) {
trackError(error, Submit.MembershipUpdate);
return error;
if (result.error) {
trackError(result.error, Submit.MembershipUpdate);
} else {
trackEvent(Submit.MembershipUpdate, { total: result.deleted.length });
}
} finally {
await invalidate(Dependencies.MEMBERSHIPS);
}
return result;
}
</script>
@@ -1,6 +1,13 @@
<script lang="ts">
import { EmptySearch, Trim } from '$lib/components';
import { Badge, Layout, Table, Typography, Icon } from '@appwrite.io/pink-svelte';
import {
Badge,
Layout,
Table,
Typography,
Icon,
InteractiveText
} from '@appwrite.io/pink-svelte';
import { IconGlobeAlt } from '@appwrite.io/pink-icons-svelte';
import { Button } from '$lib/elements/forms';
import { isValueOfStringEnum } from '$lib/helpers/types';
@@ -36,9 +43,9 @@
<Table.Root
let:root
columns={[
{ id: 'client' },
{ id: 'location', width: 200 },
{ id: 'ip', width: 200 },
{ id: 'client', width: { min: 450 } },
{ id: 'location', width: { min: 200 } },
{ id: 'ip', width: { min: 200 } },
{ id: 'actions', width: 100 }
]}>
<svelte:fragment slot="header" let:root>
@@ -84,7 +91,7 @@
{/if}
</Table.Cell>
<Table.Cell column="ip" {root}>
{session.ip}
<InteractiveText variant="copy" text={session.ip} isVisible />
</Table.Cell>
<Table.Cell column="actions" {root}>
<Button
@@ -1,5 +1,10 @@
<script lang="ts">
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import {
type DeleteOperationState,
type DeleteOperation,
Id,
MultiSelectionTable
} from '$lib/components';
import type { PageData } from './$types';
import { columns } from './store';
import DualTimeView from '$lib/components/dualTimeView.svelte';
@@ -19,22 +24,24 @@
data: PageData;
} = $props();
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((id) => {
return sdk
async function handleDelete(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
const result = await batchDelete((id) =>
sdk
.forProject(page.params.region, page.params.project)
.users.deleteTarget({ userId: page.params.user, targetId: id });
});
.users.deleteTarget({ userId: page.params.user, targetId: id })
);
try {
await Promise.all(promises);
trackEvent(Submit.UserTargetDelete, { total: selectedRows.length });
} catch (error) {
trackError(error, Submit.UserTargetDelete);
return error;
if (result.error) {
trackError(result.error, Submit.UserTargetDelete);
} else {
trackEvent(Submit.UserTargetDelete, { total: result.deleted.length });
}
} finally {
await invalidate(Dependencies.USER_TARGETS);
}
return result;
}
</script>
@@ -14,9 +14,11 @@
import Table from './table.svelte';
import { registerCommands } from '$lib/commandCenter';
import { canWriteDatabases } from '$lib/stores/roles';
import { Icon } from '@appwrite.io/pink-svelte';
import { Icon, Tooltip } from '@appwrite.io/pink-svelte';
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
import EmptySearch from '$lib/components/emptySearch.svelte';
import { isServiceLimited } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
export let data: PageData;
@@ -30,6 +32,8 @@
);
}
$: isLimited = isServiceLimited('databases', $organization?.billingPlan, data.databases.total);
$: $registerCommands([
{
label: 'Create database',
@@ -37,7 +41,7 @@
showCreate = true;
},
keys: ['c'],
disabled: showCreate || !$canWriteDatabases,
disabled: showCreate || !$canWriteDatabases || isLimited,
icon: IconPlus,
group: 'databases',
rank: 10
@@ -52,10 +56,20 @@
bind:view={data.view}
searchPlaceholder="Search by name or ID">
{#if $canWriteDatabases}
<Button event="create_database" on:click={() => (showCreate = true)}>
<Icon icon={IconPlus} slot="start" size="s" />
Create database
</Button>
<Tooltip disabled={!isLimited}>
<div>
<Button
disabled={isLimited}
event="create_database"
on:click={() => (showCreate = true)}>
<Icon icon={IconPlus} slot="start" size="s" />
Create database
</Button>
</div>
<svelte:fragment slot="tooltip">
You have reached the maximum number of databases for your plan.
</svelte:fragment>
</Tooltip>
{/if}
</ResponsiveContainerHeader>
@@ -63,7 +77,11 @@
{#if data.view === 'grid'}
<Grid {data} bind:showCreate />
{:else}
<Table {data} />
<Table
tables={data.tables}
policies={data.policies}
databases={data.databases}
lastBackups={data.lastBackups} />
{/if}
<PaginationWithLimit
@@ -4,7 +4,6 @@ import { sdk } from '$lib/stores/sdk';
import { type Models, Query } from '@appwrite.io/console';
import { timeFromNow } from '$lib/helpers/date';
import type { PageLoad, RouteParams } from './$types';
import type { BackupPolicy } from '$lib/sdk/backups';
import { isSelfHosted } from '$lib/system';
import { isCloud } from '$lib/system';
@@ -70,7 +69,8 @@ async function fetchDatabasesAndBackups(
})
);
let lastBackups: Record<string, string>, policies: Record<string, BackupPolicy[]>;
let lastBackups: Record<string, string> = {};
let policies: Record<string, Models.BackupPolicy[]> = {};
if (isCloud && backupsEnabled) {
[policies, lastBackups] = await Promise.all([
@@ -85,19 +85,21 @@ async function fetchDatabasesAndBackups(
async function fetchPolicies(databases: Models.DatabaseList, params: RouteParams) {
if (isSelfHosted) return {};
const databasePolicies: Record<string, BackupPolicy[]> = {};
const databasePolicies: Record<string, Models.BackupPolicy[]> = {};
await Promise.all(
databases.databases.map(async (database) => {
try {
const { policies } = await sdk
.forProject(params.region, params.project)
.backups.listPolicies([
// TODO: are all needed!?
// Query.limit(3),
Query.equal('resourceType', 'database'),
Query.equal('resourceId', database.$id)
]);
.backups.listPolicies({
queries: [
// TODO: are all needed!?
// Query.limit(3),
Query.equal('resourceType', 'database'),
Query.equal('resourceId', database.$id)
]
});
if (policies.length > 0) {
databasePolicies[database.$id] = policies;
@@ -121,12 +123,14 @@ async function fetchLastBackups(databases: Models.DatabaseList, params: RoutePar
try {
const { archives } = await sdk
.forProject(params.region, params.project)
.backups.listArchives([
Query.limit(1),
Query.orderDesc('$createdAt'),
Query.equal('resourceType', 'database'),
Query.equal('resourceId', database.$id)
]);
.backups.listArchives({
queries: [
Query.limit(1),
Query.orderDesc('$createdAt'),
Query.equal('resourceType', 'database'),
Query.equal('resourceId', database.$id)
]
});
if (archives.length > 0) {
lastBackups[database.$id] = timeFromNow(archives[0].$createdAt);
@@ -61,16 +61,14 @@
const totalPoliciesPromise = totalPolicies.map((policy) => {
cronExpression(policy);
return sdk
.forProject(page.params.region, page.params.project)
.backups.createPolicy(
ID.unique(),
['databases'],
policy.retained,
policy.schedule,
policy.label,
resourceId
);
return sdk.forProject(page.params.region, page.params.project).backups.createPolicy({
policyId: ID.unique(),
services: ['databases'],
retention: policy.retained,
schedule: policy.schedule,
name: policy.label,
resourceId
});
});
await Promise.all(totalPoliciesPromise);
@@ -66,9 +66,10 @@
const createManualBackup = async () => {
try {
await sdk
.forProject(page.params.region, page.params.project)
.backups.createArchive(['databases'], data.database.$id);
await sdk.forProject(page.params.region, page.params.project).backups.createArchive({
services: ['databases'],
resourceId: data.database.$id
});
await invalidate(Dependencies.BACKUPS);
addNotification({
type: 'success',
@@ -119,16 +120,14 @@
const totalPoliciesPromise = totalPolicies.map((policy) => {
cronExpression(policy);
return sdk
.forProject(page.params.region, page.params.project)
.backups.createPolicy(
ID.unique(),
['databases'],
policy.retained,
policy.schedule,
policy.label,
data.database.$id
);
return sdk.forProject(page.params.region, page.params.project).backups.createPolicy({
policyId: ID.unique(),
services: ['databases'],
retention: policy.retained,
schedule: policy.schedule,
name: policy.label,
resourceId: data.database.$id
});
});
try {
@@ -2,8 +2,8 @@ import { getLimit, getPage, getView, pageToOffset, View } from '$lib/helpers/loa
import { Dependencies, PAGE_LIMIT } from '$lib/constants';
import { sdk } from '$lib/stores/sdk';
import { Query } from '@appwrite.io/console';
import type { BackupArchive, BackupArchiveList, BackupPolicyList } from '$lib/sdk/backups';
import { isCloud } from '$lib/system';
import { type Models } from '@appwrite.io/console';
export const load = async ({ params, url, route, depends, parent }) => {
depends(Dependencies.BACKUPS);
@@ -13,8 +13,8 @@ export const load = async ({ params, url, route, depends, parent }) => {
const view = getView(url, route, View.Grid);
const offset = pageToOffset(page, limit);
let backups: BackupArchiveList = { total: 0, archives: [] };
let policies: BackupPolicyList = { total: 0, policies: [] };
let backups: Models.BackupArchiveList = { total: 0, archives: [] };
let policies: Models.BackupPolicyList = { total: 0, policies: [] };
// already loaded by parent.
const { currentPlan } = await parent();
@@ -23,23 +23,23 @@ export const load = async ({ params, url, route, depends, parent }) => {
if (isCloud && backupsEnabled) {
try {
[backups, policies] = await Promise.all([
sdk
.forProject(params.region, params.project)
.backups.listArchives([
sdk.forProject(params.region, params.project).backups.listArchives({
queries: [
Query.limit(limit),
Query.offset(offset),
Query.orderDesc('$createdAt'),
Query.equal('resourceType', 'database'),
Query.equal('resourceId', params.database)
]),
]
}),
sdk
.forProject(params.region, params.project)
.backups.listPolicies([
sdk.forProject(params.region, params.project).backups.listPolicies({
queries: [
Query.orderDesc('$createdAt'),
Query.equal('resourceType', 'database'),
Query.equal('resourceId', params.database)
])
]
})
]);
} catch (e) {
// ignore
@@ -59,17 +59,17 @@ export const load = async ({ params, url, route, depends, parent }) => {
};
};
const groupArchivesByPolicy = (archives: BackupArchive[]) => {
const groupArchivesByPolicy = (archives: Models.BackupArchive[]) => {
return archives.reduce((acc, archive) => {
if (!acc.has(archive.policyId)) {
acc.set(archive.policyId, []);
}
acc.get(archive.policyId)!.push(archive);
return acc;
}, new Map<string, BackupArchive[]>());
}, new Map<string, Models.BackupArchive[]>());
};
const getLatestBackupForPolicies = (policyIdMap: Map<string, BackupArchive[]>) => {
const getLatestBackupForPolicies = (policyIdMap: Map<string, Models.BackupArchive[]>) => {
const latestBackups = new Map<string, string | null>();
for (const [policyId, archives] of policyIdMap) {
const latestBackup = archives.sort(
@@ -11,7 +11,6 @@
import { toLocaleDateTime } from '$lib/helpers/date';
import EmptyDark from '$lib/images/backups/backups-dark.png';
import EmptyLight from '$lib/images/backups/backups-light.png';
import type { BackupPolicy, BackupPolicyList } from '$lib/sdk/backups';
import { backupFrequencies } from '$lib/helpers/backups';
import { Click, trackEvent } from '$lib/actions/analytics';
import {
@@ -27,21 +26,22 @@
import { Confirm } from '$lib/components/index.js';
import Ellipse from './components/Ellipse.svelte';
import { page } from '$app/state';
import { type Models } from '@appwrite.io/console';
let showDelete = false;
let selectedPolicy: BackupPolicy = null;
let selectedPolicy: Models.BackupPolicy = null;
let showEveryPolicy = false;
export let showCreatePolicy = false;
export let policies: BackupPolicyList;
export let policies: Models.BackupPolicyList;
export let lastBackupDates: Record<string, string>;
async function deletePolicy() {
try {
await sdk
.forProject(page.params.region, page.params.project)
.backups.deletePolicy(selectedPolicy.$id);
await sdk.forProject(page.params.region, page.params.project).backups.deletePolicy({
policyId: selectedPolicy.$id
});
addNotification({
type: 'success',
message: 'Backup policy has been deleted'
@@ -3,6 +3,7 @@
Card,
Confirm,
type DeleteOperationState,
type DeleteOperation,
Modal,
MultiSelectionTable
} from '$lib/components';
@@ -14,11 +15,10 @@
import { addNotification } from '$lib/stores/notifications';
import { invalidate } from '$app/navigation';
import { calculateSize } from '$lib/helpers/sizeConvertion';
import { ID } from '@appwrite.io/console';
import { ID, type Models } from '@appwrite.io/console';
import { columns } from './store';
import { database } from '../store';
import type { BackupArchive, BackupPolicy } from '$lib/sdk/backups';
import { Click, trackEvent } from '$lib/actions/analytics';
import { Click, Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { copy } from '$lib/helpers/copy';
import { LabelCard } from '$lib/components/index.js';
import DualTimeView from '$lib/components/dualTimeView.svelte';
@@ -52,7 +52,7 @@
} = $props();
let showDelete = $state(false);
let selectedBackup: BackupArchive | null = $state(null);
let selectedBackup: Models.BackupArchive | null = $state(null);
let showDropdown = [];
@@ -86,15 +86,15 @@
);
});
function getPolicyDetails(policyId: string | null): BackupPolicy | null {
function getPolicyDetails(policyId: string | null): Models.BackupPolicy | null {
return data.policies.policies.find((policy) => policy.$id === policyId);
}
function getCleanBackupName(backup: BackupArchive): string {
function getCleanBackupName(backup: Models.BackupArchive): string {
return toLocaleDateTime(backup.$createdAt).replaceAll(',', '');
}
function getBackupStatus(backup: BackupArchive) {
function getBackupStatus(backup: Models.BackupArchive) {
switch (backup.status) {
case 'pending':
return 'pending';
@@ -110,39 +110,46 @@
}
}
async function deleteBackups(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((archiveId) => {
return sdk
async function deleteSingleBackup(archiveId: string) {
try {
await sdk
.forProject(page.params.region, page.params.project)
.backups.deleteArchive(archiveId);
});
.backups.deleteArchive({ archiveId });
addNotification({
type: 'success',
message: 'Backup deleted'
});
showDelete = false;
selectedBackup = null;
await invalidate(Dependencies.BACKUPS);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
}
}
async function deleteBackups(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
const result = await batchDelete((archiveId) =>
sdk
.forProject(page.params.region, page.params.project)
.backups.deleteArchive({ archiveId })
);
try {
await Promise.all(promises);
if (selectedBackup) {
addNotification({
type: 'success',
message: '1 backup deleted'
});
}
} catch (error) {
if (selectedBackup) {
addNotification({
type: 'error',
message: error.message
});
if (result.error) {
trackError(result.error, Submit.DatabaseBackupDelete);
} else {
return error;
trackEvent(Submit.DatabaseBackupDelete);
}
} finally {
if (selectedBackup) {
showDelete = false;
selectedBackup = null;
}
await invalidate(Dependencies.BACKUPS);
}
return result;
}
async function restoreBackup() {
@@ -154,12 +161,12 @@
try {
await sdk
.forProject(page.params.region, page.params.project)
.backups.createRestoration(
selectedBackup.$id,
['databases'],
newDatabaseInfo.id ?? ID.unique(),
newDatabaseInfo.name
);
.backups.createRestoration({
archiveId: selectedBackup.$id,
services: ['databases'],
newResourceId: newDatabaseInfo.id ?? ID.unique(),
newResourceName: newDatabaseInfo.name
});
await invalidate(Dependencies.BACKUPS);
addNotification({
@@ -299,7 +306,7 @@
bind:open={showDelete}
onSubmit={async () => {
if (!selectedBackup) return;
await deleteBackups([selectedBackup.$id]);
await deleteSingleBackup(selectedBackup.$id);
}}>
<Typography.Text>
Are you sure you want to delete the <b>{getCleanBackupName(selectedBackup)}</b> backup?
@@ -86,7 +86,7 @@
label="Min"
placeholder="Enter size"
bind:value={data.min}
step={0.1}
step="any"
{disabled}
required={editing} />
@@ -95,7 +95,7 @@
label="Max"
placeholder="Enter size"
bind:value={data.max}
step={0.1}
step="any"
{disabled}
required={editing} />
</Layout.Stack>
@@ -109,7 +109,7 @@
bind:value={data.default}
disabled={data.required || data.array || disabled}
nullable={!data.required && !data.array}
step={0.1} />
step="any" />
<RequiredArrayCheckboxes
{editing}
@@ -31,5 +31,5 @@
min={column.min}
max={column.max}
required={column.required}
step={column.type === 'double' ? 0.1 : 1}
step={column.type === 'double' ? 'any' : 1}
leadingIcon={!limited ? IconHashtag : undefined} />
@@ -297,7 +297,7 @@
<Layout.Stack direction="row">
<InputSelect
{id}
required
required={column.required}
{options}
bind:value={relatedList[0]}
placeholder={`Select ${column.key}`}
@@ -316,7 +316,7 @@
<Layout.Stack direction="row">
<InputSelect
{id}
required
required={column.required}
autofocus={limited}
options={getAvailableOptions(actualIndex)}
bind:value={relatedList[actualIndex]}
@@ -346,7 +346,7 @@
<Layout.Stack direction="row">
<InputSelect
{id}
required
required={column.required}
options={getAvailableOptions(i)}
bind:value={item}
on:change={updateRelatedList}
@@ -376,7 +376,7 @@
<Layout.Stack direction="row">
<Input.ComboBox
{id}
required
required={column.required}
on:change={addNewItem}
bind:value={newItemValue}
options={availableOptions}
@@ -95,9 +95,20 @@
value = newArray as string[] | number[] | boolean[];
}
} else {
const parsedValue = isSpatialType(column)
? JSON.parse(stringValue)
: parseValue(stringValue);
let parsedValue = parseValue(stringValue);
if (isSpatialType(column)) {
if (!stringValue?.trim()) {
parsedValue = null;
} else {
try {
parsedValue = JSON.parse(stringValue);
} catch {
return;
}
}
}
if (JSON.stringify(parsedValue) !== JSON.stringify(value)) {
value = parsedValue;
}
@@ -630,7 +630,6 @@
permissions: row.$permissions
});
invalidate(Dependencies.ROW);
trackEvent(Submit.RowUpdate);
addNotification({
message: 'Row has been updated',
@@ -1105,7 +1104,14 @@
<EditRowCell
{row}
column={rowColumn}
onRowStructureUpdate={updateRowContents}
onRowStructureUpdate={async (row) => {
const success = await updateRowContents(row);
if (success) {
// database update succeeded!
paginatedRows.update(index, row);
}
return success;
}}
noInlineEdit={isRelatedToMany && hasItems}
onChange={(row) => paginatedRows.update(index, row)}
onRevert={(row) => paginatedRows.update(index, row)}
@@ -193,7 +193,9 @@ export const expandTabs = writable(null);
export const spreadsheetRenderKey = writable('initial');
export const paginatedRowsLoading = writable(false);
export const paginatedRows = createSparsePagedDataStore<Models.DefaultRow>(SPREADSHEET_PAGE_LIMIT);
export const paginatedRows = createSparsePagedDataStore<Models.DefaultRow | Models.Row>(
SPREADSHEET_PAGE_LIMIT
);
export const PROHIBITED_ROW_KEYS = [
'$id',
@@ -3,7 +3,12 @@
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Id, MultiSelectionTable, type DeleteOperationState } from '$lib/components';
import {
Id,
MultiSelectionTable,
type DeleteOperation,
type DeleteOperationState
} from '$lib/components';
import { Dependencies } from '$lib/constants';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { canWriteTables } from '$lib/stores/roles';
@@ -20,23 +25,26 @@
data: PageData;
} = $props();
async function onDelete(selectedTables: string[]): Promise<DeleteOperationState> {
const promises = selectedTables.map((tableId) =>
async function onDelete(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
const result = await batchDelete((tableId) =>
sdk.forProject(page.params.region, page.params.project).tablesDB.deleteTable({
databaseId: page.params.database,
tableId
})
);
try {
await Promise.all(promises);
trackEvent(Submit.TableDelete);
} catch (error) {
trackError(error, Submit.TableDelete);
return error;
if (result.error) {
trackError(result.error, Submit.TableDelete);
} else {
trackEvent(Submit.TableDelete, { total: result.deleted.length });
}
} finally {
await invalidate(Dependencies.TABLES);
subNavigation.update();
}
return result;
}
function getTableHref(table: Models.Table) {
@@ -2,14 +2,23 @@
import { base } from '$app/paths';
import { page } from '$app/state';
import { Id } from '$lib/components';
import { toLocaleDateTime } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { columns } from './store';
import { IconExclamation } from '@appwrite.io/pink-icons-svelte';
import { Layout, Tooltip, Table, Icon } from '@appwrite.io/pink-svelte';
import type { BackupPolicy } from '$lib/sdk/backups';
import { type Models } from '@appwrite.io/console';
export let data;
const tables = data.tables;
let {
tables,
policies,
databases,
lastBackups
}: {
tables: Record<string, string>;
databases: Models.DatabaseList;
lastBackups: Record<string, string>;
policies: Record<string, Models.BackupPolicy[]>;
} = $props();
function getPolicyDescription(cron: string): string {
const [minute, hour, dayOfMonth, , dayOfWeek] = cron.split(' ');
@@ -19,6 +28,10 @@
if (minute !== '*' && hour === '*') return 'Hourly';
if (hour !== '*') return 'Daily';
}
function getPoliciesDescription(policies: Models.BackupPolicy[] | null): string {
return policies?.map((policy) => getPolicyDescription(policy.schedule)).join(', ') ?? '';
}
</script>
<Table.Root columns={$columns} let:root>
@@ -27,7 +40,7 @@
<Table.Header.Cell column={id} {root}>{title}</Table.Header.Cell>
{/each}
</svelte:fragment>
{#each data.databases.databases as database (database.$id)}
{#each databases.databases as database (database.$id)}
<!-- takes directly to the spreadsheet -->
{@const tableId = tables[database?.$id] ?? null}
{@const tableHref = tableId ? `/table-${tableId}` : ''}
@@ -45,18 +58,16 @@
{:else if column.id === 'name'}
{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: BackupPolicy) => getPolicyDescription(policy.schedule))
.join(', ')}
{@const backupPolicies = policies?.[database.$id] ?? null}
{@const lastBackup = lastBackups?.[database.$id] ?? null}
{@const description = getPoliciesDescription(backupPolicies)}
<Tooltip
placement="bottom"
disabled={!policies || !lastBackup}
disabled={!backupPolicies || !lastBackup}
maxWidth="fit-content">
<span class="u-trim">
{#if !policies}
{#if !backupPolicies}
<Layout.Stack direction="row" gap="xxs" alignItems="center">
<Icon
icon={IconExclamation}
@@ -72,8 +83,10 @@
{`Last backup: ${lastBackup}`}
</span>
</Tooltip>
{:else if column.type === 'datetime'}
<DualTimeView time={database[column.id]} showDatetime />
{:else}
{toLocaleDateTime(database[column.id])}
{database[column.id]}
{/if}
</Table.Cell>
{/each}
@@ -1,6 +1,6 @@
<script lang="ts">
import { goto, invalidate } from '$app/navigation';
import { base } from '$app/paths';
import { base, resolve } from '$app/paths';
import { page } from '$app/state';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Button, Form } from '$lib/elements/forms';
@@ -23,6 +23,7 @@
import { isCloud } from '$lib/system';
import { humanFileSize } from '$lib/helpers/sizeConvertion';
import { currentPlan } from '$lib/stores/organization';
import { uploader } from '$lib/stores/uploader';
export let data;
@@ -61,19 +62,19 @@
let specification = specificationOptions[0]?.value || '';
async function create() {
let func: Models.Function | null = null;
try {
const func = await sdk
.forProject(page.params.region, page.params.project)
.functions.create({
functionId: id || ID.unique(),
name,
runtime,
execute: roles?.length ? roles : undefined,
enabled: true,
entrypoint,
commands: buildCommand,
specification: specification || undefined
});
func = await sdk.forProject(page.params.region, page.params.project).functions.create({
functionId: id || ID.unique(),
name,
runtime,
execute: roles?.length ? roles : undefined,
enabled: true,
entrypoint,
commands: buildCommand,
specification: specification || undefined
});
// Add domain
await sdk.forProject(page.params.region, page.params.project).proxy.createFunctionRule({
@@ -92,25 +93,43 @@
);
await Promise.all(promises);
await sdk
.forProject(page.params.region, page.params.project)
.functions.createDeployment({
functionId: func.$id,
code: files[0],
activate: true
});
const promise = uploader.uploadFunctionDeployment({
functionId: func.$id,
code: files[0]
});
addNotification({
message: 'Deployment upload started',
type: 'success'
});
trackEvent(Submit.FunctionCreate, {
source: 'repository',
runtime: runtime
});
await goto(
`${base}/project-${page.params.region}-${page.params.project}/functions/function-${func.$id}`
);
await promise;
const upload = $uploader.files.find((f) => f.resourceId === func.$id);
if (upload?.status === 'success') {
const deploymentId = upload.$id;
const resolvedPath = resolve(
`/(console)/project-[region]-[project]/functions/function-[function]/deployment-[deployment]`,
{
region: page.params.region,
project: page.params.project,
function: func.$id,
deployment: deploymentId
}
);
await goto(resolvedPath);
}
invalidate(Dependencies.FUNCTION);
} catch (e) {
const upload = $uploader.files.find((f) => f.resourceId === func?.$id);
if (upload) {
uploader.removeFromQueue(upload.$id);
}
addNotification({
type: 'error',
message: e.message
@@ -38,9 +38,7 @@
footer?: Snippet;
} = $props();
let effectiveStatus = $derived(
getEffectiveBuildStatus(deployment.status, deployment.$createdAt, $regionalConsoleVariables)
);
let effectiveStatus = $derived(getEffectiveBuildStatus(deployment, $regionalConsoleVariables));
let totalSize = $derived(humanFileSize(deployment?.totalSize ?? 0));
</script>
@@ -6,15 +6,14 @@
import { Button } from '$lib/elements/forms';
import { InvalidFileType, removeFile } from '$lib/helpers/files';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { IconInfo } from '@appwrite.io/pink-icons-svelte';
import { Icon, Layout, Tooltip, Typography, Upload } from '@appwrite.io/pink-svelte';
import { func } from '../store';
import { page } from '$app/state';
import { consoleVariables } from '$routes/(console)/store';
import { currentPlan } from '$lib/stores/organization';
import { isCloud } from '$lib/system';
import { humanFileSize } from '$lib/helpers/sizeConvertion';
import { uploader } from '$lib/stores/uploader';
export let show = false;
@@ -30,17 +29,16 @@
async function create() {
try {
await sdk
.forProject(page.params.region, page.params.project)
.functions.createDeployment({
functionId: $func.$id,
code: files[0],
activate: true
});
await invalidate(Dependencies.DEPLOYMENTS);
files = undefined;
uploader.uploadFunctionDeployment({
functionId: $func.$id,
code: files[0]
});
show = false;
trackEvent(Submit.DeploymentCreate);
files = undefined;
await invalidate(Dependencies.DEPLOYMENTS);
trackEvent(Submit.DeploymentCreate, {
type: 'function'
});
addNotification({
type: 'success',
message: 'Deployment created successfully'
@@ -41,11 +41,7 @@
let { data } = $props();
let effectiveStatus = $derived(
getEffectiveBuildStatus(
data.deployment.status,
data.deployment.$createdAt,
$regionalConsoleVariables
)
getEffectiveBuildStatus(data.deployment, $regionalConsoleVariables)
);
let showDelete = $state(false);
let showCancel = $state(false);
@@ -29,6 +29,7 @@ export const load: PageLoad = async ({ depends, params, url, route, parent }) =>
Query.limit(limit),
Query.offset(offset),
Query.orderDesc(''),
Query.orderDesc('$updatedAt'),
...parsedQueries.values()
],
search: search || undefined
@@ -15,5 +15,12 @@ export const columns = writable<Column[]>([
title: 'Target',
type: 'string',
width: { min: 120, max: 400 }
},
{
id: 'updated',
title: '',
type: 'string',
width: { min: 160, max: 180 }
}
]);
@@ -25,6 +25,7 @@
import { regionalProtocol } from '$routes/(console)/project-[region]-[project]/store';
import DnsRecordsAction from '$lib/components/domains/dnsRecordsAction.svelte';
import ViewLogsModal from '$lib/components/domains/viewLogsModal.svelte';
import { timeFromNowShort } from '$lib/helpers/date';
let {
proxyRules,
@@ -46,6 +47,28 @@
? 'Deployed from ' + proxy.deploymentVcsProviderBranch
: 'Active deployment';
};
function updatedLabel(proxyRule: Models.ProxyRule): string {
if (proxyRule.status === 'verified') {
return '';
}
const timeStr = timeFromNowShort(proxyRule.$updatedAt);
if (timeStr === 'n/a') {
return '';
}
const prefix =
proxyRule.status === 'created'
? 'Checked'
: proxyRule.status === 'verifying'
? 'Updated'
: proxyRule.status === 'unverified'
? 'Failed'
: '';
return prefix + ' ' + timeStr;
}
</script>
<Table.Root columns={[...$columns, { id: 'actions', width: 40 }]} let:root>
@@ -117,6 +140,15 @@
</Layout.Stack>
{:else if column.id === 'target'}
{proxyTarget(proxyRule)}
{:else if column.id === 'updated' && proxyRule.status !== 'verified'}
<Layout.Stack direction="row" justifyContent="flex-end">
<Typography.Text
variant="m-400"
color="--fgcolor-neutral-tertiary"
style="font-size: 0.875rem;">
{updatedLabel(proxyRule)}
</Typography.Text>
</Layout.Stack>
{/if}
</Table.Cell>
{/each}
@@ -1,5 +1,10 @@
<script lang="ts">
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import {
type DeleteOperationState,
type DeleteOperation,
Id,
MultiSelectionTable
} from '$lib/components';
import { toLocaleDateTime } from '$lib/helpers/date';
import type { Column } from '$lib/helpers/types';
import type { Models } from '@appwrite.io/console';
@@ -30,8 +35,8 @@
let open = $state(false);
let selectedLogId: string | null = $state(null);
async function deleteExecutions(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((executionId) =>
async function deleteExecutions(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
const result = await batchDelete((executionId) =>
sdk.forProject(page.params.region, page.params.project).functions.deleteExecution({
functionId: page.params.function,
executionId
@@ -39,14 +44,16 @@
);
try {
await Promise.all(promises);
trackEvent(Submit.ExecutionDelete);
} catch (error) {
trackError(error, Submit.ExecutionDelete);
return error;
if (result.error) {
trackError(result.error, Submit.ExecutionDelete);
} else {
trackEvent(Submit.ExecutionDelete, { total: result.deleted.length });
}
} finally {
await invalidate(Dependencies.EXECUTIONS);
}
return result;
}
</script>
@@ -1,5 +1,10 @@
<script lang="ts">
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import {
type DeleteOperationState,
type DeleteOperation,
Id,
MultiSelectionTable
} from '$lib/components';
import type { PageData } from './$types';
import { type Models } from '@appwrite.io/console';
import type { Column } from '$lib/helpers/types';
@@ -52,22 +57,25 @@
invalidate(Dependencies.DEPLOYMENTS);
}
async function deleteDeployments(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((deploymentId) =>
async function deleteDeployments(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
const result = await batchDelete((deploymentId) =>
sdk.forProject(page.params.region, page.params.project).functions.deleteDeployment({
functionId: page.params.function,
deploymentId
})
);
try {
await Promise.all(promises);
trackEvent(Submit.DeploymentDelete);
} catch (error) {
trackError(error, Submit.DeploymentDelete);
return error;
if (result.error) {
trackError(result.error, Submit.DeploymentDelete);
} else {
trackEvent(Submit.DeploymentDelete, { total: result.deleted.length });
}
} finally {
await invalidate(Dependencies.DEPLOYMENTS);
}
return result;
}
</script>
@@ -87,8 +95,7 @@
{#snippet children(root)}
{#each data.deploymentList.deployments as deployment (deployment.$id)}
{@const effectiveStatus = getEffectiveBuildStatus(
deployment.status,
deployment.$createdAt,
deployment,
$regionalConsoleVariables
)}
<Table.Row.Link
@@ -3,6 +3,7 @@
import { page } from '$app/state';
import {
type DeleteOperationState,
type DeleteOperation,
Empty,
EmptyFilter,
EmptySearch,
@@ -99,22 +100,24 @@
}
]);
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((id) =>
async function handleDelete(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
const result = await batchDelete((id) =>
sdk
.forProject(page.params.region, page.params.project)
.messaging.delete({ messageId: id })
);
try {
await Promise.all(promises);
trackEvent(Submit.MessagingMessageDelete, { total: selectedRows.length });
} catch (error) {
trackError(error, Submit.MessagingMessageDelete);
return error;
if (result.error) {
trackError(result.error, Submit.MessagingMessageDelete);
} else {
trackEvent(Submit.MessagingMessageDelete, { total: result.deleted.length });
}
} finally {
await invalidate(Dependencies.MESSAGING_MESSAGES);
}
return result;
}
onMount(() => {
@@ -1,7 +1,12 @@
<script lang="ts">
import { base } from '$app/paths';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import {
type DeleteOperationState,
type DeleteOperation,
Id,
MultiSelectionTable
} from '$lib/components';
import type { PageData } from './$types';
import { columns } from './store';
import Provider from '../provider.svelte';
@@ -20,22 +25,24 @@
data: PageData;
} = $props();
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((id) => {
return sdk
async function handleDelete(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
const result = await batchDelete((id) =>
sdk
.forProject(page.params.region, page.params.project)
.messaging.deleteProvider({ providerId: id });
});
.messaging.deleteProvider({ providerId: id })
);
try {
await Promise.all(promises);
trackEvent(Submit.MessagingProviderDelete, { total: selectedRows.length });
} catch (error) {
trackError(error, Submit.MessagingProviderDelete);
return error;
if (result.error) {
trackError(result.error, Submit.MessagingProviderDelete);
} else {
trackEvent(Submit.MessagingProviderDelete, { total: result.deleted.length });
}
} finally {
await invalidate(Dependencies.MESSAGING_PROVIDERS);
}
return result;
}
</script>
@@ -1,7 +1,12 @@
<script lang="ts">
import { base } from '$app/paths';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import {
type DeleteOperationState,
type DeleteOperation,
Id,
MultiSelectionTable
} from '$lib/components';
import type { PageData } from './$types';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
@@ -20,22 +25,24 @@
columns: Column[];
} = $props();
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((id) => {
return sdk
async function handleDelete(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
const result = await batchDelete((id) =>
sdk
.forProject(page.params.region, page.params.project)
.messaging.deleteTopic({ topicId: id });
});
.messaging.deleteTopic({ topicId: id })
);
try {
await Promise.all(promises);
trackEvent(Submit.MessagingTopicDelete, { total: selectedRows.length });
} catch (error) {
trackError(error, Submit.MessagingTopicDelete);
return error;
if (result.error) {
trackError(result.error, Submit.MessagingTopicDelete);
} else {
trackEvent(Submit.MessagingTopicDelete, { total: result.deleted.length });
}
} finally {
await invalidate(Dependencies.MESSAGING_TOPICS);
}
return result;
}
</script>
@@ -2,7 +2,12 @@
import { invalidate } from '$app/navigation';
import { base } from '$app/paths';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import {
type DeleteOperationState,
type DeleteOperation,
Id,
MultiSelectionTable
} from '$lib/components';
import { Dependencies } from '$lib/constants';
import type { PageData } from './$types';
import ProviderType from '../../providerType.svelte';
@@ -32,8 +37,8 @@
return record;
});
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
async function deleteSubscriber(subscriberId: string) {
async function handleDelete(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
const result = await batchDelete(async (subscriberId) => {
await sdk
.forProject(page.params.region, page.params.project)
.messaging.deleteSubscriber({
@@ -44,19 +49,19 @@
const { target } = subscribers[subscriberId];
const { [target.$id]: _, ...rest } = $targetsById;
$targetsById = rest;
}
const promises = selectedRows.map((id) => deleteSubscriber(id));
});
try {
await Promise.all(promises);
trackEvent(Submit.MessagingTopicSubscriberDelete, { total: selectedRows.length });
} catch (error) {
trackError(error, Submit.MessagingTopicSubscriberDelete);
return error;
if (result.error) {
trackError(result.error, Submit.MessagingTopicSubscriberDelete);
} else {
trackEvent(Submit.MessagingTopicSubscriberDelete, { total: result.deleted.length });
}
} finally {
await invalidate(Dependencies.MESSAGING_TOPIC_SUBSCRIBERS);
}
return result;
}
</script>
@@ -67,7 +67,7 @@
allowSelection={$canWriteKeys}
showSuccessNotification={false}
resource={`${capitalize(label)} key`}
onDelete={(selectedRows) => {
onDelete={(_, selectedRows) => {
showDeleteModal = true;
selectedKeys = selectedRows;
}}>
@@ -81,7 +81,11 @@
import { page } from '$app/state';
import type { PageProps } from './$types';
import type { Models } from '@appwrite.io/console';
import { type DeleteOperationState, MultiSelectionTable } from '$lib/components';
import {
type DeleteOperationState,
type DeleteOperation,
MultiSelectionTable
} from '$lib/components';
import { sdk } from '$lib/stores/sdk';
import { Submit, trackError } from '$lib/actions/analytics';
import { invalidate } from '$app/navigation';
@@ -129,24 +133,26 @@
}
async function handlePlatformDelete(
selectedPlatforms: string[]
batchDelete: DeleteOperation
): Promise<DeleteOperationState> {
const promises = selectedPlatforms.map((platformId) => {
return sdk.forConsole.projects.deletePlatform({
const result = await batchDelete((platformId) =>
sdk.forConsole.projects.deletePlatform({
projectId: page.params.project,
platformId
});
});
})
);
try {
await Promise.all(promises);
trackEvent(Submit.PlatformDelete, { total: selectedPlatforms.length });
} catch (error) {
trackError(error, Submit.PlatformDelete);
return error;
if (result.error) {
trackError(result.error, Submit.PlatformDelete);
} else {
trackEvent(Submit.PlatformDelete, { total: result.deleted.length });
}
} finally {
await invalidate(Dependencies.PROJECT);
}
return result;
}
setOverviewAction(Action);
@@ -15,6 +15,7 @@
import ChangeOrganization from './changeOrganization.svelte';
import UpdateVariables from '../updateVariables.svelte';
import { page } from '$app/state';
import UpdateLabels from './updateLabels.svelte';
export let data;
@@ -84,8 +85,9 @@
<Container>
{#if $project}
<UpdateName />
{#if $canWriteProjects}
<UpdateName />
<UpdateLabels />
<UpdateServices />
<UpdateInstallations {...data.installations} limit={data.limit} offset={data.offset} />
<UpdateVariables
@@ -21,7 +21,11 @@ export const load: PageLoad = async ({ depends, url, route, params, parent }) =>
const { organization } = await parent();
const rules = await sdk.forProject(params.region, params.project).proxy.listRules({
queries: [Query.equal('type', RuleType.API), Query.equal('trigger', RuleTrigger.MANUAL)],
queries: [
Query.equal('type', RuleType.API),
Query.equal('trigger', RuleTrigger.MANUAL),
Query.orderDesc('$updatedAt')
],
search: search || undefined
});
@@ -24,6 +24,7 @@
import { regionalProtocol } from '../../store';
import DnsRecordsAction from '$lib/components/domains/dnsRecordsAction.svelte';
import ViewLogsModal from '$lib/components/domains/viewLogsModal.svelte';
import { timeFromNowShort } from '$lib/helpers/date';
let {
domains,
@@ -45,8 +46,36 @@
type: 'string',
format: 'string',
width: { min: 300, max: 550 }
},
{
id: 'updated',
title: '',
type: 'string',
width: { min: 160, max: 180 }
}
];
function updatedLabel(proxyRule: Models.ProxyRule): string {
if (proxyRule.status === 'verified') {
return '';
}
const timeStr = timeFromNowShort(proxyRule.$updatedAt);
if (timeStr === 'n/a') {
return '';
}
const prefix =
proxyRule.status === 'created'
? 'Checked'
: proxyRule.status === 'verifying'
? 'Updated'
: proxyRule.status === 'unverified'
? 'Failed'
: '';
return prefix + ' ' + timeStr;
}
</script>
<Table.Root columns={[...columns, { id: 'actions', width: 40 }]} let:root>
@@ -116,6 +145,15 @@
{/if}
</Layout.Stack>
</Layout.Stack>
{:else if column.id === 'updated' && proxyRule.status !== 'verified'}
<Layout.Stack direction="row" justifyContent="flex-end">
<Typography.Text
variant="m-400"
color="--fgcolor-neutral-tertiary"
style="font-size: 0.875rem;">
{updatedLabel(proxyRule)}
</Typography.Text>
</Layout.Stack>
{/if}
</Table.Cell>
{/each}
@@ -0,0 +1,102 @@
<script lang="ts">
import { onMount } from 'svelte';
import { invalidate } from '$app/navigation';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { CardGrid } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button, Form, InputTags } from '$lib/elements/forms';
import { symmetricDifference } from '$lib/helpers/array';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { project } from '../store';
import { Tag, Input, Layout, Icon } from '@appwrite.io/pink-svelte';
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
const alphaNumericRegExp = /^[a-zA-Z0-9]+$/;
let suggestedLabels = ['live', 'stage', 'internal'];
let labels: string[] = [];
let error = '';
onMount(async () => {
labels = [...$project.labels];
});
async function updateLabels() {
try {
await sdk.forConsole.projects.updateLabels({ projectId: $project.$id, labels });
await invalidate(Dependencies.PROJECT);
isDisabled = true;
addNotification({
type: 'success',
message: 'Project labels have been updated'
});
trackEvent(Submit.ProjectUpdateLabels);
} catch (error) {
addNotification({
message: error.message,
type: 'error'
});
trackError(error, Submit.ProjectUpdateLabels);
}
}
$: isDisabled = !!error || !symmetricDifference(labels, $project.labels).length;
$: if (labels) {
const invalidLabels = [];
labels.forEach((label) => {
if (!alphaNumericRegExp.test(label)) {
invalidLabels.push(label);
}
});
if (invalidLabels.length) {
error = `Invalid labels: ${invalidLabels.join(', ')}`;
} else {
error = '';
}
}
</script>
<Form onSubmit={updateLabels}>
<CardGrid>
<svelte:fragment slot="title">Labels</svelte:fragment>
Categorize and manage your projects for easy searching based on specific criteria by assigning
them customizable labels.
<svelte:fragment slot="aside">
<Layout.Stack gap="s">
{#key labels.length}
<InputTags
id="project-labels"
label="Labels"
placeholder="Select or type project labels"
bind:tags={labels} />
{/key}
<Layout.Stack direction="row">
{#each suggestedLabels as suggestedLabel}
<Tag
size="s"
selected={labels.includes(suggestedLabel)}
on:click={() => {
if (!labels.includes(suggestedLabel)) {
labels = [...labels, suggestedLabel];
} else {
labels = labels.filter((e) => e !== suggestedLabel);
}
}}>
<Icon icon={IconPlus} size="s" slot="start" />
{suggestedLabel}
</Tag>
{/each}
</Layout.Stack>
<Input.Helper state={error ? 'warning' : 'default'}
>{error ? error : 'Only alphanumeric characters are allowed'}</Input.Helper>
</Layout.Stack>
</svelte:fragment>
<svelte:fragment slot="actions">
<Button disabled={isDisabled} submit>Update</Button>
</svelte:fragment>
</CardGrid>
</Form>
@@ -2,7 +2,7 @@
import { base } from '$app/paths';
import { page } from '$app/state';
import { Empty, Id } from '$lib/components';
import { toLocaleDateTime } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { Container } from '$lib/layout';
import { Button } from '$lib/elements/forms';
import type { PageData } from './$types';
@@ -58,7 +58,11 @@
{:else if column.id === 'events'}
{webhook.events.length}
{:else if column.type === 'datetime'}
{webhook[column.id] ? toLocaleDateTime(webhook[column.id]) : '-'}
{#if webhook[column.id]}
<DualTimeView time={webhook[column.id]} showDatetime />
{:else}
-
{/if}
{:else if column.id === 'enabled'}
<Layout.Stack direction="row" gap="s" alignItems="normal">
<Status
@@ -54,8 +54,7 @@
<svelte:fragment slot="menu" let:toggle>
<ActionMenu.Root>
{@const effectiveStatus = getEffectiveBuildStatus(
deployment.status,
deployment.$createdAt,
deployment,
$regionalConsoleVariables
)}
{#if !inCard}
@@ -6,6 +6,7 @@
case 'ready':
return 'success';
case 'building':
case 'finalizing':
return 'warning';
case 'processing':
return undefined;
@@ -40,9 +41,7 @@
emptyCopy?: string;
} = $props();
let effectiveStatus = $derived(
getEffectiveBuildStatus(deployment.status, deployment.$createdAt, $regionalConsoleVariables)
);
let effectiveStatus = $derived(getEffectiveBuildStatus(deployment, $regionalConsoleVariables));
function setCopy() {
if (effectiveStatus === 'failed') {
@@ -73,7 +72,7 @@
variant="secondary"
type={badgeTypeDeployment(effectiveStatus)} />
</Layout.Stack>
<LogsTimer status={effectiveStatus} {deployment} />
<LogsTimer {deployment} />
</Layout.Stack>
{/if}
@@ -7,15 +7,13 @@
import { Layout, Spinner, Typography } from '@appwrite.io/pink-svelte';
let { status, deployment }: { status: string; deployment: Models.Deployment } = $props();
let { deployment }: { deployment: Models.Deployment } = $props();
let effectiveStatus = $derived(
getEffectiveBuildStatus(status, deployment.$createdAt, $regionalConsoleVariables)
);
let effectiveStatus = $derived(getEffectiveBuildStatus(deployment, $regionalConsoleVariables));
</script>
<Layout.Stack direction="row" alignItems="center" inline>
{#if ['processing', 'building'].includes(effectiveStatus)}
{#if ['processing', 'building', 'finalizing'].includes(effectiveStatus)}
<Typography.Code color="--fgcolor-neutral-secondary">
<Layout.Stack direction="row" alignItems="center" inline>
<p use:timer={{ start: deployment.$createdAt }}></p>
@@ -42,9 +42,7 @@
footer?: Snippet;
} = $props();
let effectiveStatus = $derived(
getEffectiveBuildStatus(deployment.status, deployment.$createdAt, $regionalConsoleVariables)
);
let effectiveStatus = $derived(getEffectiveBuildStatus(deployment, $regionalConsoleVariables));
let show = $state(false);
const totalSize = $derived(humanFileSize(deployment?.totalSize ?? 0));
@@ -12,32 +12,76 @@
import { onMount } from 'svelte';
import { getFrameworkIcon } from '$lib/stores/sites';
import type { Models } from '@appwrite.io/console';
import { getEffectiveBuildStatus } from '$lib/helpers/buildTimeout';
import { regionalConsoleVariables } from '$routes/(console)/project-[region]-[project]/store';
let { data } = $props();
let deployment = $state(data.deployment);
let skipScreenshotTimeout: ReturnType<typeof setTimeout> | null = $state(null);
let effectiveStatus = $derived(getEffectiveBuildStatus(deployment, $regionalConsoleVariables));
onMount(() => {
return realtime.forConsole(page.params.region, 'console', async (response) => {
if (
response.events.includes(
`sites.${data.site.$id}.deployments.${data.deployment.$id}.update`
)
) {
deployment = response.payload as Models.Deployment;
if (deployment.status === 'ready') {
const resolvedUrl = resolve(
'/(console)/project-[region]-[project]/sites/create-site/finish',
{
region: page.params.region,
project: page.params.project
const timeoutCleanup = () => {
if (skipScreenshotTimeout) {
clearTimeout(skipScreenshotTimeout);
skipScreenshotTimeout = null;
}
};
const realtimeUnsubscribe = realtime.forConsole(
page.params.region,
'console',
async (response) => {
if (
response.events.includes(
`sites.${data.site.$id}.deployments.${data.deployment.$id}.update`
)
) {
deployment = response.payload as Models.Deployment;
const isReady = deployment.status === 'ready';
const isFinished =
isReady && deployment.screenshotLight && deployment.screenshotDark;
// Fallback mechanism
// If ready but not finished for over 30 seconds, go anyway
if (isReady && !skipScreenshotTimeout) {
skipScreenshotTimeout = setTimeout(async () => {
goToFinishScreen();
}, 30000);
}
if (isReady && isFinished) {
if (skipScreenshotTimeout) {
clearTimeout(skipScreenshotTimeout);
skipScreenshotTimeout = null;
}
);
await goto(`${resolvedUrl}?site=${data.site.$id}`);
goToFinishScreen();
}
}
}
});
);
return () => {
realtimeUnsubscribe();
timeoutCleanup();
};
});
async function goToFinishScreen() {
const resolvedUrl = resolve(
'/(console)/project-[region]-[project]/sites/create-site/finish',
{
region: page.params.region,
project: page.params.project
}
);
await goto(`${resolvedUrl}?site=${data.site.$id}`);
}
</script>
<Wizard
@@ -77,7 +121,7 @@
</svelte:fragment>
<svelte:fragment slot="footer">
<Layout.Stack direction="row" alignItems="center" justifyContent="flex-end">
{#if ['processing', 'building'].includes(data.deployment.status)}
{#if ['processing', 'building', 'finalizing'].includes(effectiveStatus)}
<Typography.Text variant="m-400" color="--fgcolor-neutral-tertiary">
Deployment will continue in the background
</Typography.Text>
@@ -1,6 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { base, resolve } from '$app/paths';
import { page } from '$app/state';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Button, Form } from '$lib/elements/forms';
@@ -21,6 +21,7 @@
import { isCloud } from '$lib/system';
import { currentPlan } from '$lib/stores/organization';
import Domain from '../domain.svelte';
import { uploader } from '$lib/stores/uploader';
export let data;
let showExitModal = false;
@@ -50,6 +51,8 @@
$: readableMaxSize = humanFileSize(maxSize);
async function create() {
let site: Models.Site | null = null;
try {
if (!domainIsValid) {
addNotification({
@@ -63,7 +66,7 @@
const buildRuntime = Object.values(BuildRuntime).find(
(f) => f === framework.buildRuntime
);
let site = await sdk.forProject(page.params.region, page.params.project).sites.create({
site = await sdk.forProject(page.params.region, page.params.project).sites.create({
siteId: id || ID.unique(),
name,
framework: fr,
@@ -90,26 +93,42 @@
);
await Promise.all(promises);
const deployment = await sdk
.forProject(page.params.region, page.params.project)
.sites.createDeployment({
siteId: site.$id,
code: files[0],
activate: true,
installCommand: installCommand || undefined,
buildCommand: buildCommand || undefined,
outputDirectory: outputDirectory || undefined
});
const promise = uploader.uploadSiteDeployment({
siteId: site.$id,
code: files[0],
buildCommand: buildCommand || undefined,
installCommand: installCommand || undefined,
outputDirectory: outputDirectory || undefined
});
addNotification({
message: 'Deployment upload started',
type: 'success'
});
trackEvent(Submit.SiteCreate, {
source: 'manual',
framework: framework.key
});
await goto(
`${base}/project-${page.params.region}-${page.params.project}/sites/create-site/deploying?site=${site.$id}&deployment=${deployment.$id}`
);
await promise;
const upload = $uploader.files.find((f) => f.resourceId === site.$id);
if (upload?.status === 'success') {
const deploymentId = upload.$id;
const resolvedPath = resolve(
`/(console)/project-[region]-[project]/sites/create-site/deploying`,
{
region: page.params.region,
project: page.params.project
}
);
await goto(`${resolvedPath}?site=${site.$id}&deployment=${deploymentId}`);
}
} catch (e) {
const upload = $uploader.files.find((f) => f.resourceId === site?.$id);
if (upload) {
uploader.removeFromQueue(upload.$id);
}
addNotification({
type: 'error',
message: e.message
@@ -13,6 +13,7 @@
import { currentPlan } from '$lib/stores/organization';
import { consoleVariables } from '$routes/(console)/store';
import { humanFileSize } from '$lib/helpers/sizeConvertion';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
export let show = false;
export let site: Models.Site;
@@ -29,15 +30,22 @@
async function createDeployment() {
try {
uploader.uploadSiteDeployment(site.$id, files[0]);
uploader.uploadSiteDeployment({
siteId: site.$id,
code: files[0]
});
show = false;
invalidate(Dependencies.DEPLOYMENTS);
trackEvent(Submit.DeploymentCreate, {
type: 'site'
});
addNotification({
message: 'Deployment upload started',
type: 'success'
});
} catch (e) {
error = e.message;
trackError(e, Submit.DeploymentCreate);
}
}
@@ -26,9 +26,7 @@
let { data }: PageProps = $props();
let deployment = $derived(data.deployment);
let effectiveStatus = $derived(
getEffectiveBuildStatus(deployment.status, deployment.$createdAt, $regionalConsoleVariables)
);
let effectiveStatus = $derived(getEffectiveBuildStatus(deployment, $regionalConsoleVariables));
let showRedeploy = $state(false);
let showActivate = $state(false);
@@ -90,7 +88,7 @@
hideDivider>
<Logs {deployment} hideTitle fullHeight />
<svelte:fragment slot="end">
<LogsTimer status={effectiveStatus} {deployment} />
<LogsTimer {deployment} />
</svelte:fragment>
</Accordion>
</Card>
@@ -1,5 +1,10 @@
<script lang="ts">
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import {
type DeleteOperationState,
type DeleteOperation,
Id,
MultiSelectionTable
} from '$lib/components';
import type { PageData } from './$types';
import { type Models } from '@appwrite.io/console';
import { formatTimeDetailed } from '$lib/helpers/timeConversion';
@@ -37,25 +42,28 @@
let selectedDeployment: Models.Deployment | null = $state(null);
async function deleteDeployments(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((deploymentId) =>
async function deleteDeployments(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
const result = await batchDelete((deploymentId) =>
sdk.forProject(page.params.region, page.params.project).sites.deleteDeployment({
siteId: page.params.site,
deploymentId
})
);
try {
await Promise.all(promises);
trackEvent(Submit.DeploymentDelete);
} catch (error) {
trackError(error, Submit.DeploymentDelete);
return error;
if (result.error) {
trackError(result.error, Submit.DeploymentDelete);
} else {
trackEvent(Submit.DeploymentDelete, { total: result.deleted.length });
}
} finally {
await Promise.all([
invalidate(Dependencies.DEPLOYMENTS),
invalidate(Dependencies.SITE)
]);
}
return result;
}
</script>
@@ -74,8 +82,7 @@
{#snippet children(root)}
{#each data.deploymentList.deployments as deployment (deployment.$id)}
{@const effectiveStatus = getEffectiveBuildStatus(
deployment.status,
deployment.$createdAt,
deployment,
$regionalConsoleVariables
)}
<Table.Row.Link
@@ -73,10 +73,8 @@
<Id value={deployment.$id}>{deployment.$id}</Id>
</Table.Cell>
<Table.Cell {root}>
{@const status = deployment.status}
{@const effectiveStatus = getEffectiveBuildStatus(
status,
deployment.$createdAt,
deployment,
$regionalConsoleVariables
)}
{#if activeDeployment?.$id === deployment?.$id}
@@ -28,6 +28,7 @@ export const load = async ({ params, depends, url, route, parent }) => {
Query.limit(limit),
Query.offset(offset),
Query.orderDesc(''),
Query.orderDesc('$updatedAt'),
...parsedQueries.values()
],
search: search || undefined
@@ -15,5 +15,12 @@ export const columns = writable<Column[]>([
title: 'Target',
type: 'string',
width: { min: 120, max: 400 }
},
{
id: 'updated',
title: '',
type: 'string',
width: { min: 160, max: 180 }
}
]);
@@ -25,6 +25,7 @@
import { regionalProtocol } from '$routes/(console)/project-[region]-[project]/store';
import DnsRecordsAction from '$lib/components/domains/dnsRecordsAction.svelte';
import ViewLogsModal from '$lib/components/domains/viewLogsModal.svelte';
import { timeFromNowShort } from '$lib/helpers/date';
let {
proxyRules,
@@ -46,6 +47,28 @@
? 'Deployed from ' + proxy.deploymentVcsProviderBranch
: 'Active deployment';
};
function updatedLabel(proxyRule: Models.ProxyRule): string {
if (proxyRule.status === 'verified') {
return '';
}
const timeStr = timeFromNowShort(proxyRule.$updatedAt);
if (timeStr === 'n/a') {
return '';
}
const prefix =
proxyRule.status === 'created'
? 'Checked'
: proxyRule.status === 'verifying'
? 'Updated'
: proxyRule.status === 'unverified'
? 'Failed'
: '';
return prefix + ' ' + timeStr;
}
</script>
<Table.Root columns={[...$columns, { id: 'actions', width: 40 }]} let:root>
@@ -117,6 +140,15 @@
</Layout.Stack>
{:else if column.id === 'target'}
{proxyTarget(proxyRule)}
{:else if column.id === 'updated' && proxyRule.status !== 'verified'}
<Layout.Stack direction="row" justifyContent="flex-end">
<Typography.Text
variant="m-400"
color="--fgcolor-neutral-tertiary"
style="font-size: 0.875rem;">
{updatedLabel(proxyRule)}
</Typography.Text>
</Layout.Stack>
{/if}
</Table.Cell>
{/each}

Some files were not shown because too many files have changed in this diff Show More