Merge branch 'feat-pink-v2' into mgn-dv

# Conflicts:
#	src/routes/(console)/organization-[organization]/+page.svelte
This commit is contained in:
ernstmul
2025-04-14 12:08:28 +02:00
143 changed files with 2845 additions and 2438 deletions
+2 -2
View File
@@ -21,11 +21,11 @@
"e2e:ui": "playwright test --ui"
},
"dependencies": {
"@appwrite.io/console": "https://pkg.pr.new/appwrite/appwrite/@appwrite.io/console@9c6551f",
"@appwrite.io/console": "https://pkg.pr.new/appwrite/appwrite/@appwrite.io/console@e2f082e",
"@appwrite.io/pink-icons": "0.25.0",
"@appwrite.io/pink-icons-svelte": "https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@207628e",
"@appwrite.io/pink-legacy": "^1.0.3",
"@appwrite.io/pink-svelte": "https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@207628e",
"@appwrite.io/pink-svelte": "https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@16d19d7",
"@popperjs/core": "^2.11.8",
"@sentry/sveltekit": "^8.38.0",
"@stripe/stripe-js": "^3.5.0",
+17 -17
View File
@@ -9,8 +9,8 @@ importers:
.:
dependencies:
'@appwrite.io/console':
specifier: https://pkg.pr.new/appwrite/appwrite/@appwrite.io/console@9c6551f
version: https://pkg.pr.new/appwrite/appwrite/@appwrite.io/console@9c6551f
specifier: https://pkg.pr.new/appwrite/appwrite/@appwrite.io/console@e2f082e
version: https://pkg.pr.new/appwrite/appwrite/@appwrite.io/console@e2f082e
'@appwrite.io/pink-icons':
specifier: 0.25.0
version: 0.25.0
@@ -21,8 +21,8 @@ importers:
specifier: ^1.0.3
version: 1.0.3
'@appwrite.io/pink-svelte':
specifier: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@207628e
version: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@207628e(react-dom@18.3.1(react@18.3.1))(svelte@5.25.3)
specifier: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@16d19d7
version: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@16d19d7(react-dom@18.3.1(react@18.3.1))(svelte@5.25.3)
'@popperjs/core':
specifier: ^2.11.8
version: 2.11.8
@@ -238,18 +238,18 @@ packages:
'@analytics/type-utils@0.6.2':
resolution: {integrity: sha512-TD+xbmsBLyYy/IxFimW/YL/9L2IEnM7/EoV9Aeh56U64Ify8o27HJcKjo38XY9Tcn0uOq1AX3thkKgvtWvwFQg==}
'@appwrite.io/console@https://pkg.pr.new/appwrite/appwrite/@appwrite.io/console@9c6551f':
resolution: {tarball: https://pkg.pr.new/appwrite/appwrite/@appwrite.io/console@9c6551f}
'@appwrite.io/console@https://pkg.pr.new/appwrite/appwrite/@appwrite.io/console@e2f082e':
resolution: {tarball: https://pkg.pr.new/appwrite/appwrite/@appwrite.io/console@e2f082e}
version: 1.2.1
'@appwrite.io/pink-icons-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@207628e':
resolution: {tarball: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@207628e}
'@appwrite.io/pink-icons-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@16d19d7471dc8cb55ee3893b3e88470f383d60b1':
resolution: {tarball: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@16d19d7471dc8cb55ee3893b3e88470f383d60b1}
version: 1.0.0-next.7
peerDependencies:
svelte: ^4.0.0
'@appwrite.io/pink-icons-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@207628e41f5ce1df4b7880030c2865452408d8ed':
resolution: {tarball: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@207628e41f5ce1df4b7880030c2865452408d8ed}
'@appwrite.io/pink-icons-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@207628e':
resolution: {tarball: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@207628e}
version: 1.0.0-next.7
peerDependencies:
svelte: ^4.0.0
@@ -263,8 +263,8 @@ packages:
'@appwrite.io/pink-legacy@1.0.3':
resolution: {integrity: sha512-GGde5fmPhs+s6/3aFeMPc/kKADG/gTFkYQSy6oBN8pK0y0XNCLrZZgBv+EBbdhwdtqVEWXa0X85Mv9w7jcIlwQ==}
'@appwrite.io/pink-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@207628e':
resolution: {tarball: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@207628e}
'@appwrite.io/pink-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@16d19d7':
resolution: {tarball: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@16d19d7}
version: 1.0.0-next.85
peerDependencies:
react-dom: ^18.0.0
@@ -3733,13 +3733,13 @@ snapshots:
'@analytics/type-utils@0.6.2': {}
'@appwrite.io/console@https://pkg.pr.new/appwrite/appwrite/@appwrite.io/console@9c6551f': {}
'@appwrite.io/console@https://pkg.pr.new/appwrite/appwrite/@appwrite.io/console@e2f082e': {}
'@appwrite.io/pink-icons-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@207628e(svelte@5.25.3)':
'@appwrite.io/pink-icons-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@16d19d7471dc8cb55ee3893b3e88470f383d60b1(svelte@5.25.3)':
dependencies:
svelte: 5.25.3
'@appwrite.io/pink-icons-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@207628e41f5ce1df4b7880030c2865452408d8ed(svelte@5.25.3)':
'@appwrite.io/pink-icons-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@207628e(svelte@5.25.3)':
dependencies:
svelte: 5.25.3
@@ -3752,9 +3752,9 @@ snapshots:
'@appwrite.io/pink-icons': 1.0.0
the-new-css-reset: 1.11.3
'@appwrite.io/pink-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@207628e(react-dom@18.3.1(react@18.3.1))(svelte@5.25.3)':
'@appwrite.io/pink-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@16d19d7(react-dom@18.3.1(react@18.3.1))(svelte@5.25.3)':
dependencies:
'@appwrite.io/pink-icons-svelte': https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@207628e41f5ce1df4b7880030c2865452408d8ed(svelte@5.25.3)
'@appwrite.io/pink-icons-svelte': https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@16d19d7471dc8cb55ee3893b3e88470f383d60b1(svelte@5.25.3)
'@floating-ui/dom': 1.6.13
'@melt-ui/pp': 0.3.2(@melt-ui/svelte@0.86.5(svelte@5.25.3))(svelte@5.25.3)
'@melt-ui/svelte': 0.86.5(svelte@5.25.3)
@@ -19,9 +19,9 @@
async function handleSubmit() {
try {
const card = await submitStripeCard(name, page?.params?.organization ?? null);
show = false;
invalidate(Dependencies.PAYMENT_METHODS);
dispatch('submit', card);
show = false;
addNotification({
type: 'success',
message: 'A new payment method has been added to your account'
+3 -3
View File
@@ -7,7 +7,7 @@
import { isCloud } from '$lib/system';
import CardPlanLimit from './cardPlanLimit.svelte';
export let showEmpty = true;
export let disableEmpty = true;
export let offset = 0;
export let total = 0;
export let event: string = null;
@@ -25,8 +25,8 @@
{#if total > 3 ? total < limit + offset : total % 2 !== 0}
{#if isCloud && serviceId && total >= planLimit}
<CardPlanLimit {service} />
{:else if showEmpty}
<Empty on:click target={event}>
{:else}
<Empty on:click target={event} disabled={disableEmpty}>
<slot name="empty" />
</Empty>
{/if}
+1 -1
View File
@@ -40,7 +40,7 @@
role="button"
tabindex="0"
style:cursor="pointer"
on:click|preventDefault={handleClick}
on:click|preventDefault|stopPropagation={handleClick}
on:keyup={clickOnEnter}
on:mouseenter={() => setTimeout(() => (content = 'Click to copy'))}>
<slot />
+2 -2
View File
@@ -54,8 +54,8 @@
<InteractiveText
variant="copy"
isVisible
text={$consoleVariables._APP_DOMAIN_TARGET}>
{$consoleVariables._APP_DOMAIN_TARGET}</InteractiveText>
text={$consoleVariables._APP_DOMAIN_TARGET_CNAME}>
{$consoleVariables._APP_DOMAIN_TARGET_CNAME}</InteractiveText>
</Table.Cell>
</Table.Row.Base>
</Table.Root>
@@ -7,7 +7,7 @@
export let verified = false;
const nameserverList = $consoleVariables?._APP_DOMAINS_NAMESERVERS
? $consoleVariables?._APP_DOMAINS_NAMESERVERS.split(',')
? $consoleVariables?._APP_DOMAINS_NAMESERVERS?.split(',')
: ['ns1.appwrite.io', 'ns2.appwrite.io'];
</script>
@@ -0,0 +1,81 @@
<script lang="ts">
import { Link } from '$lib/elements';
import { consoleVariables } from '$routes/(console)/store';
import { IconInfo } from '@appwrite.io/pink-icons-svelte';
import {
Badge,
Layout,
Typography,
Table,
Icon,
InteractiveText
} from '@appwrite.io/pink-svelte';
export let domain: string;
export let verified = false;
export let variant: 'cname' | 'a' | 'aaaa';
let subdomain = domain?.split('.')?.slice(0, -2)?.join('.');
function setTarget() {
switch (variant) {
case 'cname':
return $consoleVariables._APP_DOMAIN_TARGET_CNAME;
case 'a':
return $consoleVariables._APP_DOMAIN_TARGET_A;
case 'aaaa':
return $consoleVariables._APP_DOMAIN_TARGET_AAAA;
}
}
</script>
<Layout.Stack gap="xl">
<Layout.Stack gap="s">
<Layout.Stack gap="s" direction="row" alignItems="center">
<Typography.Text variant="l-500" color="--fgcolor-neutral-primary">
{domain}
</Typography.Text>
{#if verified}
<Badge variant="secondary" type="success" content="Verified" />
{:else if verified === false}
<Badge variant="secondary" type="error" content="Verification failed" />
{:else}
<Badge
variant="secondary"
type="warning"
size="xs"
content="Pending verification" />
{/if}
</Layout.Stack>
<Typography.Text variant="m-400">
Add the following record on your DNS provider. Note that DNS changes may take time to
propagate fully.
</Typography.Text>
</Layout.Stack>
<Table.Root columns={3} 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>
</svelte:fragment>
<Table.Row.Base {root}>
<Table.Cell {root}>{variant.toUpperCase()}</Table.Cell>
<Table.Cell {root}>{subdomain || '@'}</Table.Cell>
<Table.Cell {root}>
<InteractiveText variant="copy" isVisible text={setTarget()}>
{setTarget()}</InteractiveText>
</Table.Cell>
</Table.Row.Base>
</Table.Root>
<Layout.Stack gap="s" direction="row" alignItems="center">
<Icon icon={IconInfo} size="s" color="--fgcolor-neutral-secondary" />
<Typography.Text variant="m-400" color="--fgcolor-neutral-secondary">
A list of all domain providers and their DNS setting is available <Link
variant="muted"
external
href="https://appwrite.io/docs/advanced/platform/custom-domains">here</Link
>.
</Typography.Text>
</Layout.Stack>
</Layout.Stack>
+15 -3
View File
@@ -1,8 +1,14 @@
<script lang="ts">
import { type ComponentProps } from 'svelte';
import { capitalize } from '$lib/helpers/string';
import { timeFromNow, toLocaleDateTime, toLocalDateTimeISO } from '$lib/helpers/date';
import {
timeFromNow,
toLocaleDateTime,
toLocalDateTimeISO,
toISOString
} from '$lib/helpers/date';
import { Badge, InteractiveText, Layout, Popover, Typography } from '@appwrite.io/pink-svelte';
import { menuOpen } from '$lib/components/menu/store';
export let time: string = '';
export let placement: ComponentProps<Popover>['placement'] = 'bottom';
@@ -70,7 +76,13 @@
</script>
<Popover let:show let:hide {placement} portal>
<button on:mouseenter={() => setTimeout(show, 150)} on:mouseleave={() => hidePopover(hide)}>
<button
on:mouseenter={() => {
if (!$menuOpen) {
setTimeout(show, 150);
}
}}
on:mouseleave={() => hidePopover(hide)}>
<slot>{capitalize(timeFromNow(time))}</slot>
</button>
@@ -99,7 +111,7 @@
isVisible
variant="copy"
text={toLocaleDateTime(time, 'UTC')}
value={new Date(time).toISOString()} />
value={toISOString(time)} />
<Badge variant="secondary" content="UTC" size="xs" />
</Layout.Stack>
+2 -1
View File
@@ -9,6 +9,7 @@
export let href: string = null;
export let allowCreate = true;
export let description = 'Need a hand? Learn more in our documentation.';
export let disabled = false;
export let src: string = null;
function track() {
@@ -47,7 +48,7 @@
</Empty>
</Card.Base>
{:else}
<Card.Button on:click on:click={track} aria-label="create">
<Card.Button on:click on:click={track} aria-label="create" {disabled}>
<Layout.Stack justifyContent="center" alignItems="center" gap="m">
<Avatar size="s"><Icon icon={IconPlus} size="s" /></Avatar>
<Typography.Text variation="m-400" align="center"><slot /></Typography.Text>
+1 -1
View File
@@ -45,7 +45,7 @@
}
}
$: if (backdrop) {
$: if (backdrop && show && !document.body.contains(backdrop)) {
document.body.appendChild(backdrop);
}
+38 -35
View File
@@ -138,45 +138,48 @@
{/if}
</Button>
<svelte:fragment slot="tooltip">
<Layout.Stack gap="s">
{#if displayQuickFilters}
<slot name="quick" />
{:else}
<p>Apply filter rules to refine the table view</p>
<Content
bind:columnId={selectedColumn}
bind:operatorKey
bind:value
bind:arrayValues
{columns}
{singleCondition}
on:apply={afterApply}
on:clear={() => (applied = 0)} />
{/if}
<div
class="u-flex u-cross-center u-margin-block-start-16"
class:u-main-end={!quickFilters}
class:u-main-space-between={quickFilters}>
{#if quickFilters}
<Button
text
on:click={() => (displayQuickFilters = !displayQuickFilters)}
class="u-margin-block-end-auto">
{displayQuickFilters ? 'Advanced filters' : 'Quick filters'}
</Button>
<div style:width="420px">
<Layout.Stack gap="s">
{#if displayQuickFilters}
<slot name="quick" />
{:else}
<p>Apply filter rules to refine the table view</p>
<Content
bind:columnId={selectedColumn}
bind:operatorKey
bind:value
bind:arrayValues
{columns}
{singleCondition}
on:apply={afterApply}
on:clear={() => (applied = 0)} />
{/if}
<div class="u-flex u-gap-8">
{#if singleCondition}
<Button size="s" text on:click={toggleDropdown}>Cancel</Button>
{:else}
<Button size="s" disabled={applied === 0} text on:click={clearAll}>
Clear all
<div
class="u-flex u-cross-center u-margin-block-start-16"
class:u-main-end={!quickFilters}
class:u-main-space-between={quickFilters}>
{#if quickFilters}
<Button
text
on:click={() => (displayQuickFilters = !displayQuickFilters)}
class="u-margin-block-end-auto">
{displayQuickFilters ? 'Advanced filters' : 'Quick filters'}
</Button>
{/if}
<Button size="s" on:click={apply} disabled={isButtonDisabled}>Apply</Button>
<div class="u-flex u-gap-8">
{#if singleCondition}
<Button size="s" text on:click={toggleDropdown}>Cancel</Button>
{:else}
<Button size="s" disabled={applied === 0} text on:click={clearAll}>
Clear all
</Button>
{/if}
<Button size="s" on:click={apply} disabled={isButtonDisabled}
>Apply</Button>
</div>
</div>
</div>
</Layout.Stack>
</Layout.Stack>
</div>
</svelte:fragment>
</Popover>
</div>
@@ -41,7 +41,7 @@
</div>
<div class="menu subMenu" use:melt={$subMenu}>
<Card.Base padding="xxxs">
<Card.Base padding="none">
<div use:melt={$radioGroup}>
{#each filter.options as option (option.value + option.checked)}
{#if variant === 'radio'}
@@ -4,33 +4,38 @@
import { Icon, Layout } from '@appwrite.io/pink-svelte';
import { installation, repository } from '$lib/stores/vcs';
import { sdk } from '$lib/stores/sdk';
import { createEventDispatcher, onMount } from 'svelte';
import { onMount } from 'svelte';
import { IconArrowSmRight } from '@appwrite.io/pink-icons-svelte';
import { Link } from '$lib/elements';
import { NewRepository, Repositories } from '$lib/components/git';
import ConnectGit from '$lib/components/git/connectGit.svelte';
import { Adapter, BuildRuntime, Framework, type Models } from '@appwrite.io/console';
import { addNotification } from '$lib/stores/notifications';
import { Click, trackEvent } from '$lib/actions/analytics';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import RepositoryBehaviour from '$lib/components/git/repositoryBehaviour.svelte';
const dispatch = createEventDispatcher();
let {
show = $bindable(false),
product,
callbackState = null,
onlyExisting = false,
connect = async () => {}
}: {
show?: boolean;
product: 'functions' | 'sites';
callbackState?: Record<string, string>;
onlyExisting?: boolean;
connect?: (installationId: string, repositoryId: string) => Promise<void>;
} = $props();
export let show = false;
export let site: Models.Site;
export let callbackState: Record<string, string> = null;
export let onlyExisting = false;
let repositoryBehaviour: 'new' | 'existing' | undefined = onlyExisting ? 'existing' : undefined;
let repositoryName = '';
let repositoryPrivate = true;
let selectedInstallationId = '';
let selectedRepository = '';
let installations = { installations: [], total: 0 };
let hasInstallations = !!installations?.total;
let error = '';
let repositoryBehaviour: 'new' | 'existing' | undefined = $state(
onlyExisting ? 'existing' : undefined
);
let repositoryName = $state('');
let repositoryPrivate = $state(true);
let selectedInstallationId = $state('');
let selectedRepository = $state('');
let installations = $state({ installations: [], total: 0 });
let error = $state('');
onMount(async () => {
installations = await sdk.forProject.vcs.listInstallations();
@@ -38,11 +43,9 @@
$installation = installations.installations[0];
}
selectedInstallationId = installations.total ? installations.installations[0]?.$id : '';
hasInstallations = !!installations?.total;
if (hasInstallations) {
if (!!installations?.total) {
repositoryBehaviour = 'existing';
}
console.log(selectedInstallationId);
});
async function connectRepo() {
@@ -57,29 +60,8 @@
selectedRepository = repo.id;
}
const s = await sdk.forProject.sites.update(
site.$id,
site.name,
site.framework as Framework,
site.enabled,
site.logging || undefined,
site.timeout,
site.installCommand,
site.buildCommand,
site.outputDirectory,
site.buildRuntime as BuildRuntime,
site.adapter as Adapter,
site.fallbackFile,
selectedInstallationId,
selectedRepository,
'main',
undefined,
undefined,
undefined
);
invalidate(Dependencies.SITE);
await connect(selectedInstallationId, selectedRepository);
show = false;
dispatch('connect', s);
addNotification({
type: 'success',
message: 'Repository connected successfully'
@@ -97,9 +79,10 @@
onSubmit={connectRepo}
bind:error>
<span slot="description">
Connect your site to an existing repository or create a new one.
Connect your {product === 'functions' ? 'function' : 'site'} to an existing repository or create
a new one.
</span>
{#if hasInstallations}
{#if !!installations?.total}
<Layout.Stack gap="xl">
{#if !onlyExisting}
<RepositoryBehaviour bind:repositoryBehaviour />
@@ -112,18 +95,17 @@
{installations} />
{:else}
<Repositories
bind:hasInstallations
bind:selectedRepository
product="sites"
{product}
action="button"
{callbackState}
on:connect={(e) => {
connect={(e) => {
trackEvent(Click.ConnectRepositoryClick, {
from: 'sites'
from: product
});
repository.set(e.detail);
repositoryName = e.detail.name;
selectedRepository = e.detail.id;
repository.set(e);
repositoryName = e.name;
selectedRepository = e.id;
connectRepo();
}} />
{/if}
+25 -10
View File
@@ -6,34 +6,49 @@
import type { Models } from '@appwrite.io/console';
import { IconExternalLink, IconQrcode } from '@appwrite.io/pink-icons-svelte';
import { ActionMenu, Icon, Layout, Popover, Tag, Typography } from '@appwrite.io/pink-svelte';
import { createEventDispatcher } from 'svelte';
export let domains: Models.ProxyRuleList;
export let hideQRCode = true;
let {
domains,
hideQRCode = true,
showQR = () => {}
}: {
domains: Models.ProxyRuleList;
hideQRCode?: boolean;
showQR?: (show: boolean) => void;
} = $props();
const dispatch = createEventDispatcher();
let sortedDomains = $derived(
domains?.rules?.sort((a, b) => {
if (a?.trigger === 'manual' && b?.trigger !== 'manual') {
return -1;
} else if (a?.trigger !== 'manual' && b?.trigger === 'manual') {
return 1;
}
return 0;
})
);
</script>
<Layout.Stack gap="xxs" direction="row" alignItems="center">
{#if domains?.total}
<Link external href={`${$protocol}${domains.rules[0]?.domain}`} variant="muted">
<Link external href={`${$protocol}${sortedDomains[0]?.domain}`} variant="muted">
<Layout.Stack gap="xxs" direction="row" alignItems="center">
<Trim alternativeTrim>
<Typography.Text variant="m-400" color="--fgcolor-neutral-primary">
{domains.rules[0]?.domain}
{sortedDomains[0]?.domain}
</Typography.Text>
</Trim>
</Layout.Stack>
</Link>
{#if domains.rules.length > 1}
{#if sortedDomains.length > 1}
<Popover padding="none" let:toggle>
<Tag size="xs" on:click={toggle}>
+{domains.rules.length - 1}
+{sortedDomains.length - 1}
</Tag>
<svelte:fragment slot="tooltip">
<ActionMenu.Root>
{#each domains.rules as rule, i}
{#each sortedDomains as rule, i}
{#if i !== 0}
<ActionMenu.Item.Anchor
href={`${$protocol}${rule.domain}`}
@@ -50,7 +65,7 @@
</Popover>
{/if}
{#if !hideQRCode}
<Button icon secondary size="xs" on:click={() => dispatch('showQR')}>
<Button icon secondary size="xs" on:click={() => showQR(true)}>
<Icon icon={IconQrcode} size="s" />
</Button>
{/if}
@@ -11,7 +11,11 @@
} from '@appwrite.io/pink-icons-svelte';
import { ActionMenu, Layout, Popover, Icon } from '@appwrite.io/pink-svelte';
export let deployment: Models.Deployment;
let {
deployment
}: {
deployment: Models.Deployment;
} = $props();
</script>
{#if deployment.type === 'vcs'}
+1
View File
@@ -8,3 +8,4 @@ export { default as DeploymentDomains } from './deploymentDomains.svelte';
export { default as ConnectBehaviour } from './connectBehaviour.svelte';
export { default as ProductionBranchFieldset } from './productionBranchFieldset.svelte';
export { default as RepositoryCard } from './repositoryCard.svelte';
export { default as ConnectRepoModal } from './connectRepoModal.svelte';
@@ -1,6 +1,6 @@
<script lang="ts">
import { Button, InputSelect, InputText } from '$lib/elements/forms';
import { Fieldset, Layout, Selector, Skeleton } from '@appwrite.io/pink-svelte';
import { Fieldset, Input, Layout, Selector, Skeleton } from '@appwrite.io/pink-svelte';
import SelectRootModal from './selectRootModal.svelte';
import { sdk } from '$lib/stores/sdk';
import { sortBranches } from '$lib/stores/vcs';
@@ -60,7 +60,7 @@
return a.label > b.label ? 1 : -1;
}) ?? []}
<Layout.Stack gap="xl">
<InputSelect
<Input.ComboBox
required
id="branch"
label="Production branch"
+137 -113
View File
@@ -5,7 +5,6 @@
import { sdk } from '$lib/stores/sdk';
import { repositories } from '$routes/(console)/project-[project]/functions/function-[function]/store';
import { installation, installations, repository } from '$lib/stores/vcs';
import { createEventDispatcher } from 'svelte';
import {
Layout,
Table,
@@ -15,27 +14,33 @@
Skeleton,
Button as PinkButton
} from '@appwrite.io/pink-svelte';
import { IconLockClosed } from '@appwrite.io/pink-icons-svelte';
import { IconLockClosed, IconPlus } from '@appwrite.io/pink-icons-svelte';
import ConnectGit from './connectGit.svelte';
import SvgIcon from '../svgIcon.svelte';
import { VCSDetectionType, type Models } from '@appwrite.io/console';
import { getFrameworkIcon } from '$lib/stores/sites';
import { connectGitHub } from '$lib/stores/git';
const dispatch = createEventDispatcher();
let {
action = $bindable('select'),
selectedRepository = $bindable(undefined),
installationList = $bindable($installations),
hasInstallations = $bindable($installations?.total > 0),
product = 'functions',
callbackState = null,
connect = () => {}
}: {
action?: 'button' | 'select';
selectedRepository?: string;
installationList?: Models.InstallationList;
hasInstallations?: boolean;
product?: 'functions' | 'sites';
callbackState?: Record<string, string> | null;
connect?: (repository: Models.ProviderRepository) => void;
} = $props();
export let callbackState: Record<string, string> = null;
export let selectedRepository: string = null;
export let hasInstallations = false;
export let action: 'button' | 'select' = 'select';
export let installationList = $installations;
export let product: 'functions' | 'sites' = 'functions';
let search = '';
let selectedInstallation = null;
$: {
hasInstallations = installationList?.total > 0;
}
let search = $state('');
let selectedInstallation = $state(null);
async function loadInstallations() {
if (installationList) {
@@ -64,24 +69,7 @@
$repositories.installationId !== installationId ||
$repositories.search !== search
) {
//TODO: remove forced cast after backend fixes
if (product === 'functions') {
$repositories.repositories = (
(await sdk.forProject.vcs.listRepositories(
installationId,
VCSDetectionType.Runtime,
search || undefined
)) as unknown as Models.ProviderRepositoryRuntimeList
).runtimeProviderRepositories;
} else {
$repositories.repositories = (
await sdk.forProject.vcs.listRepositories(
installationId,
VCSDetectionType.Framework,
search || undefined
)
).frameworkProviderRepositories;
}
await fetchRepos(installationId, search);
}
$repositories.search = search;
@@ -94,6 +82,26 @@
return $repositories.repositories;
}
async function fetchRepos(installationId: string, search: string) {
if (product === 'functions') {
$repositories.repositories = (
(await sdk.forProject.vcs.listRepositories(
installationId,
VCSDetectionType.Runtime,
search || undefined
)) as unknown as Models.ProviderRepositoryRuntimeList
).runtimeProviderRepositories; //TODO: remove forced cast after backend fixes
} else {
$repositories.repositories = (
(await sdk.forProject.vcs.listRepositories(
installationId,
VCSDetectionType.Framework,
search || undefined
)) as unknown as Models.ProviderRepositoryFrameworkList
).frameworkProviderRepositories;
}
}
</script>
{#if hasInstallations}
@@ -117,13 +125,23 @@
<Layout.Stack direction="row">
<InputSelect
id="installation"
options={installations.map((entry) => {
return {
label: entry.organization,
value: entry.$id
};
})}
options={[
...installations.map((entry) => {
return {
label: entry.organization,
value: entry.$id
};
}),
{
label: 'Add installation',
leadingIcon: IconPlus,
value: 'new'
}
]}
on:change={() => {
if (selectedInstallation === 'new') {
window.location.href = connectGitHub(callbackState).toString();
}
search = '';
installation.set(
installations.find((entry) => entry.$id === selectedInstallation)
@@ -154,84 +172,90 @@
</Table.Root>
{:then response}
{#if response?.length}
<Paginator
items={response}
let:paginatedItems
hideFooter={response?.length <= 6}
limit={6}>
<Table.Root columns={1} let:root>
{#each paginatedItems as repo}
<Table.Row.Base {root}>
<Table.Cell {root}>
<Layout.Stack direction="row" alignItems="center" gap="s">
{#if action === 'select'}
<input
class="is-small u-margin-inline-end-8"
type="radio"
name="repositories"
bind:group={selectedRepository}
on:change={() => repository.set(repo)}
value={repo.id} />
{/if}
{#if product === 'sites'}
{#if repo?.framework && repo.framework !== 'other'}
<Avatar size="xs" alt={repo.name}>
<SvgIcon
name={getFrameworkIcon(repo.framework)}
iconSize="small" />
</Avatar>
{:else}
<Avatar size="xs" alt={repo.name} empty />
{/if}
{:else}
{@const iconName = repo?.runtime
? repo.runtime.split('-')[0]
: undefined}
<Avatar size="xs" alt={repo.name} empty={!iconName}>
<SvgIcon name={iconName} iconSize="small" />
</Avatar>
{/if}
<Paginator items={response} hideFooter={response?.length <= 6} limit={6}>
{#snippet children(paginatedItems)}
<Table.Root columns={1} let:root>
{#each paginatedItems as repo}
<Table.Row.Base {root}>
<Table.Cell {root}>
<Layout.Stack
gap="s"
direction="row"
alignItems="center">
<Typography.Text
truncate
color="--fgcolor-neutral-secondary">
{repo.name}
</Typography.Text>
{#if repo.private}
<Icon
size="s"
icon={IconLockClosed}
color="--fgcolor-neutral-tertiary" />
alignItems="center"
gap="s">
{#if action === 'select'}
<input
class="is-small u-margin-inline-end-8"
type="radio"
name="repositories"
bind:group={selectedRepository}
onchange={() => repository.set(repo)}
value={repo.id} />
{/if}
<time datetime={repo.pushedAt}>
<Typography.Caption
variant="400"
truncate
color="--fgcolor-neutral-tertiary">
{timeFromNow(repo.pushedAt)}
</Typography.Caption>
</time>
</Layout.Stack>
{#if action === 'button'}
<Layout.Stack
direction="row"
justifyContent="flex-end">
<PinkButton.Button
{#if product === 'sites'}
{#if repo?.framework && repo.framework !== 'other'}
<Avatar size="xs" alt={repo.name}>
<SvgIcon
name={getFrameworkIcon(
repo.framework
)}
iconSize="small" />
</Avatar>
{:else}
<Avatar size="xs" alt={repo.name} empty />
{/if}
{:else}
{@const iconName = repo?.runtime
? repo.runtime.split('-')[0]
: undefined}
<Avatar
size="xs"
variant="secondary"
on:click={() => dispatch('connect', repo)}>
Connect
</PinkButton.Button>
alt={repo.name}
empty={!iconName}>
<SvgIcon name={iconName} iconSize="small" />
</Avatar>
{/if}
<Layout.Stack
gap="s"
direction="row"
alignItems="center">
<Typography.Text
truncate
color="--fgcolor-neutral-secondary">
{repo.name}
</Typography.Text>
{#if repo.private}
<Icon
size="s"
icon={IconLockClosed}
color="--fgcolor-neutral-tertiary" />
{/if}
<time datetime={repo.pushedAt}>
<Typography.Caption
variant="400"
truncate
color="--fgcolor-neutral-tertiary">
{timeFromNow(repo.pushedAt)}
</Typography.Caption>
</time>
</Layout.Stack>
{/if}
</Layout.Stack>
</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
{#if action === 'button'}
<Layout.Stack
direction="row"
justifyContent="flex-end">
<PinkButton.Button
size="xs"
variant="secondary"
on:click={() => connect(repo)}>
Connect
</PinkButton.Button>
</Layout.Stack>
{/if}
</Layout.Stack>
</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
{/snippet}
</Paginator>
{:else}
<EmptySearch hidePages hidePagination bind:search target="repositories">
+7 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { Card } from '@appwrite.io/pink-svelte';
import { createMenubar, melt } from '@melt-ui/svelte';
import { menuOpen } from '$lib/components/menu/store';
const {
elements: { menubar },
@@ -15,15 +16,20 @@
function toggle() {
open.update((state) => !state);
}
$: menuOpen.set($open);
</script>
<div use:melt={$menubar}>
{#if $open}
<div style:display="none" id="actionmenuopen"></div>
{/if}
<div use:melt={$trigger}>
<slot />
</div>
<div class="menu" use:melt={$menu}>
<Card.Base padding="xxxs">
<Card.Base padding="none">
{#if $$slots.start}
<slot name="start" />
<div class="separator" use:melt={$separator}></div>
+3
View File
@@ -0,0 +1,3 @@
import { writable } from 'svelte/store';
export const menuOpen = writable(false);
+1 -1
View File
@@ -21,7 +21,7 @@
</div>
<div class="subMenu" use:melt={$subMenu}>
<Card.Base padding="xxxs">
<Card.Base padding="none">
{#if $$slots.start}
<slot name="start" />
<div class="separator" use:melt={$separator}></div>
+5 -2
View File
@@ -243,8 +243,11 @@
class:keepTransformTransition={shouldAnimateThemeToggle}>
<ToggleButton
bind:active={activeTheme}
on:change={() =>
(shouldAnimateThemeToggle = true)}
on:change={() => {
setTimeout(() => {
shouldAnimateThemeToggle = true;
}, 150);
}}
buttons={[
{
id: 'light',
+1 -8
View File
@@ -54,14 +54,7 @@
</script>
{#if !hidePages}
<Pagination
{limit}
page={currentPage}
{total}
type="button"
on:next={next}
on:page={handleOptionClick}
on:prev={prev} />
<Pagination {limit} page={currentPage} {total} type="button" on:page={handleOptionClick} />
{:else}
<Layout.Stack direction="row" inline>
<Button.Button
+3 -2
View File
@@ -9,7 +9,8 @@
hideFooter = false,
hidePages = true,
hasLimit = false,
name = 'items'
name = 'items',
children
} = $props();
let total = $derived(items.length);
@@ -20,7 +21,7 @@
</script>
<Layout.Stack gap="s">
<slot {paginatedItems} {limit} />
{@render children(paginatedItems, limit)}
{#if !hideFooter}
<Layout.Stack direction="row" justifyContent="space-between" alignItems="center">
@@ -215,7 +215,7 @@
{groups}
on:create={create}
let:toggle>
<Button secondary icon on:click={toggle}>
<Button compact icon on:click={toggle}>
<Icon icon={IconPlus} size="s" />
</Button>
</Actions>
+34 -44
View File
@@ -1,61 +1,50 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { onMount, onDestroy } from 'svelte';
import { trackEvent } from '$lib/actions/analytics';
import { onMount } from 'svelte';
import { onDestroy } from 'svelte';
import { Icon, Input } from '@appwrite.io/pink-svelte';
import { IconSearch, IconX } from '@appwrite.io/pink-icons-svelte';
import { debounce as createDebounce } from '$lib/helpers/debounce.js';
export let search = '';
export let placeholder = '';
export let debounce = 250;
export let required = false;
export let disabled = false;
export let autofocus = false;
let element: HTMLInputElement;
let timer: ReturnType<typeof setTimeout>;
let inputValue = page.url.searchParams.get('search') ?? '';
onMount(() => {
if (element && autofocus) {
element.focus();
const runSearch = createDebounce((value: string) => {
const trimmed = value.trim();
const url = new URL(page.url);
const previous = url.searchParams.get('search') ?? '';
if (previous === trimmed) return;
if (page.data.page > 1) {
url.searchParams.delete('page');
}
});
if (trimmed === '') {
url.searchParams.delete('search');
} else {
url.searchParams.set('search', trimmed);
}
trackEvent('search');
goto(url, { keepFocus: true });
}, debounce);
$: runSearch(inputValue);
function clearInput() {
inputValue = '';
}
onDestroy(() => {
search = '';
if (timer) {
clearTimeout(timer);
}
runSearch.cancel?.();
});
$: valueChange(search ?? '');
function valueChange(value: string) {
clearTimeout(timer);
timer = setTimeout(() => {
const url = new URL(page.url);
const previous = url.searchParams.get('search') ?? '';
if (previous === value) {
return;
}
if (page.data.page > 1) {
url.searchParams.delete('page');
}
if (value === '') {
url.searchParams.delete('search');
} else {
url.searchParams.set('search', value);
}
trackEvent('search');
goto(url, { keepFocus: true });
}, debounce);
}
</script>
<div style:max-width="360px" style:width="100%">
@@ -63,14 +52,15 @@
{placeholder}
{disabled}
{required}
bind:value={search}
{autofocus}
bind:value={inputValue}
--bgcolor-neutral-default="var(--bgcolor-neutral-primary)">
<svelte:fragment slot="start">
<Icon icon={IconSearch} />
</svelte:fragment>
<svelte:fragment slot="end">
{#if search}
<Input.Action icon={IconX} on:click={() => (search = '')} />
{#if inputValue}
<Input.Action icon={IconX} on:click={clearInput} />
{/if}
</svelte:fragment>
</Input.Text>
+4 -5
View File
@@ -70,8 +70,7 @@
}
$: state = $isTabletViewport ? 'closed' : getSidebarState();
$: pathname = page.url.pathname;
$: isOnProjectSettings = /^\/console\/project-[a-zA-Z0-9-]+\/settings$/.test(pathname);
$: isOnProjectSettings = /^\/console\/project-[a-zA-Z0-9-]+\/settings$/.test(page.url.pathname);
const projectOptions = [
{ name: 'Auth', icon: IconUserGroup, slug: 'auth', category: 'build' },
@@ -145,7 +144,7 @@
<a
href={`/console/project-${project.$id}/overview`}
class="link"
class:active={pathname.includes('overview')}
class:active={page.url.pathname.includes('overview')}
on:click={() => {
trackEvent(Click.MenuOverviewClick);
sideBarIsOpen = false;
@@ -175,7 +174,7 @@
<a
href={`/console/project-${project.$id}/${projectOption.slug}`}
class="link"
class:active={pathname.includes(projectOption.slug)}
class:active={page.url.pathname.includes(projectOption.slug)}
on:click={() => {
trackEvent(`click_menu_${projectOption.slug}`);
sideBarIsOpen = false;
@@ -206,7 +205,7 @@
<a
href={`/console/project-${project.$id}/${projectOption.slug}`}
class="link"
class:active={pathname.includes(projectOption.slug)}
class:active={page.url.pathname.includes(projectOption.slug)}
on:click={() => {
trackEvent(`click_menu_${projectOption.slug}`);
sideBarIsOpen = false;
@@ -3,7 +3,7 @@
import FilePicker from '$lib/components/filePicker.svelte';
import { humanFileSize } from '$lib/helpers/sizeConvertion';
import type { Models } from '@appwrite.io/console';
import { Label } from '.';
import { Button, Label } from '.';
export let label: string = null;
export let value: Models.File = null;
@@ -62,13 +62,7 @@
</div>
<div class="u-flex u-main-center u-cross-center u-gap-16 u-flex-vertical-mobile">
Max file size: 1MB
<button
class="button is-secondary is-full-width-mobile"
type="button"
{disabled}
on:click={() => (show = true)}>
<span class="text">Browse</span>
</button>
<Button secondary {disabled} on:click={() => (show = true)}>Browse</Button>
</div>
{#if value}
+16
View File
@@ -190,6 +190,13 @@ export const diffDays = (date1: Date, date2: Date) => {
};
export function timeFromNow(datetime: string): string {
if (!datetime) {
return 'unknown time';
}
if (!isValidDate(datetime)) {
return 'invalid date';
}
return dayjs().to(dayjs(datetime));
}
@@ -216,3 +223,12 @@ export function getUTCOffset(): string {
return `${hours >= 0 ? '+' : ''}${hours}${minutes ? `:${minutes.toString().padStart(2, '0')}` : ''}`;
}
export function toISOString(date: string): string {
const d = new Date(date);
if (isNaN(d.getTime())) {
return 'n/a';
}
return d.toISOString();
}
+382
View File
@@ -0,0 +1,382 @@
import { describe, expect, it } from 'vitest';
import { parseDnsRecords } from './domains';
describe('parseDnsRecords', () => {
it('should parse standard zone file with origin', () => {
const content = `$ORIGIN example.com. ; designates the start of this zone file in the namespace
$TTL 3600 ; default expiration time (in seconds) of all RRs without their own TTL value
example.com. IN SOA ns.example.com. username.example.com. ( 2020091025 7200 3600 1209600 3600 )
example.com. IN NS ns ; ns.example.com is a nameserver for example.com
example.com. IN NS ns.somewhere.example. ; ns.somewhere.example is a backup nameserver for example.com
example.com. IN MX 10 mail.example.com. ; mail.example.com is the mailserver for example.com
@ IN MX 20 mail2.example.com. ; equivalent to above line, "@" represents zone origin
@ IN MX 50 mail3 ; equivalent to above line, but using a relative host name
example.com. IN A 192.0.2.1 ; IPv4 address for example.com
IN AAAA 2001:db8:10::1 ; IPv6 address for example.com
ns IN A 192.0.2.2 ; IPv4 address for ns.example.com
IN AAAA 2001:db8:10::2 ; IPv6 address for ns.example.com
www IN CNAME example.com. ; www.example.com is an alias for example.com
wwwtest IN CNAME www ; wwwtest.example.com is another alias for www.example.com
mail IN A 192.0.2.3 ; IPv4 address for mail.example.com
mail2 IN A 192.0.2.4 ; IPv4 address for mail2.example.com
mail3 IN A 192.0.2.5 ; IPv4 address for mail3.example.com`;
const result = parseDnsRecords(content);
expect(result.NS).toHaveLength(2);
expect(result.NS[0].name).toBe('example.com');
expect(result.NS[0].value).toBe('ns');
expect(result.NS[1].name).toBe('example.com');
expect(result.NS[1].value).toBe('ns.somewhere.example');
expect(result.MX).toHaveLength(3);
expect(result.MX[0].name).toBe('example.com');
expect(result.MX[0].priority).toBe(10);
expect(result.MX[0].value).toBe('mail.example.com');
expect(result.MX[1].name).toBe('');
expect(result.MX[1].priority).toBe(20);
expect(result.MX[1].value).toBe('mail2.example.com');
expect(result.MX[2].name).toBe('');
expect(result.MX[2].priority).toBe(50);
expect(result.MX[2].value).toBe('mail3');
expect(result.A).toHaveLength(5);
expect(result.A[0].name).toBe('example.com');
expect(result.A[0].value).toBe('192.0.2.1');
expect(result.A[1].name).toBe('ns');
expect(result.A[1].value).toBe('192.0.2.2');
expect(result.A[2].name).toBe('mail');
expect(result.A[2].value).toBe('192.0.2.3');
expect(result.A[3].name).toBe('mail2');
expect(result.A[3].value).toBe('192.0.2.4');
expect(result.A[4].name).toBe('mail3');
expect(result.A[4].value).toBe('192.0.2.5');
expect(result.AAAA).toHaveLength(2);
expect(result.AAAA[0].name).toBe('example.com');
expect(result.AAAA[0].value).toBe('2001:db8:10::1');
expect(result.AAAA[1].name).toBe('ns');
expect(result.AAAA[1].value).toBe('2001:db8:10::2');
expect(result.CNAME).toHaveLength(2);
expect(result.CNAME[0].name).toBe('www');
expect(result.CNAME[0].value).toBe('example.com');
expect(result.CNAME[1].name).toBe('wwwtest');
expect(result.CNAME[1].value).toBe('www');
});
it('should parse localhost zone file', () => {
const content = `$ORIGIN localhost.
@ 86400 IN SOA @ root (
1999010100 ; serial
10800 ; refresh (3 hours)
900 ; retry (15 minutes)
604800 ; expire (1 week)
86400 ; minimum (1 day)
)
@ 86400 IN NS @
@ 86400 IN A 127.0.0.1
@ 86400 IN AAAA ::1`;
const result = parseDnsRecords(content);
expect(result.NS).toHaveLength(1);
expect(result.NS[0].name).toBe('');
expect(result.NS[0].value).toBe('@');
expect(result.NS[0].ttl).toBe(86400);
expect(result.A).toHaveLength(1);
expect(result.A[0].name).toBe('');
expect(result.A[0].value).toBe('127.0.0.1');
expect(result.A[0].ttl).toBe(86400);
expect(result.AAAA).toHaveLength(1);
expect(result.AAAA[0].name).toBe('');
expect(result.AAAA[0].value).toBe('::1');
expect(result.AAAA[0].ttl).toBe(86400);
});
it('should parse complex zone file with multiple record types and comments', () => {
const content = `; Exported (y-m-d hh:mm:ss): 2019-01-10 13:05:04
;
; This file is intended for use for informational and archival
; purposes ONLY and MUST be edited before use on a production
; DNS server.
;
; In particular, you must update the SOA record with the correct
; authoritative name server and contact e-mail address information,
; and add the correct NS records for the name servers which will
; be authoritative for this domain.
;
; For further information, please consult the BIND documentation
; located on the following website:
;
; http://www.isc.org/
;
; And RFC 1035:
;
; http://www.ietf.org/rfc/rfc1035.txt
;
; Please note that we do NOT offer technical support for any use
; of this zone data, the BIND name server, or any other third-
; party DNS software.
;
; Use at your own risk.
; SOA Record
example.com. 3600 IN SOA ns41.domaincontrol.com. dns.net. (
2018122702
28800
7200
604800
3600
)
; A Records
@ 600 IN A 192.0.2.249
blog 10800 IN A 192.0.2.255
dev 1800 IN A 192.0.2.254
dev01 1800 IN A 192.0.2.253
dev02 1800 IN A 192.0.2.252
dev03 1800 IN A 192.0.2.251
dev04 1800 IN A 192.0.2.250
; CNAME Records
abc123b432dc7785b7ef31f04f25c3e71 1800 IN CNAME verify.bing.com.
akamai 600 IN CNAME www.example.com.edgekey.net.
email 3600 IN CNAME email.secureserver.net.
; MX Records
@ 604800 IN MX 10 amlxe.l.google.com.
@ 604800 IN MX 10 aplxe.l.google.com.
; TXT Records
@ 3600 IN TXT "google-site-verification=3J82-80dbMyCo5Q5C1G11JszeOnZPGCSYlHcPcXg"
@ 3600 IN TXT "google-site-verification=eS_QPYLE_W4nduSrlN-cddxG7ZqOnB743xsbX918"`;
const result = parseDnsRecords(content);
expect(result.A).toHaveLength(7);
expect(result.A[0].name).toBe('');
expect(result.A[0].value).toBe('192.0.2.249');
expect(result.A[0].ttl).toBe(600);
expect(result.A[1].name).toBe('blog');
expect(result.A[1].value).toBe('192.0.2.255');
expect(result.A[1].ttl).toBe(10800);
expect(result.CNAME).toHaveLength(3);
expect(result.CNAME[0].name).toBe('abc123b432dc7785b7ef31f04f25c3e71');
expect(result.CNAME[0].value).toBe('verify.bing.com');
expect(result.CNAME[0].ttl).toBe(1800);
expect(result.CNAME[1].name).toBe('akamai');
expect(result.CNAME[1].value).toBe('www.example.com.edgekey.net');
expect(result.CNAME[1].ttl).toBe(600);
expect(result.MX).toHaveLength(2);
expect(result.MX[0].name).toBe('');
expect(result.MX[0].value).toBe('amlxe.l.google.com');
expect(result.MX[0].priority).toBe(10);
expect(result.MX[0].ttl).toBe(604800);
expect(result.MX[1].name).toBe('');
expect(result.MX[1].value).toBe('aplxe.l.google.com');
expect(result.MX[1].priority).toBe(10);
expect(result.MX[1].ttl).toBe(604800);
expect(result.TXT).toHaveLength(2);
expect(result.TXT[0].name).toBe('');
expect(result.TXT[0].value).toBe(
'google-site-verification=3J82-80dbMyCo5Q5C1G11JszeOnZPGCSYlHcPcXg'
);
expect(result.TXT[0].ttl).toBe(3600);
expect(result.TXT[1].name).toBe('');
expect(result.TXT[1].value).toBe(
'google-site-verification=eS_QPYLE_W4nduSrlN-cddxG7ZqOnB743xsbX918'
);
expect(result.TXT[1].ttl).toBe(3600);
});
it('should parse zone file with origin prefix', () => {
const content = `$ORIGIN example.com.
example.com. 3600 IN SOA ns41.domaincontrol.com. dns.net. (
2018122702
28800
7200
604800
3600
)
; A Records
@ 600 IN A 192.0.2.249
blog 10800 IN A 192.0.2.255
dev 1800 IN A 192.0.2.254
dev01 1800 IN A 192.0.2.253
dev02 1800 IN A 192.0.2.252
dev03 1800 IN A 192.0.2.251
dev04 1800 IN A 192.0.2.250
abc123b432dc7785b7ef31f04f25c3e71 1800 IN CNAME verify.bing.com.
; CNAME Records
akamai 600 IN CNAME www.example.edgekey.net.
email 3600 IN CNAME email.secureserver.net.
; MX Records
@ 604800 IN MX 10 amlxe.l.google.com.
@ 604800 IN MX 10 aplxe.l.google.com.
; TXT Records
@ 3600 IN TXT "google-site-verification=3J82-80dbMyCo5Q5C1GM8os1VYVEOnZPGCSYlHcPcXg"
@ 3600 IN TXT "google-site-verification=eS_QPYLE_W4nduSrlN-cddxG7ZqOnB7k7uIG7qrsyu8"`;
const result = parseDnsRecords(content);
expect(result.A).toHaveLength(7);
expect(result.A[0].name).toBe('');
expect(result.A[0].value).toBe('192.0.2.249');
expect(result.A[0].ttl).toBe(600);
expect(result.CNAME).toHaveLength(3);
expect(result.CNAME[0].name).toBe('abc123b432dc7785b7ef31f04f25c3e71');
expect(result.CNAME[0].value).toBe('verify.bing.com');
expect(result.CNAME[1].name).toBe('akamai');
expect(result.CNAME[1].value).toBe('www.example.edgekey.net');
expect(result.MX).toHaveLength(2);
expect(result.MX[0].name).toBe('');
expect(result.MX[0].priority).toBe(10);
expect(result.MX[0].value).toBe('amlxe.l.google.com');
expect(result.TXT).toHaveLength(2);
expect(result.TXT[0].name).toBe('');
expect(result.TXT[0].value).toBe(
'google-site-verification=3J82-80dbMyCo5Q5C1GM8os1VYVEOnZPGCSYlHcPcXg'
);
});
it('should parse complex zone file with multiple servers', () => {
const content = `$ORIGIN example.com.
$TTL 86400
@ IN SOA dns1.example.com. hostmaster.example.com. (
2001062501 ; serial
21600 ; refresh after 6 hours
3600 ; retry after 1 hour
604800 ; expire after 1 week
86400 ) ; minimum TTL of 1 day
IN NS dns1.example.com.
IN NS dns2.example.com.
IN MX 10 mail.example.com.
IN MX 20 mail2.example.com.
dns1 IN A 10.0.1.1
dns2 IN A 10.0.1.2
server1 IN A 10.0.1.5
server2 IN A 10.0.1.6
ftp IN A 10.0.1.3
IN A 10.0.1.4
mail IN CNAME server1
mail2 IN CNAME server2
www IN CNAME server1`;
const result = parseDnsRecords(content);
expect(result.NS).toHaveLength(2);
expect(result.NS[0].name).toBe('');
expect(result.NS[0].value).toBe('dns1.example.com');
expect(result.NS[1].name).toBe('');
expect(result.NS[1].value).toBe('dns2.example.com');
expect(result.MX).toHaveLength(2);
expect(result.MX[0].name).toBe('');
expect(result.MX[0].priority).toBe(10);
expect(result.MX[0].value).toBe('mail.example.com');
expect(result.MX[1].name).toBe('');
expect(result.MX[1].priority).toBe(20);
expect(result.MX[1].value).toBe('mail2.example.com');
expect(result.A).toHaveLength(6);
expect(result.A[0].name).toBe('dns1');
expect(result.A[0].value).toBe('10.0.1.1');
expect(result.A[1].name).toBe('dns2');
expect(result.A[1].value).toBe('10.0.1.2');
expect(result.A[2].name).toBe('server1');
expect(result.A[2].value).toBe('10.0.1.5');
expect(result.A[3].name).toBe('server2');
expect(result.A[3].value).toBe('10.0.1.6');
expect(result.A[4].name).toBe('ftp');
expect(result.A[4].value).toBe('10.0.1.3');
expect(result.A[5].name).toBe('ftp');
expect(result.A[5].value).toBe('10.0.1.4');
expect(result.CNAME).toHaveLength(3);
expect(result.CNAME[0].name).toBe('mail');
expect(result.CNAME[0].value).toBe('server1');
expect(result.CNAME[1].name).toBe('mail2');
expect(result.CNAME[1].value).toBe('server2');
expect(result.CNAME[2].name).toBe('www');
expect(result.CNAME[2].value).toBe('server1');
});
it('should parse reverse zone file with PTR records', () => {
const content = `;; reverse zone file for 127.0.0.1 and ::1
$TTL 1814400 ; 3 weeks
@ 1814400 IN SOA localhost. root.localhost. (
1999010100 ; serial
10800 ; refresh (3 hours)
900 ; retry (15 minutes)
604800 ; expire (1 week)
86400 ; minimum (1 day)
)
@ 1814400 IN NS localhost.
1 1814400 IN PTR localhost.`;
const result = parseDnsRecords(content);
expect(result.NS).toHaveLength(1);
expect(result.NS[0].name).toBe('');
expect(result.NS[0].value).toBe('localhost');
expect(result.NS[0].ttl).toBe(1814400);
expect(result.PTR).toHaveLength(1);
expect(result.PTR[0].name).toBe('1');
expect(result.PTR[0].value).toBe('localhost');
expect(result.PTR[0].ttl).toBe(1814400);
});
it('should handle empty content', () => {
const result = parseDnsRecords('');
expect(result.A).toHaveLength(0);
expect(result.AAAA).toHaveLength(0);
expect(result.CNAME).toHaveLength(0);
expect(result.MX).toHaveLength(0);
expect(result.NS).toHaveLength(0);
expect(result.TXT).toHaveLength(0);
expect(result.PTR).toHaveLength(0);
});
it('should handle invalid content', () => {
const result = parseDnsRecords('This is not a valid zone file');
expect(result.A).toHaveLength(0);
expect(result.AAAA).toHaveLength(0);
expect(result.CNAME).toHaveLength(0);
expect(result.MX).toHaveLength(0);
expect(result.NS).toHaveLength(0);
expect(result.TXT).toHaveLength(0);
expect(result.PTR).toHaveLength(0);
});
it('should handle content with only comments', () => {
const result = parseDnsRecords(`; This is a comment
; This is another comment
; And one more comment`);
expect(result.A).toHaveLength(0);
expect(result.AAAA).toHaveLength(0);
expect(result.CNAME).toHaveLength(0);
expect(result.MX).toHaveLength(0);
expect(result.NS).toHaveLength(0);
expect(result.TXT).toHaveLength(0);
expect(result.PTR).toHaveLength(0);
});
});
+106
View File
@@ -0,0 +1,106 @@
import type { DnsRecord } from '$lib/sdk/domains';
import { sdk } from '$lib/stores/sdk';
export async function createRecord(record: Partial<DnsRecord>, domainId: string) {
switch (record.type) {
case 'A':
return await sdk.forConsole.domains.createRecordA(
domainId,
record.name,
record.value,
record.ttl,
record?.comment || undefined
);
case 'AAAA':
return await sdk.forConsole.domains.createRecordAAAA(
domainId,
record.name,
record.value,
record.ttl,
record?.comment || undefined
);
case 'CNAME':
return await sdk.forConsole.domains.createRecordCNAME(
domainId,
record.name,
record.value,
record.ttl,
record?.comment || undefined
);
case 'MX':
return await sdk.forConsole.domains.createRecordMX(
domainId,
record.name,
record.value,
record.priority || 10,
record.ttl,
record?.comment || undefined
);
case 'TXT':
return await sdk.forConsole.domains.createRecordTXT(
domainId,
record.name,
record.value,
record.ttl,
record?.comment || undefined
);
case 'NS':
return await sdk.forConsole.domains.createRecordNS(
domainId,
record.name,
record.value,
record.ttl,
record?.comment || undefined
);
case 'SRV':
return await sdk.forConsole.domains.createRecordSRV(
domainId,
record.name,
record.value,
record.ttl,
record.priority,
record.weight,
record.port,
record?.comment || undefined
);
case 'CAA':
return await sdk.forConsole.domains.createRecordCAA(
domainId,
record.name,
record.value,
record.ttl,
record?.comment || undefined
);
case 'HTTPS':
return await sdk.forConsole.domains.createRecordHTTPS(
domainId,
record.name,
record.value,
record.ttl,
record?.comment || undefined
);
case 'ALIAS':
return await sdk.forConsole.domains.createRecordAlias(
domainId,
record.name,
record.value,
record.ttl,
record?.comment || undefined
);
}
}
export type ParsedRecords = {
A: Partial<DnsRecord>[];
AAAA: Partial<DnsRecord>[];
CNAME: Partial<DnsRecord>[];
MX: Partial<DnsRecord>[];
TXT: Partial<DnsRecord>[];
NS: Partial<DnsRecord>[];
SRV: Partial<DnsRecord>[];
CAA: Partial<DnsRecord>[];
PTR: Partial<DnsRecord>[];
HTTPS: Partial<DnsRecord>[];
ALIAS: Partial<DnsRecord>[];
[key: string]: Partial<DnsRecord>[];
};
+1 -1
View File
@@ -58,7 +58,7 @@
size="s"
on:click={() => {
showCustomId = true;
}}><Icon slot="start" icon={IconPencil} /> Project ID</Tag>
}}><Icon slot="start" icon={IconPencil} size="s" /> Project ID</Tag>
</div>
{/if}
<CustomId bind:show={showCustomId} name="Project" isProject bind:id />
+12
View File
@@ -20,3 +20,15 @@ export const statusCodeOptions = [
value: 308
}
];
export type RecordType =
| 'A'
| 'AAAA'
| 'CNAME'
| 'MX'
| 'TXT'
| 'NS'
| 'SRV'
| 'CAA'
| 'HTTPS'
| 'ALIAS';
+1 -1
View File
@@ -62,7 +62,7 @@ export async function submitStripeCard(name: string, organizationId?: string) {
}
// Element needs to be submitted before confirming the setup intent
elements.submit();
await elements.submit();
const baseUrl = 'https://cloud.appwrite.io/console';
const accountUrl = `${baseUrl}/account/payments?clientSecret=${clientSecret}`;
@@ -115,7 +115,7 @@
on:submit={createProject}>
<svelte:fragment slot="submit">
<Layout.Stack direction="row" justifyContent="flex-end">
<Button.Button type="submit" variant="primary" size="s">
<Button.Button autofocus type="submit" variant="primary" size="s">
Create
</Button.Button>
</Layout.Stack>
@@ -269,6 +269,7 @@
transition: opacity 0.5s var(--animation-type);
@media (min-width: 768px) {
margin-top: 50px;
margin-bottom: 50px;
}
}
@@ -47,16 +47,21 @@
let showCreate = false;
let showCreateProjectCloud = false;
function allServiceDisabled(project: Models.Project): boolean {
return false;
async function allServiceDisabled(project: Models.Project) {
let disabled = true;
services.load(project);
$services.list.forEach((service) => {
if (service.value) {
disabled = false;
}
});
return disabled;
try {
if (!project.$id) return false;
if (!project.platforms.length) return false;
services.load(project);
$services.list.forEach((service) => {
if (service.value) {
disabled = false;
}
});
return disabled;
} catch (e) {
return true;
}
}
function filterPlatforms(platforms: { name: string; icon: string }[]) {
@@ -209,12 +214,14 @@
<svelte:fragment slot="title">
{project.name}
</svelte:fragment>
{#if allServiceDisabled(project)}
<p>
<span class="icon-pause" aria-hidden="true"></span> All services are
disabled.
</p>
{/if}
{#await allServiceDisabled(project) then isDisabled}
{#if isDisabled}
<p>
<span class="icon-pause" aria-hidden="true"></span> All services are
disabled.
</p>
{/if}
{/await}
{#each platforms as platform, i}
{#if i < 3}
@@ -230,7 +230,7 @@
</Typography.Text>
</Tooltip>
</Layout.Stack>
<ActionMenu.Root slot="tooltip">
<ActionMenu.Root slot="tooltip" let:toggle>
{#if $paymentMethods.total}
{#each filteredPaymentMethods as paymentMethod}
<ActionMenu.Item.Button
@@ -245,7 +245,10 @@
<Divider />
<ActionMenu.Item.Button
leadingIcon={IconPlus}
on:click={() => (showPayment = true)}>
on:click={(e) => {
toggle(e);
showPayment = true;
}}>
Add new payment method
</ActionMenu.Item.Button>
</ActionMenu.Root>
@@ -262,7 +265,7 @@
<Button secondary icon on:click={toggle}>
<Icon icon={IconPlus} size="s" />
</Button>
<ActionMenu.Root slot="tooltip">
<ActionMenu.Root slot="tooltip" let:toggle>
{#if $paymentMethods.total}
{#each filteredPaymentMethods as paymentMethod}
<ActionMenu.Item.Button
@@ -277,7 +280,10 @@
<Divider />
<ActionMenu.Item.Button
leadingIcon={IconPlus}
on:click={() => (showPayment = true)}>
on:click={(e) => {
toggle(e);
showPayment = true;
}}>
Add new payment method
</ActionMenu.Item.Button>
</ActionMenu.Root>
@@ -1,7 +1,7 @@
<script lang="ts">
import { base } from '$app/paths';
import { page } from '$app/state';
import { EmptySearch, PaginationWithLimit } from '$lib/components/index.js';
import { EmptySearch, PaginationWithLimit, ViewSelector } from '$lib/components/index.js';
import { Button } from '$lib/elements/forms';
import Link from '$lib/elements/link.svelte';
import { toLocaleDateTime } from '$lib/helpers/date';
@@ -29,6 +29,8 @@
import { app } from '$lib/stores/app';
import type { Domain } from '$lib/sdk/domains';
import { Click, trackEvent } from '$lib/actions/analytics';
import { columns } from './store';
import { View } from '$lib/helpers/load';
export let data;
@@ -40,59 +42,59 @@
<Container>
<Layout.Stack direction="row" justifyContent="space-between">
<SearchQuery search={data.search} placeholder="Search domains" />
<Button
on:click={() => {
trackEvent(Click.DomainCreateClick, {
source: 'organization_domain_overview'
});
}}
href={`${base}/organization-${page.params.organization}/domains/add-domain`}>
<Icon icon={IconPlus} size="s" />
Add domain
</Button>
<Layout.Stack direction="row" gap="m" inline>
<ViewSelector view={View.Table} {columns} hideView />
<Button
on:click={() => {
trackEvent(Click.DomainCreateClick, {
source: 'organization_domain_overview'
});
}}
href={`${base}/organization-${page.params.organization}/domains/add-domain`}>
<Icon icon={IconPlus} size="s" />
Add domain
</Button>
</Layout.Stack>
</Layout.Stack>
{#if data.domains.total}
<Table.Root
let:root
columns={[
{ id: 'domain' },
{ id: 'registrar' },
{ id: 'nameservers' },
{ id: 'expiry_date' },
{ id: 'renewal' },
{ id: 'auto_renewal' },
{ id: 'actions', width: 40 }
]}>
<Table.Root let:root columns={[...$columns, { id: 'actions', width: 40 }]}>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="domain" {root}>Domain</Table.Header.Cell>
<Table.Header.Cell column="registrar" {root}>Registrar</Table.Header.Cell>
<Table.Header.Cell column="nameservers" {root}>Nameservers</Table.Header.Cell>
<Table.Header.Cell column="expiry_date" {root}>Expiry date</Table.Header.Cell>
<Table.Header.Cell column="renewal" {root}>Renewal</Table.Header.Cell>
<Table.Header.Cell column="auto_renewal" {root}>Auto renewal</Table.Header.Cell>
<Table.Header.Cell column="actions" {root} />
{#each $columns as { id, title }}
<Table.Header.Cell column={id} {root}>
{title}
</Table.Header.Cell>
{/each}
<Table.Header.Cell {root} column="actions"></Table.Header.Cell>
</svelte:fragment>
{#each data.domains.domains as domain}
<Table.Row.Link
{root}
href={`${base}/organization-${page.params.organization}/domains/domain-${domain.$id}`}>
<Table.Cell column="domain" {root}>
<Link external icon href={`${$protocol}${domain.domain}`} variant="quiet">
{domain.domain}
</Link>
</Table.Cell>
<Table.Cell column="registrar" {root}>{domain?.registrar || '-'}</Table.Cell>
<Table.Cell column="nameservers" {root}
>{domain?.nameservers || '-'}</Table.Cell>
<Table.Cell column="expiry_date" {root}>
{domain?.expiry ? toLocaleDateTime(domain.expiry) : '-'}
</Table.Cell>
<Table.Cell column="renewal" {root}>
{domain.renewal ? toLocaleDateTime(domain.renewal) : '-'}
</Table.Cell>
<Table.Cell column="auto_renewal" {root}
>{domain.autoRenewal ? 'On' : 'Off'}</Table.Cell>
{#each $columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === 'domain'}
<Link
external
icon
href={`${$protocol}${domain.domain}`}
variant="quiet">
{domain.domain}
</Link>
{:else if column.id === 'registrar'}
{domain.registrar || '-'}
{:else if column.id === 'nameservers'}
{domain.nameservers || '-'}
{:else if column.id === 'expiry_date'}
{domain?.expiry ? toLocaleDateTime(domain.expiry) : '-'}
{:else if column.id === 'renewal'}
{domain?.renewal ? toLocaleDateTime(domain.renewal) : '-'}
{:else if column.id === 'auto_renewal'}
{domain?.autoRenewal ? 'On' : 'Off'}
{/if}
</Table.Cell>
{/each}
<Table.Cell column="actions" {root}>
<Layout.Stack direction="row" justifyContent="flex-end">
<Popover let:toggle placement="bottom-end" padding="none">
@@ -23,6 +23,8 @@
import Table from './table.svelte';
import AddPresetModal from './addPresetModal.svelte';
import ImportRecordModal from './importRecordModal.svelte';
import { sdk } from '$lib/stores/sdk';
import { addNotification } from '$lib/stores/notifications';
export let data;
@@ -30,6 +32,32 @@
let showPresetModal = false;
let showImportModal = false;
let selectedPreset = '';
async function downloadRecords() {
try {
const zone = await sdk.forConsole.domains.getZone(data.domain.$id);
if ('message' in zone) {
const blob = new Blob([zone.message as string], { type: 'text/plain' });
const downloadLink = document.createElement('a');
downloadLink.href = URL.createObjectURL(blob);
downloadLink.download = `${data.domain.domain}.txt`;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
URL.revokeObjectURL(downloadLink.href);
} else {
throw new Error('Failed to download records');
}
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
}
}
</script>
<Container>
@@ -45,7 +73,7 @@
Import zone file
</Button>
<Tooltip>
<PinkButton.Button variant="secondary" icon>
<PinkButton.Button variant="secondary" icon on:click={downloadRecords}>
<Icon icon={IconDownload} size="s" />
</PinkButton.Button>
<svelte:fragment slot="tooltip">Export as .txt</svelte:fragment>
@@ -11,6 +11,7 @@
import { presets } from './store';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { createRecord } from '$lib/helpers/domains';
export let show = false;
export let selectedPreset: (typeof presets)[number];
@@ -35,16 +36,9 @@
async function handleSubmit() {
try {
//TODO: create DNS records
if (records.total) {
const promises = records.dnsRecords.map((record) =>
sdk.forConsole.domains.createRecordMX(
page.params.domain,
record.name,
record.value,
record.ttl,
record.priority
)
const promises = records.dnsRecords.map(
async (record) => await createRecord(record, page.params.domain)
);
await Promise.all(promises);
}
@@ -62,8 +56,6 @@
trackError(e, Submit.RecordCreate);
}
}
$: console.log(selectedPreset);
</script>
<Modal
@@ -16,19 +16,8 @@
import { recordTypes } from './store';
import { Dependencies } from '$lib/constants';
import { invalidate } from '$app/navigation';
type RecordType =
| 'A'
| 'AAAA'
| 'CNAME'
| 'MX'
| 'TXT'
| 'NS'
| 'SRV'
| 'CAA'
| 'PTR'
| 'HTTPS'
| 'ALIAS';
import type { RecordType } from '$lib/stores/domains';
import { createRecord } from '$lib/helpers/domains';
export let show = false;
@@ -41,105 +30,23 @@
let error = '';
async function handleSubmit() {
const record = {
name,
type,
value,
ttl,
priority,
comment
};
try {
switch (type) {
case 'A':
await sdk.forConsole.domains.createRecordA(
page.params.domain,
name,
value,
ttl,
comment || undefined
);
break;
case 'AAAA':
await sdk.forConsole.domains.createRecordAAAA(
page.params.domain,
name,
value,
ttl,
comment || undefined
);
break;
case 'CNAME':
await sdk.forConsole.domains.createRecordCNAME(
page.params.domain,
name,
value,
ttl,
comment || undefined
);
break;
case 'MX':
await sdk.forConsole.domains.createRecordMX(
page.params.domain,
name,
value,
ttl,
priority,
comment || undefined
);
break;
case 'TXT':
await sdk.forConsole.domains.createRecordTXT(
page.params.domain,
name,
value,
ttl,
comment || undefined
);
break;
case 'NS':
await sdk.forConsole.domains.createRecordNS(
page.params.domain,
name,
value,
ttl,
comment || undefined
);
break;
case 'CAA':
await sdk.forConsole.domains.createRecordCAA(
page.params.domain,
name,
value,
ttl,
comment || undefined
);
break;
case 'HTTPS':
await sdk.forConsole.domains.createRecordHTTPS(
page.params.domain,
name,
value,
ttl,
comment || undefined
);
break;
case 'ALIAS':
await sdk.forConsole.domains.createRecordAlias(
page.params.domain,
name,
value,
ttl,
comment || undefined
);
break;
default:
break;
}
await createRecord(record, page.params.domain);
name = '';
type = 'A';
value = '';
ttl = 3600;
priority = undefined;
comment = '';
error = '';
show = false;
invalidate(Dependencies.DOMAINS);
addNotification({
@@ -6,246 +6,28 @@
import { Icon, Layout, Tooltip, Typography, Upload } from '@appwrite.io/pink-svelte';
import { IconInfo } from '@appwrite.io/pink-icons-svelte';
import { removeFile } from '$lib/helpers/files';
import { sdk } from '$lib/stores/sdk';
import type { DnsRecord } from '$lib/sdk/domains';
import { page } from '$app/state';
import { sdk } from '$lib/stores/sdk';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
export let show = false;
let files: FileList;
let error = '';
type ParsedRecords = {
A: Partial<DnsRecord>[];
AAAA: Partial<DnsRecord>[];
CNAME: Partial<DnsRecord>[];
MX: Partial<DnsRecord>[];
TXT: Partial<DnsRecord>[];
NS: Partial<DnsRecord>[];
SRV: Partial<DnsRecord>[];
CAA: Partial<DnsRecord>[];
PTR: Partial<DnsRecord>[];
HTTPS: Partial<DnsRecord>[];
ALIAS: Partial<DnsRecord>[];
[key: string]: Partial<DnsRecord>[];
};
function parseZoneFile(content: string): ParsedRecords {
const records: ParsedRecords = {
A: [],
AAAA: [],
CNAME: [],
MX: [],
TXT: [],
NS: [],
SRV: [],
CAA: [],
PTR: [],
HTTPS: [],
ALIAS: []
};
// Split the content into lines and process each line
const lines = content.split('\n');
for (let line of lines) {
// Remove comments, but save them for record comment field
let comment = '';
const commentIndex = line.indexOf(';');
if (commentIndex !== -1) {
comment = line.substring(commentIndex + 1).trim();
line = line.substring(0, commentIndex).trim();
}
// Skip empty lines or pure comment lines
if (!line.trim()) continue;
// Split line into parts (whitespace separated)
const parts = line.trim().split(/\s+/);
// Need at least a name, TTL/class/type, and value
if (parts.length < 3) continue;
let recordType = '';
let name = '';
let value = '';
let ttl = 3600;
let priority: number | undefined;
let weight: number | undefined;
let port: number | undefined;
// Try to determine the record type from the parts
for (let i = 0; i < parts.length; i++) {
const part = parts[i].toUpperCase();
if (['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA'].includes(part)) {
recordType = part;
name = parts[0] === '@' ? '' : parts[0]; // @ represents the domain itself
// Handle TTL if it appears to be a number
const possibleTtl = parseInt(parts[i - 1]);
if (!isNaN(possibleTtl) && i > 0) {
ttl = possibleTtl;
}
if (recordType === 'MX' && i + 2 < parts.length) {
priority = parseInt(parts[i + 1]);
value = parts[i + 2];
} else if (recordType === 'SRV' && i + 4 < parts.length) {
priority = parseInt(parts[i + 1]);
weight = parseInt(parts[i + 2]);
port = parseInt(parts[i + 3]);
value = parts[i + 4];
} else if (i + 1 < parts.length) {
value = parts.slice(i + 1).join(' ');
}
break;
}
}
if (recordType && value) {
if (recordType === 'TXT' && value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1);
}
const record: Partial<DnsRecord> = {
name,
value,
ttl,
comment: comment || undefined
};
if (priority !== undefined) {
record.priority = priority;
}
if (!records[recordType]) {
records[recordType] = [];
}
records[recordType].push(record);
}
}
return records;
}
async function handleSubmit() {
try {
if (!files?.length) return;
const file = files[0];
const content = await file.text();
const parsedRecords = parseZoneFile(content);
let recordCount = 0;
for (const [type, records] of Object.entries(parsedRecords)) {
recordCount += records.length;
for (const record of records) {
switch (type) {
case 'A':
await sdk.forConsole.domains.createRecordA(
page.params.domain,
record.name,
record.value,
record.ttl,
record.comment
);
break;
case 'AAAA':
await sdk.forConsole.domains.createRecordAAAA(
page.params.domain,
record.name,
record.value,
record.ttl,
record.comment
);
break;
case 'CNAME':
await sdk.forConsole.domains.createRecordCNAME(
page.params.domain,
record.name,
record.value,
record.ttl,
record.comment
);
break;
case 'MX':
await sdk.forConsole.domains.createRecordMX(
page.params.domain,
record.name,
record.value,
record.priority || 10,
record.ttl,
record.comment
);
break;
case 'TXT':
await sdk.forConsole.domains.createRecordTXT(
page.params.domain,
record.name,
record.value,
record.ttl,
record.comment
);
break;
case 'NS':
await sdk.forConsole.domains.createRecordNS(
page.params.domain,
record.name,
record.value,
record.ttl,
record.comment
);
break;
case 'SRV':
await sdk.forConsole.domains.createRecordSRV(
page.params.domain,
record.name,
record.value,
record.ttl,
record.priority,
record.weight,
record.port,
record.comment
);
break;
case 'CAA':
await sdk.forConsole.domains.createRecordCAA(
page.params.domain,
record.name,
record.value,
record.ttl,
record.comment
);
break;
case 'PTR':
await sdk.forConsole.domains.createRecordHTTPS(
page.params.domain,
record.name,
record.value,
record.ttl,
record.comment
);
break;
case 'ALIAS':
await sdk.forConsole.domains.createRecordAlias(
page.params.domain,
record.name,
record.value,
record.ttl,
record.comment
);
break;
}
}
}
await sdk.forConsole.domains.updateZone(page.params.domain, content);
invalidate(Dependencies.DOMAIN);
show = false;
addNotification({
type: 'success',
message: `${recordCount} DNS records have been imported successfully`
message: `DNS records have been imported successfully`
});
trackEvent(Submit.RecordCreate);
} catch (e) {
@@ -20,8 +20,6 @@
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
// type RecordType = 'A' | 'AAAA' | 'CNAME' | 'MX' | 'TXT' | 'NS' | 'SRV' | 'CAA' | 'PTR';
export let show = false;
export let selectedRecord: DnsRecord;
@@ -30,7 +28,6 @@
async function handleSubmit() {
try {
//TODO: update DNS record
switch (record.type) {
case 'A':
await sdk.forConsole.domains.updateRecordA(
@@ -0,0 +1,11 @@
import type { Column } from '$lib/helpers/types';
import { writable } from 'svelte/store';
export const columns = writable<Column[]>([
{ id: 'domain', title: 'Domain', type: 'string', width: { min: 300 } },
{ id: 'registrar', title: 'Registrar', type: 'string' },
{ id: 'nameservers', title: 'Nameservers', type: 'string' },
{ id: 'expiry_date', title: 'Expiry Date', type: 'datetime' },
{ id: 'renewal', title: 'Renewal', type: 'boolean' },
{ id: 'auto_renewal', title: 'Auto Renewal', type: 'boolean' }
]);
@@ -52,37 +52,38 @@
</Button>
</Layout.Stack>
</div>
<Paginator
items={members.memberships}
let:paginatedItems
hideFooter={members?.total <= 5}>
<Table.Root columns={2} let:root>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell {root}>Members</Table.Header.Cell>
<Table.Header.Cell {root}>Joined</Table.Header.Cell>
</svelte:fragment>
{#each paginatedItems as member}
<Table.Row.Base {root}>
<Table.Cell {root}>
<Layout.Stack direction="row" alignItems="center" gap="s">
<AvatarInitials
size="xs"
name={member.userName
? member.userName
: member.userEmail} />
<Layout.Stack gap="none">
<Typography.Text>
{member.userName ? member.userName : member.userEmail}
</Typography.Text>
<Paginator items={members.memberships} hideFooter={members?.total <= 5}>
{#snippet children(paginatedItems: typeof members.memberships)}
<Table.Root columns={2} let:root>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell {root}>Members</Table.Header.Cell>
<Table.Header.Cell {root}>Joined</Table.Header.Cell>
</svelte:fragment>
{#each paginatedItems as member}
<Table.Row.Base {root}>
<Table.Cell {root}>
<Layout.Stack direction="row" alignItems="center" gap="s">
<AvatarInitials
size="xs"
name={member.userName
? member.userName
: member.userEmail} />
<Layout.Stack gap="none">
<Typography.Text>
{member.userName
? member.userName
: member.userEmail}
</Typography.Text>
</Layout.Stack>
</Layout.Stack>
</Layout.Stack>
</Table.Cell>
<Table.Cell {root}>
<DualTimeView time={member.joined ?? member.$createdAt} />
</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
</Table.Cell>
<Table.Cell {root}>
<DualTimeView time={member.joined ?? member.$createdAt} />
</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
{/snippet}
</Paginator>
{:else}
<EmptyCardCloud service="members" eventSource="organization_usage" />
@@ -59,7 +59,7 @@
<SearchQuery search={data.search} placeholder="Search by name, email, phone, or ID" />
</Layout.Stack>
<Layout.Stack direction="row" alignItems="center" justifyContent="flex-end">
<ViewSelector view={View.Table} {columns} hideView allowNoColumns />
<ViewSelector view={View.Table} {columns} hideView />
<Button on:mousedown={() => ($showCreateUser = true)} event="create_user" size="s">
<Icon size="s" icon={IconPlus} slot="start" />
<span class="text">Create user</span>
@@ -48,13 +48,13 @@
<div class="u-flex u-main-end u-gap-16 is-not-mobile">
<Filters query={data.query} {columns} />
<div>
<ViewSelector view={View.Table} {columns} hideView allowNoColumns />
<ViewSelector view={View.Table} {columns} hideView />
</div>
</div>
</div>
<div class="u-flex u-gap-16 is-only-mobile u-margin-block-start-16">
<div class="u-flex-basis-50-percent">
<ViewSelector view={View.Table} {columns} hideView allowNoColumns />
<ViewSelector view={View.Table} {columns} hideView />
</div>
<div class="u-flex-basis-50-percent">
<Filters query={data.query} {columns} fullWidthMobile />
@@ -34,7 +34,7 @@
<div class="u-flex u-main-end u-gap-16 is-not-mobile">
<Filters query={data.query} {columns} />
<ViewSelector view={View.Table} {columns} hideView allowNoColumns />
<ViewSelector view={View.Table} {columns} hideView />
</div>
</div>
</div>
@@ -9,7 +9,8 @@
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { user } from './store';
import { Tag, Input, Layout } from '@appwrite.io/pink-svelte';
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 = ['admin', 'premium', 'mvp'];
@@ -39,7 +40,6 @@
trackError(error, Submit.UserUpdateLabels);
}
}
$: console.log(labels);
// TODO: Remove type cast when console SDK is updated
$: isDisabled =
!!error ||
@@ -68,11 +68,13 @@
New label-based roles will be assigned.
<svelte:fragment slot="aside">
<Layout.Stack gap="s">
<InputTags
id="user-labels"
label="Labels"
placeholder="Select or type user labels"
bind:tags={labels} />
{#key labels.length}
<InputTags
id="user-labels"
label="Labels"
placeholder="Select or type user labels"
bind:tags={labels} />
{/key}
<Layout.Stack direction="row">
{#each suggestedLabels as suggestedLabel}
<Tag
@@ -85,7 +87,7 @@
labels = labels.filter((e) => e !== suggestedLabel);
}
}}>
<span class="icon-plus" aria-hidden="true"></span>
<Icon icon={IconPlus} size="s" slot="start" />
{suggestedLabel}
</Tag>
{/each}
@@ -47,7 +47,7 @@
<Container>
<Layout.Stack direction="row" justifyContent="space-between">
<Layout.Stack direction="row" alignItems="center">
<SearchQuery search={data.search} placeholder="Search databases" />
<SearchQuery search={data.search} placeholder="Search by name or ID" />
</Layout.Stack>
<Layout.Stack direction="row" alignItems="center" justifyContent="flex-end">
<ViewSelector
@@ -16,7 +16,7 @@
<Container>
<Layout.Stack direction="row" justifyContent="space-between">
<Layout.Stack direction="row" alignItems="center">
<SearchQuery search={data.search} placeholder="Search collections" />
<SearchQuery placeholder="Search by ID or name" />
</Layout.Stack>
<Layout.Stack direction="row" alignItems="center" justifyContent="flex-end">
<ViewSelector
@@ -25,7 +25,7 @@
</script>
<div>
<Card.Base padding="s">
<Card.Base padding="xs">
<div class="u-flex u-gap-24 u-flex-vertical-mobile u-cross-center">
<div
style:--p-file-preview-border-color="transparent"
@@ -49,7 +49,7 @@
disabled={!(hasAttributes && hasValidAttributes)}
analyticsSource="database_documents" />
<Layout.Stack direction="row" alignItems="center" justifyContent="flex-end">
<ViewSelector view={data.view} {columns} hideView allowNoColumns />
<ViewSelector view={data.view} {columns} hideView />
<Button
disabled={!(hasAttributes && hasValidAttributes)}
href={`${base}/project-${page.params.project}/databases/database-${page.params.database}/collection-${page.params.collection}/create`}
@@ -86,6 +86,7 @@
href={`${base}/project-${page.params.project}/databases/database-${page.params.database}/collection-${page.params.collection}/`}
bind:showExitModal
column
columnSize="s"
confirmExit>
<Form bind:this={formComponent} onSubmit={create} bind:isSubmitting>
<Layout.Stack gap="xxl">
@@ -6,6 +6,14 @@
export let label: string;
export let value: string | null;
export let attribute: Models.AttributeUrl;
$: nullable = attribute.required ? false : !value;
</script>
<InputEmail {id} {label} placeholder="Enter URL" required={attribute.required} bind:value />
<InputEmail
{id}
{label}
{nullable}
placeholder="Enter URL"
required={attribute.required}
bind:value />
@@ -6,12 +6,14 @@
export let label: string;
export let value: number;
export let attribute: Models.AttributeInteger;
$: nullable = attribute.required ? false : !value;
</script>
<InputNumber
{id}
{label}
nullable
{nullable}
required={attribute.required}
min={attribute.min}
max={attribute.max}
@@ -6,13 +6,15 @@
export let label: string;
export let value: string;
export let attribute: Models.AttributeString;
$: nullable = attribute.required ? false : !value;
</script>
{#if attribute.size >= 50}
<InputTextarea
{id}
{label}
nullable={!attribute.required}
{nullable}
placeholder="Enter string"
required={attribute.required}
maxlength={attribute.size}
@@ -21,7 +23,7 @@
<InputText
{id}
{label}
nullable={!attribute.required}
{nullable}
placeholder="Enter string"
required={attribute.required}
maxlength={attribute.size}
@@ -6,12 +6,14 @@
export let label: string;
export let value: string | null;
export let attribute: Models.AttributeUrl;
$: nullable = attribute.required ? false : !value;
</script>
<InputURL
{id}
{label}
{nullable}
placeholder="Enter URL"
nullable={!attribute.required}
required={attribute.required}
bind:value />
@@ -3,7 +3,7 @@
import { base } from '$app/paths';
import { page } from '$app/state';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { Modal, Trim } from '$lib/components';
import { Confirm, Trim } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
@@ -52,7 +52,7 @@
) as Models.AttributeRelationship[];
</script>
<Modal title="Delete document" onSubmit={handleDelete} bind:show={showDelete}>
<Confirm title="Delete document" onSubmit={handleDelete} bind:open={showDelete}>
<p data-private>
Are you sure you want to delete <b
>the document from <span data-private>{$collection.name}</span></b
@@ -108,4 +108,4 @@
<Button text on:click={() => (showDelete = false)}>Cancel</Button>
<Button danger submit disabled={relAttributes?.length && !checked}>Delete</Button>
</svelte:fragment>
</Modal>
</Confirm>
@@ -158,7 +158,7 @@
</Layout.Stack>
{/each}
<div>
<Button secondary on:click={addAttribute} disabled={addAttributeDisabled}>
<Button compact on:click={addAttribute} disabled={addAttributeDisabled}>
<Icon icon={IconPlus} slot="start" size="s" />
Add attribute
</Button>
@@ -27,34 +27,36 @@
<Modal bind:show title={selectedRelationship?.key} hideFooter>
{#if data?.length}
<Paginator items={data} {limit} let:paginatedItems>
<Table.Root
let:root
columns={[{ id: '$id', width: 150 }, ...args.map((id) => ({ id }))]}>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="$id" {root}>Document ID</Table.Header.Cell>
{#if args?.length}
{#each args as arg}
<Table.Header.Cell column={arg} {root}>{arg}</Table.Header.Cell>
{/each}
{/if}
</svelte:fragment>
{#each paginatedItems as doc}
<Table.Row.Link
{root}
href={`${base}/project-${projectId}/databases/database-${databaseId}/collection-${selectedRelationship.relatedCollection}/document-${doc.$id}`}
on:click={() => (show = false)}>
<Table.Cell column="$id" {root}>
<Id value={doc.$id}>{doc.$id}</Id>
</Table.Cell>
<Paginator items={data} {limit}>
{#snippet children(paginatedItems: typeof data)}
<Table.Root
let:root
columns={[{ id: '$id', width: 150 }, ...args.map((id) => ({ id }))]}>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="$id" {root}>Document ID</Table.Header.Cell>
{#if args?.length}
{#each args as arg}
<Table.Cell column={arg} {root}>{doc[arg]}</Table.Cell>
<Table.Header.Cell column={arg} {root}>{arg}</Table.Header.Cell>
{/each}
{/if}
</Table.Row.Link>
{/each}
</Table.Root>
</svelte:fragment>
{#each paginatedItems as doc}
<Table.Row.Link
{root}
href={`${base}/project-${projectId}/databases/database-${databaseId}/collection-${selectedRelationship.relatedCollection}/document-${doc.$id}`}
on:click={() => (show = false)}>
<Table.Cell column="$id" {root}>
<Id value={doc.$id}>{doc.$id}</Id>
</Table.Cell>
{#if args?.length}
{#each args as arg}
<Table.Cell column={arg} {root}>{doc[arg]}</Table.Cell>
{/each}
{/if}
</Table.Row.Link>
{/each}
</Table.Root>
{/snippet}
</Paginator>
{/if}
</Modal>
@@ -57,9 +57,8 @@
<Form onSubmit={updateDisplayName}>
<CardGrid>
<svelte:fragment slot="title">Display name</svelte:fragment>
Select string attributes as display names for your documents. The selected names will be used
as short forms to identify documents in the Appwrite console, like when creating database relationships.
You can specify up to 5 names.
Select up to 5 string attributes to display as document names in the Appwrite console. These
help identify documents in places like relationships.
<svelte:fragment slot="aside">
<Layout.Stack gap="s">
@@ -94,7 +93,7 @@
{/if}
<div>
<Button
secondary
compact
disabled={addAttributeDisabled}
on:click={() => {
names[names.length] = null;
@@ -48,22 +48,19 @@
}
}
$: if (
$: arePermsDisabled = !(
collectionPermissions &&
symmetricDifference(collectionPermissions, $collection.$permissions).length
) {
arePermsDisabled = false;
} else arePermsDisabled = true;
);
</script>
<CardGrid>
<svelte:fragment slot="title">Permissions</svelte:fragment>
Choose who can access your collection and documents. For more about
<Link.Anchor
Choose who can access your collection and documents. <Link.Anchor
href="https://appwrite.io/docs/products/databases/permissions"
target="_blank"
rel="noopener noreferrer">
Permissions
Learn more
</Link.Anchor>.
<svelte:fragment slot="aside">
{#if collectionPermissions}
@@ -14,17 +14,17 @@
import { isTabletViewport } from '$lib/stores/viewport';
import { BottomSheet } from '$lib/components';
$: data = page.data as PageData;
$: project = page.params.project;
$: databaseId = page.params.database;
$: collectionId = page.params.collection;
let data = $derived(page.data) as PageData;
let project = $derived(page.params.project);
let databaseId = $derived(page.params.database);
let collectionId = $derived(page.params.collection);
$: sortedCollections = data?.allCollections?.collections?.sort((a, b) =>
a.name.localeCompare(b.name)
const sortedCollections = $derived.by(() =>
data?.allCollections?.collections?.slice().sort((a, b) => a.name.localeCompare(b.name))
);
$: selectedCollection = sortedCollections?.find(
(collection) => collection.$id === collectionId
const selectedCollection = $derived.by(() =>
sortedCollections()?.find((collection) => collection.$id === collectionId)
);
let openBottomSheet = false;
@@ -157,7 +157,7 @@
let:root
allowSelection
bind:selectedRows
columns={[{ id: '$id', width: 150 }, ...$columns, { id: '$created' }, { id: '$updated' }]}>
columns={[{ id: '$id', width: 200 }, ...$columns, { id: '$created' }, { id: '$updated' }]}>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="$id" {root}>Document ID</Table.Header.Cell>
{#each $columns as column}
@@ -10,7 +10,7 @@
import { database } from './store';
import { toLocaleDate } from '$lib/helpers/date';
import { type Models, Query } from '@appwrite.io/console';
import { Table } from '@appwrite.io/pink-svelte';
import { Spinner, Table } from '@appwrite.io/pink-svelte';
const databaseId = page.params.database;
@@ -86,6 +86,8 @@
$: if (!showDelete) {
collections = null;
collectionItems = [];
} else {
listCollections();
}
</script>
@@ -98,53 +100,52 @@
Are you sure you want to delete <b>{$database.name}</b>?
{/if}
</p>
{#await listCollections()}
{#if isLoadingDocumentsCount}
<div class="u-flex u-main-center">
<div class="loader"></div>
<Spinner />
</div>
{:then}
{#if error}
<p class="text">
Are you sure you want to delete <b>{$database.name}</b>?
</p>
{:else if collectionItems.length > 0}
<div class="u-flex-vertical u-gap-16">
<Table.Root columns={2} let:root>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell {root}>Collection</Table.Header.Cell>
<Table.Header.Cell {root}>Last Updated</Table.Header.Cell>
</svelte:fragment>
{#each collectionItems as collection}
<Table.Row.Base {root}>
<Table.Cell {root}>{collection.name}</Table.Cell>
<Table.Cell {root}>{toLocaleDate(collection.updatedAt)}</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
{:else if error}
<p class="text">
Are you sure you want to delete <b>{$database.name}</b>?
</p>
{:else if collectionItems.length > 0}
<div class="u-flex-vertical u-gap-16">
<Table.Root columns={2} let:root>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell {root}>Collection</Table.Header.Cell>
<Table.Header.Cell {root}>Last Updated</Table.Header.Cell>
</svelte:fragment>
{#each collectionItems as collection}
<Table.Row.Base {root}>
<Table.Cell {root}>{collection.name}</Table.Cell>
<Table.Cell {root}>{toLocaleDate(collection.updatedAt)}</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
{#if collectionItems.length < collections.total}
<div class="u-flex u-gap-16 u-cross-center">
<button class="u-underline" on:click={listCollections} type="button">
Show more
</button>
{#if isLoadingDocumentsCount}
<div class="loader is-small"></div>
{/if}
</div>
{:else if collectionItems.length > 25}
<button
class="u-underline"
on:click={() => {
collectionItems = collectionItems.slice(0, 3);
}}
type="button">
Show less
{#if collectionItems.length < collections.total}
<div class="u-flex u-gap-16 u-cross-center">
<button class="u-underline" on:click={listCollections} type="button">
Show more
</button>
{/if}
</div>
{/if}
{/await}
{#if isLoadingDocumentsCount}
<div class="loader is-small"></div>
{/if}
</div>
{:else if collectionItems.length > 25}
<button
class="u-underline"
on:click={() => {
collectionItems = collectionItems.slice(0, 3);
}}
type="button">
Show less
</button>
{/if}
</div>
{/if}
{#if !isLoadingDocumentsCount}
<p class="text" data-private>
@@ -12,7 +12,7 @@
</script>
<CardContainer
showEmpty={$canWriteCollections}
disableEmpty={!$canWriteCollections}
total={data.collections.total}
on:click={() => (showCreate = true)}
event="collection">
@@ -10,7 +10,7 @@
</script>
<CardContainer
showEmpty={$canWriteDatabases}
disableEmpty={!$canWriteDatabases}
total={data.databases.total}
on:click={() => (showCreate = true)}
event="database"
@@ -77,7 +77,7 @@
<Container>
<Layout.Stack direction="row" justifyContent="space-between">
<SearchQuery search={data.search} placeholder="Search functions" />
<SearchQuery placeholder="Search by name or ID" />
<Button href={`${base}/project-${project}/functions/create-function`}>
<Icon icon={IconPlus} slot="start" />
@@ -88,12 +88,12 @@
{#if data.functions.total}
<CardContainer
{offset}
showEmpty={$canWriteFunctions}
disableEmpty={!$canWriteFunctions}
event="functions"
total={data.functions.total}
service="functions"
on:click={() => goto(`${base}/project-${project}/functions/create-function`)}>
{#each data.functions.functions as func}
{#each data.functions.functions as func (func.$id)}
<GridItem1 href={`${base}/project-${project}/functions/function-${func.$id}`}>
<svelte:fragment slot="title">
<Layout.Stack gap="l" alignItems="center" direction="row" inline>
@@ -33,7 +33,6 @@
previousPage = from?.url?.pathname || previousPage;
});
let hasInstallations: boolean;
let selectedRepository: string;
const featuredTemplatesList = data.templatesList.templates
@@ -57,12 +56,10 @@
baseRuntimesList.some((base) => base.$id === r.name)
);
function connect(e: CustomEvent<Models.ProviderRepository>) {
function connect(e: Models.ProviderRepository) {
trackEvent(Click.ConnectRepositoryClick, { from: 'cover' });
repository.set(e.detail);
goto(
`${wizardBase}/create-function/repository-${e.detail.id}?installation=${$installation.$id}`
);
repository.set(e);
goto(`${wizardBase}/create-function/repository-${e.id}?installation=${$installation.$id}`);
}
</script>
@@ -103,16 +100,15 @@
<Typography.Title size="s">Connect Git repository</Typography.Title>
<Repositories
bind:hasInstallations
bind:selectedRepository
action="button"
callbackState={{
from: 'github',
to: 'cover'
}}
on:connect={connect} />
{connect} />
</Layout.Stack>
{#if hasInstallations}
{#if data.installations.total}
<Layout.Stack gap="l">
<Divider />
<Link variant="quiet" href="#/">
@@ -48,28 +48,26 @@
let runtime: Runtime;
let entrypoint = '';
let buildCommand = '';
let scopes: string[] = [];
let roles: string[] = [];
let variables: Partial<Models.Variable>[] = [];
let files: FileList;
let specification = specificationOptions[0].value;
async function create() {
try {
console.log(runtime);
const func = await sdk.forProject.functions.create(
id || ID.unique(),
name,
runtime,
undefined,
roles?.length ? roles : undefined,
undefined,
undefined,
undefined,
true,
undefined,
entrypoint,
buildCommand,
undefined,
scopes,
undefined,
undefined,
undefined,
@@ -99,8 +97,8 @@
func.$id,
files[0],
true,
entrypoint,
buildCommand
undefined,
undefined
);
trackEvent(Submit.FunctionCreate, {
@@ -188,7 +186,7 @@
options={runtimeOptions}
showEntrypoint />
<Configuration bind:buildCommand bind:scopes />
<Configuration bind:buildCommand bind:roles />
</Layout.Stack>
</Form>
<svelte:fragment slot="aside">
@@ -5,7 +5,7 @@
import { Accordion, Fieldset, Layout } from '@appwrite.io/pink-svelte';
export let buildCommand = '';
export let scopes: string[] = [];
export let roles: string[] = [];
</script>
<Fieldset legend="Settings">
@@ -28,7 +28,7 @@
>Learn more</Link>
</span>
<Roles bind:roles={scopes} />
<Roles bind:roles />
</Layout.Stack>
</Accordion>
</Layout.Stack>
@@ -17,9 +17,7 @@
{repository?.organization}/{repository?.name}
</Typography.Text>
</Layout.Stack>
<Button
secondary
href={`${base}/project-${page.params.project}/sites/create-site/repositories`}>
<Button secondary href={`${base}/project-${page.params.project}/functions/create-function`}>
Change
</Button>
</Layout.Stack>
@@ -44,7 +44,6 @@
let showExitModal = false;
let isCreatingRepository = false;
let hasInstallations = !!data?.installations?.total;
let formComponent: Form;
let isSubmitting = writable(false);
@@ -69,9 +68,6 @@
let specification = specificationOptions[0].value;
onMount(async () => {
if (page.url.searchParams.has('runtime')) {
runtime = page.url.searchParams.get('runtime') as Runtime;
}
if (!$installation?.$id) {
$installation = data.installations.installations[0];
}
@@ -87,7 +83,11 @@
const versionB = b.split('-')[1];
return versionB.localeCompare(versionA, undefined, { numeric: true });
});
runtime = matchingRuntimes[0];
if (page.url.searchParams.has('runtime')) {
runtime = page.url.searchParams.get('runtime') as Runtime;
} else {
runtime = matchingRuntimes[0];
}
}
});
@@ -133,8 +133,8 @@
data.template.timeout ? data.template.timeout : undefined,
undefined,
undefined,
entrypoint || rt.entrypoint,
undefined,
entrypoint || rt?.entrypoint || undefined,
rt?.commands || undefined,
selectedScopes?.length ? selectedScopes : undefined,
connectBehaviour === 'later' ? undefined : $installation?.$id || undefined,
connectBehaviour === 'later' ? undefined : $repository?.id || undefined,
@@ -150,8 +150,6 @@
func.$id
);
console.log(variables);
// Add variables
const promises = variables.map((variable) =>
sdk.forProject.functions.createVariable(
@@ -249,7 +247,6 @@
bind:specification
{specificationOptions}
{options} />
<Permissions
templateScopes={data.template.scopes}
bind:selectedScopes
@@ -258,7 +255,7 @@
<ConnectBehaviour bind:connectBehaviour />
</Layout.Stack>
{#if connectBehaviour === 'now'}
{#if hasInstallations}
{#if !!data?.installations?.total}
<Fieldset legend="Git repository">
<Layout.Stack gap="xl">
<RepositoryBehaviour bind:repositoryBehaviour />
@@ -285,16 +282,15 @@
</Layout.Stack>
{:else}
<Repositories
bind:hasInstallations
bind:selectedRepository
action="button"
on:connect={(e) => {
connect={(e) => {
trackEvent(Click.ConnectRepositoryClick, {
from: 'template-wizard'
});
repository.set(e.detail);
repositoryName = e.detail.name;
selectedRepository = e.detail.id;
repository.set(e);
repositoryName = e.name;
selectedRepository = e.id;
showConfig = true;
}} />
{/if}
@@ -13,20 +13,70 @@
Typography
} from '@appwrite.io/pink-svelte';
import { DeploymentSource, DeploymentCreatedBy, DeploymentDomains } from '$lib/components/git';
import { func } from '../store';
import { capitalize } from '$lib/helpers/string';
import { isCloud } from '$lib/system';
import { IconInfo } from '@appwrite.io/pink-icons-svelte';
import Link from '$lib/elements/link.svelte';
import { base } from '$app/paths';
import { page } from '$app/state';
import type { Snippet } from 'svelte';
export let deployment: Models.Deployment;
export let proxyRuleList: Models.ProxyRuleList;
export let variant: 'primary' | 'secondary' = 'primary';
export let activeDeployment = false;
let {
deployment,
proxyRuleList,
variant = 'primary',
activeDeployment = false,
footer
}: {
deployment: Models.Deployment;
proxyRuleList: Models.ProxyRuleList;
variant?: 'primary' | 'secondary';
activeDeployment?: boolean;
footer?: Snippet;
} = $props();
$: totalSize = humanFileSize(deployment?.totalSize ?? 0);
let totalSize = $derived(humanFileSize(deployment?.totalSize ?? 0));
</script>
{#snippet titleSnippet(title: string)}
<Typography.Text variant="m-400" color="--fgcolor-neutral-tertiary">
{title}
</Typography.Text>
{/snippet}
{#snippet textGroup(title: string, text: string)}
<Layout.Stack gap="xxs" inline>
{@render titleSnippet(title)}
<Typography.Text variant="m-400" color="--fgcolor-neutral-primary">
{text}
</Typography.Text>
</Layout.Stack>
{/snippet}
{#snippet protection(text: string, tooltip: string)}
<Layout.Stack gap="xxs" inline>
<Typography.Text variant="m-400" color="--fgcolor-neutral-tertiary">
<Layout.Stack direction="row" gap="xxs" alignItems="center">
{text}
<Tooltip>
<Icon icon={IconInfo} size="s" />
<span slot="tooltip">
{tooltip}
</span>
</Tooltip>
</Layout.Stack>
</Typography.Text>
<Layout.Stack inline alignItems="flex-start">
<Badge
size="xs"
variant="secondary"
type={isCloud ? 'success' : null}
content={isCloud ? 'Connected' : 'Available on Cloud'} />
</Layout.Stack>
</Layout.Stack>
{/snippet}
<Card padding="m" radius="m" {variant}>
<Layout.Stack gap="xxl">
<Layout.GridFraction start={4} end={6}>
@@ -57,20 +107,24 @@
<Layout.Stack gap="xl">
<Layout.Stack direction="row" alignItems="flex-start">
<Layout.Stack gap="xxs">
<Typography.Text variant="m-400" color="--fgcolor-neutral-tertiary">
Domains
</Typography.Text>
{@render titleSnippet('Domains')}
<DeploymentDomains domains={proxyRuleList} />
{#if $func.deploymentId === deployment.$id}
<DeploymentDomains domains={proxyRuleList} />
{:else}
<Typography.Text variant="m-400" color="--fgcolor-neutral-primary">
Only the <Link
href={`${base}/project-${page.params.project}/functions/function-${$func.$id}/deployment-${$func.deploymentId}`}
variant="default">active deployment</Link> has a domain.
</Typography.Text>
{/if}
</Layout.Stack>
</Layout.Stack>
<Layout.Stack direction="row" gap="xl">
{#if deployment.status === 'failed'}
<Layout.Stack gap="xxs" inline>
<Typography.Text variant="m-400" color="--fgcolor-neutral-tertiary">
Status
</Typography.Text>
{@render titleSnippet('Status')}
<Typography.Text variant="m-400" color="--fgcolor-neutral-primary">
<Status status={deployment.status} label={deployment.status} />
</Typography.Text>
@@ -90,27 +144,15 @@
<Layout.Stack gap="xxl" direction="row" wrap="wrap">
<Layout.Stack gap="xxl" direction="row" wrap="wrap" inline>
{#if deployment?.buildDuration}
<Layout.Stack gap="xxs" inline>
<Typography.Text variant="m-400" color="--fgcolor-neutral-tertiary">
Build duration
</Typography.Text>
<Typography.Text variant="m-400" color="--fgcolor-neutral-primary">
{formatTimeDetailed(deployment.buildDuration)}
</Typography.Text>
</Layout.Stack>
{@render textGroup(
'Build duration',
formatTimeDetailed(deployment.buildDuration)
)}
{/if}
{@render textGroup('Total size', `${totalSize.value} ${totalSize.unit}`)}
<Layout.Stack gap="xxs" inline>
<Typography.Text variant="m-400" color="--fgcolor-neutral-tertiary">
Total size
</Typography.Text>
<Typography.Text variant="m-400" color="--fgcolor-neutral-primary">
{totalSize.value}{totalSize.unit}
</Typography.Text>
</Layout.Stack>
<Layout.Stack gap="xxs" inline>
<Typography.Text variant="m-400" color="--fgcolor-neutral-tertiary">
Runtime
</Typography.Text>
{@render titleSnippet('Runtime')}
<Typography.Text variant="m-400" color="--fgcolor-neutral-primary">
<Layout.Stack direction="row" gap="xxs" alignItems="center">
<SvgIcon
@@ -122,53 +164,18 @@
</Typography.Text>
</Layout.Stack>
</Layout.Stack>
<Layout.Stack gap="xxl" direction="row" wrap="wrap" inline>
<Layout.Stack gap="xxs" inline>
<Typography.Text variant="m-400" color="--fgcolor-neutral-tertiary">
<Layout.Stack direction="row" gap="xxs" alignItems="center">
Global CDN <Tooltip>
<Icon icon={IconInfo} size="s" />
<span slot="tooltip">
Optimized speed by caching content on servers closer to
users.
</span>
</Tooltip>
</Layout.Stack>
</Typography.Text>
<Layout.Stack inline alignItems="flex-start">
<Badge
size="xs"
variant="secondary"
type={isCloud ? 'success' : null}
content={isCloud ? 'Connected' : 'Available on Cloud'} />
</Layout.Stack>
</Layout.Stack>
<Layout.Stack gap="xxs" inline>
<Typography.Text variant="m-400" color="--fgcolor-neutral-tertiary">
<Layout.Stack direction="row" gap="xxs" alignItems="center">
DDoS protection <Tooltip>
<Icon icon={IconInfo} size="s" />
<span slot="tooltip">
Safeguards your site by detecting and blocking malicious
traffic.
</span>
</Tooltip>
</Layout.Stack>
</Typography.Text>
<Layout.Stack inline alignItems="flex-start">
<Badge
size="xs"
variant="secondary"
type={isCloud ? 'success' : null}
content={isCloud ? 'Connected' : 'Available on Cloud'} />
</Layout.Stack>
</Layout.Stack>
</Layout.Stack>
{@render protection(
'Global CDN',
'Optimized speed by caching content on servers closer to users.'
)}
{@render protection(
'DDoS protection',
'Safeguards your site by detecting and blocking malicious traffic.'
)}
</Layout.Stack>
<Layout.Stack gap="xxs" inline style="width: min-content;">
<Typography.Text variant="m-400" color="--fgcolor-neutral-tertiary">
Source
</Typography.Text>
{@render titleSnippet('Source')}
<Typography.Text variant="m-400" color="--fgcolor-neutral-primary">
<div>
<DeploymentSource {deployment} />
@@ -178,13 +185,13 @@
</Layout.Stack>
</Layout.GridFraction>
{#if $$slots.footer}
{#if footer}
<span
style="margin-left: calc(-1* var(--space-9));margin-right: calc(-1* var(--space-9));width:auto;">
<Divider />
</span>
<Layout.Stack direction="row-reverse">
<slot name="footer" />
{@render footer()}
</Layout.Stack>
{/if}
</Layout.Stack>
@@ -1,148 +0,0 @@
<script lang="ts">
import { Modal } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { Icon, Layout } from '@appwrite.io/pink-svelte';
import { installation, repository } from '$lib/stores/vcs';
import { sdk } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import { IconArrowSmRight } from '@appwrite.io/pink-icons-svelte';
import { Link } from '$lib/elements';
import { NewRepository, Repositories } from '$lib/components/git';
import ConnectGit from '$lib/components/git/connectGit.svelte';
import { Runtime, type Models } from '@appwrite.io/console';
import { addNotification } from '$lib/stores/notifications';
import { Click, trackEvent } from '$lib/actions/analytics';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import RepositoryBehaviour from '$lib/components/git/repositoryBehaviour.svelte';
import { isValueOfStringEnum } from '$lib/helpers/types';
export let show = false;
export let func: Models.Function;
export let callbackState: Record<string, string> = null;
let repositoryBehaviour: 'new' | 'existing' | undefined = undefined;
let repositoryName = '';
let repositoryPrivate = true;
let selectedInstallationId = '';
let selectedRepository = '';
let installations = { installations: [], total: 0 };
let hasInstallations = !!installations?.total;
let error = '';
onMount(async () => {
installations = await sdk.forProject.vcs.listInstallations();
if (!$installation?.$id && installations?.total) {
$installation = installations.installations[0];
}
selectedInstallationId = installations.total ? installations.installations[0]?.$id : '';
hasInstallations = !!installations?.total;
if (hasInstallations) {
repositoryBehaviour = 'existing';
}
console.log(selectedInstallationId);
});
async function connectRepo() {
try {
if (repositoryBehaviour === 'new') {
const repo = await sdk.forProject.vcs.createRepository(
$installation.$id,
repositoryName,
repositoryPrivate
);
repository.set(repo);
selectedRepository = repo.id;
}
if (!isValueOfStringEnum(Runtime, func.runtime)) {
throw new Error(`Invalid runtime: ${func.runtime}`);
}
await sdk.forProject.functions.update(
func.$id,
func.name,
func.runtime,
func.execute || undefined,
func.events || undefined,
func.schedule || undefined,
func.timeout || undefined,
func.enabled || undefined,
func.logging || undefined,
func.entrypoint,
func.commands || undefined,
func.scopes || undefined,
selectedInstallationId,
selectedRepository,
'main',
undefined,
undefined,
undefined
);
await invalidate(Dependencies.FUNCTION);
show = false;
addNotification({
type: 'success',
message: 'Repository connected successfully'
});
} catch (e) {
error = e.message;
}
}
</script>
<Modal
title="Connect repository"
bind:show
hideFooter={!repositoryBehaviour}
onSubmit={connectRepo}
bind:error>
<span slot="description">
Connect your function to an existing repository or create a new one.
</span>
{#if hasInstallations}
<Layout.Stack gap="xl">
<RepositoryBehaviour bind:repositoryBehaviour />
{#if repositoryBehaviour === 'new'}
<NewRepository
bind:repositoryName
bind:repositoryPrivate
bind:selectedInstallationId
{installations} />
{:else}
<Repositories
bind:hasInstallations
bind:selectedRepository
action="button"
{callbackState}
on:connect={(e) => {
trackEvent(Click.ConnectRepositoryClick, {
from: 'functions'
});
repository.set(e.detail);
repositoryName = e.detail.name;
selectedRepository = e.detail.id;
connectRepo();
}} />
{/if}
</Layout.Stack>
{:else}
<ConnectGit {callbackState} />
{/if}
<svelte:fragment slot="footer">
{#if repositoryBehaviour === 'existing'}
<Layout.Stack>
<Link variant="quiet" href="#/">
<Layout.Stack direction="row" gap="xs">
Missing a repository? check your permissions <Icon
icon={IconArrowSmRight} />
</Layout.Stack>
</Link>
</Layout.Stack>
{:else if repositoryBehaviour === 'new'}
<Button text size="s" on:click={() => (show = false)}>Cancel</Button>
<Button size="s" submit disabled={!repositoryName || !$installation?.$id}>
Create
</Button>
{/if}
</svelte:fragment>
</Modal>
@@ -209,8 +209,8 @@
callbackState={{
createDeployment: 'true'
}}
on:connect={(e) => {
repository.set(e.detail);
connect={(e) => {
repository.set(e);
hasRepository = true;
}} />
{/if}
@@ -21,7 +21,7 @@
await sdk.forProject.functions.createDeployment(
$func.$id,
files[0],
undefined,
true,
undefined,
undefined
);
@@ -74,7 +74,7 @@
deployment={activeDeployment}
proxyRuleList={data.proxyRuleList}
activeDeployment>
<svelte:fragment slot="footer">
{#snippet footer()}
<Layout.Stack direction="row" gap="s" alignItems="center" inline>
<Menu>
<Button secondary icon text>
@@ -112,7 +112,7 @@
Execute
</Button>
</Layout.Stack>
</svelte:fragment>
{/snippet}
</DeploymentCard>
{:else if data.activeDeployment?.status === 'building'}
<Card.Base padding="none">
@@ -171,9 +171,7 @@
<Layout.Stack>
<Layout.Stack direction="row" alignItems="center">
<Layout.Stack direction="row" gap="s" wrap="wrap">
{#if data.deploymentList.total}
<QuickFilters {columns} analyticsSource="function_deployments" />
{/if}
<QuickFilters {columns} analyticsSource="function_deployments" />
</Layout.Stack>
<Layout.Stack direction="row" gap="s" inline>
{#if data.deploymentList.total}
@@ -72,7 +72,7 @@
<Container>
<DeploymentCard proxyRuleList={data.proxyRuleList} deployment={data.deployment}>
<svelte:fragment slot="footer">
{#snippet footer()}
<Layout.Stack direction="row" inline>
{#if data.deployment.status === 'processing' || data.deployment.status === 'building' || data.deployment.status === 'waiting'}
<Button
@@ -120,7 +120,7 @@
}}>Activate</Button>
{/if}
</Layout.Stack>
</svelte:fragment>
{/snippet}
</DeploymentCard>
<Card.Base padding="s">
@@ -3,148 +3,40 @@
import { page } from '$app/state';
import { EmptySearch, PaginationWithLimit } from '$lib/components/index.js';
import { Button } from '$lib/elements/forms';
import Link from '$lib/elements/link.svelte';
import Container from '$lib/layout/container.svelte';
import { protocol } from '$routes/(console)/store.js';
import type { Models } from '@appwrite.io/console';
import {
IconDotsHorizontal,
IconExternalLink,
IconPlus,
IconRefresh,
IconTrash
} from '@appwrite.io/pink-icons-svelte';
import {
ActionMenu,
Card,
Empty,
Icon,
Layout,
Popover,
Table
} from '@appwrite.io/pink-svelte';
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
import { Card, Empty, Icon, Layout } from '@appwrite.io/pink-svelte';
import DeleteDomainModal from './deleteDomainModal.svelte';
import RetryDomainModal from './retryDomainModal.svelte';
import SearchQuery from '$lib/components/searchQuery.svelte';
import { app } from '$lib/stores/app';
import { Click, trackEvent } from '$lib/actions/analytics';
import CreatePreviewDomainModal from './createPreviewDomainModal.svelte';
import Table from './table.svelte';
export let data;
let { data } = $props();
let showDelete = false;
let showRetry = false;
let showDelete = $state(false);
let showRetry = $state(false);
let selectedDomain: Models.ProxyRule = null;
let showPreviewDomainModal = false;
</script>
<Container>
<Layout.Stack direction="row" justifyContent="space-between">
<SearchQuery search={data.search} placeholder="Search domain" />
<Popover padding="none" let:toggle placement="bottom-end">
<Button
on:click={(event) => {
toggle(event);
trackEvent(Click.DomainCreateClick, {
source: 'functions_domain_overview'
});
}}>
<Icon icon={IconPlus} size="s" />
Add domain
</Button>
<svelte:fragment slot="tooltip">
<ActionMenu.Root>
<ActionMenu.Item.Anchor
href={`${base}/project-${page.params.project}/functions/function-${page.params.function}/domains/add-domain`}>
Custom domain
</ActionMenu.Item.Anchor>
<ActionMenu.Item.Button on:click={() => (showPreviewDomainModal = true)}>
Preview domain
</ActionMenu.Item.Button>
</ActionMenu.Root>
</svelte:fragment>
</Popover>
<SearchQuery placeholder="Search domain" />
<Button
href={`${base}/project-${page.params.project}/functions/function-${page.params.function}/domains/add-domain`}
on:click={() => {
trackEvent(Click.DomainCreateClick, {
source: 'functions_domain_overview'
});
}}>
<Icon icon={IconPlus} size="s" />
Add domain
</Button>
</Layout.Stack>
{#if data.domains.total}
<Table.Root
let:root
columns={[
{ id: 'domain', width: { min: 200 } },
{ id: 'redirect' },
{ id: 'branch' },
{ id: 'actions', width: 40 }
]}>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="domain" {root}>Domain</Table.Header.Cell>
<Table.Header.Cell column="redirect" {root}>Redirect to</Table.Header.Cell>
<Table.Header.Cell column="branch" {root}>Production branch</Table.Header.Cell>
<Table.Header.Cell column="actions" {root} />
</svelte:fragment>
{#each data.domains.rules as domain}
<Table.Row.Base {root}>
<Table.Cell column="domain" {root}>
<Link external href={`${$protocol}${domain.domain}`} variant="quiet">
<Layout.Stack direction="row" alignItems="center" gap="xs">
{domain.domain}
<Icon icon={IconExternalLink} size="s" />
</Layout.Stack>
</Link>
</Table.Cell>
<Table.Cell column="redirect" {root}>
{domain?.redirectUrl || 'No redirect'}
{domain?.redirectStatusCode ? `(${domain.redirectStatusCode})` : ''}
</Table.Cell>
<Table.Cell column="branch" {root}>
{domain.deploymentVcsProviderBranch || '-'}
</Table.Cell>
<Table.Cell column="actions" {root}>
<Popover let:toggle placement="bottom-start" padding="none">
<Button
text
icon
on:click={(e) => {
e.preventDefault();
toggle(e);
}}>
<Icon icon={IconDotsHorizontal} size="s" />
</Button>
<svelte:fragment slot="tooltip" let:toggle>
<ActionMenu.Root>
{#if domain.status !== 'verified'}
<ActionMenu.Item.Button
leadingIcon={IconRefresh}
on:click={(e) => {
e.preventDefault();
selectedDomain = domain;
showRetry = true;
toggle(e);
}}>
Retry
</ActionMenu.Item.Button>
{/if}
<ActionMenu.Item.Button
status="danger"
leadingIcon={IconTrash}
on:click={(e) => {
e.preventDefault();
selectedDomain = domain;
showDelete = true;
toggle(e);
trackEvent(Click.DomainDeleteClick, {
source: 'functions_domain_overview'
});
}}>
Delete
</ActionMenu.Item.Button>
</ActionMenu.Root>
</svelte:fragment>
</Popover>
</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
<Table domains={data.domains} />
<PaginationWithLimit
name="Domains"
@@ -197,7 +89,3 @@
{#if showRetry}
<RetryDomainModal bind:show={showRetry} {selectedDomain} />
{/if}
{#if showPreviewDomainModal}
<CreatePreviewDomainModal bind:show={showPreviewDomainModal} />
{/if}
@@ -1,68 +1,36 @@
<script lang="ts">
import { base } from '$app/paths';
import { page } from '$app/state';
import { Button, Form, InputSelect } from '$lib/elements/forms';
import { Button, Form, InputDomain, InputSelect, InputURL } from '$lib/elements/forms';
import { Wizard } from '$lib/layout';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { Fieldset, Layout, Tooltip, Icon, Alert } from '@appwrite.io/pink-svelte';
import { Fieldset, Layout, Tooltip, Icon, Input, Alert } from '@appwrite.io/pink-svelte';
import { goto, invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { sortBranches } from '$lib/stores/vcs';
import { organization } from '$lib/stores/organization';
import { consoleVariables } from '$routes/(console)/store';
import InputDomain from '$lib/elements/forms/inputDomain.svelte';
import { protocol } from '$routes/(console)/store';
import { IconInfo } from '@appwrite.io/pink-icons-svelte';
import { LabelCard } from '$lib/components';
import { Runtime, StatusCode, type Models } from '@appwrite.io/console';
import { statusCodeOptions } from '$lib/stores/domains';
import { writable } from 'svelte/store';
import { isCloud } from '$lib/system';
import { onMount } from 'svelte';
import { StatusCode } from '@appwrite.io/console';
import ConnectRepoModal from '../../(modals)/connectRepoModal.svelte';
import { ConnectRepoModal } from '$lib/components/git/index.js';
import { isValueOfStringEnum } from '$lib/helpers/types.js';
const backPage = `${base}/project-${page.params.project}/functions/function-${page.params.function}/domains`;
const routeBase = `${base}/project-${page.params.project}/functions/function-${page.params.function}/domains`;
export let data;
let { data } = $props();
let formComponent: Form;
let isSubmitting = writable(false);
let showConnectRepo = false;
let behaviour: 'REDIRECT' | 'CREATE' = 'CREATE';
let domain = '';
let redirect: string = null;
let statusCode: number = null;
let branch = null;
const redirectOptions = data.domains.rules
.filter((d) => !d.domain.endsWith($consoleVariables._APP_DOMAIN_FUNCTIONS))
.map((domain) => ({
label: domain.domain,
value: domain.domain
}));
const statusCodeOptions = [
{
label: '301 Moved permanently',
value: 301
},
{
label: '302 Found',
value: 302
},
{
label: '303 See other',
value: 303
},
{
label: '307 Temporary redirect',
value: 307
},
{
label: '308 Permanent redirect',
value: 308
}
];
let isSubmitting = $state(writable(false));
let showConnectRepo = $state(false);
let behaviour: 'REDIRECT' | 'BRANCH' | 'ACTIVE' = $state('ACTIVE');
let domainName = $state('');
let redirect: string = $state(null);
let statusCode = $state(307);
let branch: string = $state(null);
onMount(() => {
if (
@@ -71,75 +39,39 @@
) {
showConnectRepo = true;
}
if (page.url.searchParams.has('domain')) {
domainName = page.url.searchParams.get('domain');
}
});
async function addDomain() {
const isPreviewDomain = domain.endsWith($consoleVariables._APP_DOMAIN_FUNCTIONS);
const isNewDomain = data.domains.rules.findIndex((rule) => rule.domain === domain) === -1;
const isSubDomain = domain.split('.').length >= 2;
try {
if (behaviour === 'CREATE') {
if (isCloud && !isPreviewDomain) {
//Redirect if subdomain so user can choose how to proceed
if (isSubDomain) {
goto(
`${base}/project-${page.params.project}/functions/function-${page.params.function}/domains/add-domain/verify?domain=${domain}`
);
}
//Create domain if it's a new domain
else if (isNewDomain) {
const domainData = await sdk.forConsole.domains.create(
$organization.$id,
domain
);
await sdk.forProject.proxy.createFunctionRule(
domain,
page.params.function,
branch
);
goto(
`${base}/project-${page.params.project}/functions/function-${page.params.function}/domains/add-domain/verify-${domainData.$id}}`
);
}
}
//if selfhosted or preview domain create function rule
else {
await sdk.forProject.proxy.createFunctionRule(
domain,
page.params.function,
branch
);
addNotification({
type: 'success',
message: 'Domain added successfully'
});
await goto(backPage);
await invalidate(Dependencies.FUNCTION_DOMAINS);
}
let rule: Models.ProxyRule;
if (behaviour === 'BRANCH') {
rule = await sdk.forProject.proxy.createFunctionRule(
domainName,
page.params.function,
branch
);
} else if (behaviour === 'REDIRECT') {
if (isCloud && !isPreviewDomain) {
//Redirect if subdomain so user can choose how to proceed
if (isSubDomain) {
goto(
`${base}/project-${page.params.project}/functions/function-${page.params.function}/domains/add-domain/verify?domain=${domain}?redirect=${redirect}?statusCode=${statusCode}`
);
}
//Create domain if it's a new domain
else if (isNewDomain) {
const domainData = await sdk.forConsole.domains.create(
$organization.$id,
domain
);
const sc = Object.values(StatusCode).find(
(code) => parseInt(code) === statusCode
);
await sdk.forProject.proxy.createRedirectRule(domain, redirect, sc);
goto(
`${base}/project-${page.params.project}/functions/function-${page.params.function}/domains/add-domain/verify-${domainData.$id}}`
);
}
}
const sc = Object.values(StatusCode).find((code) => parseInt(code) === statusCode);
rule = await sdk.forProject.proxy.createRedirectRule(
domainName,
$protocol + redirect,
sc
);
} else if (behaviour === 'ACTIVE') {
rule = await sdk.forProject.proxy.createFunctionRule(
domainName,
page.params.function
);
}
if (rule?.status === 'verified') {
await goto(routeBase);
await invalidate(Dependencies.FUNCTION_DOMAINS);
} else {
await goto(`${routeBase}/add-domain/verify-${domainName}?rule=${rule.$id}`);
await invalidate(Dependencies.FUNCTION_DOMAINS);
}
} catch (error) {
addNotification({
@@ -148,12 +80,43 @@
});
}
}
async function connect(selectedInstallationId: string, selectedRepository: string) {
try {
if (!isValueOfStringEnum(Runtime, data.func.runtime)) {
throw new Error(`Invalid runtime: ${data.func.runtime}`);
}
await sdk.forProject.functions.update(
data.func.$id,
data.func.name,
data.func.runtime as Runtime,
data.func.execute || undefined,
data.func.events || undefined,
data.func.schedule || undefined,
data.func.timeout || undefined,
data.func.enabled || undefined,
data.func.logging || undefined,
data.func.entrypoint,
data.func.commands || undefined,
data.func.scopes || undefined,
selectedInstallationId,
selectedRepository,
'main',
undefined,
undefined,
undefined
);
await invalidate(Dependencies.FUNCTION);
} catch (error) {
console.log(error);
}
}
</script>
<Wizard title="Add custom domain" href={backPage} column columnSize="s">
<Wizard title="Add domain" href={routeBase} column columnSize="s" confirmExit>
<Form bind:this={formComponent} onSubmit={addDomain} bind:isSubmitting>
<Layout.Stack gap="xxl">
<Fieldset legend="Details">
<Fieldset legend="Domain">
<!-- <Input.ComboBox
label="Domain"
id="domain"
@@ -168,43 +131,47 @@
<InputDomain
label="Domain"
id="domain"
bind:value={domain}
bind:value={domainName}
required
placeholder="appwrite.example.com" />
</Fieldset>
<Layout.Grid columns={2} columnsXS={1}>
<LabelCard
value="CREATE"
bind:group={behaviour}
title="Connect to active deployment">
Connect this domain to your active deployment and configure the linked Git
branch as needed.
<Layout.Grid columns={3} columnsXS={1}>
<LabelCard value="ACTIVE" bind:group={behaviour} title="Active deployment">
Point this domain to the latest deployed version.
</LabelCard>
<LabelCard
value="REDIRECT"
bind:group={behaviour}
title="Redirect to another domain">
Automatically forward traffic from this domain to another URL of your choice.
<LabelCard value="BRANCH" bind:group={behaviour} title="Git branch">
Point this domain to a specific branch in your repository.
</LabelCard>
<LabelCard value="REDIRECT" bind:group={behaviour} title="Redirect">
Forward all traffic from this domain to another URL.
</LabelCard>
</Layout.Grid>
{#if behaviour === 'CREATE'}
<Fieldset legend="Configuration">
{#if behaviour === 'BRANCH'}
<Fieldset legend="Settings">
<Layout.Stack gap="xl">
{#if data.branches?.total}
{#if data.function?.providerRepositoryId}
{@const sortedBranches = sortBranches(data.branches.branches)}
{@const options = sortedBranches.map((branch) => ({
label: branch.name,
value: branch.name
}))}
<InputSelect
{options}
label="Production branch"
id="branch"
required
bind:value={branch}
placeholder="Select branch" />
<Layout.Stack gap="s">
<InputSelect
{options}
label="Production branch"
id="branch"
required
bind:value={branch}
placeholder="Select branch" />
{#if !data.branches?.total}
<Input.Helper state="default">
No branches found in the selected repository. Create a
branch to see it here.
</Input.Helper>
{/if}
</Layout.Stack>
{:else}
<InputSelect
disabled
@@ -214,7 +181,6 @@
required
value="main"
placeholder="Select branch" />
<Alert.Inline
title=" There is no repository connected to your function">
<Layout.Stack>
@@ -232,42 +198,20 @@
{/if}
</Layout.Stack>
</Fieldset>
<!-- {:else if data.function.installationId || data.installations?.total}
<PinkCard.Base padding="none" border="dashed">
<Empty
type="secondary"
title="Connect Git repository"
description="Link a repository to associate your domain with an active deployment.">
<svelte:fragment slot="actions">
<Button secondary on:click={() => (showConnectRepo = true)}>
<Icon icon={IconGithub} size="s" slot="start" />
Connect Git repository
</Button>
</svelte:fragment>
</Empty>
</PinkCard.Base>
{:else}
<ConnectGit callbackState={{ newInstallation: 'true' }} />
{/if} -->
{:else if behaviour === 'REDIRECT'}
<Fieldset legend="Configuration">
<Fieldset legend="Settings">
<Layout.Stack gap="xl">
<InputSelect
<InputURL
label="Redirect to"
id="redirect"
placeholder="Select domain"
options={redirectOptions}
placeholder="https://appwrite.io/docs"
bind:value={redirect}
required>
<Tooltip slot="info">
<Icon icon={IconInfo} size="s" />
<span slot="tooltip">
Redirect this domain. Domains added to your project will be
listed here.
</span>
<span slot="tooltip"> Redirect your domain to this URL.</span>
</Tooltip>
</InputSelect>
</InputURL>
<InputSelect
options={statusCodeOptions}
label="Status code"
@@ -280,8 +224,9 @@
{/if}
</Layout.Stack>
</Form>
<svelte:fragment slot="footer">
<Button secondary href={backPage}>Cancel</Button>
<Button secondary href={routeBase}>Cancel</Button>
<Button on:click={() => formComponent.triggerSubmit()} bind:disabled={$isSubmitting}>
Add
</Button>
@@ -291,6 +236,8 @@
{#if showConnectRepo}
<ConnectRepoModal
bind:show={showConnectRepo}
func={data.function}
{connect}
product="functions"
onlyExisting
callbackState={{ connectRepo: 'true' }} />
{/if}
@@ -1,9 +1,11 @@
import { sdk } from '$lib/stores/sdk';
import { Query } from '@appwrite.io/console';
import { RuleTrigger, RuleType } from '$lib/stores/sdk';
import { Dependencies } from '$lib/constants';
export const load = async ({ parent }) => {
export const load = async ({ parent, depends }) => {
const { function: func } = await parent();
depends(Dependencies.DOMAINS, Dependencies.FUNCTION_DOMAINS);
const [domains, installations] = await Promise.all([
sdk.forProject.proxy.listRules([
@@ -0,0 +1,160 @@
<script lang="ts">
import { IconGlobeAlt } from '@appwrite.io/pink-icons-svelte';
import {
Card,
Divider,
Fieldset,
Icon,
Layout,
Tabs,
Typography
} from '@appwrite.io/pink-svelte';
import { Button, Form } from '$lib/elements/forms';
import { sdk } from '$lib/stores/sdk';
import { organization } from '$lib/stores/organization';
import { addNotification } from '$lib/stores/notifications';
import { goto, invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { isCloud } from '$lib/system';
import { page } from '$app/state';
import Wizard from '$lib/layout/wizard.svelte';
import { base } from '$app/paths';
import { writable } from 'svelte/store';
import { consoleVariables } from '$routes/(console)/store';
import NameserverTable from '$lib/components/domains/nameserverTable.svelte';
import RecordTable from '$lib/components/domains/recordTable.svelte';
let { data } = $props();
const ruleId = page.url.searchParams.get('rule');
let isSubDomain = $derived(page.params.domain?.split('.')?.length >= 3);
let selectedTab: 'cname' | 'nameserver' | 'a' | 'aaaa' = $state(
!!$consoleVariables._APP_DOMAIN_TARGET_CNAME && isSubDomain
? 'cname'
: !!$consoleVariables._APP_DOMAIN_TARGET_A
? 'a'
: !!$consoleVariables._APP_DOMAIN_TARGET_AAAA
? 'aaaa'
: 'nameserver'
);
let verified = $state(false);
let routeBase = `${base}/project-${page.params.project}/functions/function-${page.params.function}/domains`;
let isSubmitting = $state(writable(false));
async function verify() {
const isNewDomain =
data.domainsList.domains.findIndex((rule) => rule.domain === page.params.domain) === -1;
try {
if (selectedTab !== 'nameserver') {
const ruleData = await sdk.forProject.proxy.updateRuleVerification(ruleId);
verified = ruleData.status === 'verified';
} else if (isNewDomain && isCloud) {
const domainData = await sdk.forConsole.domains.create(
$organization.$id,
page.params.domain
);
verified = domainData.nameservers.toLocaleLowerCase() === 'appwrite';
}
addNotification({
type: 'success',
message: 'Domain added successfully'
});
await goto(routeBase);
await invalidate(Dependencies.DOMAINS);
await invalidate(Dependencies.FUNCTION_DOMAINS);
} catch (error) {
verified = false;
isSubmitting.set(false);
addNotification({
type: 'error',
message: error.message
});
}
}
async function back() {
if (ruleId) {
await sdk.forProject.proxy.deleteRule(ruleId);
}
await goto(`${routeBase}/add-domain?domain=${page.params.domain}`);
}
</script>
<Wizard title="Add domain" href={routeBase} column columnSize="s">
<Form onSubmit={verify} bind:isSubmitting>
<Layout.Stack gap="xxl">
<Card.Base radius="s" padding="s">
<Layout.Stack
direction="row"
justifyContent="space-between"
alignItems="center"
gap="xs">
<Layout.Stack direction="row" alignItems="center" gap="xs">
<Icon icon={IconGlobeAlt} color="--fgcolor-neutral-primary" />
<Typography.Text variation="m-500" color="--fgcolor-neutral-primary">
{page.params.domain}
</Typography.Text>
</Layout.Stack>
<Button secondary on:click={back}>Change</Button>
</Layout.Stack>
</Card.Base>
<Fieldset legend="Verification">
<Layout.Stack gap="xl">
<div>
<Tabs.Root variant="secondary" let:root>
{#if isSubDomain && !!$consoleVariables._APP_DOMAIN_TARGET_CNAME && $consoleVariables._APP_DOMAIN_TARGET_CNAME !== 'localhost'}
<Tabs.Item.Button
{root}
on:click={() => (selectedTab = 'cname')}
active={selectedTab === 'cname'}>
CNAME
</Tabs.Item.Button>
{/if}
{#if isCloud}
<Tabs.Item.Button
{root}
on:click={() => (selectedTab = 'nameserver')}
active={selectedTab === 'nameserver'}>
Nameservers
</Tabs.Item.Button>
{/if}
{#if !!$consoleVariables._APP_DOMAIN_TARGET_A && $consoleVariables._APP_DOMAIN_TARGET_A !== '127.0.0.1'}
<Tabs.Item.Button
{root}
on:click={() => (selectedTab = 'a')}
active={selectedTab === 'a'}>
A
</Tabs.Item.Button>
{/if}
{#if !!$consoleVariables._APP_DOMAIN_TARGET_AAAA && $consoleVariables._APP_DOMAIN_TARGET_AAAA !== '::1'}
<Tabs.Item.Button
{root}
on:click={() => (selectedTab = 'aaaa')}
active={selectedTab === 'aaaa'}>
AAAA
</Tabs.Item.Button>
{/if}
</Tabs.Root>
<Divider />
</div>
{#if selectedTab === 'nameserver'}
<NameserverTable domain={page.params.domain} {verified} />
{:else}
<RecordTable domain={page.params.domain} {verified} variant={selectedTab} />
{/if}
<Divider />
<Layout.Stack direction="row" justifyContent="flex-end">
<div>
<Button submit disabled={$isSubmitting}>Verify</Button>
</div>
</Layout.Stack>
</Layout.Stack>
</Fieldset>
</Layout.Stack>
</Form>
</Wizard>
@@ -0,0 +1,22 @@
import { sdk } from '$lib/stores/sdk';
import { Dependencies } from '$lib/constants.js';
import { isCloud } from '$lib/system';
import type { Domain, DomainsList } from '$lib/sdk/domains.js';
export const load = async ({ parent, depends, params }) => {
depends(Dependencies.DOMAINS);
let domain: Domain;
let domainsList: DomainsList;
if (isCloud) {
[domain, domainsList] = await Promise.all([
sdk.forConsole.domains.get(params.domain),
sdk.forConsole.domains.list()
]);
}
return {
domain,
domainsList
};
};
@@ -1,182 +0,0 @@
<script lang="ts">
import {
Badge,
Divider,
Fieldset,
Icon,
InteractiveText,
Layout,
Table,
Tabs,
Typography
} from '@appwrite.io/pink-svelte';
import { Card } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { Wizard } from '$lib/layout';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { base } from '$app/paths';
import { page } from '$app/state';
import { IconInfo } from '@appwrite.io/pink-icons-svelte';
import { Link } from '$lib/elements';
import { consoleVariables } from '$routes/(console)/store';
const backPage = `${base}/project-${page.params.project}/sites/site-${page.params.site}/domains`;
let nameservers = $consoleVariables?._APP_DOMAINS_NAMESERVERS
? $consoleVariables?._APP_DOMAINS_NAMESERVERS.split(',')
: ['ns1.appwrite.io', 'ns2.appwrite.io'];
export let data;
async function verifyStatus() {
try {
await sdk.forProject.proxy.updateRuleVerification(data.domain.$id);
console.log(data.domain);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
}
}
let selectedTab: 'cname' | 'nameserver' = 'cname';
$: isVerified = data.domain?.nameservers?.toLocaleLowerCase() === 'appwrite';
</script>
<Wizard title="Add custom domain" href={backPage} column columnSize="s" hideFooter>
{#if isVerified}
<Card radius="s">
<Layout.Stack gap="s" direction="row" alignItems="center">
<Typography.Text variant="l-500">{data.domain.domain}</Typography.Text>
<Badge variant="secondary" type="success" content="Verified" />
</Layout.Stack>
</Card>
{:else}
<Fieldset legend="Verification">
<Layout.Stack gap="xl">
<div>
<Tabs.Root variant="secondary" let:root>
<Tabs.Item.Button
{root}
on:click={() => (selectedTab = 'cname')}
active={selectedTab === 'cname'}>
CNAME
</Tabs.Item.Button>
<Tabs.Item.Button
{root}
on:click={() => (selectedTab = 'nameserver')}
active={selectedTab === 'nameserver'}>
Nameservers
</Tabs.Item.Button>
</Tabs.Root>
<Divider />
</div>
{#if selectedTab === 'cname'}
<Layout.Stack gap="s">
<Layout.Stack gap="s" direction="row" alignItems="center">
<Typography.Text variant="l-500" color="--fgcolor-neutral-primary"
>{data.domain?.domain}</Typography.Text>
{#if isVerified}
<Badge variant="secondary" type="success" content="Verified" />
{:else}
<Badge
variant="secondary"
type="warning"
size="xs"
content="Pending verification" />
{/if}
</Layout.Stack>
<Typography.Text variant="m-400">
Add the following record on your DNS provider. Note that DNS changes may
take time to propagate fully.
</Typography.Text>
</Layout.Stack>
<Table.Root columns={3} 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>
</svelte:fragment>
<Table.Row.Base {root}>
<Table.Cell {root}>CNAME</Table.Cell>
<Table.Cell {root}>{data.domain?.domain}</Table.Cell>
<Table.Cell {root}>
<InteractiveText
variant="copy"
isVisible
text={globalThis?.location?.origin}>
{globalThis?.location?.origin}</InteractiveText>
</Table.Cell>
</Table.Row.Base>
</Table.Root>
<Layout.Stack gap="s" direction="row" alignItems="center">
<Icon icon={IconInfo} size="s" color="--fgcolor-neutral-secondary" />
<Typography.Text variant="m-400" color="--fgcolor-neutral-secondary">
A list of all domain providers and their DNS setting is available <Link
variant="muted"
external
href="https://appwrite.io/docs/advanced/platform/custom-domains"
>here</Link
>.
</Typography.Text>
</Layout.Stack>
<Divider />
<Layout.Stack direction="row" justifyContent="flex-end">
<Button on:click={verifyStatus}>Verify</Button>
</Layout.Stack>
{:else}
<Layout.Stack gap="s">
<Layout.Stack gap="s" direction="row" alignItems="center">
<Typography.Text variant="l-500" color="--fgcolor-neutral-primary"
>{data.domain?.domain}</Typography.Text>
<Badge
variant="secondary"
type="warning"
size="xs"
content="Pending verification" />
</Layout.Stack>
<Typography.Text variant="m-400">
Add the following nameservers on your DNS provider. Note that DNS
changes may take time to propagate fully.
</Typography.Text>
</Layout.Stack>
<Table.Root columns={2} let:root>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell {root}>Type</Table.Header.Cell>
<Table.Header.Cell {root}>Value</Table.Header.Cell>
</svelte:fragment>
{#each nameservers as nameserver}
<Table.Row.Base {root}>
<Table.Cell {root}>NS</Table.Cell>
<Table.Cell {root}>
<InteractiveText variant="copy" isVisible text={nameserver}>
{nameserver}</InteractiveText>
</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
<!-- <Layout.Stack gap="s" direction="row" alignItems="center">
<Icon icon={IconInfo} size="s" color="--fgcolor-neutral-secondary" />
<Typography.Text variant="m-400" color="--fgcolor-neutral-secondary">
A list of all domain providers and their DNS setting is available <Link
variant="muted"
href="https://appwrite.io/docs/advanced/platform/custom-domains"
>here</Link
>.
</Typography.Text>
</Layout.Stack> -->
<Divider />
<Layout.Stack direction="row" justifyContent="flex-end">
<Button on:click={verifyStatus}>Verify</Button>
</Layout.Stack>
{/if}
</Layout.Stack>
</Fieldset>
{/if}
</Wizard>
@@ -1,16 +0,0 @@
// import { sdk } from '$lib/stores/sdk';
import { isCloud } from '$lib/system';
export const load = async (/*{ params }*/) => {
if (isCloud) {
// if (params?.id) {
// return {
// domain: await sdk.forConsole.domains.get(params.id)
// };
// }
} else {
return {
domain: undefined
};
}
};
@@ -1,104 +0,0 @@
<script lang="ts">
import { Modal } from '$lib/components';
import { Button, InputText } from '$lib/elements/forms';
import { sdk } from '$lib/stores/sdk';
import { addNotification } from '$lib/stores/notifications';
import { invalidate } from '$app/navigation';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { Dependencies } from '$lib/constants';
import { consoleVariables } from '$routes/(console)/store';
import { page } from '$app/state';
import { Layout, Status, Typography } from '@appwrite.io/pink-svelte';
import { ConsoleResourceType, ID } from '@appwrite.io/console';
import { debounce } from '$lib/helpers/debounce';
export let show = false;
let baseDomain = ID.unique();
let domain = baseDomain;
let domainStatus: 'complete' | 'failed' | 'pending' = 'complete';
let error = null;
async function onSubmit() {
try {
await sdk.forProject.proxy.createFunctionRule(
`${domain}.${$consoleVariables._APP_DOMAIN_FUNCTIONS}`,
page.params.function
);
await invalidate(Dependencies.FUNCTION_DOMAINS);
show = false;
addNotification({
type: 'success',
message: 'Preview domain has been added'
});
trackEvent(Submit.DomainCreate);
} catch (e) {
error = e;
trackError(e, Submit.DomainCreate);
}
}
function setDomainLabel(status: typeof domainStatus) {
switch (status) {
case 'complete':
return 'Domain is available';
case 'failed':
return 'Domain is not available';
case 'pending':
return 'Checking domain availability';
}
}
const checkDomain = debounce(async (value: string) => {
if (!value) {
domainStatus = 'failed';
return;
}
try {
await sdk.forConsole.console.getResource(
`${value}.${$consoleVariables._APP_DOMAIN_FUNCTIONS}`,
ConsoleResourceType.Rules
);
domainStatus = 'complete';
baseDomain = domain;
} catch {
domainStatus = 'failed';
} finally {
baseDomain = domain;
}
}, 500);
$: if (domain !== baseDomain) {
domainStatus = 'pending';
checkDomain(domain);
}
$: if (!show) {
error = null;
}
</script>
<Modal title="Add preview domain" bind:show {onSubmit} bind:error>
<span slot="description">
Get an auto-generated domain to quickly access your project. You can customize its prefix.
</span>
<Layout.Stack>
<Layout.Stack gap="s">
<InputText id="domain" placeholder="my-domain" bind:value={domain}>
<svelte:fragment slot="end">
<Typography.Text variant="m-400" color="--fgcolor-neutral-tertiary">
.{$consoleVariables._APP_DOMAIN_FUNCTIONS}
</Typography.Text>
</svelte:fragment>
</InputText>
<Status status={domainStatus} label={setDomainLabel(domainStatus)}></Status>
</Layout.Stack>
</Layout.Stack>
<svelte:fragment slot="footer">
<Button text on:click={() => (show = false)}>Cancel</Button>
<Button submit>Add</Button>
</svelte:fragment>
</Modal>
@@ -0,0 +1,19 @@
import type { Column } from '$lib/helpers/types';
import { writable } from 'svelte/store';
export const columns = writable<Column[]>([
{
id: 'domain',
title: 'Domain',
type: 'string',
format: 'string',
width: { min: 200 }
},
{
id: 'target',
title: 'Target',
type: 'string',
width: { min: 120, max: 400 }
}
]);
@@ -0,0 +1,121 @@
<script lang="ts">
import { Click, trackEvent } from '$lib/actions/analytics';
import { Link } from '$lib/elements';
import { Button } from '$lib/elements/forms';
import { protocol } from '$routes/(console)/store';
import type { Models } from '@appwrite.io/console';
import { IconDotsHorizontal, IconRefresh, IconTrash } from '@appwrite.io/pink-icons-svelte';
import {
ActionMenu,
Badge,
Icon,
Layout,
Popover,
Table,
Typography
} from '@appwrite.io/pink-svelte';
import DeleteDomainModal from './deleteDomainModal.svelte';
import RetryDomainModal from './retryDomainModal.svelte';
import { columns } from './store';
let {
domains
}: {
domains: Models.ProxyRuleList;
} = $props();
let showDelete = $state(false);
let showRetry = $state(false);
let selectedDomain: Models.ProxyRule = $state(null);
</script>
<Table.Root columns={[...$columns, { id: 'actions', width: 40 }]} let:root>
<svelte:fragment slot="header" let:root>
{#each $columns as { id, title }}
<Table.Header.Cell column={id} {root}>
{title}
</Table.Header.Cell>
{/each}
<Table.Header.Cell column="actions" {root} />
</svelte:fragment>
{#each domains.rules as domain}
<Table.Row.Base {root}>
{#each $columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === 'domain'}
<Link external href={`${$protocol}${domain.domain}`} variant="quiet" icon>
<Typography.Text truncate>
{domain.domain}
{#if domain.status !== 'verified'}
<Badge
variant="secondary"
type="error"
content="Verification failed"
size="s" />
{/if}
</Typography.Text>
</Link>
{:else if column.id === 'target'}
{domain?.redirectUrl
? 'Redirect to ' + domain.redirectUrl
: domain?.deploymentVcsProviderBranch
? 'Deployed from' + domain.deploymentVcsProviderBranch
: '-'}
{/if}
</Table.Cell>
{/each}
<Table.Cell column="actions" {root}>
<Layout.Stack direction="row" justifyContent="flex-end">
<Popover let:toggle placement="bottom-start" padding="none">
<Button
text
icon
on:click={(e) => {
e.preventDefault();
toggle(e);
}}>
<Icon icon={IconDotsHorizontal} size="s" />
</Button>
<svelte:fragment slot="tooltip" let:toggle>
<ActionMenu.Root>
{#if domain.status !== 'verified'}
<ActionMenu.Item.Button
leadingIcon={IconRefresh}
on:click={(e) => {
selectedDomain = domain;
showRetry = true;
toggle(e);
}}>
Retry
</ActionMenu.Item.Button>
{/if}
<ActionMenu.Item.Button
status="danger"
leadingIcon={IconTrash}
on:click={(e) => {
selectedDomain = domain;
showDelete = true;
toggle(e);
trackEvent(Click.DomainDeleteClick, {
source: 'sites_domain_overview'
});
}}>
Delete
</ActionMenu.Item.Button>
</ActionMenu.Root>
</svelte:fragment>
</Popover>
</Layout.Stack>
</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
{#if showDelete}
<DeleteDomainModal bind:show={showDelete} {selectedDomain} />
{/if}
{#if showRetry}
<RetryDomainModal bind:show={showRetry} {selectedDomain} />
{/if}
@@ -21,10 +21,10 @@
import { logStatusConverter } from './store';
import { LogsRequest, LogsResponse } from '$lib/components/logs';
export let open = false;
export let selectedLogId: string;
export let logs: Models.Execution[];
export let logging: boolean;
export let open = false;
function nextLog() {
const currentIndex = logs.findIndex((log) => log.$id === selectedLogId);
@@ -38,6 +38,7 @@
selectedLogId = logs[currentIndex - 1].$id;
}
}
$: if (!open) {
selectedLogId = null;
}
@@ -47,7 +48,9 @@
$: isLastLog = logs.findIndex((log) => log.$id === selectedLogId) === logs.length - 1;
</script>
<Sheet bind:open>
<svelte:window onclick={() => (open = false)} />
<Sheet bind:open closeOnBlur={false}>
<div slot="header" style:width="100%">
<Layout.Stack direction="row" justifyContent="space-between" alignItems="center">
<Layout.Stack direction="row" gap="m" alignItems="center">
@@ -107,7 +110,6 @@
<Status
status={logStatusConverter(selectedLog.status)}
label={capitalize(selectedLog.status)}>
{capitalize(selectedLog.status)}
</Status>
</div>
<span slot="tooltip">
@@ -14,8 +14,8 @@
export let columns: Column[];
export let logs: Models.ExecutionList;
let openSheet = false;
let selectedLogId: string = null;
let open = false;
</script>
<Table.Root {columns} let:root>
@@ -27,8 +27,9 @@
{#each logs.executions as log}
<Table.Row.Button
{root}
on:click={() => {
openSheet = true;
on:click={(e) => {
e.stopPropagation();
open = true;
selectedLogId = log.$id;
}}>
{#each columns as column}
@@ -67,7 +68,6 @@
<Status
status={logStatusConverter(status)}
label={capitalize(status)}>
{status}
</Status>
</div>
<span slot="tooltip">
@@ -83,4 +83,4 @@
{/each}
</Table.Root>
<Sheet bind:open={openSheet} bind:selectedLogId logs={logs.executions} logging={$func.logging} />
<Sheet bind:open bind:selectedLogId logs={logs.executions} logging={$func.logging} />
@@ -26,12 +26,7 @@
<Layout.Stack gap="xxxs" inline>
<Typography.Caption variant="400">Updated</Typography.Caption>
<Typography.Text variant="m-400" color="--fgcolor-neutral-primary">
<Tooltip>
{capitalize(timeFromNow($func.$updatedAt))}
<span slot="tooltip">
{toLocaleDateTime($func.$updatedAt)}
</span>
</Tooltip>
{toLocaleDateTime($func.$updatedAt)}
</Typography.Text>
</Layout.Stack>
<Layout.Stack gap="xxxs" inline>
@@ -22,9 +22,8 @@
} from '@appwrite.io/pink-svelte';
import Card from '$lib/components/card.svelte';
import { IconGithub } from '@appwrite.io/pink-icons-svelte';
import { ConnectGit, RepositoryCard } from '$lib/components/git';
import { ConnectGit, ConnectRepoModal, RepositoryCard } from '$lib/components/git';
import { isValueOfStringEnum } from '$lib/helpers/types';
import ConnectRepoModal from '../(modals)/connectRepoModal.svelte';
export let func: Models.Function;
export let installations: Models.InstallationList;
@@ -114,6 +113,37 @@
selectedBranch !== func?.providerBranch ||
silentMode !== func?.providerSilentMode ||
selectedDir !== func?.providerRootDirectory;
async function connect(selectedInstallationId: string, selectedRepository: string) {
try {
if (!isValueOfStringEnum(Runtime, func.runtime)) {
throw new Error(`Invalid runtime: ${func.runtime}`);
}
await sdk.forProject.functions.update(
func.$id,
func.name,
func.runtime as Runtime,
func.execute || undefined,
func.events || undefined,
func.schedule || undefined,
func.timeout || undefined,
func.enabled || undefined,
func.logging || undefined,
func.entrypoint,
func.commands || undefined,
func.scopes || undefined,
selectedInstallationId,
selectedRepository,
'main',
undefined,
undefined,
undefined
);
await invalidate(Dependencies.FUNCTION);
} catch (error) {
console.log(error);
}
}
</script>
<Form onSubmit={updateConfiguration}>
@@ -224,7 +254,7 @@
</Form>
{#if showConnectRepo}
<ConnectRepoModal bind:show={showConnectRepo} {func} />
<ConnectRepoModal bind:show={showConnectRepo} {connect} product="functions" />
{/if}
{#if showDisconnect}
@@ -82,7 +82,7 @@
<Container>
<Layout.GridFraction start={1} end={3} gap="xxl">
<Layout.Stack gap="xl">
<SearchQuery search={data.search} placeholder="Search template" />
<SearchQuery placeholder="Search template" />
<Layout.Stack>
<Accordion title="Use case">
<Layout.Stack>
@@ -140,86 +140,88 @@
</Layout.Stack>
<Layout.Stack gap="l">
{#if data.templates?.length > 0}
<Paginator
items={data.templates}
let:paginatedItems
limit={12}
hidePages={false}
hasLimit>
<Layout.Grid columns={2} columnsXL={3} columnsXS={1}>
{#each paginatedItems as template}
{@const baseRuntimes = getBaseRuntimes(template.runtimes)}
{@const displayed = baseRuntimes.slice(0, 2)}
{@const hidden = baseRuntimes.slice(1, -1)}
<PinkCard.Base radius="m" padding="xs">
<Layout.Stack height="100%" justifyContent="space-between" gap="xl">
<Layout.Stack gap="xxxs">
<Typography.Text
variant="m-500"
color="--fgcolor-neutral-primary">
{template.name}
</Typography.Text>
<Typography.Text variant="m-400">
{template.tagline}
</Typography.Text>
</Layout.Stack>
<Paginator items={data.templates} limit={12} hidePages={false} hasLimit>
{#snippet children(paginatedItems: typeof data.templates)}
<Layout.Grid columns={2} columnsXL={3} columnsXS={1}>
{#each paginatedItems as template}
{@const baseRuntimes = getBaseRuntimes(template.runtimes)}
{@const displayed = baseRuntimes.slice(0, 2)}
{@const hidden = baseRuntimes.slice(1, -1)}
<PinkCard.Base radius="m" padding="xs">
<Layout.Stack
direction="row"
height="100%"
justifyContent="space-between"
alignItems="center">
<AvatarGroup>
{#each displayed as runtime}
{@const icon = getIconFromRuntime(runtime.name)}
{#if icon}
<Avatar alt={runtime.name} size="xs">
<SvgIcon name={icon} iconSize="small" />
</Avatar>
{/if}
{/each}
{#if hidden.length}
<Tooltip>
<Avatar alt="hidden runtime number" size="xs">
<span style:font-size="10px">
+{hidden.length}
</span>
</Avatar>
<span slot="tooltip">
{hidden.map((n) => n.name).join(', ')}
</span>
</Tooltip>
{/if}
</AvatarGroup>
gap="xl">
<Layout.Stack gap="xxxs">
<Typography.Text
variant="m-500"
color="--fgcolor-neutral-primary">
{template.name}
</Typography.Text>
<Typography.Text variant="m-400">
{template.tagline}
</Typography.Text>
</Layout.Stack>
<Layout.Stack
direction="row"
gap="s"
alignItems="center"
inline>
<Button
href={`${base}/project-${page.params.project}/functions/templates/template-${template.id}`}
text>
<span class="text">Details</span>
</Button>
{#if $canWriteFunctions}
<ContainerButton
title="functions"
disabled={buttonDisabled}
buttonType="secondary"
buttonHref={`${base}/project-${page.params.project}/functions/create-function/template-${template.id}`}
showIcon={false}
buttonText="Create"
buttonEventData={{
source: 'functions_template'
}}
buttonEvent="create_function" />
{/if}
justifyContent="space-between"
alignItems="center">
<AvatarGroup>
{#each displayed as runtime}
{@const icon = getIconFromRuntime(runtime.name)}
{#if icon}
<Avatar alt={runtime.name} size="xs">
<SvgIcon name={icon} iconSize="small" />
</Avatar>
{/if}
{/each}
{#if hidden.length}
<Tooltip>
<Avatar
alt="hidden runtime number"
size="xs">
<span style:font-size="10px">
+{hidden.length}
</span>
</Avatar>
<span slot="tooltip">
{hidden.map((n) => n.name).join(', ')}
</span>
</Tooltip>
{/if}
</AvatarGroup>
<Layout.Stack
direction="row"
gap="s"
alignItems="center"
inline>
<Button
href={`${base}/project-${page.params.project}/functions/templates/template-${template.id}`}
text>
<span class="text">Details</span>
</Button>
{#if $canWriteFunctions}
<ContainerButton
title="functions"
disabled={buttonDisabled}
buttonType="secondary"
buttonHref={`${base}/project-${page.params.project}/functions/create-function/template-${template.id}`}
showIcon={false}
buttonText="Create"
buttonEventData={{
source: 'functions_template'
}}
buttonEvent="create_function" />
{/if}
</Layout.Stack>
</Layout.Stack>
</Layout.Stack>
</Layout.Stack>
</PinkCard.Base>
{/each}
</Layout.Grid>
</PinkCard.Base>
{/each}
</Layout.Grid>
{/snippet}
</Paginator>
{:else if data?.search}
<EmptySearch hidePagination search={data.search}>
@@ -90,11 +90,11 @@
<Layout.Stack direction="row" alignItems="center">
<SearchQuery
search={data.search}
placeholder="Search by ID, description, type, or status" />
placeholder="Search by description, type, status, or ID" />
</Layout.Stack>
<Layout.Stack direction="row" alignItems="center" justifyContent="flex-end">
<Filters query={data.query} {columns} analyticsSource="messaging_messages" />
<ViewSelector view={data.view} {columns} hideView allowNoColumns />
<ViewSelector view={data.view} {columns} hideView />
{#if $canWriteMessages}
<CreateMessageDropdown />
{/if}
@@ -89,6 +89,7 @@
<Wizard
title="Create email message"
columnSize="s"
href={`${base}/project-${page.params.project}/messaging/`}
bind:showExitModal
column
@@ -26,7 +26,7 @@
let showCustomId = false;
let id: string;
let file: Models.File;
let data: Writable<[string, string][]> = writable([]);
let data: Writable<[string, string][]> = writable([['', '']]);
let title: string;
let body: string;
let topics: string[];
@@ -135,51 +135,53 @@
</Layout.Stack>
</Fieldset>
<Fieldset legend="Custom data">
<Layout.Stack>
<Layout.Stack gap="l">
<Typography.Text>
A key/value payload of additional metadata that's hidden from users. Use
this to include information to support logic such as redirection and
routing.
</Typography.Text>
{#each $data as [key, value], index}
<Layout.Stack direction="row" alignItems="flex-end">
<InputText
id={`key-${index}`}
bind:value={key}
placeholder="Enter key"
label={index === 0 ? 'Key' : undefined} />
<Layout.Stack direction="row" alignItems="flex-end" gap="xs">
<Layout.Stack gap="s">
{#each $data as [key, value], index}
<Layout.Stack direction="row" alignItems="flex-end">
<InputText
id={`value-${index}`}
bind:value
placeholder="Enter value"
label={index === 0 ? 'Value' : undefined} />
<Button
icon
compact
disabled={(!key || !value) && index === 0}
on:click={() => {
if (index === 0 && $data?.length === 1) {
$data = [['', '']];
} else {
$data.splice(index, 1);
$data = $data;
}
}}>
<span class="icon-x" aria-hidden="true"></span>
</Button>
id={`key-${index}`}
bind:value={key}
placeholder="Enter key"
label={index === 0 ? 'Key' : undefined} />
<Layout.Stack direction="row" alignItems="flex-end" gap="xs">
<InputText
id={`value-${index}`}
bind:value
placeholder="Enter value"
label={index === 0 ? 'Value' : undefined} />
<Button
icon
compact
disabled={(!key || !value) && index === 0}
on:click={() => {
if (index === 0 && $data?.length === 1) {
$data = [['', '']];
} else {
$data.splice(index, 1);
$data = $data;
}
}}>
<span class="icon-x" aria-hidden="true"></span>
</Button>
</Layout.Stack>
</Layout.Stack>
</Layout.Stack>
{/each}
<div>
<Button
secondary
disabled={$data.length > 0 && $data[$data.length - 1][0] === ''}
on:click={() => ($data = [...$data, ['', '']])}>
<Icon icon={IconPlus} slot="start" size="s" />
Add data
</Button>
</div>
{/each}
<div>
<Button
compact
disabled={$data.length > 0 && $data[$data.length - 1][0] === ''}
on:click={() => ($data = [...$data, ['', '']])}>
<Icon icon={IconPlus} slot="start" size="s" />
Add data
</Button>
</div>
</Layout.Stack>
</Layout.Stack>
</Fieldset>
<Fieldset legend="Targets">

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