From e7c369ca3c74e322d0e4d87477d7af9f7d961a77 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Tue, 12 May 2026 17:19:49 -0400 Subject: [PATCH 01/13] feat(webhooks): implement webhook delivery monitoring and details - Added new API endpoints for listing webhook deliveries, retrieving individual delivery attempts, and fetching delivery statistics. - Introduced new components for displaying webhook delivery details, including status, request/response data, and error handling. - Enhanced the OpenAPI specification to include new schemas and paths related to webhook deliveries. - Updated the dashboard layout to support navigation to webhook delivery details and statistics. These changes aim to improve the monitoring and management of webhook deliveries within the dashboard. --- spec/openapi.argus.yaml | 298 ++++++++++++ .../[webhookId]/(tabs)/deliveries/page.tsx | 27 ++ .../webhooks/[webhookId]/(tabs)/layout.tsx | 40 ++ .../[webhookId]/(tabs)/overview/page.tsx | 37 ++ .../[teamSlug]/webhooks/[webhookId]/page.tsx | 17 + src/configs/layout.ts | 21 + src/configs/sidebar.ts | 7 +- src/configs/urls.ts | 7 + .../modules/webhooks/repository.server.ts | 150 ++++++ src/core/server/api/routers/webhooks.ts | 164 +++++++ src/core/server/functions/webhooks/schema.ts | 36 ++ src/core/shared/contracts/argus-api.types.ts | 239 ++++++++++ .../webhooks/detail/deliveries-content.tsx | 435 ++++++++++++++++++ .../settings/webhooks/detail/header.tsx | 101 ++++ .../settings/webhooks/detail/index.ts | 3 + .../settings/webhooks/detail/layout.tsx | 41 ++ .../webhooks/detail/overview-content.tsx | 227 +++++++++ .../webhooks/detail/range-selector.tsx | 71 +++ .../settings/webhooks/detail/status-badge.tsx | 36 ++ .../dashboard/settings/webhooks/table-row.tsx | 30 +- 20 files changed, 1981 insertions(+), 6 deletions(-) create mode 100644 src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/deliveries/page.tsx create mode 100644 src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/layout.tsx create mode 100644 src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/overview/page.tsx create mode 100644 src/app/dashboard/[teamSlug]/webhooks/[webhookId]/page.tsx create mode 100644 src/features/dashboard/settings/webhooks/detail/deliveries-content.tsx create mode 100644 src/features/dashboard/settings/webhooks/detail/header.tsx create mode 100644 src/features/dashboard/settings/webhooks/detail/index.ts create mode 100644 src/features/dashboard/settings/webhooks/detail/layout.tsx create mode 100644 src/features/dashboard/settings/webhooks/detail/overview-content.tsx create mode 100644 src/features/dashboard/settings/webhooks/detail/range-selector.tsx create mode 100644 src/features/dashboard/settings/webhooks/detail/status-badge.tsx diff --git a/spec/openapi.argus.yaml b/spec/openapi.argus.yaml index 87baf4dca..e1c8f01e6 100644 --- a/spec/openapi.argus.yaml +++ b/spec/openapi.argus.yaml @@ -46,6 +46,13 @@ components: schema: type: string format: uuid + deliveryID: + name: deliveryID + in: path + required: true + schema: + type: string + format: uuid responses: "400": @@ -266,6 +273,159 @@ components: type: string description: Secret used to sign the webhook payloads + WebhookDelivery: + description: Webhook delivery attempt + required: + - id + - teamId + - webhookId + - eventId + - sandboxId + - eventType + - deliveryStatus + - durationMs + - requestUrl + - requestHeaders + - requestBody + - errorClass + - timestamp + properties: + id: + type: string + format: uuid + description: Delivery attempt identifier + teamId: + type: string + format: uuid + description: Team identifier + webhookId: + type: string + format: uuid + description: Webhook configuration identifier + eventId: + type: string + format: uuid + description: Sandbox event identifier + sandboxId: + type: string + description: Sandbox identifier + eventType: + type: string + description: Sandbox event type + deliveryStatus: + type: string + enum: [success, failed] + description: Delivery attempt status + httpStatusCode: + type: integer + format: int32 + nullable: true + description: HTTP response status code, if a response was received + durationMs: + type: integer + format: int32 + description: Delivery request duration in milliseconds + requestUrl: + type: string + format: uri + description: URL attempted for this delivery + requestHeaders: + type: string + description: JSON-encoded request headers with sensitive values redacted + requestBody: + type: string + description: Serialized webhook request body + responseHeaders: + type: string + nullable: true + description: JSON-encoded response headers, if a response was received + responseBody: + type: string + nullable: true + description: Truncated response body, if a response was received + errorClass: + type: string + description: Machine-readable non-HTTP or HTTP failure class + errorMessage: + type: string + nullable: true + description: Error message for failures without a useful response body + timestamp: + type: string + format: date-time + description: Time when the delivery attempt started + + WebhookDeliveryStats: + description: Webhook delivery aggregate stats + required: + - buckets + - total + - failed + - minDurationMs + - avgDurationMs + - maxDurationMs + properties: + buckets: + type: array + items: + $ref: "#/components/schemas/WebhookDeliveryStatsBucket" + total: + type: integer + format: int64 + failed: + type: integer + format: int64 + minDurationMs: + type: integer + format: int32 + avgDurationMs: + type: number + format: double + maxDurationMs: + type: integer + format: int32 + + WebhookDeliveryStatsBucket: + description: Webhook delivery stats for a time bucket + required: + - timestamp + - total + - failed + - avgDurationMs + properties: + timestamp: + type: string + format: date-time + total: + type: integer + format: int64 + failed: + type: integer + format: int64 + avgDurationMs: + type: number + format: double + + WebhookDeliveryEvent: + description: Webhook delivery attempts grouped by sandbox event + required: + - eventId + - eventType + - sandboxId + - attempts + properties: + eventId: + type: string + format: uuid + eventType: + type: string + sandboxId: + type: string + attempts: + type: array + items: + $ref: "#/components/schemas/WebhookDelivery" + paths: /health: get: @@ -501,5 +661,143 @@ paths: $ref: "#/components/responses/404" "401": $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" + + /events/webhooks/{webhookID}/deliveries: + get: + operationId: webhookDeliveriesList + description: List webhook delivery attempts. + tags: [webhooks] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + parameters: + - $ref: "#/components/parameters/webhookID" + - name: offset + in: query + required: false + schema: + type: integer + format: int32 + minimum: 0 + default: 0 + - name: limit + in: query + required: false + schema: + type: integer + format: int32 + minimum: 1 + maximum: 100 + default: 25 + - name: orderAsc + in: query + required: false + schema: + type: boolean + default: false + - name: deliveryStatus + in: query + required: false + style: form + explode: true + schema: + type: array + items: + type: string + enum: [success, failed] + description: Filter deliveries by delivery status + - name: eventType + in: query + required: false + style: form + explode: true + schema: + type: array + items: + type: string + description: Filter deliveries by event type + responses: + "200": + description: List of webhook delivery attempts grouped by event. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/WebhookDeliveryEvent" + "400": + $ref: "#/components/responses/400" + "404": + $ref: "#/components/responses/404" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" + + /events/webhooks/{webhookID}/deliveries/{deliveryID}: + get: + operationId: webhookDeliveryGet + description: Get a webhook delivery attempt. + tags: [webhooks] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + parameters: + - $ref: "#/components/parameters/webhookID" + - $ref: "#/components/parameters/deliveryID" + responses: + "200": + description: Webhook delivery attempt. + content: + application/json: + schema: + $ref: "#/components/schemas/WebhookDelivery" + "404": + $ref: "#/components/responses/404" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" + + /events/webhooks/{webhookID}/stats: + get: + operationId: webhookDeliveryStats + description: Get webhook delivery aggregate stats. + tags: [webhooks] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + parameters: + - $ref: "#/components/parameters/webhookID" + - name: start + in: query + required: false + schema: + type: string + format: date-time + description: Inclusive stats range start. Defaults to 24 hours ago. + - name: end + in: query + required: false + schema: + type: string + format: date-time + description: Exclusive stats range end. Defaults to now. + responses: + "200": + description: Webhook delivery stats. + content: + application/json: + schema: + $ref: "#/components/schemas/WebhookDeliveryStats" + "404": + $ref: "#/components/responses/404" + "401": + $ref: "#/components/responses/401" "500": $ref: "#/components/responses/500" \ No newline at end of file diff --git a/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/deliveries/page.tsx b/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/deliveries/page.tsx new file mode 100644 index 000000000..989610029 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/deliveries/page.tsx @@ -0,0 +1,27 @@ +import { WebhookDeliveriesContent } from '@/features/dashboard/settings/webhooks/detail' +import { prefetch, trpc } from '@/trpc/server' + +type WebhookDeliveriesPageProps = { + params: Promise<{ + teamSlug: string + webhookId: string + }> +} + +export default async function WebhookDeliveriesPage({ + params, +}: WebhookDeliveriesPageProps) { + const { teamSlug, webhookId } = await params + + prefetch( + trpc.webhooks.listDeliveries.queryOptions({ + teamSlug, + webhookId, + limit: 25, + offset: 0, + deliveryStatus: 'all', + }) + ) + + return +} diff --git a/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/layout.tsx b/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/layout.tsx new file mode 100644 index 000000000..f2958fcaa --- /dev/null +++ b/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/layout.tsx @@ -0,0 +1,40 @@ +import { notFound } from 'next/navigation' +import { INCLUDE_ARGUS } from '@/configs/flags' +import { WebhookDetailLayout } from '@/features/dashboard/settings/webhooks/detail' +import { HydrateClient, prefetch, trpc } from '@/trpc/server' + +type WebhookTabsLayoutProps = { + children: React.ReactNode + params: Promise<{ + teamSlug: string + webhookId: string + }> +} + +export default async function WebhookTabsLayout({ + children, + params, +}: WebhookTabsLayoutProps) { + if (!INCLUDE_ARGUS) { + return notFound() + } + + const { teamSlug, webhookId } = await params + + prefetch( + trpc.webhooks.get.queryOptions( + { teamSlug, webhookId }, + { + retry: false, + } + ) + ) + + return ( + + + {children} + + + ) +} diff --git a/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/overview/page.tsx b/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/overview/page.tsx new file mode 100644 index 000000000..6cf7d4b82 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/overview/page.tsx @@ -0,0 +1,37 @@ +import { WebhookOverviewContent } from '@/features/dashboard/settings/webhooks/detail' +import { prefetch, trpc } from '@/trpc/server' + +type WebhookOverviewPageProps = { + params: Promise<{ + teamSlug: string + webhookId: string + }> +} + +// Builds the initial stats range, e.g. now -> last 24 hours. +const getDefaultStatsRange = () => { + const end = new Date() + const start = new Date(end.getTime() - 24 * 60 * 60 * 1000) + + return { + start: start.toISOString(), + end: end.toISOString(), + } +} + +export default async function WebhookOverviewPage({ + params, +}: WebhookOverviewPageProps) { + const { teamSlug, webhookId } = await params + const range = getDefaultStatsRange() + + prefetch( + trpc.webhooks.getDeliveryStats.queryOptions({ + teamSlug, + webhookId, + ...range, + }) + ) + + return +} diff --git a/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/page.tsx b/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/page.tsx new file mode 100644 index 000000000..e2af1c389 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/page.tsx @@ -0,0 +1,17 @@ +import { redirect } from 'next/navigation' +import { PROTECTED_URLS } from '@/configs/urls' + +type WebhookDetailPageProps = { + params: Promise<{ + teamSlug: string + webhookId: string + }> +} + +export default async function WebhookDetailPage({ + params, +}: WebhookDetailPageProps) { + const { teamSlug, webhookId } = await params + + redirect(PROTECTED_URLS.WEBHOOK_OVERVIEW(teamSlug, webhookId)) +} diff --git a/src/configs/layout.ts b/src/configs/layout.ts index 0f29ab80c..632cdfde9 100644 --- a/src/configs/layout.ts +++ b/src/configs/layout.ts @@ -95,6 +95,27 @@ const DASHBOARD_LAYOUT_CONFIGS: Record< title: 'Webhooks', type: 'default', }), + '/dashboard/*/webhooks/*/*': (pathname) => { + const parts = pathname.split('/') + const teamSlug = parts[2]! + const webhookId = parts[4]! + const webhookIdSliced = `${webhookId.slice(0, 6)}...${webhookId.slice(-6)}` + + return { + title: [ + { + label: 'Webhooks', + href: PROTECTED_URLS.WEBHOOKS(teamSlug), + }, + { label: `Webhook ${webhookIdSliced}` }, + ], + type: 'custom', + copyValue: webhookId, + custom: { + includeHeaderBottomStyles: true, + }, + } + }, // team '/dashboard/*/general': () => ({ diff --git a/src/configs/sidebar.ts b/src/configs/sidebar.ts index 7ca6f3ed0..a6d225b93 100644 --- a/src/configs/sidebar.ts +++ b/src/configs/sidebar.ts @@ -1,8 +1,8 @@ -import { JSX } from 'react' import { AccountSettingsIcon, CardIcon, GaugeIcon, + type Icon, KeyIcon, PersonsIcon, SandboxIcon, @@ -21,8 +21,7 @@ type SidebarNavArgs = { export type SidebarNavItem = { label: string href: (args: SidebarNavArgs) => string - // eslint-disable-next-line @typescript-eslint/no-explicit-any - icon: (...args: any[]) => JSX.Element + icon: Icon group?: string activeMatch?: string } @@ -51,7 +50,7 @@ export const SIDEBAR_MAIN_LINKS: SidebarNavItem[] = [ href: (args: SidebarNavArgs) => PROTECTED_URLS.WEBHOOKS(args.teamSlug!), icon: WebhookIcon, - activeMatch: `/dashboard/*/webhooks`, + activeMatch: `/dashboard/*/webhooks/**`, }, ] : []), diff --git a/src/configs/urls.ts b/src/configs/urls.ts index 3bc638cea..042d27a6c 100644 --- a/src/configs/urls.ts +++ b/src/configs/urls.ts @@ -37,6 +37,12 @@ export const PROTECTED_URLS = { `/dashboard/${teamSlug}/sandboxes/${sandboxId}/filesystem`, WEBHOOKS: (teamSlug: string) => `/dashboard/${teamSlug}/webhooks`, + WEBHOOK: (teamSlug: string, webhookId: string) => + `/dashboard/${teamSlug}/webhooks/${webhookId}/overview`, + WEBHOOK_OVERVIEW: (teamSlug: string, webhookId: string) => + `/dashboard/${teamSlug}/webhooks/${webhookId}/overview`, + WEBHOOK_DELIVERIES: (teamSlug: string, webhookId: string) => + `/dashboard/${teamSlug}/webhooks/${webhookId}/deliveries`, TEMPLATES: (teamSlug: string) => `/dashboard/${teamSlug}/templates/list`, TEMPLATES_LIST: (teamSlug: string) => `/dashboard/${teamSlug}/templates/list`, @@ -69,6 +75,7 @@ export const RESOLVER_URLS = { export const TEAM_SPECIFIC_RESOURCE_SEGMENTS: readonly string[] = [ 'sandboxes', 'templates', + 'webhooks', ] export const HELP_URLS = { diff --git a/src/core/modules/webhooks/repository.server.ts b/src/core/modules/webhooks/repository.server.ts index 4102832d1..03ec7349c 100644 --- a/src/core/modules/webhooks/repository.server.ts +++ b/src/core/modules/webhooks/repository.server.ts @@ -24,10 +24,42 @@ export interface UpsertWebhookInput { enabled: boolean } +export interface ListWebhookDeliveriesInput { + webhookId: string + limit: number + offset: number + orderAsc: boolean + deliveryStatus?: 'success' | 'failed' + eventType?: string +} + +export interface GetWebhookDeliveryInput { + webhookId: string + deliveryId: string +} + +export interface GetWebhookDeliveryStatsInput { + webhookId: string + start?: string + end?: string +} + export interface WebhooksRepository { listWebhooks(): Promise< RepoResult > + getWebhook( + webhookId: string + ): Promise> + listWebhookDeliveries( + input: ListWebhookDeliveriesInput + ): Promise> + getWebhookDelivery( + input: GetWebhookDeliveryInput + ): Promise> + getWebhookDeliveryStats( + input: GetWebhookDeliveryStatsInput + ): Promise> upsertWebhook(input: UpsertWebhookInput): Promise> deleteWebhook(webhookId: string): Promise> updateWebhookSecret( @@ -67,6 +99,124 @@ export function createWebhooksRepository( return ok(response.data ?? []) }, + async getWebhook(webhookId) { + const response = await deps.infraClient.GET( + '/events/webhooks/{webhookID}', + { + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + params: { + path: { webhookID: webhookId }, + }, + } + ) + + if (!response.response.ok || response.error) { + return err( + repoErrorFromHttp( + response.response.status, + response.error?.message ?? 'Failed to get webhook', + response.error + ) + ) + } + + return ok(response.data) + }, + async listWebhookDeliveries(input) { + const deliveryStatus = input.deliveryStatus + ? [input.deliveryStatus] + : undefined + const eventType = input.eventType ? [input.eventType] : undefined + const response = await deps.infraClient.GET( + '/events/webhooks/{webhookID}/deliveries', + { + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + params: { + path: { webhookID: input.webhookId }, + query: { + limit: input.limit, + offset: input.offset, + orderAsc: input.orderAsc, + deliveryStatus, + eventType, + }, + }, + } + ) + + if (!response.response.ok || response.error) { + return err( + repoErrorFromHttp( + response.response.status, + response.error?.message ?? 'Failed to list webhook deliveries', + response.error + ) + ) + } + + return ok(response.data ?? []) + }, + async getWebhookDelivery(input) { + const response = await deps.infraClient.GET( + '/events/webhooks/{webhookID}/deliveries/{deliveryID}', + { + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + params: { + path: { + webhookID: input.webhookId, + deliveryID: input.deliveryId, + }, + }, + } + ) + + if (!response.response.ok || response.error) { + return err( + repoErrorFromHttp( + response.response.status, + response.error?.message ?? 'Failed to get webhook delivery', + response.error + ) + ) + } + + return ok(response.data) + }, + async getWebhookDeliveryStats(input) { + const response = await deps.infraClient.GET( + '/events/webhooks/{webhookID}/stats', + { + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + params: { + path: { webhookID: input.webhookId }, + query: { + start: input.start, + end: input.end, + }, + }, + } + ) + + if (!response.response.ok || response.error) { + return err( + repoErrorFromHttp( + response.response.status, + response.error?.message ?? 'Failed to get webhook delivery stats', + response.error + ) + ) + } + + return ok(response.data) + }, async upsertWebhook(input) { if (input.mode === 'update') { if (!input.webhookId) { diff --git a/src/core/server/api/routers/webhooks.ts b/src/core/server/api/routers/webhooks.ts index 096e95101..e2d0a4eec 100644 --- a/src/core/server/api/routers/webhooks.ts +++ b/src/core/server/api/routers/webhooks.ts @@ -3,12 +3,48 @@ import { throwTRPCErrorFromRepoError } from '@/core/server/adapters/errors' import { withTeamAuthedRequestRepository } from '@/core/server/api/middlewares/repository' import { DeleteWebhookInputSchema, + GetWebhookDeliveryInputSchema, + GetWebhookDeliveryStatsInputSchema, + GetWebhookInputSchema, + ListWebhookDeliveriesInputSchema, UpdateWebhookSecretInputSchema, UpsertWebhookInputSchema, } from '@/core/server/functions/webhooks/schema' import { createTRPCRouter } from '@/core/server/trpc/init' import { protectedTeamProcedure } from '@/core/server/trpc/procedures' import { l } from '@/core/shared/clients/logger/logger' +import type { components as ArgusComponents } from '@/core/shared/contracts/argus-api.types' + +type WebhookDelivery = ArgusComponents['schemas']['WebhookDelivery'] +type WebhookDeliveryEvent = ArgusComponents['schemas']['WebhookDeliveryEvent'] + +// Returns the newest delivery attempt, e.g. [10:00, 10:05] -> 10:05. +const getLatestAttempt = ( + attempts: WebhookDelivery[] +): WebhookDelivery | null => { + const sortedAttempts = [...attempts].sort( + (left, right) => + new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime() + ) + + return sortedAttempts[0] ?? null +} + +const toDeliveryEventGroup = (event: WebhookDeliveryEvent) => { + const attempts = [...event.attempts].sort( + (left, right) => + new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime() + ) + + return { + eventId: event.eventId, + eventType: event.eventType, + sandboxId: event.sandboxId, + attempts, + attemptCount: attempts.length, + latestAttempt: getLatestAttempt(attempts), + } +} const webhooksRepositoryProcedure = protectedTeamProcedure.use( withTeamAuthedRequestRepository( @@ -39,6 +75,134 @@ export const webhooksRouter = createTRPCRouter({ return { webhooks: result.data } }), + get: webhooksRepositoryProcedure + .input(GetWebhookInputSchema) + .query(async ({ ctx, input }) => { + const result = await ctx.webhooksRepository.getWebhook(input.webhookId) + + if (!result.ok) { + l.error( + { + key: 'get_webhook_trpc:error', + status: result.error.status, + error: result.error, + team_id: ctx.teamId, + user_id: ctx.session.user.id, + context: { webhookId: input.webhookId }, + }, + `Failed to get webhook: ${result.error.status}: ${result.error.message}` + ) + + throwTRPCErrorFromRepoError(result.error) + } + + return { webhook: result.data } + }), + + listDeliveries: webhooksRepositoryProcedure + .input(ListWebhookDeliveriesInputSchema) + .query(async ({ ctx, input }) => { + const result = await ctx.webhooksRepository.listWebhookDeliveries({ + webhookId: input.webhookId, + limit: input.limit, + offset: input.offset, + orderAsc: input.orderAsc, + deliveryStatus: + input.deliveryStatus === 'all' ? undefined : input.deliveryStatus, + eventType: input.eventType, + }) + + if (!result.ok) { + l.error( + { + key: 'list_webhook_deliveries_trpc:error', + status: result.error.status, + error: result.error, + team_id: ctx.teamId, + user_id: ctx.session.user.id, + context: { + webhookId: input.webhookId, + deliveryStatus: input.deliveryStatus, + eventType: input.eventType, + }, + }, + `Failed to list webhook deliveries: ${result.error.status}: ${result.error.message}` + ) + + throwTRPCErrorFromRepoError(result.error) + } + + return { + groups: result.data.map(toDeliveryEventGroup), + pagination: { + limit: input.limit, + offset: input.offset, + }, + } + }), + + getDelivery: webhooksRepositoryProcedure + .input(GetWebhookDeliveryInputSchema) + .query(async ({ ctx, input }) => { + const result = await ctx.webhooksRepository.getWebhookDelivery({ + webhookId: input.webhookId, + deliveryId: input.deliveryId, + }) + + if (!result.ok) { + l.error( + { + key: 'get_webhook_delivery_trpc:error', + status: result.error.status, + error: result.error, + team_id: ctx.teamId, + user_id: ctx.session.user.id, + context: { + webhookId: input.webhookId, + deliveryId: input.deliveryId, + }, + }, + `Failed to get webhook delivery: ${result.error.status}: ${result.error.message}` + ) + + throwTRPCErrorFromRepoError(result.error) + } + + return { delivery: result.data } + }), + + getDeliveryStats: webhooksRepositoryProcedure + .input(GetWebhookDeliveryStatsInputSchema) + .query(async ({ ctx, input }) => { + const result = await ctx.webhooksRepository.getWebhookDeliveryStats({ + webhookId: input.webhookId, + start: input.start, + end: input.end, + }) + + if (!result.ok) { + l.error( + { + key: 'get_webhook_delivery_stats_trpc:error', + status: result.error.status, + error: result.error, + team_id: ctx.teamId, + user_id: ctx.session.user.id, + context: { + webhookId: input.webhookId, + start: input.start, + end: input.end, + }, + }, + `Failed to get webhook delivery stats: ${result.error.status}: ${result.error.message}` + ) + + throwTRPCErrorFromRepoError(result.error) + } + + return { stats: result.data } + }), + upsert: webhooksRepositoryProcedure .input(UpsertWebhookInputSchema) .mutation(async ({ ctx, input }) => { diff --git a/src/core/server/functions/webhooks/schema.ts b/src/core/server/functions/webhooks/schema.ts index 6e19e54ca..98d6dec79 100644 --- a/src/core/server/functions/webhooks/schema.ts +++ b/src/core/server/functions/webhooks/schema.ts @@ -45,8 +45,44 @@ export const UpdateWebhookSecretInputSchema = z.object({ signatureSecret: WebhookSecretSchema, }) +const DeliveryStatusFilterSchema = z.enum(['all', 'success', 'failed']) + +export const GetWebhookInputSchema = z.object({ + webhookId: z.uuid(), +}) + +export const ListWebhookDeliveriesInputSchema = z.object({ + webhookId: z.uuid(), + limit: z.number().int().min(1).max(100).optional().default(25), + offset: z.number().int().min(0).optional().default(0), + orderAsc: z.boolean().optional().default(false), + deliveryStatus: DeliveryStatusFilterSchema.optional().default('all'), + eventType: z.string().trim().min(1).optional(), +}) + +export const GetWebhookDeliveryInputSchema = z.object({ + webhookId: z.uuid(), + deliveryId: z.uuid(), +}) + +export const GetWebhookDeliveryStatsInputSchema = z.object({ + webhookId: z.uuid(), + start: z.iso.datetime().optional(), + end: z.iso.datetime().optional(), +}) + export type UpsertWebhookInput = z.input export type DeleteWebhookInput = z.input export type UpdateWebhookSecretInput = z.input< typeof UpdateWebhookSecretInputSchema > +export type GetWebhookInput = z.input +export type ListWebhookDeliveriesInput = z.input< + typeof ListWebhookDeliveriesInputSchema +> +export type GetWebhookDeliveryInput = z.input< + typeof GetWebhookDeliveryInputSchema +> +export type GetWebhookDeliveryStatsInput = z.input< + typeof GetWebhookDeliveryStatsInputSchema +> diff --git a/src/core/shared/contracts/argus-api.types.ts b/src/core/shared/contracts/argus-api.types.ts index b9286818e..dc0303a7b 100644 --- a/src/core/shared/contracts/argus-api.types.ts +++ b/src/core/shared/contracts/argus-api.types.ts @@ -164,6 +164,57 @@ export interface paths { patch: operations['webhookUpdate'] trace?: never } + '/events/webhooks/{webhookID}/deliveries': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** @description List webhook delivery attempts. */ + get: operations['webhookDeliveriesList'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/events/webhooks/{webhookID}/deliveries/{deliveryID}': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** @description Get a webhook delivery attempt. */ + get: operations['webhookDeliveryGet'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/events/webhooks/{webhookID}/stats': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** @description Get webhook delivery aggregate stats. */ + get: operations['webhookDeliveryStats'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } } export type webhooks = Record export interface components { @@ -289,6 +340,103 @@ export interface components { /** @description Secret used to sign the webhook payloads */ signatureSecret?: string } + /** @description Webhook delivery attempt */ + WebhookDelivery: { + /** + * Format: uuid + * @description Delivery attempt identifier + */ + id: string + /** + * Format: uuid + * @description Team identifier + */ + teamId: string + /** + * Format: uuid + * @description Webhook configuration identifier + */ + webhookId: string + /** + * Format: uuid + * @description Sandbox event identifier + */ + eventId: string + /** @description Sandbox identifier */ + sandboxId: string + /** @description Sandbox event type */ + eventType: string + /** + * @description Delivery attempt status + * @enum {string} + */ + deliveryStatus: 'success' | 'failed' + /** + * Format: int32 + * @description HTTP response status code, if a response was received + */ + httpStatusCode?: number | null + /** + * Format: int32 + * @description Delivery request duration in milliseconds + */ + durationMs: number + /** + * Format: uri + * @description URL attempted for this delivery + */ + requestUrl: string + /** @description JSON-encoded request headers with sensitive values redacted */ + requestHeaders: string + /** @description Serialized webhook request body */ + requestBody: string + /** @description JSON-encoded response headers, if a response was received */ + responseHeaders?: string | null + /** @description Truncated response body, if a response was received */ + responseBody?: string | null + /** @description Machine-readable non-HTTP or HTTP failure class */ + errorClass: string + /** @description Error message for failures without a useful response body */ + errorMessage?: string | null + /** + * Format: date-time + * @description Time when the delivery attempt started + */ + timestamp: string + } + /** @description Webhook delivery aggregate stats */ + WebhookDeliveryStats: { + buckets: components['schemas']['WebhookDeliveryStatsBucket'][] + /** Format: int64 */ + total: number + /** Format: int64 */ + failed: number + /** Format: int32 */ + minDurationMs: number + /** Format: double */ + avgDurationMs: number + /** Format: int32 */ + maxDurationMs: number + } + /** @description Webhook delivery stats for a time bucket */ + WebhookDeliveryStatsBucket: { + /** Format: date-time */ + timestamp: string + /** Format: int64 */ + total: number + /** Format: int64 */ + failed: number + /** Format: double */ + avgDurationMs: number + } + /** @description Webhook delivery attempts grouped by sandbox event */ + WebhookDeliveryEvent: { + /** Format: uuid */ + eventId: string + eventType: string + sandboxId: string + attempts: components['schemas']['WebhookDelivery'][] + } } responses: { /** @description Bad request */ @@ -340,6 +488,7 @@ export interface components { parameters: { sandboxID: string webhookID: string + deliveryID: string } requestBodies: never headers: never @@ -476,4 +625,94 @@ export interface operations { 500: components['responses']['500'] } } + webhookDeliveriesList: { + parameters: { + query?: { + offset?: number + limit?: number + orderAsc?: boolean + /** @description Filter deliveries by delivery status */ + deliveryStatus?: ('success' | 'failed')[] + /** @description Filter deliveries by event type */ + eventType?: string[] + } + header?: never + path: { + webhookID: components['parameters']['webhookID'] + } + cookie?: never + } + requestBody?: never + responses: { + /** @description List of webhook delivery attempts grouped by event. */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['WebhookDeliveryEvent'][] + } + } + 400: components['responses']['400'] + 401: components['responses']['401'] + 404: components['responses']['404'] + 500: components['responses']['500'] + } + } + webhookDeliveryGet: { + parameters: { + query?: never + header?: never + path: { + webhookID: components['parameters']['webhookID'] + deliveryID: components['parameters']['deliveryID'] + } + cookie?: never + } + requestBody?: never + responses: { + /** @description Webhook delivery attempt. */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['WebhookDelivery'] + } + } + 401: components['responses']['401'] + 404: components['responses']['404'] + 500: components['responses']['500'] + } + } + webhookDeliveryStats: { + parameters: { + query?: { + /** @description Inclusive stats range start. Defaults to 24 hours ago. */ + start?: string + /** @description Exclusive stats range end. Defaults to now. */ + end?: string + } + header?: never + path: { + webhookID: components['parameters']['webhookID'] + } + cookie?: never + } + requestBody?: never + responses: { + /** @description Webhook delivery stats. */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['WebhookDeliveryStats'] + } + } + 401: components['responses']['401'] + 404: components['responses']['404'] + 500: components['responses']['500'] + } + } } diff --git a/src/features/dashboard/settings/webhooks/detail/deliveries-content.tsx b/src/features/dashboard/settings/webhooks/detail/deliveries-content.tsx new file mode 100644 index 000000000..1e93d7624 --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/deliveries-content.tsx @@ -0,0 +1,435 @@ +'use client' + +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { useMemo, useState } from 'react' +import { z } from 'zod' +import { SandboxLifecycleEventTypeSchema } from '@/core/modules/sandboxes/lifecycle-event-types' +import { WEBHOOK_EVENT_LABELS } from '@/features/dashboard/settings/webhooks/constants' +import { type TRPCRouterOutputs, useTRPC } from '@/trpc/client' +import { Badge } from '@/ui/primitives/badge' +import { Button } from '@/ui/primitives/button' +import { Card } from '@/ui/primitives/card' +import { WebhookIcon } from '@/ui/primitives/icons' +import { Input } from '@/ui/primitives/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/ui/primitives/select' +import { + Table, + TableBody, + TableCell, + TableEmptyState, + TableHead, + TableHeader, + TableLoadingState, + TableRow, +} from '@/ui/primitives/table' + +type DeliveryStatusFilter = 'all' | 'success' | 'failed' +type DeliveryGroup = + TRPCRouterOutputs['webhooks']['listDeliveries']['groups'][number] +type DeliveryAttempt = NonNullable + +type WebhookDeliveriesContentProps = { + teamSlug: string + webhookId: string +} + +type DeliveryDetailPanelProps = { + attempt: DeliveryAttempt | null + group: DeliveryGroup | undefined + isLoading: boolean +} + +const DeliveryStatusFilterSchema = z.enum(['all', 'success', 'failed']) +const EMPTY_UUID = '00000000-0000-4000-8000-000000000000' + +const deliveryStatusVariantMap: Record< + DeliveryAttempt['deliveryStatus'], + React.ComponentProps['variant'] +> = { + failed: 'error', + success: 'positive', +} + +const formatDateTime = (value: string) => + new Date(value).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }) + +const formatHttpStatus = (status: number | null | undefined) => + status === null || status === undefined ? 'No response' : String(status) + +const getEventLabel = (event: string) => { + const parsed = SandboxLifecycleEventTypeSchema.safeParse(event) + if (parsed.success) return WEBHOOK_EVENT_LABELS[parsed.data] + + return event +} + +// Formats JSON strings for display, e.g. '{"a":1}' -> '{\n "a": 1\n}'. +const formatMaybeJson = (value: string | null | undefined) => { + if (!value) return '-' + + try { + return JSON.stringify(JSON.parse(value), null, 2) + } catch { + return value + } +} + +const DeliveryStatusBadge = ({ + status, +}: { + status: DeliveryAttempt['deliveryStatus'] +}) => {status} + +const DeliveryStatusSelect = ({ + value, + onChange, +}: { + value: DeliveryStatusFilter + onChange: (value: DeliveryStatusFilter) => void +}) => { + const handleValueChange = (nextValue: string) => { + const parsed = DeliveryStatusFilterSchema.safeParse(nextValue) + if (!parsed.success) return + + onChange(parsed.data) + } + + return ( + + ) +} + +const DeliveryDetailSection = ({ + title, + children, +}: { + title: string + children: React.ReactNode +}) => ( +
+

+ {title} +

+ {children} +
+) + +const DeliveryCodeBlock = ({ value }: { value: string | null | undefined }) => ( +
+    {formatMaybeJson(value)}
+  
+) + +const DeliveryDetailPanel = ({ + attempt, + group, + isLoading, +}: DeliveryDetailPanelProps) => { + if (!group || !attempt) { + return ( + + Select an event delivery to inspect the request and response. + + ) + } + + return ( + +
+
+
+

+ {getEventLabel(group.eventType)} +

+

+ {group.eventId} +

+
+ +
+
+ HTTP {formatHttpStatus(attempt.httpStatusCode)} + {attempt.durationMs.toLocaleString()}ms + {formatDateTime(attempt.timestamp)} + {group.attemptCount} attempts +
+
+ + {isLoading ? ( +

+ Loading delivery detail... +

+ ) : null} + +
+ +
+ {group.attempts.map((item) => ( +
+
+

+ {formatDateTime(item.timestamp)} +

+

+ HTTP {formatHttpStatus(item.httpStatusCode)} ·{' '} + {item.durationMs.toLocaleString()}ms +

+
+ +
+ ))} +
+
+ + +

+ {attempt.requestUrl} +

+ + +
+ + +

+ HTTP {formatHttpStatus(attempt.httpStatusCode)} +

+ + +
+ + {attempt.errorMessage || attempt.errorClass ? ( + +

+ {attempt.errorClass || 'delivery_error'} +

+

+ {attempt.errorMessage || 'No error message provided'} +

+
+ ) : null} +
+
+ ) +} + +export const WebhookDeliveriesContent = ({ + teamSlug, + webhookId, +}: WebhookDeliveriesContentProps) => { + const [deliveryStatus, setDeliveryStatus] = + useState('all') + const [eventType, setEventType] = useState('') + const [offset, setOffset] = useState(0) + const [selectedEventId, setSelectedEventId] = useState(null) + const trpc = useTRPC() + const eventTypeFilter = eventType.trim() || undefined + const deliveriesQuery = useQuery( + trpc.webhooks.listDeliveries.queryOptions( + { + teamSlug, + webhookId, + limit: 25, + offset, + deliveryStatus, + eventType: eventTypeFilter, + }, + { + placeholderData: keepPreviousData, + } + ) + ) + const groups = deliveriesQuery.data?.groups ?? [] + const selectedGroup = useMemo( + () => + groups.find((group) => group.eventId === selectedEventId) ?? groups[0], + [groups, selectedEventId] + ) + const selectedAttempt = selectedGroup?.latestAttempt ?? null + const deliveryDetailQuery = useQuery( + trpc.webhooks.getDelivery.queryOptions( + { + teamSlug, + webhookId, + deliveryId: selectedAttempt?.id ?? EMPTY_UUID, + }, + { + enabled: Boolean(selectedAttempt), + } + ) + ) + const detailedAttempt = deliveryDetailQuery.data?.delivery ?? selectedAttempt + const hasNextPage = groups.length === 25 + const hasActiveFilters = deliveryStatus !== 'all' || Boolean(eventTypeFilter) + + return ( +
+
+
+

Event deliveries

+

+ One row per sandbox event, with retry attempts grouped underneath. +

+
+
+ { + setDeliveryStatus(value) + setOffset(0) + }} + /> + { + setEventType(event.target.value) + setOffset(0) + }} + /> +
+
+ +
+ + + + + + + + + + + + + Event + Status + HTTP + Attempts + Duration + Last attempt + + + + {deliveriesQuery.isLoading ? ( + + ) : groups.length === 0 ? ( + + +

+ {hasActiveFilters + ? 'No deliveries match these filters' + : 'No deliveries yet'} +

+
+ ) : ( + groups.map((group) => { + const attempt = group.latestAttempt + const isSelected = group.eventId === selectedGroup?.eventId + + return ( + setSelectedEventId(group.eventId)} + > + +
+

+ {getEventLabel(group.eventType)} +

+

+ {group.sandboxId} +

+
+
+ + {attempt ? ( + + ) : ( + '-' + )} + + + {attempt + ? formatHttpStatus(attempt.httpStatusCode) + : '-'} + + {group.attemptCount} + + {attempt + ? `${attempt.durationMs.toLocaleString()}ms` + : '-'} + + + {attempt ? formatDateTime(attempt.timestamp) : '-'} + +
+ ) + }) + )} +
+
+
+ + +
+ +
+

+ Showing {groups.length.toLocaleString()} grouped events +

+
+ + +
+
+
+ ) +} diff --git a/src/features/dashboard/settings/webhooks/detail/header.tsx b/src/features/dashboard/settings/webhooks/detail/header.tsx new file mode 100644 index 000000000..87200b286 --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/header.tsx @@ -0,0 +1,101 @@ +'use client' + +import { useSuspenseQuery } from '@tanstack/react-query' +import Link from 'next/link' +import { PROTECTED_URLS } from '@/configs/urls' +import { SandboxLifecycleEventTypeSchema } from '@/core/modules/sandboxes/lifecycle-event-types' +import { WEBHOOK_EVENT_LABELS } from '@/features/dashboard/settings/webhooks/constants' +import { useTRPC } from '@/trpc/client' +import { Badge } from '@/ui/primitives/badge' +import { Button } from '@/ui/primitives/button' +import { ArrowLeftIcon, WebhookIcon } from '@/ui/primitives/icons' +import { WebhookStatusBadge } from './status-badge' + +type WebhookDetailHeaderProps = { + teamSlug: string + webhookId: string +} + +const formatDate = (value?: string) => { + if (!value) return 'Unknown' + + return new Date(value).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }) +} + +const getEventLabel = (event: string) => { + const parsed = SandboxLifecycleEventTypeSchema.safeParse(event) + if (parsed.success) return WEBHOOK_EVENT_LABELS[parsed.data] + + return event +} + +export const WebhookDetailHeader = ({ + teamSlug, + webhookId, +}: WebhookDetailHeaderProps) => { + const trpc = useTRPC() + const { data } = useSuspenseQuery( + trpc.webhooks.get.queryOptions({ teamSlug, webhookId }) + ) + const { webhook } = data + + return ( +
+
+
+ + +
+ +
+
+

+ {webhook.name} +

+ +
+

+ {webhook.url} +

+
+
+
+ +
+

+ Webhook ID +

+

+ {webhook.id} +

+

+ Created {formatDate(webhook.createdAt)} +

+
+
+ +
+ {webhook.events.map((event) => ( + {getEventLabel(event)} + ))} +
+
+ ) +} diff --git a/src/features/dashboard/settings/webhooks/detail/index.ts b/src/features/dashboard/settings/webhooks/detail/index.ts new file mode 100644 index 000000000..c8b5997ac --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/index.ts @@ -0,0 +1,3 @@ +export { WebhookDeliveriesContent } from './deliveries-content' +export { WebhookDetailLayout } from './layout' +export { WebhookOverviewContent } from './overview-content' diff --git a/src/features/dashboard/settings/webhooks/detail/layout.tsx b/src/features/dashboard/settings/webhooks/detail/layout.tsx new file mode 100644 index 000000000..55efba0a2 --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/layout.tsx @@ -0,0 +1,41 @@ +'use client' + +import { PROTECTED_URLS } from '@/configs/urls' +import { DashboardTabsList } from '@/ui/dashboard-tabs' +import { ListIcon, TrendIcon } from '@/ui/primitives/icons' +import { WebhookDetailHeader } from './header' + +type WebhookDetailLayoutProps = { + children: React.ReactNode + teamSlug: string + webhookId: string +} + +export const WebhookDetailLayout = ({ + children, + teamSlug, + webhookId, +}: WebhookDetailLayoutProps) => ( +
+ + , + }, + { + id: 'deliveries', + label: 'Event deliveries', + href: PROTECTED_URLS.WEBHOOK_DELIVERIES(teamSlug, webhookId), + icon: , + }, + ]} + /> + {children} +
+) diff --git a/src/features/dashboard/settings/webhooks/detail/overview-content.tsx b/src/features/dashboard/settings/webhooks/detail/overview-content.tsx new file mode 100644 index 000000000..567cb5524 --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/overview-content.tsx @@ -0,0 +1,227 @@ +'use client' + +import { useSuspenseQuery } from '@tanstack/react-query' +import { useMemo, useState } from 'react' +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts' +import { useTRPC } from '@/trpc/client' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/ui/primitives/card' +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from '@/ui/primitives/chart' +import { + getWebhookStatsRange, + WebhookRangeSelector, + type WebhookStatsRange, +} from './range-selector' + +type WebhookOverviewContentProps = { + teamSlug: string + webhookId: string +} + +type MetricCardProps = { + label: string + value: string + description: string +} + +const deliveryChartConfig = { + total: { + label: 'Total deliveries', + color: 'var(--accent-info-highlight)', + }, + failed: { + label: 'Failed deliveries', + color: 'var(--accent-error-highlight)', + }, +} satisfies ChartConfig + +const latencyChartConfig = { + avgDurationMs: { + label: 'Average duration', + color: 'var(--accent-positive-highlight)', + }, +} satisfies ChartConfig + +const formatBucketLabel = (value: string) => + new Date(value).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + }) + +const MetricCard = ({ label, value, description }: MetricCardProps) => ( + + + + {label} + + + {value} + + + +

{description}

+
+
+) + +const EmptyChartState = ({ label }: { label: string }) => ( +
+ {label} +
+) + +export const WebhookOverviewContent = ({ + teamSlug, + webhookId, +}: WebhookOverviewContentProps) => { + const [range, setRange] = useState('24h') + const trpc = useTRPC() + const rangeBounds = useMemo(() => getWebhookStatsRange(range), [range]) + const { data } = useSuspenseQuery( + trpc.webhooks.getDeliveryStats.queryOptions({ + teamSlug, + webhookId, + ...rangeBounds, + }) + ) + const { stats } = data + const successful = Math.max(stats.total - stats.failed, 0) + const failureRate = + stats.total > 0 + ? `${((stats.failed / stats.total) * 100).toFixed(1)}%` + : '0%' + const hasBuckets = stats.buckets.length > 0 + + return ( +
+
+
+

Overview

+

+ Delivery health and latency for this webhook. +

+
+ +
+ +
+ + + + +
+ +
+ + + Event deliveries + + Total and failed attempts over time + + + + {hasBuckets ? ( + + + + + + } /> + + + + + ) : ( + + )} + + + + + + Response time + Average latency in milliseconds + + + {hasBuckets ? ( + + + + + + } /> + + + + ) : ( + + )} + + +
+
+ ) +} diff --git a/src/features/dashboard/settings/webhooks/detail/range-selector.tsx b/src/features/dashboard/settings/webhooks/detail/range-selector.tsx new file mode 100644 index 000000000..f859498d2 --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/range-selector.tsx @@ -0,0 +1,71 @@ +'use client' + +import { z } from 'zod' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/ui/primitives/select' + +export type WebhookStatsRange = '24h' | '7d' | '30d' + +const WebhookStatsRangeSchema = z.enum(['24h', '7d', '30d']) + +export const WEBHOOK_STATS_RANGE_LABELS: Record = { + '24h': 'Last 24 hours', + '7d': 'Last 7 days', + '30d': 'Last 30 days', +} + +const WEBHOOK_STATS_RANGE_HOURS: Record = { + '24h': 24, + '7d': 24 * 7, + '30d': 24 * 30, +} + +type WebhookRangeSelectorProps = { + value: WebhookStatsRange + onChange: (value: WebhookStatsRange) => void +} + +// Builds ISO stats bounds from a range, e.g. "24h" -> { start: "...", end: "..." }. +export const getWebhookStatsRange = (range: WebhookStatsRange) => { + const end = new Date() + const start = new Date( + end.getTime() - WEBHOOK_STATS_RANGE_HOURS[range] * 60 * 60 * 1000 + ) + + return { + start: start.toISOString(), + end: end.toISOString(), + } +} + +export const WebhookRangeSelector = ({ + value, + onChange, +}: WebhookRangeSelectorProps) => { + const handleValueChange = (nextValue: string) => { + const parsed = WebhookStatsRangeSchema.safeParse(nextValue) + if (!parsed.success) return + + onChange(parsed.data) + } + + return ( + + ) +} diff --git a/src/features/dashboard/settings/webhooks/detail/status-badge.tsx b/src/features/dashboard/settings/webhooks/detail/status-badge.tsx new file mode 100644 index 000000000..91a54a39d --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/status-badge.tsx @@ -0,0 +1,36 @@ +import { Badge } from '@/ui/primitives/badge' + +type WebhookDeliveryHealth = 'disabled' | 'failing' | 'healthy' | 'unknown' + +const statusConfigMap: Record< + WebhookDeliveryHealth, + { label: string; variant: React.ComponentProps['variant'] } +> = { + disabled: { label: 'Disabled', variant: 'warning' }, + failing: { label: 'Failing', variant: 'error' }, + healthy: { label: 'Healthy', variant: 'positive' }, + unknown: { label: 'No deliveries', variant: 'info' }, +} + +type WebhookStatusBadgeProps = { + enabled: boolean + failedCount?: number + totalCount?: number +} + +export const WebhookStatusBadge = ({ + enabled, + failedCount, + totalCount, +}: WebhookStatusBadgeProps) => { + const health: WebhookDeliveryHealth = !enabled + ? 'disabled' + : !totalCount + ? 'unknown' + : failedCount && failedCount > 0 + ? 'failing' + : 'healthy' + const config = statusConfigMap[health] + + return {config.label} +} diff --git a/src/features/dashboard/settings/webhooks/table-row.tsx b/src/features/dashboard/settings/webhooks/table-row.tsx index c0b9c824b..7ed470362 100644 --- a/src/features/dashboard/settings/webhooks/table-row.tsx +++ b/src/features/dashboard/settings/webhooks/table-row.tsx @@ -1,6 +1,8 @@ 'use client' +import { useRouter } from 'next/navigation' import { Fragment, useState } from 'react' +import { PROTECTED_URLS } from '@/configs/urls' import { SandboxLifecycleEventTypeSchema } from '@/core/modules/sandboxes/lifecycle-event-types' import { useClipboard } from '@/lib/hooks/use-clipboard' import { defaultSuccessToast, toast } from '@/lib/hooks/use-toast' @@ -187,6 +189,7 @@ const WebhookRowActions = ({ webhook }: WebhookRowActionsProps) => { export const WebhookTableRow = ({ webhook }: WebhookRowProps) => { const { team } = useDashboard() + const router = useRouter() const createdAt = webhook.createdAt ? new Date(webhook.createdAt).toLocaleDateString('en-US', { @@ -196,8 +199,28 @@ export const WebhookTableRow = ({ webhook }: WebhookRowProps) => { }) : '-' + const webhookHref = PROTECTED_URLS.WEBHOOK(team.slug, webhook.id) + + const openWebhook = () => { + router.push(webhookHref) + } + + const handleRowKeyDown = ( + event: React.KeyboardEvent + ) => { + if (event.key !== 'Enter' && event.key !== ' ') return + + event.preventDefault() + openWebhook() + } + return ( - + @@ -217,7 +240,10 @@ export const WebhookTableRow = ({ webhook }: WebhookRowProps) => { - + event.stopPropagation()} + > From 757dbe806cba91f345e686d911dafc822181b5d0 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 13 May 2026 13:56:17 -0400 Subject: [PATCH 02/13] refactor(webhooks): simplify stats range handling and improve overview page - Removed redundant default stats range calculation in favor of a dedicated utility function. - Updated the WebhookOverviewPage to utilize the new getWebhookStatsRange function for better clarity. - Enhanced WebhookOverviewContent to accept initial range bounds as a prop, improving flexibility. - Refactored WebhookRangeSelector to handle range changes more effectively. These changes aim to streamline the management of webhook statistics and improve code maintainability. --- .../webhooks/[webhookId]/(tabs)/layout.tsx | 9 +---- .../[webhookId]/(tabs)/overview/page.tsx | 22 +++++----- .../webhooks/detail/overview-content.tsx | 18 ++++++--- .../webhooks/detail/range-selector.tsx | 31 ++------------ .../settings/webhooks/detail/stats-range.ts | 40 +++++++++++++++++++ 5 files changed, 67 insertions(+), 53 deletions(-) create mode 100644 src/features/dashboard/settings/webhooks/detail/stats-range.ts diff --git a/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/layout.tsx b/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/layout.tsx index f2958fcaa..af168afba 100644 --- a/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/layout.tsx +++ b/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/layout.tsx @@ -21,14 +21,7 @@ export default async function WebhookTabsLayout({ const { teamSlug, webhookId } = await params - prefetch( - trpc.webhooks.get.queryOptions( - { teamSlug, webhookId }, - { - retry: false, - } - ) - ) + prefetch(trpc.webhooks.get.queryOptions({ teamSlug, webhookId })) return ( diff --git a/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/overview/page.tsx b/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/overview/page.tsx index 6cf7d4b82..c68ef5028 100644 --- a/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/overview/page.tsx +++ b/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/overview/page.tsx @@ -1,4 +1,5 @@ import { WebhookOverviewContent } from '@/features/dashboard/settings/webhooks/detail' +import { getWebhookStatsRange } from '@/features/dashboard/settings/webhooks/detail/stats-range' import { prefetch, trpc } from '@/trpc/server' type WebhookOverviewPageProps = { @@ -8,22 +9,11 @@ type WebhookOverviewPageProps = { }> } -// Builds the initial stats range, e.g. now -> last 24 hours. -const getDefaultStatsRange = () => { - const end = new Date() - const start = new Date(end.getTime() - 24 * 60 * 60 * 1000) - - return { - start: start.toISOString(), - end: end.toISOString(), - } -} - export default async function WebhookOverviewPage({ params, }: WebhookOverviewPageProps) { const { teamSlug, webhookId } = await params - const range = getDefaultStatsRange() + const range = getWebhookStatsRange('24h') prefetch( trpc.webhooks.getDeliveryStats.queryOptions({ @@ -33,5 +23,11 @@ export default async function WebhookOverviewPage({ }) ) - return + return ( + + ) } diff --git a/src/features/dashboard/settings/webhooks/detail/overview-content.tsx b/src/features/dashboard/settings/webhooks/detail/overview-content.tsx index 567cb5524..b9c697833 100644 --- a/src/features/dashboard/settings/webhooks/detail/overview-content.tsx +++ b/src/features/dashboard/settings/webhooks/detail/overview-content.tsx @@ -1,7 +1,7 @@ 'use client' import { useSuspenseQuery } from '@tanstack/react-query' -import { useMemo, useState } from 'react' +import { useState } from 'react' import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts' import { useTRPC } from '@/trpc/client' import { @@ -17,15 +17,17 @@ import { ChartTooltip, ChartTooltipContent, } from '@/ui/primitives/chart' +import { WebhookRangeSelector } from './range-selector' import { getWebhookStatsRange, - WebhookRangeSelector, type WebhookStatsRange, -} from './range-selector' + type WebhookStatsRangeBounds, +} from './stats-range' type WebhookOverviewContentProps = { teamSlug: string webhookId: string + initialRangeBounds: WebhookStatsRangeBounds } type MetricCardProps = { @@ -84,10 +86,12 @@ const EmptyChartState = ({ label }: { label: string }) => ( export const WebhookOverviewContent = ({ teamSlug, webhookId, + initialRangeBounds, }: WebhookOverviewContentProps) => { const [range, setRange] = useState('24h') + const [rangeBounds, setRangeBounds] = + useState(initialRangeBounds) const trpc = useTRPC() - const rangeBounds = useMemo(() => getWebhookStatsRange(range), [range]) const { data } = useSuspenseQuery( trpc.webhooks.getDeliveryStats.queryOptions({ teamSlug, @@ -102,6 +106,10 @@ export const WebhookOverviewContent = ({ ? `${((stats.failed / stats.total) * 100).toFixed(1)}%` : '0%' const hasBuckets = stats.buckets.length > 0 + const handleRangeChange = (nextRange: WebhookStatsRange) => { + setRange(nextRange) + setRangeBounds(getWebhookStatsRange(nextRange)) + } return (
@@ -112,7 +120,7 @@ export const WebhookOverviewContent = ({ Delivery health and latency for this webhook.

- +
diff --git a/src/features/dashboard/settings/webhooks/detail/range-selector.tsx b/src/features/dashboard/settings/webhooks/detail/range-selector.tsx index f859498d2..2433e3633 100644 --- a/src/features/dashboard/settings/webhooks/detail/range-selector.tsx +++ b/src/features/dashboard/settings/webhooks/detail/range-selector.tsx @@ -8,41 +8,18 @@ import { SelectTrigger, SelectValue, } from '@/ui/primitives/select' - -export type WebhookStatsRange = '24h' | '7d' | '30d' +import { + WEBHOOK_STATS_RANGE_LABELS, + type WebhookStatsRange, +} from './stats-range' const WebhookStatsRangeSchema = z.enum(['24h', '7d', '30d']) -export const WEBHOOK_STATS_RANGE_LABELS: Record = { - '24h': 'Last 24 hours', - '7d': 'Last 7 days', - '30d': 'Last 30 days', -} - -const WEBHOOK_STATS_RANGE_HOURS: Record = { - '24h': 24, - '7d': 24 * 7, - '30d': 24 * 30, -} - type WebhookRangeSelectorProps = { value: WebhookStatsRange onChange: (value: WebhookStatsRange) => void } -// Builds ISO stats bounds from a range, e.g. "24h" -> { start: "...", end: "..." }. -export const getWebhookStatsRange = (range: WebhookStatsRange) => { - const end = new Date() - const start = new Date( - end.getTime() - WEBHOOK_STATS_RANGE_HOURS[range] * 60 * 60 * 1000 - ) - - return { - start: start.toISOString(), - end: end.toISOString(), - } -} - export const WebhookRangeSelector = ({ value, onChange, diff --git a/src/features/dashboard/settings/webhooks/detail/stats-range.ts b/src/features/dashboard/settings/webhooks/detail/stats-range.ts new file mode 100644 index 000000000..85754c499 --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/stats-range.ts @@ -0,0 +1,40 @@ +type WebhookStatsRange = '24h' | '7d' | '30d' + +type WebhookStatsRangeBounds = { + start: string + end: string +} + +const WEBHOOK_STATS_RANGE_LABELS: Record = { + '24h': 'Last 24 hours', + '7d': 'Last 7 days', + '30d': 'Last 30 days', +} + +const WEBHOOK_STATS_RANGE_HOURS: Record = { + '24h': 24, + '7d': 24 * 7, + '30d': 24 * 30, +} + +// Builds ISO stats bounds from a range, e.g. "24h" -> { start: "...", end: "..." }. +const getWebhookStatsRange = ( + range: WebhookStatsRange +): WebhookStatsRangeBounds => { + const end = new Date() + const start = new Date( + end.getTime() - WEBHOOK_STATS_RANGE_HOURS[range] * 60 * 60 * 1000 + ) + + return { + start: start.toISOString(), + end: end.toISOString(), + } +} + +export { + getWebhookStatsRange, + WEBHOOK_STATS_RANGE_LABELS, + type WebhookStatsRange, + type WebhookStatsRangeBounds, +} From 2d34a6bd044eb5fe3181cdc72770c2770b15ff00 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 13 May 2026 14:04:50 -0400 Subject: [PATCH 03/13] refactor(webhooks): update WebhookTableRow to use Link component - Replaced useRouter with Link for navigation in WebhookTableRow, enhancing accessibility and simplifying the code. - Modified WebhookNameAndUrl component to accept an href prop for direct linking. - Improved the overall structure and readability of the webhook table row component. These changes aim to streamline navigation and improve user experience in the dashboard. --- .../dashboard/settings/webhooks/table-row.tsx | 46 ++++++++----------- src/ui/primitives/icons.tsx | 40 ++++++++-------- 2 files changed, 38 insertions(+), 48 deletions(-) diff --git a/src/features/dashboard/settings/webhooks/table-row.tsx b/src/features/dashboard/settings/webhooks/table-row.tsx index 7ed470362..9e3983b90 100644 --- a/src/features/dashboard/settings/webhooks/table-row.tsx +++ b/src/features/dashboard/settings/webhooks/table-row.tsx @@ -1,6 +1,6 @@ 'use client' -import { useRouter } from 'next/navigation' +import Link from 'next/link' import { Fragment, useState } from 'react' import { PROTECTED_URLS } from '@/configs/urls' import { SandboxLifecycleEventTypeSchema } from '@/core/modules/sandboxes/lifecycle-event-types' @@ -49,6 +49,7 @@ type WebhookRowActionsProps = { } type WebhookNameAndUrlProps = { + href: string name: string url: string } @@ -61,7 +62,7 @@ const urlIconMap: Record = { idle: WebhookIcon, } -const WebhookNameAndUrl = ({ name, url }: WebhookNameAndUrlProps) => { +const WebhookNameAndUrl = ({ href, name, url }: WebhookNameAndUrlProps) => { const [wasCopied, copy] = useClipboard(1500) const [isUrlHovered, setIsUrlHovered] = useState(false) const iconState: UrlIconState = wasCopied @@ -87,7 +88,14 @@ const WebhookNameAndUrl = ({ name, url }: WebhookNameAndUrlProps) => {
-

{name}

+
- event.stopPropagation()} - > +
diff --git a/src/ui/primitives/icons.tsx b/src/ui/primitives/icons.tsx index 126e7797a..a24432e42 100644 --- a/src/ui/primitives/icons.tsx +++ b/src/ui/primitives/icons.tsx @@ -1048,8 +1048,8 @@ export const BugIcon = ({ className, ...props }: IconProps) => ( ) @@ -1065,14 +1065,14 @@ export const FeedbackIcon = ({ className, ...props }: IconProps) => ( ) @@ -1216,21 +1216,21 @@ export const UnlockIcon = ({ className, ...props }: IconProps) => ( ) @@ -1456,14 +1456,14 @@ export const ArrowRightIcon = ({ className, ...props }: IconProps) => ( ) @@ -1479,14 +1479,14 @@ export const ArrowLeftIcon = ({ className, ...props }: IconProps) => ( ) From 23dccee175a59be793b966d96847a2cde55813d9 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 13 May 2026 16:21:05 -0400 Subject: [PATCH 04/13] feat(webhooks): enhance webhook details and event display - Introduced WebhookEventBadges component to visually represent webhook events in the dashboard. - Updated WebhookDetailHeader to include a badge for the webhook ID with a copy functionality. - Refactored DashboardLayoutHeader to conditionally render webhook titles based on the route. - Added ToolboxComponent to the monitoring chart for improved user interaction. These changes aim to improve the user experience by providing clearer information and better interaction options for webhook management. --- src/configs/layout.ts | 5 +- src/features/dashboard/layouts/header.tsx | 49 +++++++- .../monitoring-sandbox-metrics-chart.tsx | 2 + .../settings/webhooks/detail/header.tsx | 110 ++++++++---------- .../settings/webhooks/event-badges.tsx | 54 +++++++++ .../dashboard/settings/webhooks/table-row.tsx | 56 +-------- 6 files changed, 155 insertions(+), 121 deletions(-) create mode 100644 src/features/dashboard/settings/webhooks/event-badges.tsx diff --git a/src/configs/layout.ts b/src/configs/layout.ts index 632cdfde9..ce22f9ac7 100644 --- a/src/configs/layout.ts +++ b/src/configs/layout.ts @@ -98,8 +98,6 @@ const DASHBOARD_LAYOUT_CONFIGS: Record< '/dashboard/*/webhooks/*/*': (pathname) => { const parts = pathname.split('/') const teamSlug = parts[2]! - const webhookId = parts[4]! - const webhookIdSliced = `${webhookId.slice(0, 6)}...${webhookId.slice(-6)}` return { title: [ @@ -107,10 +105,9 @@ const DASHBOARD_LAYOUT_CONFIGS: Record< label: 'Webhooks', href: PROTECTED_URLS.WEBHOOKS(teamSlug), }, - { label: `Webhook ${webhookIdSliced}` }, + { label: 'Webhook' }, ], type: 'custom', - copyValue: webhookId, custom: { includeHeaderBottomStyles: true, }, diff --git a/src/features/dashboard/layouts/header.tsx b/src/features/dashboard/layouts/header.tsx index 25ac0834e..3ccd42258 100644 --- a/src/features/dashboard/layouts/header.tsx +++ b/src/features/dashboard/layouts/header.tsx @@ -1,10 +1,13 @@ 'use client' +import { useSuspenseQuery } from '@tanstack/react-query' import Link from 'next/link' import { usePathname } from 'next/navigation' import { Fragment } from 'react' import { getDashboardLayoutConfig, type TitleSegment } from '@/configs/layout' +import { PROTECTED_URLS } from '@/configs/urls' import { cn } from '@/lib/utils' +import { useTRPC } from '@/trpc/client' import ClientOnly from '@/ui/client-only' import CopyButton from '@/ui/copy-button' import { SidebarTrigger } from '@/ui/primitives/sidebar' @@ -22,6 +25,7 @@ export default function DashboardLayoutHeader({ const pathname = usePathname() const config = getDashboardLayoutConfig(pathname) const copyableValue = config.copyValue ?? null + const webhookRoute = getWebhookRoute(pathname) return (

- + {webhookRoute ? ( + + ) : ( + + )}

{copyableValue && ( { + const parts = pathname.split('/') + const teamSlug = parts[2] + const resource = parts[3] + const webhookId = parts[4] + + if (resource !== 'webhooks' || !teamSlug || !webhookId) return null + return { teamSlug, webhookId } +} + +function WebhookHeaderTitle({ + teamSlug, + webhookId, +}: { + teamSlug: string + webhookId: string +}) { + const trpc = useTRPC() + const { data } = useSuspenseQuery( + trpc.webhooks.get.queryOptions({ teamSlug, webhookId }) + ) + + return ( + + + Webhooks + + / + {data.webhook.name} + + ) +} + function HeaderTitle({ title }: { title: string | TitleSegment[] }) { if (typeof title === 'string') { return title diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx index 2cdd149f4..b6392a9bd 100644 --- a/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx @@ -11,6 +11,7 @@ import { BrushComponent, GridComponent, MarkPointComponent, + ToolboxComponent, } from 'echarts/components' import * as echarts from 'echarts/core' import { SVGRenderer } from 'echarts/renderers' @@ -46,6 +47,7 @@ echarts.use([ GridComponent, BrushComponent, MarkPointComponent, + ToolboxComponent, SVGRenderer, AxisPointerComponent, ]) diff --git a/src/features/dashboard/settings/webhooks/detail/header.tsx b/src/features/dashboard/settings/webhooks/detail/header.tsx index 87200b286..2df47aa38 100644 --- a/src/features/dashboard/settings/webhooks/detail/header.tsx +++ b/src/features/dashboard/settings/webhooks/detail/header.tsx @@ -1,15 +1,14 @@ 'use client' import { useSuspenseQuery } from '@tanstack/react-query' -import Link from 'next/link' -import { PROTECTED_URLS } from '@/configs/urls' -import { SandboxLifecycleEventTypeSchema } from '@/core/modules/sandboxes/lifecycle-event-types' -import { WEBHOOK_EVENT_LABELS } from '@/features/dashboard/settings/webhooks/constants' +import { WebhookEventBadges } from '@/features/dashboard/settings/webhooks/event-badges' +import { useClipboard } from '@/lib/hooks/use-clipboard' +import { defaultSuccessToast, toast } from '@/lib/hooks/use-toast' import { useTRPC } from '@/trpc/client' import { Badge } from '@/ui/primitives/badge' import { Button } from '@/ui/primitives/button' -import { ArrowLeftIcon, WebhookIcon } from '@/ui/primitives/icons' -import { WebhookStatusBadge } from './status-badge' +import { CheckIcon, CopyIcon } from '@/ui/primitives/icons' +import { DetailsItem, DetailsRow } from '../../../layouts/details-row' type WebhookDetailHeaderProps = { teamSlug: string @@ -17,7 +16,7 @@ type WebhookDetailHeaderProps = { } const formatDate = (value?: string) => { - if (!value) return 'Unknown' + if (!value) return '-' return new Date(value).toLocaleDateString('en-US', { month: 'short', @@ -26,11 +25,36 @@ const formatDate = (value?: string) => { }) } -const getEventLabel = (event: string) => { - const parsed = SandboxLifecycleEventTypeSchema.safeParse(event) - if (parsed.success) return WEBHOOK_EVENT_LABELS[parsed.data] +const getWebhookIdBadgeLabel = (id: string) => + `${id.slice(0, 6)}...${id.slice(-6)}` - return event +const WebhookIdBadge = ({ id }: { id: string }) => { + const [wasCopied, copy] = useClipboard(1500) + + const handleCopy = async () => { + await copy(id) + toast(defaultSuccessToast('Webhook ID copied')) + } + + return ( + + {getWebhookIdBadgeLabel(id)} + + + ) } export const WebhookDetailHeader = ({ @@ -44,58 +68,20 @@ export const WebhookDetailHeader = ({ const { webhook } = data return ( -
-
-
- - -
- -
-
-

- {webhook.name} -

- -
-

- {webhook.url} -

-
+
+ + + + + +

{formatDate(webhook.createdAt)}

+
+ +
+
-
- -
-

- Webhook ID -

-

- {webhook.id} -

-

- Created {formatDate(webhook.createdAt)} -

-
-
- -
- {webhook.events.map((event) => ( - {getEventLabel(event)} - ))} -
+ +
) } diff --git a/src/features/dashboard/settings/webhooks/event-badges.tsx b/src/features/dashboard/settings/webhooks/event-badges.tsx new file mode 100644 index 000000000..b19df45b7 --- /dev/null +++ b/src/features/dashboard/settings/webhooks/event-badges.tsx @@ -0,0 +1,54 @@ +import { Fragment } from 'react' +import { SandboxLifecycleEventTypeSchema } from '@/core/modules/sandboxes/lifecycle-event-types' +import { Badge } from '@/ui/primitives/badge' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/ui/primitives/tooltip' +import { WEBHOOK_EVENT_LABELS } from './constants' + +type WebhookEventBadgesProps = { + events: readonly string[] +} + +const getWebhookEventLabel = (event: string): string => { + const matchedEvent = SandboxLifecycleEventTypeSchema.options.find( + (webhookEvent) => webhookEvent === event + ) + if (!matchedEvent) return event + return WEBHOOK_EVENT_LABELS[matchedEvent] +} + +export const WebhookEventBadges = ({ events }: WebhookEventBadgesProps) => { + const isAllEvents = + events.length === SandboxLifecycleEventTypeSchema.options.length + + if (isAllEvents) { + return ( + + + ALL ({events.length}) + + +
+ {SandboxLifecycleEventTypeSchema.options.map((event, index) => ( + + {index > 0 && ( + + )} + {WEBHOOK_EVENT_LABELS[event]} + + ))} +
+
+
+ ) + } + + return events.map((event) => ( + {getWebhookEventLabel(event)} + )) +} diff --git a/src/features/dashboard/settings/webhooks/table-row.tsx b/src/features/dashboard/settings/webhooks/table-row.tsx index 9e3983b90..ae895e4c2 100644 --- a/src/features/dashboard/settings/webhooks/table-row.tsx +++ b/src/features/dashboard/settings/webhooks/table-row.tsx @@ -1,13 +1,11 @@ 'use client' import Link from 'next/link' -import { Fragment, useState } from 'react' +import { useState } from 'react' import { PROTECTED_URLS } from '@/configs/urls' -import { SandboxLifecycleEventTypeSchema } from '@/core/modules/sandboxes/lifecycle-event-types' import { useClipboard } from '@/lib/hooks/use-clipboard' import { defaultSuccessToast, toast } from '@/lib/hooks/use-toast' import { cn } from '@/lib/utils' -import { Badge } from '@/ui/primitives/badge' import { Button } from '@/ui/primitives/button' import { DropdownMenu, @@ -27,15 +25,10 @@ import { WebhookIcon, } from '@/ui/primitives/icons' import { TableCell, TableRow } from '@/ui/primitives/table' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@/ui/primitives/tooltip' import { useDashboard } from '../../context' import { UserAvatar } from '../../shared' -import { WEBHOOK_EVENT_LABELS } from './constants' import { DeleteWebhookDialog } from './delete-webhook-dialog' +import { WebhookEventBadges } from './event-badges' import type { Webhook } from './types' import { UpdateWebhookSecretDialog } from './update-webhook-secret-dialog' import { UpsertWebhookDialog } from './upsert-webhook-dialog' @@ -116,51 +109,6 @@ const rowCellClassName = 'p-0 py-1.5 align-middle [tr:first-child>&]:pt-0' const rowContentClassName = 'flex items-center' const actionIconClassName = 'size-4 text-fg-tertiary' -const getWebhookEventLabel = (event: string): string => { - const matchedEvent = SandboxLifecycleEventTypeSchema.options.find( - (webhookEvent) => webhookEvent === event - ) - if (!matchedEvent) return event - return WEBHOOK_EVENT_LABELS[matchedEvent] -} - -type WebhookEventBadgesProps = { - events: readonly string[] -} - -const WebhookEventBadges = ({ events }: WebhookEventBadgesProps) => { - const isAllEvents = - events.length === SandboxLifecycleEventTypeSchema.options.length - - if (isAllEvents) { - return ( - - - ALL ({events.length}) - - -
- {SandboxLifecycleEventTypeSchema.options.map((event, index) => ( - - {index > 0 && ( - - )} - {WEBHOOK_EVENT_LABELS[event]} - - ))} -
-
-
- ) - } - - return events.map((event) => ( - {getWebhookEventLabel(event)} - )) -} - const WebhookRowActions = ({ webhook }: WebhookRowActionsProps) => { const [dropDownOpen, setDropDownOpen] = useState(false) From a7811d449dbdfe10f42e3ea6248096294f07015e Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Thu, 14 May 2026 13:13:24 -0400 Subject: [PATCH 05/13] refactor(webhooks): enforce delivery stats range validation and update schema - Enhanced GetWebhookDeliveryStatsInputSchema to include a validation rule ensuring the delivery stats range does not exceed 7 days. - Removed '30d' option from WebhookStatsRangeSchema and related components to simplify the selection options. These changes aim to improve data integrity and user experience when selecting webhook delivery statistics. --- src/core/server/functions/webhooks/schema.ts | 24 +++++++++++++++---- .../webhooks/detail/range-selector.tsx | 2 +- .../settings/webhooks/detail/stats-range.ts | 4 +--- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/core/server/functions/webhooks/schema.ts b/src/core/server/functions/webhooks/schema.ts index 98d6dec79..e5c296e29 100644 --- a/src/core/server/functions/webhooks/schema.ts +++ b/src/core/server/functions/webhooks/schema.ts @@ -65,11 +65,25 @@ export const GetWebhookDeliveryInputSchema = z.object({ deliveryId: z.uuid(), }) -export const GetWebhookDeliveryStatsInputSchema = z.object({ - webhookId: z.uuid(), - start: z.iso.datetime().optional(), - end: z.iso.datetime().optional(), -}) +export const GetWebhookDeliveryStatsInputSchema = z + .object({ + webhookId: z.uuid(), + start: z.iso.datetime().optional(), + end: z.iso.datetime().optional(), + }) + .superRefine((data, ctx) => { + if (!data.start || !data.end) return + + const start = new Date(data.start) + const end = new Date(data.end) + if (end.getTime() - start.getTime() <= 7 * 24 * 60 * 60 * 1000) return + + ctx.addIssue({ + code: 'custom', + message: 'Webhook delivery stats range must be 7 days or less', + path: ['start'], + }) + }) export type UpsertWebhookInput = z.input export type DeleteWebhookInput = z.input diff --git a/src/features/dashboard/settings/webhooks/detail/range-selector.tsx b/src/features/dashboard/settings/webhooks/detail/range-selector.tsx index 2433e3633..2eaf11949 100644 --- a/src/features/dashboard/settings/webhooks/detail/range-selector.tsx +++ b/src/features/dashboard/settings/webhooks/detail/range-selector.tsx @@ -13,7 +13,7 @@ import { type WebhookStatsRange, } from './stats-range' -const WebhookStatsRangeSchema = z.enum(['24h', '7d', '30d']) +const WebhookStatsRangeSchema = z.enum(['24h', '7d']) type WebhookRangeSelectorProps = { value: WebhookStatsRange diff --git a/src/features/dashboard/settings/webhooks/detail/stats-range.ts b/src/features/dashboard/settings/webhooks/detail/stats-range.ts index 85754c499..274aba4e1 100644 --- a/src/features/dashboard/settings/webhooks/detail/stats-range.ts +++ b/src/features/dashboard/settings/webhooks/detail/stats-range.ts @@ -1,4 +1,4 @@ -type WebhookStatsRange = '24h' | '7d' | '30d' +type WebhookStatsRange = '24h' | '7d' type WebhookStatsRangeBounds = { start: string @@ -8,13 +8,11 @@ type WebhookStatsRangeBounds = { const WEBHOOK_STATS_RANGE_LABELS: Record = { '24h': 'Last 24 hours', '7d': 'Last 7 days', - '30d': 'Last 30 days', } const WEBHOOK_STATS_RANGE_HOURS: Record = { '24h': 24, '7d': 24 * 7, - '30d': 24 * 30, } // Builds ISO stats bounds from a range, e.g. "24h" -> { start: "...", end: "..." }. From 854d047cb2ef8c67d110c1246a4e52de1e29ed4b Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Thu, 14 May 2026 13:54:23 -0400 Subject: [PATCH 06/13] feat(webhooks): implement cursor-based pagination for webhook deliveries - Updated the API and OpenAPI specification to replace offset-based pagination with cursor-based pagination for listing webhook deliveries. - Modified related components and queries to support the new cursor parameter, enhancing the efficiency of data retrieval. - Improved user experience by allowing seamless navigation through delivery records with the addition of a "Load more" button. These changes aim to optimize the performance and usability of webhook delivery monitoring in the dashboard. --- spec/openapi.argus.yaml | 13 ++++--- .../[webhookId]/(tabs)/deliveries/page.tsx | 3 +- .../modules/webhooks/repository.server.ts | 16 ++++++-- src/core/server/api/routers/webhooks.ts | 9 ++--- src/core/server/functions/webhooks/schema.ts | 2 +- src/core/shared/contracts/argus-api.types.ts | 5 ++- .../webhooks/detail/deliveries-content.tsx | 39 ++++++++++--------- 7 files changed, 49 insertions(+), 38 deletions(-) diff --git a/spec/openapi.argus.yaml b/spec/openapi.argus.yaml index e1c8f01e6..4ddb15569 100644 --- a/spec/openapi.argus.yaml +++ b/spec/openapi.argus.yaml @@ -675,14 +675,12 @@ paths: Supabase2TeamAuth: [] parameters: - $ref: "#/components/parameters/webhookID" - - name: offset + - name: cursor in: query required: false schema: - type: integer - format: int32 - minimum: 0 - default: 0 + type: string + description: Opaque cursor from the X-Next-Cursor response header. - name: limit in: query required: false @@ -722,6 +720,11 @@ paths: responses: "200": description: List of webhook delivery attempts grouped by event. + headers: + X-Next-Cursor: + description: Cursor to pass to the next list request, omitted when there is no next page. + schema: + type: string content: application/json: schema: diff --git a/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/deliveries/page.tsx b/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/deliveries/page.tsx index 989610029..f0609258f 100644 --- a/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/deliveries/page.tsx +++ b/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/deliveries/page.tsx @@ -14,11 +14,10 @@ export default async function WebhookDeliveriesPage({ const { teamSlug, webhookId } = await params prefetch( - trpc.webhooks.listDeliveries.queryOptions({ + trpc.webhooks.listDeliveries.infiniteQueryOptions({ teamSlug, webhookId, limit: 25, - offset: 0, deliveryStatus: 'all', }) ) diff --git a/src/core/modules/webhooks/repository.server.ts b/src/core/modules/webhooks/repository.server.ts index 03ec7349c..58a16c3ec 100644 --- a/src/core/modules/webhooks/repository.server.ts +++ b/src/core/modules/webhooks/repository.server.ts @@ -27,12 +27,17 @@ export interface UpsertWebhookInput { export interface ListWebhookDeliveriesInput { webhookId: string limit: number - offset: number + cursor?: string orderAsc: boolean deliveryStatus?: 'success' | 'failed' eventType?: string } +interface ListWebhookDeliveriesResult { + data: ArgusComponents['schemas']['WebhookDeliveryEvent'][] + nextCursor: string | null +} + export interface GetWebhookDeliveryInput { webhookId: string deliveryId: string @@ -53,7 +58,7 @@ export interface WebhooksRepository { ): Promise> listWebhookDeliveries( input: ListWebhookDeliveriesInput - ): Promise> + ): Promise> getWebhookDelivery( input: GetWebhookDeliveryInput ): Promise> @@ -139,7 +144,7 @@ export function createWebhooksRepository( path: { webhookID: input.webhookId }, query: { limit: input.limit, - offset: input.offset, + cursor: input.cursor, orderAsc: input.orderAsc, deliveryStatus, eventType, @@ -158,7 +163,10 @@ export function createWebhooksRepository( ) } - return ok(response.data ?? []) + return ok({ + data: response.data ?? [], + nextCursor: response.response.headers.get('X-Next-Cursor'), + }) }, async getWebhookDelivery(input) { const response = await deps.infraClient.GET( diff --git a/src/core/server/api/routers/webhooks.ts b/src/core/server/api/routers/webhooks.ts index e2d0a4eec..5a591307e 100644 --- a/src/core/server/api/routers/webhooks.ts +++ b/src/core/server/api/routers/webhooks.ts @@ -105,7 +105,7 @@ export const webhooksRouter = createTRPCRouter({ const result = await ctx.webhooksRepository.listWebhookDeliveries({ webhookId: input.webhookId, limit: input.limit, - offset: input.offset, + cursor: input.cursor, orderAsc: input.orderAsc, deliveryStatus: input.deliveryStatus === 'all' ? undefined : input.deliveryStatus, @@ -133,11 +133,8 @@ export const webhooksRouter = createTRPCRouter({ } return { - groups: result.data.map(toDeliveryEventGroup), - pagination: { - limit: input.limit, - offset: input.offset, - }, + groups: result.data.data.map(toDeliveryEventGroup), + nextCursor: result.data.nextCursor, } }), diff --git a/src/core/server/functions/webhooks/schema.ts b/src/core/server/functions/webhooks/schema.ts index e5c296e29..4ed051aef 100644 --- a/src/core/server/functions/webhooks/schema.ts +++ b/src/core/server/functions/webhooks/schema.ts @@ -54,7 +54,7 @@ export const GetWebhookInputSchema = z.object({ export const ListWebhookDeliveriesInputSchema = z.object({ webhookId: z.uuid(), limit: z.number().int().min(1).max(100).optional().default(25), - offset: z.number().int().min(0).optional().default(0), + cursor: z.string().optional(), orderAsc: z.boolean().optional().default(false), deliveryStatus: DeliveryStatusFilterSchema.optional().default('all'), eventType: z.string().trim().min(1).optional(), diff --git a/src/core/shared/contracts/argus-api.types.ts b/src/core/shared/contracts/argus-api.types.ts index dc0303a7b..9b33faa5b 100644 --- a/src/core/shared/contracts/argus-api.types.ts +++ b/src/core/shared/contracts/argus-api.types.ts @@ -628,7 +628,8 @@ export interface operations { webhookDeliveriesList: { parameters: { query?: { - offset?: number + /** @description Opaque cursor from the X-Next-Cursor response header. */ + cursor?: string limit?: number orderAsc?: boolean /** @description Filter deliveries by delivery status */ @@ -647,6 +648,8 @@ export interface operations { /** @description List of webhook delivery attempts grouped by event. */ 200: { headers: { + /** @description Cursor to pass to the next list request, omitted when there is no next page. */ + 'X-Next-Cursor'?: string [name: string]: unknown } content: { diff --git a/src/features/dashboard/settings/webhooks/detail/deliveries-content.tsx b/src/features/dashboard/settings/webhooks/detail/deliveries-content.tsx index 1e93d7624..0e810d7f7 100644 --- a/src/features/dashboard/settings/webhooks/detail/deliveries-content.tsx +++ b/src/features/dashboard/settings/webhooks/detail/deliveries-content.tsx @@ -1,6 +1,10 @@ 'use client' -import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { + keepPreviousData, + useInfiniteQuery, + useQuery, +} from '@tanstack/react-query' import { useMemo, useState } from 'react' import { z } from 'zod' import { SandboxLifecycleEventTypeSchema } from '@/core/modules/sandboxes/lifecycle-event-types' @@ -245,26 +249,28 @@ export const WebhookDeliveriesContent = ({ const [deliveryStatus, setDeliveryStatus] = useState('all') const [eventType, setEventType] = useState('') - const [offset, setOffset] = useState(0) const [selectedEventId, setSelectedEventId] = useState(null) const trpc = useTRPC() const eventTypeFilter = eventType.trim() || undefined - const deliveriesQuery = useQuery( - trpc.webhooks.listDeliveries.queryOptions( + const deliveriesQuery = useInfiniteQuery( + trpc.webhooks.listDeliveries.infiniteQueryOptions( { teamSlug, webhookId, limit: 25, - offset, deliveryStatus, eventType: eventTypeFilter, }, { + getNextPageParam: (page) => page.nextCursor ?? undefined, placeholderData: keepPreviousData, } ) ) - const groups = deliveriesQuery.data?.groups ?? [] + const groups = useMemo( + () => deliveriesQuery.data?.pages.flatMap((page) => page.groups) ?? [], + [deliveriesQuery.data] + ) const selectedGroup = useMemo( () => groups.find((group) => group.eventId === selectedEventId) ?? groups[0], @@ -284,7 +290,6 @@ export const WebhookDeliveriesContent = ({ ) ) const detailedAttempt = deliveryDetailQuery.data?.delivery ?? selectedAttempt - const hasNextPage = groups.length === 25 const hasActiveFilters = deliveryStatus !== 'all' || Boolean(eventTypeFilter) return ( @@ -301,7 +306,7 @@ export const WebhookDeliveriesContent = ({ value={deliveryStatus} onChange={(value) => { setDeliveryStatus(value) - setOffset(0) + setSelectedEventId(null) }} /> { setEventType(event.target.value) - setOffset(0) + setSelectedEventId(null) }} />
@@ -416,17 +421,13 @@ export const WebhookDeliveriesContent = ({
-
From 1e62dc806499400c22981dea70e494ce8996ea6a Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Thu, 14 May 2026 14:04:11 -0400 Subject: [PATCH 07/13] Fix warning --- .../sandboxes/monitoring/charts/team-metrics-chart/index.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/index.tsx b/src/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/index.tsx index d0ac1cbd5..2af4f9757 100644 --- a/src/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/index.tsx +++ b/src/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/index.tsx @@ -12,6 +12,7 @@ import { GridComponent, MarkLineComponent, MarkPointComponent, + ToolboxComponent, TooltipComponent, } from 'echarts/components' import * as echarts from 'echarts/core' @@ -42,6 +43,7 @@ echarts.use([ MarkPointComponent, MarkLineComponent, AxisPointerComponent, + ToolboxComponent, CanvasRenderer, ]) From 9bff86020f9a8f7f34a330a09b57329c90d8038d Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Thu, 14 May 2026 15:10:38 -0400 Subject: [PATCH 08/13] feat(webhooks): enhance webhook detail header and introduce IdBadge component - Refactored WebhookDetailHeader to utilize the new IdBadge component for displaying and copying webhook IDs. - Added functionality to show the latest event timestamp and created date in a more user-friendly format. - Introduced IdBadge component to encapsulate ID display and copy functionality, improving code reusability and readability. - Updated shared index to export the new IdBadge component. These changes aim to improve the user experience in managing webhook details by providing clearer information and streamlined interactions. --- .../settings/webhooks/detail/header.tsx | 92 +++++++++---------- src/features/dashboard/shared/id-badge.tsx | 51 ++++++++++ src/features/dashboard/shared/index.ts | 1 + 3 files changed, 95 insertions(+), 49 deletions(-) create mode 100644 src/features/dashboard/shared/id-badge.tsx diff --git a/src/features/dashboard/settings/webhooks/detail/header.tsx b/src/features/dashboard/settings/webhooks/detail/header.tsx index 2df47aa38..4081f8925 100644 --- a/src/features/dashboard/settings/webhooks/detail/header.tsx +++ b/src/features/dashboard/settings/webhooks/detail/header.tsx @@ -2,12 +2,14 @@ import { useSuspenseQuery } from '@tanstack/react-query' import { WebhookEventBadges } from '@/features/dashboard/settings/webhooks/event-badges' -import { useClipboard } from '@/lib/hooks/use-clipboard' +import { IdBadge } from '@/features/dashboard/shared' import { defaultSuccessToast, toast } from '@/lib/hooks/use-toast' +import { + formatChartTimestampLocal, + formatDate, + formatUTCTimestamp, +} from '@/lib/utils/formatting' import { useTRPC } from '@/trpc/client' -import { Badge } from '@/ui/primitives/badge' -import { Button } from '@/ui/primitives/button' -import { CheckIcon, CopyIcon } from '@/ui/primitives/icons' import { DetailsItem, DetailsRow } from '../../../layouts/details-row' type WebhookDetailHeaderProps = { @@ -15,48 +17,6 @@ type WebhookDetailHeaderProps = { webhookId: string } -const formatDate = (value?: string) => { - if (!value) return '-' - - return new Date(value).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }) -} - -const getWebhookIdBadgeLabel = (id: string) => - `${id.slice(0, 6)}...${id.slice(-6)}` - -const WebhookIdBadge = ({ id }: { id: string }) => { - const [wasCopied, copy] = useClipboard(1500) - - const handleCopy = async () => { - await copy(id) - toast(defaultSuccessToast('Webhook ID copied')) - } - - return ( - - {getWebhookIdBadgeLabel(id)} - - - ) -} - export const WebhookDetailHeader = ({ teamSlug, webhookId, @@ -65,22 +25,56 @@ export const WebhookDetailHeader = ({ const { data } = useSuspenseQuery( trpc.webhooks.get.queryOptions({ teamSlug, webhookId }) ) + const latestDeliveryQuery = useSuspenseQuery( + trpc.webhooks.listDeliveries.queryOptions({ + teamSlug, + webhookId, + limit: 1, + deliveryStatus: 'all', + }) + ) const { webhook } = data + const latestAttempt = + latestDeliveryQuery.data?.groups[0]?.latestAttempt ?? null + const latestEventTimestamp = latestAttempt?.timestamp + const latestEventLabel = latestEventTimestamp + ? `${formatChartTimestampLocal(latestEventTimestamp, true)}, ${formatChartTimestampLocal(latestEventTimestamp)}` + : '-' + const latestEventTitle = latestEventTimestamp + ? formatUTCTimestamp(new Date(latestEventTimestamp)) + : undefined + const handleIdCopied = () => + toast(defaultSuccessToast('Webhook ID copied to clipboard')) return (
- + - -

{formatDate(webhook.createdAt)}

+ +

+ {webhook.url} +

+ +

{formatDate(new Date(webhook.createdAt), 'MMM d, yyyy') ?? '-'}

+
+ +

{latestEventLabel}

+
) diff --git a/src/features/dashboard/shared/id-badge.tsx b/src/features/dashboard/shared/id-badge.tsx new file mode 100644 index 000000000..2685ae179 --- /dev/null +++ b/src/features/dashboard/shared/id-badge.tsx @@ -0,0 +1,51 @@ +'use client' + +import type { MouseEvent } from 'react' +import { useClipboard } from '@/lib/hooks/use-clipboard' +import { Badge } from '@/ui/primitives/badge' +import { Button } from '@/ui/primitives/button' +import { CheckIcon, CopyIcon } from '@/ui/primitives/icons' + +const getIdBadgeLabel = (id: string): string => { + if (id.length <= 8) return id.toUpperCase() + return `${id.slice(0, 4)}...${id.slice(-4)}`.toUpperCase() +} + +interface IdBadgeProps { + id: string + copyAriaLabel?: string + onCopied?: () => void +} + +export const IdBadge = ({ + id, + copyAriaLabel = 'Copy full ID', + onCopied, +}: IdBadgeProps) => { + const [wasCopied, copy] = useClipboard() + const displayId = getIdBadgeLabel(id) + + const handleCopy = async (event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + + await copy(id) + onCopied?.() + } + + return ( + + {displayId} + + + ) +} diff --git a/src/features/dashboard/shared/index.ts b/src/features/dashboard/shared/index.ts index acbd7102a..9722c392c 100644 --- a/src/features/dashboard/shared/index.ts +++ b/src/features/dashboard/shared/index.ts @@ -1 +1,2 @@ +export { IdBadge } from './id-badge' export { UserAvatar } from './user-avatar' From 730292bb5cc732a3a7a4b04f710a502b403a8a0e Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Thu, 14 May 2026 15:13:27 -0400 Subject: [PATCH 09/13] Use URL state for webhook stats range --- .../[webhookId]/(tabs)/overview/page.tsx | 17 ++++++++++--- .../webhooks/detail/overview-content.tsx | 25 ++++++++++++++----- .../settings/webhooks/detail/stats-range.ts | 15 +++++++++++ 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/overview/page.tsx b/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/overview/page.tsx index c68ef5028..f581382c3 100644 --- a/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/overview/page.tsx +++ b/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/overview/page.tsx @@ -1,5 +1,9 @@ import { WebhookOverviewContent } from '@/features/dashboard/settings/webhooks/detail' -import { getWebhookStatsRange } from '@/features/dashboard/settings/webhooks/detail/stats-range' +import { + DEFAULT_WEBHOOK_STATS_RANGE, + getWebhookStatsRange, + loadWebhookStatsRangeParams, +} from '@/features/dashboard/settings/webhooks/detail/stats-range' import { prefetch, trpc } from '@/trpc/server' type WebhookOverviewPageProps = { @@ -7,19 +11,23 @@ type WebhookOverviewPageProps = { teamSlug: string webhookId: string }> + searchParams: Promise> } export default async function WebhookOverviewPage({ params, + searchParams, }: WebhookOverviewPageProps) { const { teamSlug, webhookId } = await params - const range = getWebhookStatsRange('24h') + const { range: rangeParam } = await loadWebhookStatsRangeParams(searchParams) + const range = rangeParam ?? DEFAULT_WEBHOOK_STATS_RANGE + const rangeBounds = getWebhookStatsRange(range) prefetch( trpc.webhooks.getDeliveryStats.queryOptions({ teamSlug, webhookId, - ...range, + ...rangeBounds, }) ) @@ -27,7 +35,8 @@ export default async function WebhookOverviewPage({ ) } diff --git a/src/features/dashboard/settings/webhooks/detail/overview-content.tsx b/src/features/dashboard/settings/webhooks/detail/overview-content.tsx index b9c697833..4918323c5 100644 --- a/src/features/dashboard/settings/webhooks/detail/overview-content.tsx +++ b/src/features/dashboard/settings/webhooks/detail/overview-content.tsx @@ -1,7 +1,8 @@ 'use client' import { useSuspenseQuery } from '@tanstack/react-query' -import { useState } from 'react' +import { useQueryStates } from 'nuqs' +import { useMemo } from 'react' import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts' import { useTRPC } from '@/trpc/client' import { @@ -20,6 +21,7 @@ import { import { WebhookRangeSelector } from './range-selector' import { getWebhookStatsRange, + webhookStatsRangeParams, type WebhookStatsRange, type WebhookStatsRangeBounds, } from './stats-range' @@ -27,6 +29,7 @@ import { type WebhookOverviewContentProps = { teamSlug: string webhookId: string + initialRange: WebhookStatsRange initialRangeBounds: WebhookStatsRangeBounds } @@ -86,11 +89,22 @@ const EmptyChartState = ({ label }: { label: string }) => ( export const WebhookOverviewContent = ({ teamSlug, webhookId, + initialRange, initialRangeBounds, }: WebhookOverviewContentProps) => { - const [range, setRange] = useState('24h') - const [rangeBounds, setRangeBounds] = - useState(initialRangeBounds) + const [rangeParams, setRangeParams] = useQueryStates( + webhookStatsRangeParams, + { + history: 'push', + shallow: true, + } + ) + const range = rangeParams.range ?? initialRange + const rangeBounds = useMemo( + () => + range === initialRange ? initialRangeBounds : getWebhookStatsRange(range), + [range, initialRange, initialRangeBounds] + ) const trpc = useTRPC() const { data } = useSuspenseQuery( trpc.webhooks.getDeliveryStats.queryOptions({ @@ -107,8 +121,7 @@ export const WebhookOverviewContent = ({ : '0%' const hasBuckets = stats.buckets.length > 0 const handleRangeChange = (nextRange: WebhookStatsRange) => { - setRange(nextRange) - setRangeBounds(getWebhookStatsRange(nextRange)) + setRangeParams({ range: nextRange }) } return ( diff --git a/src/features/dashboard/settings/webhooks/detail/stats-range.ts b/src/features/dashboard/settings/webhooks/detail/stats-range.ts index 274aba4e1..97e3639a7 100644 --- a/src/features/dashboard/settings/webhooks/detail/stats-range.ts +++ b/src/features/dashboard/settings/webhooks/detail/stats-range.ts @@ -1,3 +1,7 @@ +import { createLoader, parseAsStringEnum } from 'nuqs/server' + +const WEBHOOK_STATS_RANGE_VALUES = ['24h', '7d'] as const + type WebhookStatsRange = '24h' | '7d' type WebhookStatsRangeBounds = { @@ -5,6 +9,14 @@ type WebhookStatsRangeBounds = { end: string } +const DEFAULT_WEBHOOK_STATS_RANGE: WebhookStatsRange = '24h' + +const webhookStatsRangeParams = { + range: parseAsStringEnum(WEBHOOK_STATS_RANGE_VALUES), +} + +const loadWebhookStatsRangeParams = createLoader(webhookStatsRangeParams) + const WEBHOOK_STATS_RANGE_LABELS: Record = { '24h': 'Last 24 hours', '7d': 'Last 7 days', @@ -31,7 +43,10 @@ const getWebhookStatsRange = ( } export { + DEFAULT_WEBHOOK_STATS_RANGE, getWebhookStatsRange, + loadWebhookStatsRangeParams, + webhookStatsRangeParams, WEBHOOK_STATS_RANGE_LABELS, type WebhookStatsRange, type WebhookStatsRangeBounds, From 8ea57e9b162d5c8629bf0f187349c012305c4daa Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Thu, 14 May 2026 15:21:19 -0400 Subject: [PATCH 10/13] refactor(webhooks): simplify webhook name display in settings - Removed the Button wrapper around the webhook name link for a cleaner presentation. - Updated the Link component to include a title attribute for better accessibility and user experience. These changes enhance the clarity and usability of the webhook settings interface. --- .../settings/webhooks/detail/overview-content.tsx | 2 +- .../dashboard/settings/webhooks/table-row.tsx | 15 +++++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/features/dashboard/settings/webhooks/detail/overview-content.tsx b/src/features/dashboard/settings/webhooks/detail/overview-content.tsx index 4918323c5..fb0ad5bb9 100644 --- a/src/features/dashboard/settings/webhooks/detail/overview-content.tsx +++ b/src/features/dashboard/settings/webhooks/detail/overview-content.tsx @@ -21,9 +21,9 @@ import { import { WebhookRangeSelector } from './range-selector' import { getWebhookStatsRange, - webhookStatsRangeParams, type WebhookStatsRange, type WebhookStatsRangeBounds, + webhookStatsRangeParams, } from './stats-range' type WebhookOverviewContentProps = { diff --git a/src/features/dashboard/settings/webhooks/table-row.tsx b/src/features/dashboard/settings/webhooks/table-row.tsx index ae895e4c2..c8ef13b0a 100644 --- a/src/features/dashboard/settings/webhooks/table-row.tsx +++ b/src/features/dashboard/settings/webhooks/table-row.tsx @@ -81,14 +81,13 @@ const WebhookNameAndUrl = ({ href, name, url }: WebhookNameAndUrlProps) => {
- + + {name} +