mirror of
https://github.com/appwrite/console.git
synced 2026-06-06 19:27:48 +00:00
Merge branch 'main' into 'billing-sdk-refactor'.
This commit is contained in:
+2
-2
@@ -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",
|
||||
|
||||
Generated
+14
-15
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+159
@@ -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>
|
||||
-61
@@ -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>
|
||||
-80
@@ -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>
|
||||
-56
@@ -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>
|
||||
-54
@@ -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>
|
||||
-56
@@ -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>
|
||||
|
||||
|
||||
+13
-10
@@ -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>
|
||||
|
||||
|
||||
+18
-11
@@ -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>
|
||||
|
||||
|
||||
+10
-7
@@ -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>
|
||||
|
||||
|
||||
+12
-5
@@ -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
|
||||
|
||||
+18
-11
@@ -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);
|
||||
|
||||
+12
-13
@@ -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 {
|
||||
|
||||
+14
-14
@@ -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(
|
||||
|
||||
+6
-6
@@ -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'
|
||||
|
||||
+46
-39
@@ -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?
|
||||
|
||||
+3
-3
@@ -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}
|
||||
|
||||
+1
-1
@@ -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} />
|
||||
|
||||
+4
-4
@@ -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}
|
||||
|
||||
+14
-3
@@ -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;
|
||||
}
|
||||
|
||||
+8
-2
@@ -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)}
|
||||
|
||||
+3
-1
@@ -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',
|
||||
|
||||
+16
-8
@@ -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}
|
||||
|
||||
+42
-23
@@ -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
|
||||
|
||||
+1
-3
@@ -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>
|
||||
|
||||
|
||||
+10
-12
@@ -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'
|
||||
|
||||
+1
-5
@@ -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);
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+7
@@ -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 }
|
||||
}
|
||||
]);
|
||||
|
||||
+32
@@ -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}
|
||||
|
||||
+15
-8
@@ -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>
|
||||
|
||||
|
||||
+17
-10
@@ -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>
|
||||
|
||||
|
||||
+16
-11
@@ -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
|
||||
|
||||
+1
-2
@@ -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));
|
||||
|
||||
+61
-17
@@ -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>
|
||||
|
||||
+34
-15
@@ -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
|
||||
|
||||
+9
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-4
@@ -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>
|
||||
|
||||
+17
-10
@@ -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
|
||||
|
||||
+1
-3
@@ -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 }
|
||||
}
|
||||
]);
|
||||
|
||||
+32
@@ -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
Reference in New Issue
Block a user