diff --git a/spec/openapi.argus.yaml b/spec/openapi.argus.yaml index 87baf4dca..c8e17631b 100644 --- a/spec/openapi.argus.yaml +++ b/spec/openapi.argus.yaml @@ -219,7 +219,6 @@ components: - teamId - name - createdAt - - teamId - url - enabled - events @@ -266,6 +265,190 @@ components: type: string description: Secret used to sign the webhook payloads + WebhookDelivery: + description: Webhook delivery attempt + required: + - id + - teamId + - webhookId + - eventId + - sandboxId + - eventType + - status + - durationMs + - requestBody + - requestHeaders + - requestUrl + - 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 + status: + type: string + enum: [success, failed] + description: Delivery attempt status + durationMs: + type: integer + format: int32 + description: Delivery request duration in milliseconds + requestBody: + type: string + description: Serialized webhook request body + requestHeaders: + type: string + description: JSON-encoded request headers with sensitive values redacted + requestUrl: + type: string + format: uri + description: URL attempted for this delivery + responseBody: + type: string + nullable: true + description: Truncated response body, if a response was received + responseHeaders: + type: string + nullable: true + description: JSON-encoded response headers, if a response was received + responseHttpStatusCode: + type: integer + format: int32 + nullable: true + description: HTTP response status code, if a response was received + errorClass: + type: string + nullable: true + enum: + - http_error + - dns_error + - timeout + - transport_error + - request_error + - signature_error + - canceled + 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 + - durationMs + properties: + buckets: + type: array + items: + $ref: "#/components/schemas/WebhookDeliveryStatsBucket" + total: + type: integer + format: int64 + failed: + type: integer + format: int64 + durationMs: + $ref: "#/components/schemas/WebhookDeliveryDurationStats" + + WebhookDeliveryDurationStats: + description: Webhook delivery duration statistics in milliseconds + required: + - minimum + - average + - maximum + properties: + minimum: + type: number + format: double + average: + type: number + format: double + maximum: + type: number + format: double + + WebhookDeliveryStatsBucket: + description: Webhook delivery stats for a time bucket + required: + - timestamp + - total + - failed + - durationMs + properties: + timestamp: + type: string + format: date-time + total: + type: integer + format: int64 + failed: + type: integer + format: int64 + durationMs: + $ref: "#/components/schemas/WebhookDeliveryDurationStats" + + WebhookDeliveryGroup: + 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" + + WebhookDeliveriesListPayload: + description: Paginated webhook delivery attempts grouped by event + required: + - data + - nextCursor + properties: + data: + type: array + items: + $ref: "#/components/schemas/WebhookDeliveryGroup" + nextCursor: + type: string + nullable: true + description: Cursor to pass to the next list request, or null when there is no next page. + paths: /health: get: @@ -309,6 +492,16 @@ paths: schema: type: boolean default: false + - name: types + in: query + required: false + style: form + explode: true + schema: + type: array + items: + type: string + description: Filter events to the provided event types responses: "200": description: Successfully returned the sandbox events @@ -318,6 +511,8 @@ paths: type: array items: $ref: "#/components/schemas/SandboxEvent" + "400": + $ref: "#/components/responses/400" "404": $ref: "#/components/responses/404" "401": @@ -357,6 +552,16 @@ paths: schema: type: boolean default: false + - name: types + in: query + required: false + style: form + explode: true + schema: + type: array + items: + type: string + description: Filter events to the provided event types responses: "200": description: Successfully returned the sandbox events @@ -366,6 +571,8 @@ paths: type: array items: $ref: "#/components/schemas/SandboxEvent" + "400": + $ref: "#/components/responses/400" "404": $ref: "#/components/responses/404" "401": @@ -502,4 +709,126 @@ paths: "401": $ref: "#/components/responses/401" "500": - $ref: "#/components/responses/500" \ No newline at end of file + $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 previous response's nextCursor field. + - 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: start + in: query + required: false + schema: + type: string + format: date-time + description: Include deliveries at or after this timestamp. + - name: end + in: query + required: false + schema: + type: string + format: date-time + description: Include deliveries before this timestamp. + - name: deliveryStatus + in: query + required: false + style: form + explode: false + schema: + type: array + items: + type: string + enum: [success, failed] + description: Filter deliveries by delivery status + - name: eventType + in: query + required: false + style: form + explode: false + 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: + $ref: "#/components/schemas/WebhookDeliveriesListPayload" + "400": + $ref: "#/components/responses/400" + "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" 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..0ae08599a --- /dev/null +++ b/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/deliveries/page.tsx @@ -0,0 +1,25 @@ +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, + }) + ) + + 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..d0eaf8cff --- /dev/null +++ b/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/layout.tsx @@ -0,0 +1,36 @@ +import { notFound } from 'next/navigation' +import { INCLUDE_ARGUS } from '@/configs/env-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 })) + prefetch( + trpc.webhooks.listDeliveries.queryOptions({ teamSlug, webhookId, limit: 1 }) + ) + + 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 6b3de107b..15cd4032f 100644 --- a/src/configs/layout.ts +++ b/src/configs/layout.ts @@ -104,6 +104,10 @@ const DASHBOARD_LAYOUT_CONFIGS: Record< title: 'Webhooks', type: 'default', }), + '/dashboard/*/webhooks/*/overview': (pathname) => + webhookDetailLayoutConfig(pathname), + '/dashboard/*/webhooks/*/deliveries': (pathname) => + webhookDetailLayoutConfig(pathname), // team '/dashboard/*/general': () => ({ @@ -199,6 +203,28 @@ function templateDetailLayoutConfig(pathname: string): DashboardLayoutConfig { } } +function webhookDetailLayoutConfig(pathname: string): DashboardLayoutConfig { + 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: webhookIdSliced }, + ], + type: 'custom', + copyValue: webhookId, + custom: { + includeHeaderBottomStyles: true, + }, + } +} + /** * Returns the layout config for a given dashboard pathname. * @param pathname - The current route pathname diff --git a/src/configs/sidebar.ts b/src/configs/sidebar.ts index be3d4ce34..995a02b3e 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 3f5ab5201..8fad936af 100644 --- a/src/configs/urls.ts +++ b/src/configs/urls.ts @@ -39,6 +39,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`, @@ -79,6 +85,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 b9c0abf02..62a145256 100644 --- a/src/core/modules/webhooks/repository.server.ts +++ b/src/core/modules/webhooks/repository.server.ts @@ -3,7 +3,10 @@ import 'server-only' import { authHeaders } from '@/configs/api' import type { UpsertWebhookInput } from '@/core/server/functions/webhooks/schema' import { infra } from '@/core/shared/clients/api' -import type { components as ArgusComponents } from '@/core/shared/contracts/argus-api.types' +import type { + components as ArgusComponents, + operations as ArgusOperations, +} from '@/core/shared/contracts/argus-api.types' import { repoErrorFromHttp } from '@/core/shared/errors' import type { TeamRequestScope } from '@/core/shared/repository-scope' import { err, ok, type RepoResult } from '@/core/shared/result' @@ -15,10 +18,34 @@ type WebhooksRepositoryDeps = { export type WebhooksScope = TeamRequestScope +type ListWebhookDeliveriesInput = NonNullable< + ArgusOperations['webhookDeliveriesList']['parameters']['query'] +> & { + webhookId: string +} + +type ListWebhookDeliveriesResult = + ArgusComponents['schemas']['WebhookDeliveriesListPayload'] + +type GetWebhookDeliveryStatsInput = NonNullable< + ArgusOperations['webhookDeliveryStats']['parameters']['query'] +> & { + webhookId: string +} + export interface WebhooksRepository { listWebhooks(): Promise< RepoResult > + getWebhook( + webhookId: string + ): Promise> + listWebhookDeliveries( + input: ListWebhookDeliveriesInput + ): Promise> + getWebhookDeliveryStats( + input: GetWebhookDeliveryStatsInput + ): Promise> upsertWebhook(input: UpsertWebhookInput): Promise> deleteWebhook(webhookId: string): Promise> updateWebhookSecret( @@ -58,6 +85,97 @@ 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 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, + start: input.start, + end: input.end, + deliveryStatus: input.deliveryStatus, + eventType: input.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?.data ?? [], + nextCursor: response.data?.nextCursor ?? null, + }) + }, + 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 7adfbc1ff..85cd59da5 100644 --- a/src/core/server/api/routers/webhooks.ts +++ b/src/core/server/api/routers/webhooks.ts @@ -3,12 +3,34 @@ import { throwTRPCErrorFromRepoError } from '@/core/server/adapters/errors' import { withTeamAuthedRequestRepository } from '@/core/server/api/middlewares/repository' import { DeleteWebhookInputSchema, + 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 WebhookDeliveryGroup = ArgusComponents['schemas']['WebhookDeliveryGroup'] + +const toDeliveryGroup = (group: WebhookDeliveryGroup) => { + const attempts = [...group.attempts].sort( + (left, right) => + new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime() + ) + + return { + eventId: group.eventId, + eventType: group.eventType, + sandboxId: group.sandboxId, + attempts, + attemptCount: attempts.length, + latestAttempt: attempts[0] ?? null, + } +} const webhooksRepositoryProcedure = protectedTeamProcedure.use( withTeamAuthedRequestRepository( @@ -39,6 +61,102 @@ 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, + start: input.start, + end: input.end, + deliveryStatus: 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(toDeliveryGroup), + nextCursor: result.data.nextCursor, + } + }), + + 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 e31e35fab..949b7b8a5 100644 --- a/src/core/server/functions/webhooks/schema.ts +++ b/src/core/server/functions/webhooks/schema.ts @@ -45,9 +45,53 @@ export const UpdateWebhookSecretInputSchema = z.object({ signatureSecret: WebhookSecretSchema, }) +const DeliveryStatusSchema = z.enum(['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), + start: z.iso.datetime().optional(), + end: z.iso.datetime().optional(), + deliveryStatus: z.array(DeliveryStatusSchema).optional(), + eventType: z.array(SandboxLifecycleEventTypeSchema).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 UpsertWebhookFormInput = z.input export type UpsertWebhookInput = z.output 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 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..21d74c1ce 100644 --- a/src/core/shared/contracts/argus-api.types.ts +++ b/src/core/shared/contracts/argus-api.types.ts @@ -53,6 +53,8 @@ export interface paths { offset?: number limit?: number orderAsc?: boolean + /** @description Filter events to the provided event types */ + types?: string[] } header?: never path: { @@ -71,6 +73,7 @@ export interface paths { 'application/json': components['schemas']['SandboxEvent'][] } } + 400: components['responses']['400'] 401: components['responses']['401'] 404: components['responses']['404'] 500: components['responses']['500'] @@ -98,6 +101,8 @@ export interface paths { offset?: number limit?: number orderAsc?: boolean + /** @description Filter events to the provided event types */ + types?: string[] } header?: never path?: never @@ -114,6 +119,7 @@ export interface paths { 'application/json': components['schemas']['SandboxEvent'][] } } + 400: components['responses']['400'] 401: components['responses']['401'] 404: components['responses']['404'] 500: components['responses']['500'] @@ -164,6 +170,40 @@ 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}/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 +329,123 @@ 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} + */ + status: 'success' | 'failed' + /** + * Format: int32 + * @description Delivery request duration in milliseconds + */ + durationMs: number + /** @description Serialized webhook request body */ + requestBody: string + /** @description JSON-encoded request headers with sensitive values redacted */ + requestHeaders: string + /** + * Format: uri + * @description URL attempted for this delivery + */ + requestUrl: string + /** @description Truncated response body, if a response was received */ + responseBody?: string | null + /** @description JSON-encoded response headers, if a response was received */ + responseHeaders?: string | null + /** + * Format: int32 + * @description HTTP response status code, if a response was received + */ + responseHttpStatusCode?: number | null + /** + * @description Machine-readable non-HTTP or HTTP failure class + * @enum {string|null} + */ + errorClass: + | 'http_error' + | 'dns_error' + | 'timeout' + | 'transport_error' + | 'request_error' + | 'signature_error' + | 'canceled' + | null + /** @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 + durationMs: components['schemas']['WebhookDeliveryDurationStats'] + } + /** @description Webhook delivery duration statistics in milliseconds */ + WebhookDeliveryDurationStats: { + /** Format: double */ + minimum: number + /** Format: double */ + average: number + /** Format: double */ + maximum: number + } + /** @description Webhook delivery stats for a time bucket */ + WebhookDeliveryStatsBucket: { + /** Format: date-time */ + timestamp: string + /** Format: int64 */ + total: number + /** Format: int64 */ + failed: number + durationMs: components['schemas']['WebhookDeliveryDurationStats'] + } + /** @description Webhook delivery attempts grouped by sandbox event */ + WebhookDeliveryGroup: { + /** Format: uuid */ + eventId: string + eventType: string + sandboxId: string + attempts: components['schemas']['WebhookDelivery'][] + } + /** @description Paginated webhook delivery attempts grouped by event */ + WebhookDeliveriesListPayload: { + data: components['schemas']['WebhookDeliveryGroup'][] + /** @description Cursor to pass to the next list request, or null when there is no next page. */ + nextCursor: string | null + } } responses: { /** @description Bad request */ @@ -476,4 +633,73 @@ export interface operations { 500: components['responses']['500'] } } + webhookDeliveriesList: { + parameters: { + query?: { + /** @description Opaque cursor from the previous response's nextCursor field. */ + cursor?: string + limit?: number + orderAsc?: boolean + /** @description Include deliveries at or after this timestamp. */ + start?: string + /** @description Include deliveries before this timestamp. */ + end?: string + /** @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']['WebhookDeliveriesListPayload'] + } + } + 400: components['responses']['400'] + 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/common/virtualized-table-ui.tsx b/src/features/dashboard/common/virtualized-table-ui.tsx index 2a9febc8b..b6de83e3b 100644 --- a/src/features/dashboard/common/virtualized-table-ui.tsx +++ b/src/features/dashboard/common/virtualized-table-ui.tsx @@ -43,9 +43,9 @@ export const VirtualizedTableRow = ({ export const VirtualizedTableLoaderBody = () => ( - + -
+
@@ -65,16 +65,16 @@ export const VirtualizedTableEmptyState = ({ -
+
{EMPTY_STATE_ROWS.map((_, index) => (
{index === 1 ? ( -
+
{children}
) : null} diff --git a/src/features/dashboard/sandbox/header/started-at.tsx b/src/features/dashboard/sandbox/header/started-at.tsx index 2e6614ba1..712a7cb53 100644 --- a/src/features/dashboard/sandbox/header/started-at.tsx +++ b/src/features/dashboard/sandbox/header/started-at.tsx @@ -1,41 +1,21 @@ 'use client' +import { Timestamp } from '@/features/dashboard/shared' import CopyButton from '@/ui/copy-button' import { useSandboxContext } from '../context' -export default function StartedAt() { +const StartedAt = () => { const { sandboxLifecycle } = useSandboxContext() const startedAt = sandboxLifecycle?.createdAt - 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', - }) + if (!startedAt) return null return (
-

- {prefix}, {timeStr} -

- + +
) } + +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/chart-utils.ts b/src/features/dashboard/settings/webhooks/detail/chart-utils.ts new file mode 100644 index 000000000..cea94cd5d --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/chart-utils.ts @@ -0,0 +1,98 @@ +import type { TRPCRouterOutputs } from '@/trpc/client' +import type { StatsChartPoint } from './stats-chart' +import type { WebhookStatsRangeBounds } from './stats-range' + +type WebhookDeliveryStats = + TRPCRouterOutputs['webhooks']['getDeliveryStats']['stats'] + +type WebhookDeliveryStatsBucket = WebhookDeliveryStats['buckets'][number] +type DeliveryCountMetric = 'failed' | 'total' +type ResponseTimeMetric = 'avg' | 'max' | 'min' + +const getBucketTimestampRange = ( + rangeBounds: WebhookStatsRangeBounds, + bucketIntervalSeconds: number +) => { + const intervalMs = bucketIntervalSeconds * 1000 + const start = Math.ceil(rangeBounds.start / intervalMs) * intervalMs + const end = Math.floor(rangeBounds.end / intervalMs) * intervalMs + + return { end, intervalMs, start } +} + +// Builds delivery count points from API buckets, e.g. 10m buckets -> chart points with missing buckets filled as 0. +const getDeliveryCountSeriesData = ( + buckets: WebhookDeliveryStatsBucket[], + rangeBounds: WebhookStatsRangeBounds, + bucketIntervalSeconds: number, + metric: DeliveryCountMetric = 'total' +) => { + const countByTimestamp = new Map() + + for (const bucket of buckets) { + const count = metric === 'failed' ? bucket.failed : bucket.total + const timestampMs = new Date(bucket.timestamp).getTime() + countByTimestamp.set(timestampMs, count) + } + + const points: StatsChartPoint[] = [] + const { end, intervalMs, start } = getBucketTimestampRange( + rangeBounds, + bucketIntervalSeconds + ) + + for (let timestampMs = start; timestampMs <= end; timestampMs += intervalMs) { + const value = countByTimestamp.get(timestampMs) ?? 0 + + points.push({ + synthetic: value === 0, + timestamp: new Date(timestampMs).toISOString(), + value, + }) + } + + return points +} + +// Builds response-time points from API buckets, e.g. a bucket average -> one chart point. +const getResponseTimeSeriesData = ( + buckets: WebhookDeliveryStatsBucket[], + rangeBounds: WebhookStatsRangeBounds, + bucketIntervalSeconds: number, + metric: ResponseTimeMetric +) => { + const valueByTimestamp = new Map() + + for (const bucket of buckets) { + if (bucket.total <= 0) continue + + const value = + metric === 'avg' + ? bucket.durationMs.average + : metric === 'max' + ? bucket.durationMs.maximum + : bucket.durationMs.minimum + + valueByTimestamp.set(new Date(bucket.timestamp).getTime(), value) + } + + const points: StatsChartPoint[] = [] + const { end, intervalMs, start } = getBucketTimestampRange( + rangeBounds, + bucketIntervalSeconds + ) + + for (let timestampMs = start; timestampMs <= end; timestampMs += intervalMs) { + const value = valueByTimestamp.get(timestampMs) ?? 0 + + points.push({ + synthetic: value === 0, + timestamp: new Date(timestampMs).toISOString(), + value, + }) + } + + return points +} + +export { getDeliveryCountSeriesData, getResponseTimeSeriesData } 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..3af04f47a --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/deliveries-content.tsx @@ -0,0 +1,583 @@ +'use client' + +import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query' +import { + useVirtualizer, + type VirtualItem, + type Virtualizer, +} from '@tanstack/react-virtual' +import { useQueryStates } from 'nuqs' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { z } from 'zod' +import { SandboxLifecycleEventTypeSchema } from '@/core/modules/sandboxes/lifecycle-event-types' +import { + VirtualizedTableLoaderBody, + VirtualizedTableRow, +} from '@/features/dashboard/common/virtualized-table-ui' +import { + EventTypeBadge, + EventTypeFilter, + eventTypeFilterParams, + IdBadge, +} from '@/features/dashboard/shared' +import { defaultSuccessToast, toast } from '@/lib/hooks/use-toast' +import { cn } from '@/lib/utils' +import { type TRPCRouterOutputs, useTRPC } from '@/trpc/client' +import { JsonPopover } from '@/ui/json-popover' +import { Badge } from '@/ui/primitives/badge' +import { Button } from '@/ui/primitives/button' +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/ui/primitives/dropdown-menu' +import { WebhookIcon } from '@/ui/primitives/icons' +import { + Table, + TableBody, + TableCell, + TableEmptyState, + TableHead, + TableHeader, + TableRow, +} from '@/ui/primitives/table' +import { + deliveryFilterParams, + WEBHOOK_DELIVERY_STATUSES, + type WebhookDeliveryStatus, +} from './delivery-filter-params' + +type WebhookDeliveriesContentProps = { + teamSlug: string + webhookId: string +} + +type WebhookDeliveryGroup = + TRPCRouterOutputs['webhooks']['listDeliveries']['groups'][number] + +const JsonValueSchema = z.unknown() +const ROW_HEIGHT_PX = 32 +const VIRTUAL_OVERSCAN = 16 +const SCROLL_LOAD_THRESHOLD_PX = 240 + +const deliveryTableHeadClassName = + 'flex h-8 items-center whitespace-nowrap p-0 pr-12 [&>span]:whitespace-nowrap' +const deliveryTableCellClassName = 'flex h-8 items-center p-0 pr-12' +const deliveryDetailPopoverClassName = + 'min-w-0 max-w-[180px] normal-case text-fg-tertiary hover:text-fg hover:underline' + +const deliveryStatusVariantMap: Record< + WebhookDeliveryStatus, + 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) + +// Parses a JSON string safely, e.g. '{"ok":true}' -> { ok: true }. +const parseMaybeJson = (value: string | null | undefined) => { + if (!value) return undefined + + try { + const parsedValue: unknown = JSON.parse(value) + const result = JsonValueSchema.safeParse(parsedValue) + + return result.success ? result.data : value + } catch { + return value + } +} + +const DeliveryStatusBadge = ({ status }: { status: WebhookDeliveryStatus }) => ( + {status} +) + +const getDeliveryStatusTriggerLabel = (statuses: WebhookDeliveryStatus[]) => { + if (statuses.length === WEBHOOK_DELIVERY_STATUSES.length) return 'All' + if (statuses.length === 0) return 'None' + const [first] = statuses + if (statuses.length === 1 && first) + return first.charAt(0).toUpperCase() + first.slice(1) + + return `${statuses.length}/${WEBHOOK_DELIVERY_STATUSES.length}` +} + +const DeliveryStatusFilter = ({ + statuses, + onStatusesChange, +}: { + statuses: WebhookDeliveryStatus[] + onStatusesChange: (statuses: WebhookDeliveryStatus[]) => void +}) => { + const isAllSelected = statuses.length === WEBHOOK_DELIVERY_STATUSES.length + + const toggleStatus = (status: WebhookDeliveryStatus) => { + const next = statuses.includes(status) + ? statuses.filter((item) => item !== status) + : [...statuses, status] + onStatusesChange(next) + } + + const toggleAll = (checked: boolean) => { + onStatusesChange(checked ? [...WEBHOOK_DELIVERY_STATUSES] : []) + } + + return ( + + + + + + event.preventDefault()} + > + All statuses + + + {WEBHOOK_DELIVERY_STATUSES.map((status) => ( + toggleStatus(status)} + onSelect={(event) => event.preventDefault()} + > + + + ))} + + + ) +} + +const DeliveryDetailCell = ({ + value, +}: { + value: string | null | undefined +}) => { + const parsedValue = useMemo(() => parseMaybeJson(value), [value]) + + if (parsedValue === undefined) { + return n/a + } + + if (typeof parsedValue === 'string') { + return ( + + {parsedValue} + + ) + } + + return ( + + {value} + + ) +} + +interface WebhookDeliveriesTableProps { + groups: WebhookDeliveryGroup[] + isLoading: boolean + emptyStateLabel: string + scrollContainer: HTMLDivElement | null + hasNextPage: boolean + isFetchingNextPage: boolean + onLoadMore: () => void +} + +const WebhookDeliveriesTable = ({ + groups, + isLoading, + emptyStateLabel, + scrollContainer, + hasNextPage, + isFetchingNextPage, + onLoadMore, +}: WebhookDeliveriesTableProps) => { + 'use no memo' + + return ( + + + + + Event + + + Sandbox ID + + + Status + + + Last attempt + + + Attempts + + + Duration + + + Request headers + + + Request body + + + Response HTTP + + + Response headers + + + Response body + + + + + {isLoading ? ( + + ) : groups.length === 0 ? ( + + + + {emptyStateLabel} + + + ) : scrollContainer ? ( + + ) : null} +
+ ) +} + +interface VirtualizedDeliveriesBodyProps { + groups: WebhookDeliveryGroup[] + scrollContainer: HTMLDivElement | null + hasNextPage: boolean + isFetchingNextPage: boolean + onLoadMore: () => void +} + +const VirtualizedDeliveriesBody = ({ + groups, + scrollContainer, + hasNextPage, + isFetchingNextPage, + onLoadMore, +}: VirtualizedDeliveriesBodyProps) => { + 'use no memo' + + const initialRect = useMemo(() => { + if (!scrollContainer) return undefined + + return { + height: scrollContainer.clientHeight, + width: scrollContainer.clientWidth, + } + }, [scrollContainer]) + + useScrollLoadMore({ + scrollContainer, + hasNextPage, + isFetchingNextPage, + onLoadMore, + }) + + const virtualizer = useVirtualizer({ + count: groups.length, + estimateSize: () => ROW_HEIGHT_PX, + getScrollElement: () => scrollContainer, + initialRect, + overscan: VIRTUAL_OVERSCAN, + paddingStart: 8, + }) + + return ( + + {virtualizer.getVirtualItems().map((virtualRow) => { + const group = groups[virtualRow.index] + if (!group) return null + + return ( + + ) + })} + + ) +} + +interface WebhookDeliveryRowProps { + group: WebhookDeliveryGroup + virtualRow: VirtualItem + virtualizer: Virtualizer +} + +const WebhookDeliveryRow = ({ + group, + virtualRow, + virtualizer, +}: WebhookDeliveryRowProps) => { + const attempt = group.latestAttempt + + return ( + + +
+ +
+
+ + toast(defaultSuccessToast('Sandbox ID copied'))} + /> + + + {attempt ? : '-'} + + + {attempt ? formatDateTime(attempt.timestamp) : '-'} + + + {group.attemptCount} + + + {attempt ? `${attempt.durationMs.toLocaleString()}ms` : '-'} + + + + + + + + + {attempt ? formatHttpStatus(attempt.responseHttpStatusCode) : '-'} + + + + + + + +
+ ) +} + +interface UseScrollLoadMoreParams { + scrollContainer: HTMLDivElement | null + hasNextPage: boolean + isFetchingNextPage: boolean + onLoadMore: () => void +} + +const useScrollLoadMore = ({ + scrollContainer, + hasNextPage, + isFetchingNextPage, + onLoadMore, +}: UseScrollLoadMoreParams) => { + useEffect(() => { + if (!scrollContainer) return + + const handleScroll = () => { + const distanceToBottom = + scrollContainer.scrollHeight - + scrollContainer.scrollTop - + scrollContainer.clientHeight + + if ( + distanceToBottom < SCROLL_LOAD_THRESHOLD_PX && + hasNextPage && + !isFetchingNextPage + ) { + onLoadMore() + } + } + + const frame = requestAnimationFrame(handleScroll) + scrollContainer.addEventListener('scroll', handleScroll, { + passive: true, + }) + + return () => { + cancelAnimationFrame(frame) + scrollContainer.removeEventListener('scroll', handleScroll) + } + }, [scrollContainer, hasNextPage, isFetchingNextPage, onLoadMore]) +} + +export const WebhookDeliveriesContent = ({ + teamSlug, + webhookId, +}: WebhookDeliveriesContentProps) => { + const [scrollContainer, setScrollContainer] = useState( + null + ) + const [filters, setFilters] = useQueryStates( + { + ...deliveryFilterParams, + ...eventTypeFilterParams, + }, + { shallow: true } + ) + const trpc = useTRPC() + const deliveryStatuses = useMemo( + () => filters.statuses ?? [...WEBHOOK_DELIVERY_STATUSES], + [filters.statuses] + ) + const hasSelectedDeliveryStatuses = deliveryStatuses.length > 0 + const hasAllDeliveryStatuses = + deliveryStatuses.length === WEBHOOK_DELIVERY_STATUSES.length + const deliveryStatusFilter = hasAllDeliveryStatuses + ? undefined + : deliveryStatuses + const handleDeliveryStatusesChange = useCallback( + (nextStatuses: WebhookDeliveryStatus[]) => { + const nextHasAllStatuses = + nextStatuses.length === WEBHOOK_DELIVERY_STATUSES.length + + setFilters({ + statuses: nextHasAllStatuses ? null : nextStatuses, + }) + }, + [setFilters] + ) + const eventTypes = useMemo( + () => filters.types ?? [...SandboxLifecycleEventTypeSchema.options], + [filters.types] + ) + const hasSelectedEventTypes = eventTypes.length > 0 + const hasAllEventTypes = + eventTypes.length === SandboxLifecycleEventTypeSchema.options.length + const eventTypeFilter = hasAllEventTypes ? undefined : eventTypes + const handleEventTypesChange = useCallback( + (nextEventTypes: typeof eventTypes) => { + const nextHasAllEventTypes = + nextEventTypes.length === SandboxLifecycleEventTypeSchema.options.length + + setFilters({ + types: nextHasAllEventTypes ? null : nextEventTypes, + }) + }, + [setFilters] + ) + const deliveriesQuery = useInfiniteQuery( + trpc.webhooks.listDeliveries.infiniteQueryOptions( + { + teamSlug, + webhookId, + limit: 25, + deliveryStatus: deliveryStatusFilter, + eventType: eventTypeFilter, + }, + { + enabled: hasSelectedEventTypes && hasSelectedDeliveryStatuses, + getNextPageParam: (page) => page.nextCursor ?? undefined, + placeholderData: keepPreviousData, + } + ) + ) + const groups = useMemo(() => { + return hasSelectedEventTypes && hasSelectedDeliveryStatuses + ? (deliveriesQuery.data?.pages.flatMap((page) => page.groups) ?? []) + : [] + }, [deliveriesQuery.data, hasSelectedDeliveryStatuses, hasSelectedEventTypes]) + const hasActiveFilters = !hasAllDeliveryStatuses || !hasAllEventTypes + const isDeliveriesLoading = + hasSelectedEventTypes && + hasSelectedDeliveryStatuses && + deliveriesQuery.isLoading + + const emptyStateLabel = !hasSelectedDeliveryStatuses + ? 'No statuses selected' + : !hasSelectedEventTypes + ? 'No events selected' + : hasActiveFilters + ? 'No deliveries match these filters' + : 'No deliveries yet' + const handleLoadMore = useCallback(() => { + if (!deliveriesQuery.hasNextPage || deliveriesQuery.isFetchingNextPage) + return + + deliveriesQuery.fetchNextPage() + }, [deliveriesQuery]) + + return ( +
+
+ + +
+ +
+ +
+
+ ) +} diff --git a/src/features/dashboard/settings/webhooks/detail/delivery-filter-params.ts b/src/features/dashboard/settings/webhooks/detail/delivery-filter-params.ts new file mode 100644 index 000000000..a017545de --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/delivery-filter-params.ts @@ -0,0 +1,27 @@ +import { createParser, parseAsArrayOf } from 'nuqs/server' + +const WEBHOOK_DELIVERY_STATUSES = ['success', 'failed'] as const + +type WebhookDeliveryStatus = (typeof WEBHOOK_DELIVERY_STATUSES)[number] + +// Maps URL value to delivery status, e.g. "failed" -> "failed". +const deliveryStatusParser = createParser({ + parse: (value) => { + const matchedStatus = WEBHOOK_DELIVERY_STATUSES.find( + (status) => status === value + ) + + return matchedStatus ?? null + }, + serialize: (value: WebhookDeliveryStatus) => value, +}) + +const deliveryFilterParams = { + statuses: parseAsArrayOf(deliveryStatusParser), +} + +export { + deliveryFilterParams, + WEBHOOK_DELIVERY_STATUSES, + type WebhookDeliveryStatus, +} diff --git a/src/features/dashboard/settings/webhooks/detail/fallbacks.tsx b/src/features/dashboard/settings/webhooks/detail/fallbacks.tsx new file mode 100644 index 000000000..da5f2b774 --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/fallbacks.tsx @@ -0,0 +1,49 @@ +import { Skeleton } from '@/ui/primitives/skeleton' + +const headerItemSkeletonClassNames = [ + 'h-4 w-20', + 'h-4 w-64', + 'h-4 w-14', + 'h-4 w-36', + 'h-4 w-36', +] + +export const WebhookDetailHeaderFallback = () => ( +
+
+ {headerItemSkeletonClassNames.map((className, index) => ( +
+ + +
+ ))} +
+
+) + +export const WebhookDetailContentFallback = () => ( +
+
+ +
+ +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ + + +
+ ))} +
+ +
+ {Array.from({ length: 2 }).map((_, index) => ( +
+ + +
+ ))} +
+
+) 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..8e7c44bc2 --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/header.tsx @@ -0,0 +1,101 @@ +'use client' + +import { useSuspenseQuery } from '@tanstack/react-query' +import { useMemo } from 'react' +import type { TitleSegment } from '@/configs/layout' +import { PROTECTED_URLS } from '@/configs/urls' +import { WebhookEventBadges } from '@/features/dashboard/settings/webhooks/event-badges' +import { Timestamp } from '@/features/dashboard/shared' +import { usePageTitle } from '@/lib/hooks/use-page-title' +import { defaultSuccessToast, toast } from '@/lib/hooks/use-toast' +import { useTRPC } from '@/trpc/client' +import CopyButton from '@/ui/copy-button' +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, + }) + ) + const { webhook } = data + const latestAttempt = + latestDeliveryQuery.data?.groups[0]?.latestAttempt ?? null + const titleSegments = useMemo( + () => [ + { + label: 'Webhooks', + href: PROTECTED_URLS.WEBHOOKS(teamSlug), + }, + { label: webhook.name }, + ], + [teamSlug, webhook.name] + ) + + usePageTitle(titleSegments, webhookId) + + return ( +
+ + +
+

+ {webhook.url} +

+ toast(defaultSuccessToast('Webhook URL copied'))} + value={webhook.url} + /> +
+
+ +
+ +
+
+ +
+ + toast(defaultSuccessToast('Timestamp copied'))} + value={webhook.createdAt} + /> +
+
+ + {latestAttempt ? ( +
+ + toast(defaultSuccessToast('Timestamp copied'))} + value={latestAttempt.timestamp} + /> +
+ ) : ( +

-

+ )} +
+
+
+ ) +} 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..ff92caf09 --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/layout.tsx @@ -0,0 +1,48 @@ +'use client' + +import { Suspense } from 'react' +import { PROTECTED_URLS } from '@/configs/urls' +import { DashboardTabsList } from '@/ui/dashboard-tabs' +import { ListIcon, TrendIcon } from '@/ui/primitives/icons' +import { + WebhookDetailContentFallback, + WebhookDetailHeaderFallback, +} from './fallbacks' +import { WebhookDetailHeader } from './header' + +type WebhookDetailLayoutProps = { + children: React.ReactNode + teamSlug: string + webhookId: string +} + +export const WebhookDetailLayout = ({ + children, + teamSlug, + webhookId, +}: WebhookDetailLayoutProps) => ( +
+ }> + + + , + }, + { + id: 'deliveries', + label: 'Events', + 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..c59aaf597 --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/overview-content.tsx @@ -0,0 +1,402 @@ +'use client' + +import { useSuspenseQuery } from '@tanstack/react-query' +import { useQueryStates } from 'nuqs' +import type { ReactNode } from 'react' +import { useMemo } from 'react' +import { useTRPC } from '@/trpc/client' +import { + getDeliveryCountSeriesData, + getResponseTimeSeriesData, +} from './chart-utils' +import { StatsChart, type StatsChartSeries } from './stats-chart' +import { StatsIntervalSelect } from './stats-interval-select' +import { + getValidWebhookStatsBounds, + getWebhookStatsApiBounds, + getWebhookStatsBucketIntervalSeconds, + getWebhookStatsRange, + getWebhookStatsRangeFromBounds, + type WebhookStatsRange, + type WebhookStatsRangeBounds, + webhookStatsTimeframeParams, +} from './stats-range' + +type WebhookOverviewContentProps = { + teamSlug: string + webhookId: string + initialRangeBounds: WebhookStatsRangeBounds +} + +type MetricPanelProps = { + label: string + value: string + description: string +} + +type ChartPanelProps = { + children: ReactNode + legendItems: ChartLegendItem[] + title: string +} + +type ChartLegendItem = { + label: string + indicatorClassName: string +} + +const MetricPanel = ({ label, value, description }: MetricPanelProps) => ( +
+

{label}

+

+ {value} +

+

{description}

+
+) + +const ChartLegend = ({ items }: { items: ChartLegendItem[] }) => ( +
+ {items.map((item) => ( +
+ + {item.label} +
+ ))} +
+) + +const ChartPanel = ({ children, legendItems, title }: ChartPanelProps) => ( +
+
+
+ {title} +
+ +
+
{children}
+
+) + +const deliveryLegendItems = [ + { + label: 'Total', + indicatorClassName: 'bg-accent-info-highlight', + }, + { + label: 'Failed', + indicatorClassName: 'bg-accent-error-highlight', + }, +] satisfies ChartLegendItem[] + +const latencyLegendItems = [ + { + label: 'Min', + indicatorClassName: 'bg-accent-positive-highlight', + }, + { + label: 'Avg', + indicatorClassName: 'bg-bg-inverted', + }, + { + label: 'Max', + indicatorClassName: 'bg-accent-main-highlight', + }, +] satisfies ChartLegendItem[] + +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.stats + const buckets = stats.buckets + const failureRate = + stats.total > 0 + ? `${((stats.failed / stats.total) * 100).toFixed(1)}%` + : '0%' + const bucketIntervalSeconds = + getWebhookStatsBucketIntervalSeconds(rangeBounds) + const rangeStartMs = rangeBounds.start + const rangeEndMs = rangeBounds.end + const hasFailedDeliveries = buckets.some((bucket) => bucket.failed > 0) + const totalDeliveryData = getDeliveryCountSeriesData( + buckets, + rangeBounds, + bucketIntervalSeconds + ) + const failedDeliveryData = getDeliveryCountSeriesData( + buckets, + rangeBounds, + bucketIntervalSeconds, + 'failed' + ) + const successfulDeliveryBandData = totalDeliveryData.map((point, index) => { + const failedValue = failedDeliveryData[index]?.value ?? 0 + const totalValue = point.value ?? 0 + + return { + ...point, + value: Math.max(totalValue - failedValue, 0), + } + }) + const deliverySeries = [ + { + name: 'Failed deliveries fill', + colorVar: '--accent-error-highlight', + alignAreaGradientToYAxis: true, + areaFromOpacity: 0.14, + areaGradientData: failedDeliveryData, + areaToOpacity: 0.04, + showArea: true, + lineOpacity: 0, + showSymbol: false, + showTooltipMarker: false, + stack: 'delivery-area', + z: 1, + data: failedDeliveryData, + }, + { + name: 'Successful deliveries fill', + colorVar: '--accent-info-highlight', + alignAreaGradientToYAxis: true, + areaFromOpacity: 0.12, + areaGradientData: totalDeliveryData, + areaToOpacity: 0.03, + showArea: true, + lineOpacity: 0, + showSymbol: false, + showTooltipMarker: false, + stack: 'delivery-area', + z: 1, + data: successfulDeliveryBandData, + }, + { + name: 'Total deliveries', + colorVar: '--accent-info-highlight', + showSymbol: false, + z: 2, + data: totalDeliveryData, + }, + { + name: 'Failed deliveries', + colorVar: '--accent-error-highlight', + showSymbol: false, + z: hasFailedDeliveries ? 3 : 1, + data: failedDeliveryData, + }, + { + name: 'Total zero baseline', + colorVar: '--accent-info-highlight', + showSymbol: false, + showTooltipMarker: false, + z: 4, + data: totalDeliveryData.map((point) => ({ + ...point, + value: point.value === 0 ? 0 : null, + })), + }, + ] satisfies StatsChartSeries[] + const minLatencyData = getResponseTimeSeriesData( + buckets, + rangeBounds, + bucketIntervalSeconds, + 'min' + ) + const avgLatencyData = getResponseTimeSeriesData( + buckets, + rangeBounds, + bucketIntervalSeconds, + 'avg' + ) + const maxLatencyData = getResponseTimeSeriesData( + buckets, + rangeBounds, + bucketIntervalSeconds, + 'max' + ) + const getLatencyBandData = ( + lowerData: typeof minLatencyData | null, + upperData: typeof minLatencyData + ) => + upperData.map((point, index) => { + const lowerValue = lowerData?.[index]?.value ?? 0 + const upperValue = point.value ?? 0 + + return { + ...point, + value: Math.max(upperValue - lowerValue, 0), + } + }) + const latencySeries = [ + { + name: 'Min fill', + colorVar: '--accent-positive-highlight', + alignAreaGradientToYAxis: true, + areaGradientData: minLatencyData, + showArea: true, + areaFromOpacity: 0.08, + areaToOpacity: 0.018, + connectNulls: false, + lineOpacity: 0, + showTooltipMarker: false, + showSymbol: false, + stack: 'latency-area', + z: 1, + data: getLatencyBandData(null, minLatencyData), + }, + { + name: 'Avg fill', + colorVar: '--bg-inverted', + alignAreaGradientToYAxis: true, + areaGradientData: avgLatencyData, + showArea: true, + areaFromOpacity: 0.08, + areaToOpacity: 0.018, + connectNulls: false, + lineOpacity: 0, + showTooltipMarker: false, + showSymbol: false, + stack: 'latency-area', + z: 1, + data: getLatencyBandData(minLatencyData, avgLatencyData), + }, + { + name: 'Max fill', + colorVar: '--accent-main-highlight', + alignAreaGradientToYAxis: true, + areaGradientData: maxLatencyData, + showArea: true, + areaFromOpacity: 0.1, + areaToOpacity: 0.02, + connectNulls: false, + lineOpacity: 0, + showTooltipMarker: false, + showSymbol: false, + stack: 'latency-area', + z: 1, + data: getLatencyBandData(avgLatencyData, maxLatencyData), + }, + { + name: 'Min', + colorVar: '--accent-positive-highlight', + connectNulls: false, + showSymbol: false, + z: 2, + data: minLatencyData, + }, + { + name: 'Avg', + colorVar: '--bg-inverted', + connectNulls: false, + showSymbol: false, + z: 4, + data: avgLatencyData, + }, + { + name: 'Max', + colorVar: '--accent-main-highlight', + connectNulls: false, + showSymbol: false, + z: 2, + data: maxLatencyData, + }, + ] satisfies StatsChartSeries[] + const handleRangeChange = (nextRange: WebhookStatsRange) => { + setTimeframeParams(getWebhookStatsRange(nextRange)) + } + + return ( +
+
+ +
+ +
+ + + + +
+ +
+ + + + + + + `${value.toLocaleString('en-US', { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + })}ms` + } + yAxisValueFormatter={(value) => + `${Math.round(value).toLocaleString()}ms` + } + /> + +
+
+ ) +} diff --git a/src/features/dashboard/settings/webhooks/detail/stats-chart.tsx b/src/features/dashboard/settings/webhooks/detail/stats-chart.tsx new file mode 100644 index 000000000..e7b203ce1 --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/stats-chart.tsx @@ -0,0 +1,805 @@ +'use client' + +import type { EChartsOption, SeriesOption } from 'echarts' +import { LineChart, ScatterChart } from 'echarts/charts' +import { + AxisPointerComponent, + GridComponent, + TooltipComponent, +} from 'echarts/components' +import * as echarts from 'echarts/core' +import { SVGRenderer } from 'echarts/renderers' +import ReactEChartsCore from 'echarts-for-react/lib/core' +import { useTheme } from 'next-themes' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCssVars } from '@/lib/hooks/use-css-vars' +import { cn } from '@/lib/utils' +import { calculateAxisMax } from '@/lib/utils/chart' +import { withOpacity } from '../../../sandbox/monitoring/utils/chart-colors' +import type { WebhookStatsRange } from './stats-range' + +echarts.use([ + LineChart, + ScatterChart, + GridComponent, + TooltipComponent, + AxisPointerComponent, + SVGRenderer, +]) + +type StatsChartPoint = { + synthetic?: boolean + timestamp: string + value: number | null +} + +type StatsChartSeries = { + name: string + data: StatsChartPoint[] + alignAreaGradientToYAxis?: boolean + areaFromOpacity?: number + areaFromVar?: StatsChartColorVar + areaToOpacity?: number + areaToVar?: StatsChartColorVar + connectNulls?: boolean + areaGradientData?: StatsChartPoint[] + lineWidth?: number + lineOpacity?: number + showTooltipMarker?: boolean + showSymbol?: boolean + showArea?: boolean + stack?: string + z?: number + colorVar: StatsChartColorVar +} + +type StatsChartProps = { + series: StatsChartSeries[] + bucketIntervalSeconds?: number + chartType?: 'line' | 'scatter' + className?: string + valueFormatter?: (value: number) => string + yAxisValueFormatter?: (value: number) => string + xAxisRange?: WebhookStatsRange + xAxisMax?: number + xAxisMin?: number +} + +const HOUR_MS = 60 * 60 * 1000 +const DAY_MS = 24 * HOUR_MS +const AXIS_LABEL_GRID_GAP = 8 +const MONO_AXIS_LABEL_CHAR_WIDTH = 7.2 +const CHART_LINE_WIDTH = 1 +const CHART_AREA_OPACITY = 1 +const CHART_FALLBACK_AREA_OPACITY = 0.18 +const CHART_AXIS_LABEL_FONT_SIZE = 12 +const MARKER_RIGHT_THRESHOLD_PX = 86 +const MARKER_OVERLAP_THRESHOLD_PX = 24 +const MARKER_LABEL_VERTICAL_GAP_PX = 20 +const MARKER_LABEL_MIN_GAP_PX = 24 +const MARKER_LABEL_TOP_CLEARANCE_PX = 28 +const MARKER_LABEL_BOTTOM_CLEARANCE_PX = 64 + +const STATS_CHART_COLOR_VARS = [ + '--accent-info-highlight', + '--accent-error-highlight', + '--accent-positive-highlight', + '--accent-main-highlight', + '--bg-inverted', +] as const + +const STATS_CHART_STYLE_VARS = [ + '--stroke', + '--bg-1', + '--font-mono', + '--fg-tertiary', +] as const + +type StatsChartColorVar = (typeof STATS_CHART_COLOR_VARS)[number] + +type ChartPointValue = [number, number] + +type CrosshairMarker = { + key: string + xPx: number + yPx: number + valueContent: string + dotColor: string + placeValueOnRight: boolean + labelOffsetYPx: number + labelYPx: number +} + +const formatAxisLabel = ( + value: number, + range: NonNullable, + bounds: Pick +) => { + const date = new Date(value) + + if (range === 'this-week') { + return date.toLocaleDateString('en-US', { weekday: 'short' }) + } + + const isWholeHour = + date.getMinutes() === 0 && + date.getSeconds() === 0 && + date.getMilliseconds() === 0 + if (!isWholeHour) return '' + if (bounds.xAxisMin && value < bounds.xAxisMin) return '' + if (bounds.xAxisMax && value >= bounds.xAxisMax) return '' + if (range === '12h' && bounds.xAxisMin) { + const firstWholeHour = Math.ceil(bounds.xAxisMin / HOUR_MS) * HOUR_MS + if ((value - firstWholeHour) % (2 * HOUR_MS) !== 0) return '' + } + + return date + .toLocaleTimeString('en-US', { hour: 'numeric' }) + .replace(/\s/g, '') +} + +const getXAxisInterval = ({ + range, + xAxisMax, + xAxisMin, +}: Pick & { + range: NonNullable +}) => { + if (range === 'this-week') return DAY_MS + if (range === '4h') return HOUR_MS + if (range === '12h') return 2 * HOUR_MS + if (!xAxisMin || !xAxisMax) return 2 * HOUR_MS + + const rangeMs = xAxisMax - xAxisMin + if (rangeMs <= 6 * HOUR_MS) return HOUR_MS + if (rangeMs <= 12 * HOUR_MS) return 2 * HOUR_MS + + return 4 * HOUR_MS +} + +const defaultValueFormatter = (value: number) => value.toLocaleString() + +const toChartPointValue = (point: StatsChartPoint): ChartPointValue | null => { + if (point.value === null) return null + + return [new Date(point.timestamp).getTime(), point.value] +} + +const getClosestPoint = ( + points: StatsChartPoint[], + timestampMs: number +): ChartPointValue | null => { + const values = points + .map(toChartPointValue) + .filter((point): point is ChartPointValue => Boolean(point)) + if (values.length === 0) return null + + return values.reduce((closest, point) => + Math.abs(point[0] - timestampMs) < Math.abs(closest[0] - timestampMs) + ? point + : closest + ) +} + +const applyMarkerLabelOffsets = ( + markers: CrosshairMarker[], + chartHeight: number +): CrosshairMarker[] => { + const minLabelYPx = MARKER_LABEL_TOP_CLEARANCE_PX + const maxLabelYPx = Math.max( + minLabelYPx, + chartHeight - MARKER_LABEL_BOTTOM_CLEARANCE_PX + ) + const clampLabelYPx = (value: number) => + Math.min(Math.max(value, minLabelYPx), maxLabelYPx) + const spreadLabels = (nextMarkers: CrosshairMarker[]) => { + if (nextMarkers.length < 2) return nextMarkers + + const sortedMarkers = [...nextMarkers].sort( + (a, b) => a.labelYPx - b.labelYPx + ) + + for (let index = 1; index < sortedMarkers.length; index += 1) { + const previousMarker = sortedMarkers[index - 1] + const currentMarker = sortedMarkers[index] + if (!previousMarker || !currentMarker) continue + + currentMarker.labelYPx = Math.max( + currentMarker.labelYPx, + previousMarker.labelYPx + MARKER_LABEL_MIN_GAP_PX + ) + } + + const lastMarker = sortedMarkers.at(-1) + if (lastMarker && lastMarker.labelYPx > maxLabelYPx) { + const overflowYPx = lastMarker.labelYPx - maxLabelYPx + sortedMarkers.forEach((marker) => { + marker.labelYPx -= overflowYPx + }) + } + + const firstMarker = sortedMarkers[0] + if (firstMarker && firstMarker.labelYPx < minLabelYPx) { + const underflowYPx = minLabelYPx - firstMarker.labelYPx + sortedMarkers.forEach((marker) => { + marker.labelYPx += underflowYPx + }) + } + + const labelYPxByMarkerKey = new Map( + sortedMarkers.map((marker) => [marker.key, marker.labelYPx]) + ) + + return nextMarkers.map((marker) => ({ + ...marker, + labelYPx: labelYPxByMarkerKey.get(marker.key) ?? marker.labelYPx, + })) + } + + if (markers.length < 2) { + return spreadLabels( + markers.map((marker) => ({ + ...marker, + labelYPx: clampLabelYPx(marker.yPx + marker.labelOffsetYPx), + })) + ) + } + + const sortedMarkers = [...markers].sort((a, b) => a.yPx - b.yPx) + const offsetsByMarkerKey = new Map() + let clusterStart = 0 + + for (let index = 1; index <= sortedMarkers.length; index += 1) { + const previousMarker = sortedMarkers[index - 1] + const currentMarker = sortedMarkers[index] + if (!previousMarker) continue + + const shouldSplitCluster = + !currentMarker || + Math.abs(currentMarker.yPx - previousMarker.yPx) > + MARKER_OVERLAP_THRESHOLD_PX + if (!shouldSplitCluster) continue + + const cluster = sortedMarkers.slice(clusterStart, index) + const halfIndex = (cluster.length - 1) / 2 + + cluster.forEach((marker, clusterIndex) => { + offsetsByMarkerKey.set( + marker.key, + (clusterIndex - halfIndex) * MARKER_LABEL_VERTICAL_GAP_PX + ) + }) + + clusterStart = index + } + + return spreadLabels( + markers.map((marker) => { + const labelOffsetYPx = + offsetsByMarkerKey.get(marker.key) ?? marker.labelOffsetYPx + + return { + ...marker, + labelOffsetYPx, + labelYPx: clampLabelYPx(marker.yPx + labelOffsetYPx), + } + }) + ) +} + +const getTooltipDayLabel = (date: Date) => { + const now = new Date() + const yesterday = new Date() + yesterday.setDate(now.getDate() - 1) + + if (date.toDateString() === now.toDateString()) return 'Today' + if (date.toDateString() === yesterday.toDateString()) return 'Yesterday' + + return date.toLocaleDateString('en-US', { weekday: 'long' }) +} + +const formatTooltipTime = (date: Date, showMinutes: boolean) => + date + .toLocaleTimeString('en-US', { + hour: 'numeric', + minute: showMinutes ? '2-digit' : undefined, + }) + .replace(/\s/g, '') + .toLowerCase() + +const formatTooltipTimestamp = ( + timestampMs: number, + range: NonNullable +) => { + const date = new Date(timestampMs) + const day = getTooltipDayLabel(date) + if (range === 'this-week') return day + + const time = formatTooltipTime(date, false) + + return `${day}, ${time}` +} + +const formatTooltipInterval = ( + startTimestampMs: number, + bucketIntervalSeconds: number, + range: NonNullable +) => { + const startDate = new Date(startTimestampMs) + if (range === 'this-week') return getTooltipDayLabel(startDate) + + const endDate = new Date(startTimestampMs + bucketIntervalSeconds * 1000) + const showMinutes = bucketIntervalSeconds < HOUR_MS / 1000 + const startTime = formatTooltipTime(startDate, showMinutes) + const endTime = formatTooltipTime(endDate, showMinutes) + + return `${getTooltipDayLabel(startDate)}, ${startTime} — ${endTime}` +} + +const getTooltipSyntheticValue = (param: unknown) => { + if (!param || typeof param !== 'object') return false + if (!('data' in param)) return false + if (!param.data || typeof param.data !== 'object') return false + if (!('synthetic' in param.data)) return false + + return param.data.synthetic === true +} + +const StatsChart = memo(function StatsChart({ + series, + bucketIntervalSeconds, + chartType = 'scatter', + className, + valueFormatter = defaultValueFormatter, + yAxisValueFormatter = valueFormatter, + xAxisRange = 'this-week', + xAxisMax, + xAxisMin, +}: StatsChartProps) { + const chartRef = useRef(null) + const chartInstanceRef = useRef(null) + const [hoveredTimestampMs, setHoveredTimestampMs] = useState( + null + ) + const [chartRevision, setChartRevision] = useState(0) + const { resolvedTheme } = useTheme() + const cssVars = useCssVars([ + ...STATS_CHART_COLOR_VARS, + ...STATS_CHART_STYLE_VARS, + ] as const) + + const stroke = cssVars['--stroke'] || '#d4d4d4' + const bg1 = cssVars['--bg-1'] || '#fff' + const fgTertiary = cssVars['--fg-tertiary'] || '#666' + const fontMono = cssVars['--font-mono'] || 'monospace' + const axisPointerColor = stroke + + const handleChartReady = useCallback((chart: echarts.ECharts) => { + chartInstanceRef.current = chart + chart.on('finished', () => setChartRevision((revision) => revision + 1)) + }, []) + + const handleUpdateAxisPointer = useCallback( + (params: { + axesInfo?: { value?: unknown }[] + xAxisInfo?: { value?: unknown }[] + value?: unknown + }) => { + const pointerValue = + params.axesInfo?.[0]?.value ?? + params.xAxisInfo?.[0]?.value ?? + params.value + const timestampMs = + typeof pointerValue === 'number' ? pointerValue : Number(pointerValue) + if (!Number.isFinite(timestampMs)) return + + setHoveredTimestampMs(Math.floor(timestampMs)) + }, + [] + ) + + const handleGlobalOut = useCallback(() => { + setHoveredTimestampMs(null) + chartInstanceRef.current?.dispatchAction({ type: 'hideTip' }) + chartInstanceRef.current?.dispatchAction({ + type: 'updateAxisPointer', + currTrigger: 'leave', + }) + }, []) + + const option = useMemo(() => { + const values = series.flatMap((item) => + item.data.flatMap((point) => (point.value === null ? [] : [point.value])) + ) + const yAxisMax = calculateAxisMax(values.length > 0 ? values : [0], 1.5) + const yAxisLabels = [0, yAxisMax / 2, yAxisMax].map(yAxisValueFormatter) + const yAxisLabelGutter = + Math.ceil( + Math.max(...yAxisLabels.map((label) => label.length)) * + MONO_AXIS_LABEL_CHAR_WIDTH + ) + AXIS_LABEL_GRID_GAP + const xAxisInterval = getXAxisInterval({ + range: xAxisRange, + xAxisMax, + xAxisMin, + }) + + const chartSeries: SeriesOption[] = series.map((item) => { + const color = cssVars[item.colorVar] || '#000' + const areaFrom = item.areaFromVar + ? (cssVars[item.areaFromVar] ?? + withOpacity( + color, + item.areaFromOpacity ?? CHART_FALLBACK_AREA_OPACITY + )) + : withOpacity( + color, + item.areaFromOpacity ?? CHART_FALLBACK_AREA_OPACITY + ) + const areaTo = item.areaToVar + ? (cssVars[item.areaToVar] ?? + withOpacity(color, item.areaToOpacity ?? 0)) + : withOpacity(color, item.areaToOpacity ?? 0) + const areaGradientData = item.areaGradientData ?? item.data + const itemMaxValue = Math.max( + ...areaGradientData.flatMap((point) => + point.value === null ? [] : [point.value] + ) + ) + const areaGradientY = + item.alignAreaGradientToYAxis && itemMaxValue > 0 + ? 1 - yAxisMax / itemMaxValue + : 0 + + return { + name: item.name, + type: chartType, + stack: item.stack, + z: item.z, + data: item.data.map((point) => ({ + synthetic: point.synthetic, + value: [new Date(point.timestamp).getTime(), point.value], + })), + symbol: chartType === 'line' ? 'none' : 'circle', + symbolSize: (_value: unknown, params: unknown) => + getTooltipSyntheticValue(params) ? 0 : 6, + showSymbol: item.showSymbol ?? chartType === 'scatter', + connectNulls: item.connectNulls, + smooth: false, + itemStyle: { + color, + }, + areaStyle: item.showArea + ? { + opacity: CHART_AREA_OPACITY, + color: { + type: 'linear', + x: 0, + y: areaGradientY, + x2: 0, + y2: 1, + colorStops: [ + { offset: 0, color: areaFrom }, + { offset: 1, color: areaTo }, + ], + }, + } + : undefined, + lineStyle: { + color, + opacity: item.lineOpacity ?? 1, + width: item.lineWidth ?? CHART_LINE_WIDTH, + }, + emphasis: { + disabled: true, + }, + } + }) + + return { + backgroundColor: 'transparent', + animation: false, + grid: { + top: 16, + right: 16, + bottom: 28, + left: yAxisLabelGutter, + }, + tooltip: { + show: true, + trigger: 'axis', + confine: true, + transitionDuration: 0, + enterable: false, + hideDelay: 0, + backgroundColor: 'transparent', + borderWidth: 0, + textStyle: { + color: 'transparent', + fontSize: 0, + }, + axisPointer: { + type: 'line', + lineStyle: { + color: axisPointerColor, + type: 'solid', + width: CHART_LINE_WIDTH, + }, + label: { + show: false, + }, + }, + formatter: () => '', + position: [-9999, -9999], + }, + xAxis: { + type: 'time', + min: xAxisMin, + max: xAxisMax, + interval: xAxisInterval, + boundaryGap: [0, 0], + axisLine: { show: true, lineStyle: { color: stroke } }, + axisTick: { show: false }, + axisLabel: { + color: fgTertiary, + fontFamily: fontMono, + fontSize: CHART_AXIS_LABEL_FONT_SIZE, + hideOverlap: true, + formatter: (value: number) => + formatAxisLabel(value, xAxisRange, { xAxisMax, xAxisMin }), + }, + splitLine: { show: false }, + axisPointer: { + show: true, + type: 'line', + lineStyle: { + color: stroke, + type: 'solid', + width: CHART_LINE_WIDTH, + }, + snap: false, + label: { + show: false, + }, + }, + }, + yAxis: { + type: 'value', + min: 0, + max: yAxisMax, + interval: yAxisMax / 2, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { + align: 'left', + color: fgTertiary, + fontFamily: fontMono, + fontSize: CHART_AXIS_LABEL_FONT_SIZE, + interval: 0, + margin: yAxisLabelGutter, + formatter: (value: number) => yAxisValueFormatter(value), + }, + splitLine: { + show: true, + lineStyle: { + color: withOpacity(stroke, 0.7), + type: 'dashed', + }, + interval: 0, + }, + axisPointer: { show: false }, + }, + series: chartSeries, + } + }, [ + series, + chartType, + cssVars, + stroke, + axisPointerColor, + fgTertiary, + fontMono, + yAxisValueFormatter, + xAxisRange, + xAxisMax, + xAxisMin, + ]) + + useEffect(() => { + const frame = requestAnimationFrame(() => { + chartRef.current?.getEchartsInstance().resize() + }) + + return () => cancelAnimationFrame(frame) + }, []) + + const crosshairMarkers = useMemo(() => { + void chartRevision + + const chart = chartInstanceRef.current + if (hoveredTimestampMs === null || !chart || chart.isDisposed()) { + return [] + } + const chartHeight = chart.getHeight() + + const markers = series.flatMap((item) => { + if (item.showTooltipMarker === false) return [] + + const closestPoint = getClosestPoint(item.data, hoveredTimestampMs) + if (!closestPoint) return [] + + const [timestampMs, value] = closestPoint + if ( + (xAxisMin !== undefined && timestampMs < xAxisMin) || + (xAxisMax !== undefined && timestampMs > xAxisMax) + ) { + return [] + } + + const pixel = chart.convertToPixel({ xAxisIndex: 0, yAxisIndex: 0 }, [ + timestampMs, + value, + ]) + if (!Array.isArray(pixel) || pixel.length < 2) return [] + + const [xPx, yPx] = pixel + if ( + typeof xPx !== 'number' || + typeof yPx !== 'number' || + !Number.isFinite(xPx) || + !Number.isFinite(yPx) + ) { + return [] + } + + const firstPoint = item.data.map(toChartPointValue).find(Boolean) + const firstTimestampMs = firstPoint?.[0] ?? null + const firstPointPixel = + firstTimestampMs === null + ? null + : chart.convertToPixel({ xAxisIndex: 0, yAxisIndex: 0 }, [ + firstTimestampMs, + 0, + ]) + const firstPointXPx = + Array.isArray(firstPointPixel) && typeof firstPointPixel[0] === 'number' + ? firstPointPixel[0] + : null + + return [ + { + key: `${item.name}-${timestampMs}`, + xPx, + yPx, + valueContent: valueFormatter(value), + dotColor: cssVars[item.colorVar] || stroke, + placeValueOnRight: + firstPointXPx !== null && + xPx - firstPointXPx <= MARKER_RIGHT_THRESHOLD_PX, + labelOffsetYPx: 0, + labelYPx: yPx, + }, + ] + }) + + return applyMarkerLabelOffsets(markers, chartHeight) + }, [ + chartRevision, + cssVars, + hoveredTimestampMs, + series, + stroke, + valueFormatter, + xAxisMax, + xAxisMin, + ]) + + const xAxisHoverBadge = useMemo(() => { + void chartRevision + + const chart = chartInstanceRef.current + if (hoveredTimestampMs === null || !chart || chart.isDisposed()) { + return null + } + + const pixel = chart.convertToPixel({ xAxisIndex: 0, yAxisIndex: 0 }, [ + hoveredTimestampMs, + 0, + ]) + if (!Array.isArray(pixel) || typeof pixel[0] !== 'number') return null + + return { + xPx: pixel[0], + label: bucketIntervalSeconds + ? formatTooltipInterval( + hoveredTimestampMs, + bucketIntervalSeconds, + xAxisRange + ) + : formatTooltipTimestamp(hoveredTimestampMs, xAxisRange), + } + }, [bucketIntervalSeconds, chartRevision, hoveredTimestampMs, xAxisRange]) + + const onEvents = useMemo( + () => ({ + globalout: handleGlobalOut, + updateAxisPointer: handleUpdateAxisPointer, + }), + [handleGlobalOut, handleUpdateAxisPointer] + ) + + return ( +
+ + {crosshairMarkers.length > 0 || xAxisHoverBadge ? ( +
+ {crosshairMarkers.map((marker) => ( +
+
+ +
+
+
+ {marker.valueContent} +
+
+
+ ))} + {xAxisHoverBadge ? ( +
+ {xAxisHoverBadge.label} +
+ ) : null} +
+ ) : null} +
+ ) +}) + +export { StatsChart, type StatsChartPoint, type StatsChartSeries } diff --git a/src/features/dashboard/settings/webhooks/detail/stats-interval-select.tsx b/src/features/dashboard/settings/webhooks/detail/stats-interval-select.tsx new file mode 100644 index 000000000..4b498aa51 --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/stats-interval-select.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 StatsIntervalSelectProps = { + value: WebhookStatsRange + onChange: (value: WebhookStatsRange) => void +} + +export const StatsIntervalSelect = ({ + value, + onChange, +}: StatsIntervalSelectProps) => { + 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..495dd3608 --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/stats-range.ts @@ -0,0 +1,144 @@ +import { createLoader, parseAsInteger } from 'nuqs/server' + +type WebhookStatsRangeBounds = { + start: number + end: number +} + +type WebhookStatsApiBounds = { + start: string + end: string +} + +const MAX_WEBHOOK_STATS_RANGE_MS = 7 * 24 * 60 * 60 * 1000 +const HOUR_MS = 60 * 60 * 1000 +const DAY_MS = 24 * HOUR_MS + +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 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: 'Last 7 days', + getStart: (end: number) => end - 7 * 24 * 60 * 60 * 1000, + }, +] 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(), +}) + +// Mirrors Belt's server-side webhook stats bucketing for frontend gap filling and tooltips. +const getWebhookStatsBucketIntervalSeconds = ({ + start, + end, +}: WebhookStatsRangeBounds): number => { + const rangeMs = end - start + if (rangeMs <= 4 * HOUR_MS) return 300 + if (rangeMs <= 12 * HOUR_MS) return 1800 + if (rangeMs <= DAY_MS) return 3600 + + return 86400 +} + +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 && end - start <= MAX_WEBHOOK_STATS_RANGE_MS + ? { start, end } + : getWebhookStatsRange(DEFAULT_WEBHOOK_STATS_RANGE) + +export { + DEFAULT_WEBHOOK_STATS_RANGE, + getWebhookStatsApiBounds, + getWebhookStatsBucketIntervalSeconds, + 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/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 8779ce4c8..9e6e4421c 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' @@ -30,6 +32,7 @@ import { TooltipContent, TooltipTrigger, } from '@/ui/primitives/tooltip' +import { RowHoverFrame } from '@/ui/row-hover-frame' import { useDashboard } from '../../context' import { UserAvatar } from '../../shared' import { WEBHOOK_EVENT_LABELS } from './constants' @@ -85,7 +88,12 @@ const WebhookNameAndUrl = ({ name, url }: WebhookNameAndUrlProps) => {
-

{name}

+

+ {name} +

@@ -102,7 +110,7 @@ const WebhookNameAndUrl = ({ name, url }: WebhookNameAndUrlProps) => { ) } -const rowCellClassName = 'p-0 py-1.5 align-middle [tr:first-child>&]:pt-0' +const rowCellClassName = 'p-0 py-1.5 align-middle' const rowContentClassName = 'flex items-center' const actionIconClassName = 'size-4 text-fg-tertiary' @@ -157,7 +165,10 @@ const WebhookRowActions = ({ webhook }: WebhookRowActionsProps) => { return ( - + e.stopPropagation()} + > @@ -187,6 +198,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,9 +208,30 @@ export const WebhookTableRow = ({ webhook }: WebhookRowProps) => { }) : '-' + const webhookHref = PROTECTED_URLS.WEBHOOK(team.slug, webhook.id) + const handleRowClick = (event: React.MouseEvent) => { + if (!(event.target instanceof Node)) return + if (!event.currentTarget.contains(event.target)) return + + router.push(webhookHref) + } + return ( - + + @@ -208,9 +241,9 @@ export const WebhookTableRow = ({ webhook }: WebhookRowProps) => {
- +
-

+

{createdAt}

diff --git a/src/features/dashboard/settings/webhooks/table.tsx b/src/features/dashboard/settings/webhooks/table.tsx index dd34cb992..8b7c40648 100644 --- a/src/features/dashboard/settings/webhooks/table.tsx +++ b/src/features/dashboard/settings/webhooks/table.tsx @@ -20,7 +20,7 @@ interface WebhooksTableProps { } const headerCellClassName = - 'h-[17px] p-0 pb-2 align-top font-sans! text-[12px] leading-[17px] text-left font-normal text-fg-tertiary uppercase' + 'h-[17px] p-0 pb-0.5 align-top font-sans! text-[12px] leading-[17px] text-left font-normal text-fg-tertiary uppercase' export const WebhooksTable = ({ webhooks, @@ -34,11 +34,16 @@ export const WebhooksTable = ({ : 'No webhooks match your search' return ( - +
- + @@ -56,13 +61,23 @@ export const WebhooksTable = ({ webhooks.length > 0 && [ '[&_tr]:border-stroke', '[&_tr:last-child]:border-b [&_tr:last-child]:border-stroke', + '[&_tr:hover]:border-b-transparent', + '[&_tr:last-child:hover]:border-b-transparent', + '[&_tr:has(+_tr:hover)]:border-b-transparent', + '[&_tr:has(button[aria-haspopup=menu][data-state=open])]:border-b-transparent', + '[&_tr:last-child:has(button[aria-haspopup=menu][data-state=open])]:border-b-transparent', + '[&_tr:has(+_tr_button[aria-haspopup=menu][data-state=open])]:border-b-transparent', ] )} > {isLoading ? ( - + ) : webhooks.length === 0 ? ( - + { + const parsed = SandboxLifecycleEventTypeSchema.safeParse(type) + + if (!parsed.success) { + return ( + + {type} + + ) + } + + const { icon: IconComponent, label } = SANDBOX_EVENT_TYPE_MAP[parsed.data] + + return ( + + + {label} + + ) +} diff --git a/src/features/dashboard/shared/event-type-filter-params.ts b/src/features/dashboard/shared/event-type-filter-params.ts new file mode 100644 index 000000000..c57e4f0b8 --- /dev/null +++ b/src/features/dashboard/shared/event-type-filter-params.ts @@ -0,0 +1,24 @@ +import { createParser, parseAsArrayOf } from 'nuqs/server' +import { + SANDBOX_LIFECYCLE_EVENT_TYPE_PREFIX, + type SandboxLifecycleEventType, + SandboxLifecycleEventTypeSchema, +} from '@/core/modules/sandboxes/lifecycle-event-types' + +// Maps URL value to lifecycle event type, e.g. "created" -> "sandbox.lifecycle.created". +const eventTypeParser = createParser({ + parse: (value) => { + const result = SandboxLifecycleEventTypeSchema.safeParse( + `${SANDBOX_LIFECYCLE_EVENT_TYPE_PREFIX}${value}` + ) + return result.success ? result.data : null + }, + serialize: (value: SandboxLifecycleEventType) => + value.slice(SANDBOX_LIFECYCLE_EVENT_TYPE_PREFIX.length), +}) + +const eventTypeFilterParams = { + types: parseAsArrayOf(eventTypeParser), +} + +export { eventTypeFilterParams } diff --git a/src/features/dashboard/shared/event-type-filter.tsx b/src/features/dashboard/shared/event-type-filter.tsx new file mode 100644 index 000000000..c452f9515 --- /dev/null +++ b/src/features/dashboard/shared/event-type-filter.tsx @@ -0,0 +1,79 @@ +'use client' + +import { + type SandboxLifecycleEventType, + SandboxLifecycleEventTypeSchema, +} from '@/core/modules/sandboxes/lifecycle-event-types' +import { Button } from '@/ui/primitives/button' +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/ui/primitives/dropdown-menu' +import { EventTypeBadge } from './event-type-badge' +import { SANDBOX_EVENT_TYPE_MAP } from './event-type-map' + +const getTriggerLabel = (selected: SandboxLifecycleEventType[]) => { + if (selected.length === SandboxLifecycleEventTypeSchema.options.length) + return 'All' + if (selected.length === 0) return 'None' + const [first] = selected + if (selected.length === 1 && first) return SANDBOX_EVENT_TYPE_MAP[first].label + return `${selected.length}/${SandboxLifecycleEventTypeSchema.options.length}` +} + +interface EventTypeFilterProps { + types: SandboxLifecycleEventType[] + onTypesChange: (types: SandboxLifecycleEventType[]) => void +} + +export const EventTypeFilter = ({ + types, + onTypesChange, +}: EventTypeFilterProps) => { + const isAllSelected = + types.length === SandboxLifecycleEventTypeSchema.options.length + + const toggleType = (type: SandboxLifecycleEventType) => { + const next = types.includes(type) + ? types.filter((t) => t !== type) + : [...types, type] + onTypesChange(next) + } + + const toggleAll = (checked: boolean) => { + onTypesChange(checked ? [...SandboxLifecycleEventTypeSchema.options] : []) + } + + return ( + + + + + + e.preventDefault()} + > + All events + + + {SandboxLifecycleEventTypeSchema.options.map((type) => ( + toggleType(type)} + onSelect={(e) => e.preventDefault()} + > + + + ))} + + + ) +} diff --git a/src/features/dashboard/shared/event-type-map.ts b/src/features/dashboard/shared/event-type-map.ts new file mode 100644 index 000000000..a7cbe577a --- /dev/null +++ b/src/features/dashboard/shared/event-type-map.ts @@ -0,0 +1,22 @@ +import type { SandboxLifecycleEventType } from '@/core/modules/sandboxes/lifecycle-event-types' +import { + BlockIcon, + CheckmarkIcon, + type Icon, + PausedIcon, + RefreshIcon, + RunningIcon, +} from '@/ui/primitives/icons' + +const SANDBOX_EVENT_TYPE_MAP: Record< + SandboxLifecycleEventType, + { icon: Icon; label: string } +> = { + 'sandbox.lifecycle.created': { icon: CheckmarkIcon, label: 'Created' }, + 'sandbox.lifecycle.updated': { icon: RefreshIcon, label: 'Updated' }, + 'sandbox.lifecycle.paused': { icon: PausedIcon, label: 'Paused' }, + 'sandbox.lifecycle.resumed': { icon: RunningIcon, label: 'Resumed' }, + 'sandbox.lifecycle.killed': { icon: BlockIcon, label: 'Killed' }, +} + +export { SANDBOX_EVENT_TYPE_MAP } diff --git a/src/features/dashboard/shared/index.ts b/src/features/dashboard/shared/index.ts index 9722c392c..ee940ace7 100644 --- a/src/features/dashboard/shared/index.ts +++ b/src/features/dashboard/shared/index.ts @@ -1,2 +1,6 @@ +export { EventTypeBadge } from './event-type-badge' +export { EventTypeFilter } from './event-type-filter' +export { eventTypeFilterParams } from './event-type-filter-params' 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..aaca2bc1b --- /dev/null +++ b/src/features/dashboard/shared/timestamp.tsx @@ -0,0 +1,9 @@ +import { formatDisplayTimestamp } from '@/lib/utils/formatting' + +type TimestampProps = { + value: string +} + +export const Timestamp = ({ value }: TimestampProps) => { + return

{formatDisplayTimestamp(value)}

+} diff --git a/src/lib/utils/formatting.ts b/src/lib/utils/formatting.ts index a9eb4540f..cfe2f4fc9 100644 --- a/src/lib/utils/formatting.ts +++ b/src/lib/utils/formatting.ts @@ -135,6 +135,29 @@ export function formatChartTimestampUTC( return formatInTimeZone(date, 'UTC', 'h:mm:ss a') } +/** Formats a timestamp with a relative day label, e.g. "2026-05-19T14:35:10Z" -> "Today, 2:35:10 PM". */ +export const formatDisplayTimestamp = (value: string | number | Date) => { + 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}` +} + /** Formats elapsed time as a compact relative label; e.g. `new Date(Date.now() - 7200000)` -> `"2h ago"` */ export const formatRelativeAgo = (date: Date): string => { const now = Date.now() diff --git a/tests/unit/chart-utils.test.ts b/tests/unit/chart-utils.test.ts index 5b0ad4829..63dd68cc5 100644 --- a/tests/unit/chart-utils.test.ts +++ b/tests/unit/chart-utils.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' import type { ClientTeamMetric } from '@/core/modules/sandboxes/models.client' import { transformMetrics } from '@/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/utils' +import { + getDeliveryCountSeriesData, + getResponseTimeSeriesData, +} from '@/features/dashboard/settings/webhooks/detail/chart-utils' import { calculateAxisMax } from '@/lib/utils/chart' describe('team-metrics-chart-utils', () => { @@ -94,3 +98,159 @@ describe('team-metrics-chart-utils', () => { }) }) }) + +describe('webhook chart utils', () => { + it('fills missing delivery count buckets from API bucket data', () => { + const rangeBounds = { + start: Date.UTC(2026, 5, 3, 13, 30), + end: Date.UTC(2026, 5, 3, 17, 30), + } + const buckets = [ + { + timestamp: new Date(Date.UTC(2026, 5, 3, 16)).toISOString(), + total: 1, + failed: 0, + durationMs: { + minimum: 65, + average: 82, + maximum: 99, + }, + }, + { + timestamp: new Date(Date.UTC(2026, 5, 3, 17)).toISOString(), + total: 1, + failed: 0, + durationMs: { + minimum: 65, + average: 82, + maximum: 99, + }, + }, + ] satisfies Parameters[0] + + expect(getDeliveryCountSeriesData(buckets, rangeBounds, 3600)).toEqual([ + { + synthetic: true, + timestamp: new Date(Date.UTC(2026, 5, 3, 14)).toISOString(), + value: 0, + }, + { + synthetic: true, + timestamp: new Date(Date.UTC(2026, 5, 3, 15)).toISOString(), + value: 0, + }, + { + synthetic: false, + timestamp: new Date(Date.UTC(2026, 5, 3, 16)).toISOString(), + value: 1, + }, + { + synthetic: false, + timestamp: new Date(Date.UTC(2026, 5, 3, 17)).toISOString(), + value: 1, + }, + ]) + }) + + it('maps response times from API bucket data', () => { + const rangeBounds = { + start: Date.UTC(2026, 5, 3, 16, 30), + end: Date.UTC(2026, 5, 3, 17), + } + const buckets = [ + { + timestamp: new Date(Date.UTC(2026, 5, 3, 16, 40)).toISOString(), + total: 1, + failed: 0, + durationMs: { + minimum: 65, + average: 82, + maximum: 99, + }, + }, + { + timestamp: new Date(Date.UTC(2026, 5, 3, 16, 50)).toISOString(), + total: 1, + failed: 0, + durationMs: { + minimum: 50, + average: 100, + maximum: 150, + }, + }, + ] satisfies Parameters[0] + + expect(getResponseTimeSeriesData(buckets, rangeBounds, 600, 'avg')).toEqual( + [ + { + synthetic: true, + timestamp: new Date(Date.UTC(2026, 5, 3, 16, 30)).toISOString(), + value: 0, + }, + { + synthetic: false, + timestamp: new Date(Date.UTC(2026, 5, 3, 16, 40)).toISOString(), + value: 82, + }, + { + synthetic: false, + timestamp: new Date(Date.UTC(2026, 5, 3, 16, 50)).toISOString(), + value: 100, + }, + { + synthetic: true, + timestamp: new Date(Date.UTC(2026, 5, 3, 17)).toISOString(), + value: 0, + }, + ] + ) + expect(getResponseTimeSeriesData(buckets, rangeBounds, 600, 'min')).toEqual( + [ + { + synthetic: true, + timestamp: new Date(Date.UTC(2026, 5, 3, 16, 30)).toISOString(), + value: 0, + }, + { + synthetic: false, + timestamp: new Date(Date.UTC(2026, 5, 3, 16, 40)).toISOString(), + value: 65, + }, + { + synthetic: false, + timestamp: new Date(Date.UTC(2026, 5, 3, 16, 50)).toISOString(), + value: 50, + }, + { + synthetic: true, + timestamp: new Date(Date.UTC(2026, 5, 3, 17)).toISOString(), + value: 0, + }, + ] + ) + expect(getResponseTimeSeriesData(buckets, rangeBounds, 600, 'max')).toEqual( + [ + { + synthetic: true, + timestamp: new Date(Date.UTC(2026, 5, 3, 16, 30)).toISOString(), + value: 0, + }, + { + synthetic: false, + timestamp: new Date(Date.UTC(2026, 5, 3, 16, 40)).toISOString(), + value: 99, + }, + { + synthetic: false, + timestamp: new Date(Date.UTC(2026, 5, 3, 16, 50)).toISOString(), + value: 150, + }, + { + synthetic: true, + timestamp: new Date(Date.UTC(2026, 5, 3, 17)).toISOString(), + value: 0, + }, + ] + ) + }) +})