-
Notifications
You must be signed in to change notification settings - Fork 238
feat: implement realtime logging interface with subscription support #3080
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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 | ||
| }; | ||
| }; |
| 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. | ||||||
| $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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The script block uses Svelte 5 runes ( |
||||||
| </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)} | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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> | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 andsubscriptionstaysnullindefinitely. Adding a.catch()that callsaddNotification({ type: 'error', … })would surface the failure in line with the project's error handling convention.