diff --git a/spec/openapi.argus.yaml b/spec/openapi.argus.yaml index 87baf4dca..4ddb15569 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,146 @@ 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: cursor + in: query + required: false + schema: + type: string + description: Opaque cursor from the X-Next-Cursor response header. + - 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. + 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: + 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..f0609258f --- /dev/null +++ b/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/deliveries/page.tsx @@ -0,0 +1,26 @@ +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.infiniteQueryOptions({ + teamSlug, + webhookId, + limit: 25, + 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..af168afba --- /dev/null +++ b/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/layout.tsx @@ -0,0 +1,33 @@ +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 })) + + 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..2be576401 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/overview/page.tsx @@ -0,0 +1,46 @@ +import { WebhookOverviewContent } from '@/features/dashboard/settings/webhooks/detail' +import { + getValidWebhookStatsBounds, + getWebhookStatsApiBounds, + getWebhookStatsRange, + loadWebhookStatsTimeframeParams, +} from '@/features/dashboard/settings/webhooks/detail/stats-range' +import { prefetch, trpc } from '@/trpc/server' + +type WebhookOverviewPageProps = { + params: Promise<{ + teamSlug: string + webhookId: string + }> + searchParams: Promise> +} + +export default async function WebhookOverviewPage({ + params, + searchParams, +}: WebhookOverviewPageProps) { + const { teamSlug, webhookId } = await params + const timeframeParams = await loadWebhookStatsTimeframeParams(searchParams) + const fallbackRangeBounds = getWebhookStatsRange('this-week') + const rangeBounds = getValidWebhookStatsBounds({ + start: timeframeParams.start ?? fallbackRangeBounds.start, + end: timeframeParams.end ?? fallbackRangeBounds.end, + }) + const apiRangeBounds = getWebhookStatsApiBounds(rangeBounds) + + prefetch( + trpc.webhooks.getDeliveryStats.queryOptions({ + teamSlug, + webhookId, + ...apiRangeBounds, + }) + ) + + 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..ce22f9ac7 100644 --- a/src/configs/layout.ts +++ b/src/configs/layout.ts @@ -95,6 +95,24 @@ const DASHBOARD_LAYOUT_CONFIGS: Record< title: 'Webhooks', type: 'default', }), + '/dashboard/*/webhooks/*/*': (pathname) => { + const parts = pathname.split('/') + const teamSlug = parts[2]! + + return { + title: [ + { + label: 'Webhooks', + href: PROTECTED_URLS.WEBHOOKS(teamSlug), + }, + { label: 'Webhook' }, + ], + type: 'custom', + 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..58a16c3ec 100644 --- a/src/core/modules/webhooks/repository.server.ts +++ b/src/core/modules/webhooks/repository.server.ts @@ -24,10 +24,47 @@ export interface UpsertWebhookInput { enabled: boolean } +export interface ListWebhookDeliveriesInput { + webhookId: string + limit: 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 +} + +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 +104,127 @@ 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, + cursor: input.cursor, + 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({ + data: response.data ?? [], + nextCursor: response.response.headers.get('X-Next-Cursor'), + }) + }, + 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..5a591307e 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,131 @@ 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, + cursor: input.cursor, + 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.data.map(toDeliveryEventGroup), + nextCursor: result.data.nextCursor, + } + }), + + 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..4ed051aef 100644 --- a/src/core/server/functions/webhooks/schema.ts +++ b/src/core/server/functions/webhooks/schema.ts @@ -45,8 +45,58 @@ 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), + cursor: z.string().optional(), + 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(), + }) + .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 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..9b33faa5b 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,97 @@ export interface operations { 500: components['responses']['500'] } } + webhookDeliveriesList: { + parameters: { + query?: { + /** @description Opaque cursor from the X-Next-Cursor response header. */ + cursor?: string + 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: { + /** @description Cursor to pass to the next list request, omitted when there is no next page. */ + 'X-Next-Cursor'?: string + [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/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/header/started-at.tsx b/src/features/dashboard/sandbox/header/started-at.tsx index 2e6614ba1..119c66dfa 100644 --- a/src/features/dashboard/sandbox/header/started-at.tsx +++ b/src/features/dashboard/sandbox/header/started-at.tsx @@ -1,41 +1,15 @@ 'use client' -import CopyButton from '@/ui/copy-button' +import { Timestamp } from '@/features/dashboard/shared' import { useSandboxContext } from '../context' -export default function StartedAt() { +const StartedAt = () => { const { sandboxLifecycle } = useSandboxContext() const startedAt = sandboxLifecycle?.createdAt - if (!startedAt) { - return null - } + if (!startedAt) return null - const date = new Date(startedAt) - const now = new Date() - const isToday = date.toDateString() === now.toDateString() - const isYesterday = - date.toDateString() === - new Date(now.setDate(now.getDate() - 1)).toDateString() - - const prefix = isToday - ? 'Today' - : isYesterday - ? 'Yesterday' - : date.toLocaleDateString() - - const timeStr = date.toLocaleTimeString([], { - hour: 'numeric', - minute: '2-digit', - second: '2-digit', - }) - - return ( -
-

- {prefix}, {timeStr} -

- -
- ) + return } + +export default StartedAt 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/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, ]) 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..24f3f040a --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/deliveries-content.tsx @@ -0,0 +1,428 @@ +'use client' + +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' +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 [selectedEventId, setSelectedEventId] = useState(null) + const trpc = useTRPC() + const eventTypeFilter = eventType.trim() || undefined + const deliveriesQuery = useInfiniteQuery( + trpc.webhooks.listDeliveries.infiniteQueryOptions( + { + teamSlug, + webhookId, + limit: 25, + deliveryStatus, + eventType: eventTypeFilter, + }, + { + getNextPageParam: (page) => page.nextCursor ?? undefined, + placeholderData: keepPreviousData, + } + ) + ) + const groups = useMemo( + () => deliveriesQuery.data?.pages.flatMap((page) => page.groups) ?? [], + [deliveriesQuery.data] + ) + 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 hasActiveFilters = deliveryStatus !== 'all' || Boolean(eventTypeFilter) + + return ( +
+
+ { + setDeliveryStatus(value) + setSelectedEventId(null) + }} + /> + { + setEventType(event.target.value) + setSelectedEventId(null) + }} + /> +
+ +
+ + + + + + + + + + + + + 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..5223ee1fa --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/header.tsx @@ -0,0 +1,63 @@ +'use client' + +import { useSuspenseQuery } from '@tanstack/react-query' +import { WebhookEventBadges } from '@/features/dashboard/settings/webhooks/event-badges' +import { Timestamp } from '@/features/dashboard/shared' +import { useTRPC } from '@/trpc/client' +import { DetailsItem, DetailsRow } from '../../../layouts/details-row' + +type WebhookDetailHeaderProps = { + teamSlug: string + webhookId: string +} + +export const WebhookDetailHeader = ({ + teamSlug, + webhookId, +}: WebhookDetailHeaderProps) => { + const trpc = useTRPC() + 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 + + return ( +
+ + +

+ {webhook.url} +

+
+ +
+ +
+
+ + + + + {latestAttempt ? ( + + ) : ( +

-

+ )} +
+
+
+ ) +} 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..aed45d0fc --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/overview-content.tsx @@ -0,0 +1,240 @@ +'use client' + +import { useSuspenseQuery } from '@tanstack/react-query' +import { useQueryStates } from 'nuqs' +import { useMemo } 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 { WebhookRangeSelector } from './range-selector' +import { + getValidWebhookStatsBounds, + getWebhookStatsApiBounds, + getWebhookStatsRange, + getWebhookStatsRangeFromBounds, + type WebhookStatsRange, + type WebhookStatsRangeBounds, + webhookStatsTimeframeParams, +} from './stats-range' + +type WebhookOverviewContentProps = { + teamSlug: string + webhookId: string + initialRangeBounds: WebhookStatsRangeBounds +} + +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, + initialRangeBounds, +}: WebhookOverviewContentProps) => { + const [timeframeParams, setTimeframeParams] = useQueryStates( + webhookStatsTimeframeParams, + { + history: 'push', + shallow: true, + } + ) + const rangeBounds = useMemo( + () => + getValidWebhookStatsBounds({ + start: timeframeParams.start ?? initialRangeBounds.start, + end: timeframeParams.end ?? initialRangeBounds.end, + }), + [timeframeParams.start, timeframeParams.end, initialRangeBounds] + ) + const apiRangeBounds = useMemo( + () => getWebhookStatsApiBounds(rangeBounds), + [rangeBounds] + ) + const range = getWebhookStatsRangeFromBounds(rangeBounds) + const trpc = useTRPC() + const { data } = useSuspenseQuery( + trpc.webhooks.getDeliveryStats.queryOptions({ + teamSlug, + webhookId, + ...apiRangeBounds, + }) + ) + 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 + const handleRangeChange = (nextRange: WebhookStatsRange) => { + setTimeframeParams(getWebhookStatsRange(nextRange)) + } + + return ( +
+
+ +
+ +
+ + + + +
+ +
+ + + {hasBuckets ? ( + + + + + + } /> + + + + + ) : ( + + )} + + + + + + {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..b22683afd --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/range-selector.tsx @@ -0,0 +1,45 @@ +'use client' + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/ui/primitives/select' +import { + isWebhookStatsRange, + WEBHOOK_STATS_RANGE_OPTIONS, + type WebhookStatsRange, +} from './stats-range' + +type WebhookRangeSelectorProps = { + value: WebhookStatsRange + onChange: (value: WebhookStatsRange) => void +} + +export const WebhookRangeSelector = ({ + value, + onChange, +}: WebhookRangeSelectorProps) => { + const handleValueChange = (nextValue: string) => { + if (!isWebhookStatsRange(nextValue)) return + + onChange(nextValue) + } + + return ( + + ) +} 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..b544264e8 --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/stats-range.ts @@ -0,0 +1,130 @@ +import { createLoader, parseAsInteger } from 'nuqs/server' + +type WebhookStatsRangeBounds = { + start: number + end: number +} + +type WebhookStatsApiBounds = { + start: string + end: string +} + +const webhookStatsTimeframeParams = { + start: parseAsInteger, + end: parseAsInteger, +} + +const loadWebhookStatsTimeframeParams = createLoader( + webhookStatsTimeframeParams +) + +const getStableNow = () => { + const now = Date.now() + return Math.floor(now / 1_000) * 1_000 +} + +const getStartOfDay = (timestamp: number) => { + const date = new Date(timestamp) + date.setHours(0, 0, 0, 0) + return date.getTime() +} + +const getStartOfWeek = (timestamp: number) => { + const date = new Date(timestamp) + const daysSinceMonday = (date.getDay() + 6) % 7 + date.setDate(date.getDate() - daysSinceMonday) + date.setHours(0, 0, 0, 0) + return date.getTime() +} + +const WEBHOOK_STATS_RANGE_OPTIONS = [ + { + value: '4h', + label: 'Last 4 hours', + getStart: (end: number) => end - 4 * 60 * 60 * 1000, + }, + { + value: '12h', + label: 'Last 12 hours', + getStart: (end: number) => end - 12 * 60 * 60 * 1000, + }, + { value: 'today', label: 'Today', getStart: getStartOfDay }, + { value: 'this-week', label: 'This week', getStart: getStartOfWeek }, +] as const + +const WEBHOOK_STATS_RANGE_VALUES = WEBHOOK_STATS_RANGE_OPTIONS.map( + (option) => option.value +) as [WebhookStatsRange, ...WebhookStatsRange[]] + +type WebhookStatsRange = (typeof WEBHOOK_STATS_RANGE_OPTIONS)[number]['value'] + +const DEFAULT_WEBHOOK_STATS_RANGE: WebhookStatsRange = 'this-week' + +const getWebhookStatsRangeOption = (range: WebhookStatsRange) => { + const matchedOption = WEBHOOK_STATS_RANGE_OPTIONS.find( + (option) => option.value === range + ) + if (matchedOption) return matchedOption + + return WEBHOOK_STATS_RANGE_OPTIONS[0] +} + +const isWebhookStatsRange = (range: string): range is WebhookStatsRange => + WEBHOOK_STATS_RANGE_OPTIONS.some((option) => option.value === range) + +// Builds millisecond stats bounds from a range, e.g. "4h" -> { start: 177..., end: 177... }. +const getWebhookStatsRange = ( + range: WebhookStatsRange +): WebhookStatsRangeBounds => { + const end = getStableNow() + const option = getWebhookStatsRangeOption(range) + + return { + start: option.getStart(end), + end, + } +} + +const getWebhookStatsApiBounds = ({ + start, + end, +}: WebhookStatsRangeBounds): WebhookStatsApiBounds => ({ + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), +}) + +const getWebhookStatsRangeFromBounds = ({ + start, + end, +}: WebhookStatsRangeBounds): WebhookStatsRange => { + return ( + WEBHOOK_STATS_RANGE_OPTIONS.find( + (option) => Math.abs(option.getStart(end) - start) < 60_000 + )?.value ?? DEFAULT_WEBHOOK_STATS_RANGE + ) +} + +const getValidWebhookStatsBounds = ({ + start, + end, +}: Partial): WebhookStatsRangeBounds => + start && end && end > start + ? { start, end } + : getWebhookStatsRange(DEFAULT_WEBHOOK_STATS_RANGE) + +export { + DEFAULT_WEBHOOK_STATS_RANGE, + getWebhookStatsApiBounds, + getWebhookStatsRange, + getWebhookStatsRangeFromBounds, + getValidWebhookStatsBounds, + isWebhookStatsRange, + loadWebhookStatsTimeframeParams, + webhookStatsTimeframeParams, + WEBHOOK_STATS_RANGE_OPTIONS, + WEBHOOK_STATS_RANGE_VALUES, + type WebhookStatsApiBounds, + type WebhookStatsRange, + type WebhookStatsRangeBounds, +} 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/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 c0b9c824b..c8ef13b0a 100644 --- a/src/features/dashboard/settings/webhooks/table-row.tsx +++ b/src/features/dashboard/settings/webhooks/table-row.tsx @@ -1,11 +1,11 @@ 'use client' -import { Fragment, useState } from 'react' -import { SandboxLifecycleEventTypeSchema } from '@/core/modules/sandboxes/lifecycle-event-types' +import Link from 'next/link' +import { useState } from 'react' +import { PROTECTED_URLS } from '@/configs/urls' 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, @@ -25,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' @@ -47,6 +42,7 @@ type WebhookRowActionsProps = { } type WebhookNameAndUrlProps = { + href: string name: string url: string } @@ -59,7 +55,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 @@ -85,7 +81,13 @@ const WebhookNameAndUrl = ({ name, url }: WebhookNameAndUrlProps) => {
-

{name}

+ + {name} + + + ) +} diff --git a/src/features/dashboard/shared/index.ts b/src/features/dashboard/shared/index.ts index acbd7102a..a18a97d6f 100644 --- a/src/features/dashboard/shared/index.ts +++ b/src/features/dashboard/shared/index.ts @@ -1 +1,3 @@ +export { IdBadge } from './id-badge' +export { Timestamp } from './timestamp' export { UserAvatar } from './user-avatar' diff --git a/src/features/dashboard/shared/timestamp.tsx b/src/features/dashboard/shared/timestamp.tsx new file mode 100644 index 000000000..f419279f5 --- /dev/null +++ b/src/features/dashboard/shared/timestamp.tsx @@ -0,0 +1,36 @@ +'use client' + +import CopyButton from '@/ui/copy-button' + +type TimestampProps = { + value: string +} + +export const Timestamp = ({ value }: TimestampProps) => { + const date = new Date(value) + const now = new Date() + const yesterday = new Date() + yesterday.setDate(now.getDate() - 1) + + const isToday = date.toDateString() === now.toDateString() + const isYesterday = date.toDateString() === yesterday.toDateString() + const prefix = isToday + ? 'Today' + : isYesterday + ? 'Yesterday' + : date.toLocaleDateString() + const timeStr = date.toLocaleTimeString([], { + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + }) + + return ( +
+

+ {prefix}, {timeStr} +

+ +
+ ) +} 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) => ( )