Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/lib/components/sidebar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
IconFolder,
IconGlobeAlt,
IconLightningBolt,
IconRss,
IconSearch,
IconUserGroup
} from '@appwrite.io/pink-icons-svelte';
Expand Down Expand Up @@ -78,6 +79,7 @@
{ name: 'Databases', icon: IconDatabase, slug: 'databases', category: 'build' },
{ name: 'Functions', icon: IconLightningBolt, slug: 'functions', category: 'build' },
{ name: 'Messaging', icon: IconChatBubble, slug: 'messaging', category: 'build' },
{ name: 'Realtime', icon: IconRss, slug: 'realtime', category: 'build' },
{ name: 'Storage', icon: IconFolder, slug: 'storage', category: 'build' },
{ name: 'Sites', icon: IconGlobeAlt, slug: 'sites', category: 'deploy' }
];
Expand Down
15 changes: 9 additions & 6 deletions src/lib/stores/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,8 @@ export const realtime = {
forProject(
region: string,
channels: string | string[],
callback: AppwriteRealtimeResponseEvent
callback: AppwriteRealtimeResponseEvent,
queries?: string[]
) {
const endpoint = getApiEndpoint(region);
if (endpoint !== clientRealtime.config.endpoint) {
Expand All @@ -145,19 +146,20 @@ export const realtime = {
// because uses a different client!
const realtime = new Realtime(clientRealtime);

return createRealtimeSubscription(realtime, channels, callback);
return createRealtimeSubscription(realtime, channels, callback, queries);
},

forConsole(
region: string,
channels: string | string[],
callback: AppwriteRealtimeResponseEvent
callback: AppwriteRealtimeResponseEvent,
queries?: string[]
): () => void {
const realtimeInstance = region
? sdk.forConsoleIn(region).realtime
: sdk.forConsole.realtime;

return createRealtimeSubscription(realtimeInstance, channels, callback);
return createRealtimeSubscription(realtimeInstance, channels, callback, queries);
}
};

Expand Down Expand Up @@ -218,10 +220,11 @@ export type AppwriteRealtimeResponseEvent = (response: RealtimeResponse) => void
function createRealtimeSubscription(
realtimeInstance: Realtime,
channels: string | string[],
callback: AppwriteRealtimeResponseEvent
callback: AppwriteRealtimeResponseEvent,
queries?: string[]
): () => void {
const channelsArray = Array.isArray(channels) ? channels : [channels];
const subscriptionPromise = realtimeInstance.subscribe(channelsArray, callback);
const subscriptionPromise = realtimeInstance.subscribe(channelsArray, callback, queries);

return () => {
subscriptionPromise.then((sub) => sub.close());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<script lang="ts">
let { children } = $props();
</script>

<svelte:head>
<title>Realtime - Appwrite</title>
</svelte:head>

{@render children()}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { LayoutLoad } from './$types';
import Breadcrumbs from './breadcrumbs.svelte';
import Header from './header.svelte';

export const load: LayoutLoad = async () => {
return {
header: Header,
breadcrumbs: Breadcrumbs
};
};
187 changes: 187 additions & 0 deletions src/routes/(console)/project-[region]-[project]/realtime/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
<script lang="ts">
import { untrack } from 'svelte';
import { page } from '$app/state';
import { Container } from '$lib/layout';
import { Button, InputSelect } from '$lib/elements/forms';
import { sdk } from '$lib/stores/sdk';
import { Query, type Realtime } from '@appwrite.io/console';
import { Badge, Empty, Icon, Layout, Table, Typography } from '@appwrite.io/pink-svelte';
import { IconPause, IconPlay, IconTrash } from '@appwrite.io/pink-icons-svelte';
import { toLocaleTimeISO } from '$lib/helpers/date';
import {
actionOptions,
frameScopeId,
MAX_EVENTS,
typeOptions,
type TailFrame,
type TailStats
} from './store';

type Subscription = Awaited<ReturnType<Realtime['subscribe']>>;

let events = $state<TailFrame[]>([]);
let paused = $state(false);
let delivered = $state(0);
let dropped = $state(0);
let typeFilter = $state('');
let actionFilter = $state('');
let subscription = $state<Subscription | null>(null);

const projectId = $derived(page.params.project);
const region = $derived(page.params.region);

function buildQueries(): string[] {
const queries: string[] = [];
if (typeFilter) queries.push(Query.equal('type', [typeFilter]));
if (actionFilter) queries.push(Query.equal('action', [actionFilter]));
return queries;
}

const columns = [
{ id: 'timestamp', title: 'Time', width: { min: 100, max: 120 } },
{ id: 'type', title: 'Type', width: { min: 90, max: 130 } },
{ id: 'action', title: 'Action', width: { min: 90, max: 110 } },
{ id: 'event', title: 'Event', width: { min: 220, max: 420 } },
{ id: 'resource', title: 'Resource', width: { min: 120, max: 180 } },
{ id: 'userId', title: 'User', width: { min: 120, max: 200 } }
];

const actionStatus: Record<string, 'success' | 'warning' | 'error'> = {
create: 'success',
update: 'warning',
delete: 'error'
};

function onMessage(response: { events: string[]; payload: unknown }) {
if (response.events.includes('console.tail.stats')) {
const stats = response.payload as TailStats;
delivered = stats.delivered ?? delivered;
dropped = stats.dropped ?? dropped;
return;
}

if (response.events.includes('console.tail')) {
if (paused) return;
const frames = (response.payload as TailFrame[]) ?? [];
if (!frames.length) return;
// Newest frame on top: reverse the batch (chronological) before prepending.
events = [...frames].reverse().concat(events).slice(0, MAX_EVENTS);
}
}

// Open exactly one realtime connection for the page. The Appwrite Realtime client
// multiplexes everything over a single socket, so we subscribe once per project and
// never tear the socket down on filter changes. Re-runs only when the project/region
// identity changes (filters are read untracked so they don't trigger a reconnect).
$effect(() => {
const channel = `console.tail.${projectId}`;
const realtime = sdk.forConsoleIn(region).realtime;

let cancelled = false;
let localSub: Subscription | null = null;

realtime.subscribe(channel, onMessage, untrack(buildQueries)).then((sub) => {
if (cancelled) {
sub.close();
return;
}
localSub = sub;
subscription = sub;
});

return () => {
cancelled = true;
localSub?.close();
if (subscription === localSub) subscription = null;
};
});

// Apply server-side filter changes in place via update() — no reconnect. Runs when a
// filter changes or when the subscription is first established.
Comment on lines +83 to +100
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unhandled subscription error

The async realtime.subscribe(…).then(…) call has no .catch() or error branch. If the WebSocket handshake fails (bad region, network outage, permissions), the Promise rejects silently — no notification is shown to the user and subscription stays null indefinitely. Adding a .catch() that calls addNotification({ type: 'error', … }) would surface the failure in line with the project's error handling convention.

$effect(() => {
const queries = buildQueries();
subscription?.update({ queries });
});

function clear() {
events = [];
dropped = 0;
delivered = 0;
}
</script>

<Container>
<Layout.Stack direction="row" alignItems="flex-end" justifyContent="space-between" wrap="wrap">
<Layout.Stack direction="row" gap="s" alignItems="flex-end" inline>
<InputSelect id="type" label="Type" options={typeOptions} bind:value={typeFilter} />
<InputSelect
id="action"
label="Action"
options={actionOptions}
bind:value={actionFilter} />
</Layout.Stack>

<Layout.Stack direction="row" gap="s" inline>
<Button secondary on:click={() => (paused = !paused)}>
<Icon icon={paused ? IconPlay : IconPause} slot="start" size="s" />
{paused ? 'Resume' : 'Pause'}
</Button>
<Button secondary disabled={!events.length} on:click={clear}>
<Icon icon={IconTrash} slot="start" size="s" />
Clear
</Button>
</Layout.Stack>
Comment on lines +120 to +133
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Mixed Svelte 4/5 template syntax

The script block uses Svelte 5 runes ($state, $derived, $effect) but the template uses Svelte 4 syntax: on:click event directives, named slot="start" attributes, and let:root slot bindings on Table.Root. AGENTS.md explicitly prohibits mixing both syntaxes in a single component. These should be the Svelte 5 equivalents: onclick={}, snippet {#snippet start()}…{/snippet}, and the snippet-based table API.

</Layout.Stack>

{#if dropped > 0}
<Layout.Stack direction="row" gap="s" alignItems="center">
<Badge variant="secondary" type="warning" content={`${dropped} dropped`} size="xs" />
<Typography.Text color="--fgcolor-neutral-secondary">
Sampling dropped events under load ({delivered} delivered).
</Typography.Text>
</Layout.Stack>
{/if}

{#if events.length}
<Table.Root {columns} let:root>
<svelte:fragment slot="header" let:root>
{#each columns as { id, title }}
<Table.Header.Cell column={id} {root}>{title}</Table.Header.Cell>
{/each}
</svelte:fragment>
{#each events as frame, index (index)}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Using the array index as the Svelte keyed-each key means every existing row's key shifts whenever new events are prepended to the front of the list, so Svelte patches all rows instead of only inserting the new ones. For a live log viewer that can receive high-frequency bursts this causes avoidable DOM churn. A stable key derived from content is preferred.

Suggested change
{#each events as frame, index (index)}
{#each events as frame (`${frame.timestamp}:${frame.event}`)}

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

<Table.Row.Base {root}>
<Table.Cell column="timestamp" {root}>
{toLocaleTimeISO(frame.timestamp)}
</Table.Cell>
<Table.Cell column="type" {root}>{frame.type}</Table.Cell>
<Table.Cell column="action" {root}>
<Badge
variant="secondary"
type={actionStatus[frame.action] ?? undefined}
content={frame.action}
size="xs" />
</Table.Cell>
<Table.Cell column="event" {root}>
<Typography.Code
style="display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
{frame.event}
</Typography.Code>
</Table.Cell>
<Table.Cell column="resource" {root}>
<Typography.Text truncate>{frameScopeId(frame) || '-'}</Typography.Text>
</Table.Cell>
<Table.Cell column="userId" {root}>
<Typography.Text truncate>{frame.userId || '-'}</Typography.Text>
</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
{:else}
<Empty
title={paused ? 'Paused' : 'Waiting for events…'}
description={paused
? 'Resume to keep streaming live project events.'
: 'Live project events will appear here as they happen.'} />
{/if}
</Container>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { PageLoad } from './$types';

// Realtime tail data arrives over a websocket subscription opened in +page.svelte,
// so there is nothing to fetch here. This load only exists to keep the route convention.
export const load: PageLoad = async () => {
return {};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<script lang="ts">
import { page } from '$app/state';
import { Breadcrumbs } from '$lib/layout';
import { resolveRoute } from '$lib/stores/navigation';

const breadcrumbs = $derived.by(() => {
const project = page.data.project;
const organization = page.data.organization;
const organizationId = organization?.$id ?? project.teamId;

return [
{
href: resolveRoute('/(console)/organization-[organization]', {
organization: organizationId
}),
title: organization?.name
},
{
href: resolveRoute('/(console)/project-[region]-[project]', page.params),
title: project?.name
},
{
href: resolveRoute('/(console)/project-[region]-[project]/realtime', page.params),
title: 'Realtime'
}
];
});
</script>

<Breadcrumbs {breadcrumbs} />
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<script lang="ts">
import { Cover } from '$lib/layout';
import { Typography } from '@appwrite.io/pink-svelte';
</script>

<Cover>
<svelte:fragment slot="header">
<Typography.Title color="--fgcolor-neutral-primary" size="xl">Realtime</Typography.Title>
</svelte:fragment>
Comment on lines +6 to +9
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Svelte 4 slot syntax in a new file

<svelte:fragment slot="header"> is Svelte 4 syntax. Since this is a brand-new file there is no migration cost; it should use the Svelte 5 snippet API ({#snippet header()}…{/snippet}) from the start, per the project's convention that new code should use runes/snippets.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

</Cover>
53 changes: 53 additions & 0 deletions src/routes/(console)/project-[region]-[project]/realtime/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Maximum number of tail frames kept in the buffer before the oldest are dropped.
export const MAX_EVENTS = 500;

// A single compact event frame as delivered on the `console.tail.<projectId>` channel.
// No document body is ever sent — only metadata. Scope keys are type-specific.
export type TailFrame = {
event: string;
type: string;
action: string;
userId?: string;
timestamp: string;
resourceId?: string;
databaseId?: string;
collectionId?: string;
bucketId?: string;
functionId?: string;
teamId?: string;
};

// The `console.tail.stats` payload, emitted when sampling drops events under load.
export type TailStats = {
$type: string;
delivered: number;
dropped: number;
};

export const typeOptions: { value: string; label: string }[] = [
{ value: '', label: 'All types' },
{ value: 'databases', label: 'Databases' },
{ value: 'buckets', label: 'Storage' },
{ value: 'functions', label: 'Functions' },
{ value: 'teams', label: 'Teams' },
{ value: 'users', label: 'Users' }
];

export const actionOptions: { value: string; label: string }[] = [
{ value: '', label: 'All actions' },
{ value: 'create', label: 'Create' },
{ value: 'update', label: 'Update' },
{ value: 'delete', label: 'Delete' }
];

// Returns the most relevant scope id for a frame, for display in the Resource column.
export function frameScopeId(frame: TailFrame): string {
return (
frame.databaseId ??
frame.bucketId ??
frame.functionId ??
frame.teamId ??
frame.resourceId ??
''
);
}
Comment on lines +44 to +53
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 collectionId is declared in TailFrame but omitted from the priority chain. Any event where only collectionId is set (no databaseId) will fall through to bucketId or further, silently displaying the wrong resource — or nothing at all.

Suggested change
export function frameScopeId(frame: TailFrame): string {
return (
frame.databaseId ??
frame.bucketId ??
frame.functionId ??
frame.teamId ??
frame.resourceId ??
''
);
}
export function frameScopeId(frame: TailFrame): string {
return (
frame.collectionId ??
frame.databaseId ??
frame.bucketId ??
frame.functionId ??
frame.teamId ??
frame.resourceId ??
''
);
}

Loading