diff --git a/spec/openapi.argus.yaml b/spec/openapi.argus.yaml index 87baf4dca..60f1992db 100644 --- a/spec/openapi.argus.yaml +++ b/spec/openapi.argus.yaml @@ -266,6 +266,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: @@ -501,5 +685,127 @@ paths: $ref: "#/components/responses/404" "401": $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" + + /events/webhooks/{webhookID}/deliveries: + get: + operationId: webhookDeliveriesList + description: List webhook delivery attempts. + tags: [webhooks] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + parameters: + - $ref: "#/components/parameters/webhookID" + - name: cursor + in: query + required: false + schema: + type: string + description: Opaque cursor from the 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: true + schema: + type: array + items: + type: string + enum: [success, failed] + description: Filter deliveries by delivery status + - name: eventType + in: query + required: false + style: form + explode: true + schema: + type: array + items: + type: string + description: Filter deliveries by event type + responses: + "200": + description: List of webhook delivery attempts grouped by event. + content: + application/json: + schema: + $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" \ No newline at end of file diff --git a/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/deliveries/page.tsx b/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/deliveries/page.tsx new file mode 100644 index 000000000..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..af168afba --- /dev/null +++ b/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/layout.tsx @@ -0,0 +1,33 @@ +import { notFound } from 'next/navigation' +import { INCLUDE_ARGUS } from '@/configs/flags' +import { WebhookDetailLayout } from '@/features/dashboard/settings/webhooks/detail' +import { HydrateClient, prefetch, trpc } from '@/trpc/server' + +type WebhookTabsLayoutProps = { + children: React.ReactNode + params: Promise<{ + teamSlug: string + webhookId: string + }> +} + +export default async function WebhookTabsLayout({ + children, + params, +}: WebhookTabsLayoutProps) { + if (!INCLUDE_ARGUS) { + return notFound() + } + + const { teamSlug, webhookId } = await params + + prefetch(trpc.webhooks.get.queryOptions({ teamSlug, webhookId })) + + return ( + + + {children} + + + ) +} diff --git a/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/overview/page.tsx b/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/overview/page.tsx new file mode 100644 index 000000000..2be576401 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/(tabs)/overview/page.tsx @@ -0,0 +1,46 @@ +import { WebhookOverviewContent } from '@/features/dashboard/settings/webhooks/detail' +import { + getValidWebhookStatsBounds, + getWebhookStatsApiBounds, + getWebhookStatsRange, + loadWebhookStatsTimeframeParams, +} from '@/features/dashboard/settings/webhooks/detail/stats-range' +import { prefetch, trpc } from '@/trpc/server' + +type WebhookOverviewPageProps = { + params: Promise<{ + teamSlug: string + webhookId: string + }> + searchParams: Promise> +} + +export default async function WebhookOverviewPage({ + params, + searchParams, +}: WebhookOverviewPageProps) { + const { teamSlug, webhookId } = await params + const timeframeParams = await loadWebhookStatsTimeframeParams(searchParams) + const fallbackRangeBounds = getWebhookStatsRange('this-week') + const rangeBounds = getValidWebhookStatsBounds({ + start: timeframeParams.start ?? fallbackRangeBounds.start, + end: timeframeParams.end ?? fallbackRangeBounds.end, + }) + const apiRangeBounds = getWebhookStatsApiBounds(rangeBounds) + + prefetch( + trpc.webhooks.getDeliveryStats.queryOptions({ + teamSlug, + webhookId, + ...apiRangeBounds, + }) + ) + + return ( + + ) +} diff --git a/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/page.tsx b/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/page.tsx new file mode 100644 index 000000000..e2af1c389 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/webhooks/[webhookId]/page.tsx @@ -0,0 +1,17 @@ +import { redirect } from 'next/navigation' +import { PROTECTED_URLS } from '@/configs/urls' + +type WebhookDetailPageProps = { + params: Promise<{ + teamSlug: string + webhookId: string + }> +} + +export default async function WebhookDetailPage({ + params, +}: WebhookDetailPageProps) { + const { teamSlug, webhookId } = await params + + redirect(PROTECTED_URLS.WEBHOOK_OVERVIEW(teamSlug, webhookId)) +} diff --git a/src/app/dashboard/[teamSlug]/webhooks/page.tsx b/src/app/dashboard/[teamSlug]/webhooks/page.tsx index 19bca5b4d..340fb4851 100644 --- a/src/app/dashboard/[teamSlug]/webhooks/page.tsx +++ b/src/app/dashboard/[teamSlug]/webhooks/page.tsx @@ -1,60 +1,29 @@ import { notFound } from 'next/navigation' import { INCLUDE_ARGUS } from '@/configs/flags' -import WebhookAddEditDialog from '@/features/dashboard/settings/webhooks/add-edit-dialog' -import WebhooksTable from '@/features/dashboard/settings/webhooks/table' -import Frame from '@/ui/frame' -import { Button } from '@/ui/primitives/button' -import { - Card, - CardContent, - CardDescription, - CardHeader, -} from '@/ui/primitives/card' -import { AddIcon } from '@/ui/primitives/icons' +import { Page } from '@/features/dashboard/layouts/page' +import { WebhooksPageContent } from '@/features/dashboard/settings/webhooks/webhooks-page-content' +import { HydrateClient, prefetch, trpc } from '@/trpc/server' -interface WebhooksPageClientProps { +interface WebhooksPageProps { params: Promise<{ teamSlug: string }> } -export default async function WebhooksPage({ - params, -}: WebhooksPageClientProps) { +export default async function WebhooksPage({ params }: WebhooksPageProps) { if (!INCLUDE_ARGUS) { return notFound() } - return ( - - - -
- - Webhooks allow your external service to be notified when sandbox - lifecycle events happen. When the specified event happens, we'll - send a POST request to the configured URLs. - + const { teamSlug } = await params - - - -
-
+ prefetch(trpc.webhooks.list.queryOptions({ teamSlug })) - -
- -
-
-
- + return ( + + + + + ) } diff --git a/src/configs/layout.ts b/src/configs/layout.ts index 0f29ab80c..ce22f9ac7 100644 --- a/src/configs/layout.ts +++ b/src/configs/layout.ts @@ -95,6 +95,24 @@ const DASHBOARD_LAYOUT_CONFIGS: Record< title: 'Webhooks', type: 'default', }), + '/dashboard/*/webhooks/*/*': (pathname) => { + const parts = pathname.split('/') + const teamSlug = parts[2]! + + return { + title: [ + { + label: 'Webhooks', + href: PROTECTED_URLS.WEBHOOKS(teamSlug), + }, + { label: 'Webhook' }, + ], + type: 'custom', + custom: { + includeHeaderBottomStyles: true, + }, + } + }, // team '/dashboard/*/general': () => ({ diff --git a/src/configs/sidebar.ts b/src/configs/sidebar.ts index 7ca6f3ed0..a6d225b93 100644 --- a/src/configs/sidebar.ts +++ b/src/configs/sidebar.ts @@ -1,8 +1,8 @@ -import { JSX } from 'react' import { AccountSettingsIcon, CardIcon, GaugeIcon, + type Icon, KeyIcon, PersonsIcon, SandboxIcon, @@ -21,8 +21,7 @@ type SidebarNavArgs = { export type SidebarNavItem = { label: string href: (args: SidebarNavArgs) => string - // eslint-disable-next-line @typescript-eslint/no-explicit-any - icon: (...args: any[]) => JSX.Element + icon: Icon group?: string activeMatch?: string } @@ -51,7 +50,7 @@ export const SIDEBAR_MAIN_LINKS: SidebarNavItem[] = [ href: (args: SidebarNavArgs) => PROTECTED_URLS.WEBHOOKS(args.teamSlug!), icon: WebhookIcon, - activeMatch: `/dashboard/*/webhooks`, + activeMatch: `/dashboard/*/webhooks/**`, }, ] : []), diff --git a/src/configs/urls.ts b/src/configs/urls.ts index 9412b2a97..d792550fd 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`, @@ -71,6 +77,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 f7188d7df..c1398125e 100644 --- a/src/core/modules/webhooks/repository.server.ts +++ b/src/core/modules/webhooks/repository.server.ts @@ -1,6 +1,7 @@ import 'server-only' import { SUPABASE_AUTH_HEADERS } 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 { repoErrorFromHttp } from '@/core/shared/errors' @@ -14,20 +15,41 @@ type WebhooksRepositoryDeps = { export type WebhooksScope = TeamRequestScope -export interface UpsertWebhookInput { - mode: 'create' | 'edit' - webhookId?: string - name: string - url: string - events: string[] - signatureSecret?: string - enabled: boolean +export interface ListWebhookDeliveriesInput { + webhookId: string + limit: number + cursor?: string + orderAsc: boolean + start?: string + end?: string + deliveryStatus?: ('success' | 'failed')[] + eventType?: string[] +} + +interface ListWebhookDeliveriesResult { + data: ArgusComponents['schemas']['WebhookDeliveryGroup'][] + nextCursor: string | null +} + +export interface GetWebhookDeliveryStatsInput { + webhookId: string + start?: string + end?: 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( @@ -67,35 +89,163 @@ 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, + }, + }, + querySerializer: { + array: { style: 'form', explode: true }, + }, + } + ) + + 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) { - const response = - input.mode === 'edit' - ? await deps.infraClient.PATCH('/events/webhooks/{webhookID}', { - headers: { - ...deps.authHeaders(scope.accessToken, scope.teamId), - }, - params: { - path: { webhookID: input.webhookId ?? '' }, - }, - body: { - name: input.name, - url: input.url, - events: input.events, - enabled: input.enabled, - }, - }) - : await deps.infraClient.POST('/events/webhooks', { - headers: { - ...deps.authHeaders(scope.accessToken, scope.teamId), - }, - body: { - name: input.name, - url: input.url, - events: input.events, - enabled: input.enabled, - signatureSecret: input.signatureSecret ?? '', - }, - }) + if (input.mode === 'update') { + if (!input.webhookId) { + return err( + repoErrorFromHttp( + 400, + 'webhookId is required when updating a webhook' + ) + ) + } + + const response = await deps.infraClient.PATCH( + '/events/webhooks/{webhookID}', + { + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + params: { + path: { webhookID: input.webhookId }, + }, + body: { + name: input.name, + url: input.url, + events: input.events, + enabled: input.enabled, + }, + } + ) + + if (!response.response.ok || response.error) { + return err( + repoErrorFromHttp( + response.response.status, + response.error?.message ?? 'Failed to upsert webhook', + response.error + ) + ) + } + + return ok(undefined) + } + + if (!input.signatureSecret) { + return err( + repoErrorFromHttp( + 400, + 'signatureSecret is required when creating a webhook' + ) + ) + } + + const response = await deps.infraClient.POST('/events/webhooks', { + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + body: { + name: input.name, + url: input.url, + events: input.events, + enabled: input.enabled, + signatureSecret: input.signatureSecret, + }, + }) if (!response.response.ok || response.error) { return err( diff --git a/src/core/server/actions/webhooks-actions.ts b/src/core/server/actions/webhooks-actions.ts deleted file mode 100644 index 738ca5692..000000000 --- a/src/core/server/actions/webhooks-actions.ts +++ /dev/null @@ -1,157 +0,0 @@ -'use server' - -import { revalidatePath } from 'next/cache' -import { createWebhooksRepository } from '@/core/modules/webhooks/repository.server' -import { - authActionClient, - withTeamAuthedRequestRepository, - withTeamSlugResolution, -} from '@/core/server/actions/client' -import { toActionErrorFromRepoError } from '@/core/server/adapters/errors' -import { - DeleteWebhookSchema, - UpdateWebhookSecretSchema, - UpsertWebhookSchema, -} from '@/core/server/functions/webhooks/schema' -import { l } from '@/core/shared/clients/logger/logger' - -const withWebhooksRepository = withTeamAuthedRequestRepository( - createWebhooksRepository, - (webhooksRepository) => ({ webhooksRepository }) -) - -export const upsertWebhookAction = authActionClient - .schema(UpsertWebhookSchema) - .metadata({ actionName: 'upsertWebhook' }) - .use(withTeamSlugResolution) - .use(withWebhooksRepository) - .action(async ({ parsedInput, ctx }) => { - const { - mode, - teamSlug, - webhookId, - name, - url, - events, - signatureSecret, - enabled, - } = parsedInput - const { session, teamId } = ctx - - const response = await ctx.webhooksRepository.upsertWebhook({ - mode: mode === 'add' ? 'create' : 'edit', - webhookId: webhookId ?? undefined, - name, - url, - events, - signatureSecret: signatureSecret ?? undefined, - enabled, - }) - - if (!response.ok) { - const status = response.error.status - - l.error( - { - key: - mode === 'edit' - ? 'update_webhook:infra_error' - : 'create_webhook:infra_error', - error: response.error, - team_id: teamId, - user_id: session.user.id, - context: { - status, - teamId, - mode, - name, - url, - events, - }, - }, - `Failed to ${mode === 'edit' ? 'update' : 'create'} webhook: ${status}: ${response.error.message}` - ) - - return toActionErrorFromRepoError(response.error) - } - - revalidatePath(`/dashboard/${teamSlug}/webhooks`, 'page') - - return { success: true } - }) - -export const deleteWebhookAction = authActionClient - .schema(DeleteWebhookSchema) - .metadata({ actionName: 'deleteWebhook' }) - .use(withTeamSlugResolution) - .use(withWebhooksRepository) - .action(async ({ parsedInput, ctx }) => { - const { teamSlug, webhookId } = parsedInput - const { session, teamId } = ctx - - const response = await ctx.webhooksRepository.deleteWebhook(webhookId) - - if (!response.ok) { - const status = response.error.status - - l.error( - { - key: 'delete_webhook:infra_error', - status, - error: response.error, - team_id: teamId, - user_id: session.user.id, - context: { - teamId, - }, - }, - `Failed to delete webhook: ${status}: ${response.error.message}` - ) - - return toActionErrorFromRepoError(response.error) - } - - revalidatePath(`/dashboard/${teamSlug}/webhooks`, 'page') - - return { success: true } - }) - -export const updateWebhookSecretAction = authActionClient - .schema(UpdateWebhookSecretSchema) - .metadata({ actionName: 'updateWebhookSecret' }) - .use(withTeamSlugResolution) - .use(withWebhooksRepository) - .action(async ({ parsedInput, ctx }) => { - const { teamSlug, webhookId, signatureSecret } = parsedInput - const { session, teamId } = ctx - - const response = await ctx.webhooksRepository.updateWebhookSecret( - webhookId, - signatureSecret - ) - - if (!response.ok) { - const status = response.error.status - - l.error( - { - key: 'update_webhook_secret:infra_error', - error: response.error, - team_id: teamId, - user_id: session.user.id, - context: { - status, - teamId, - webhookId, - }, - }, - `Failed to update webhook secret: ${status}: ${response.error.message}` - ) - - return toActionErrorFromRepoError(response.error) - } - - revalidatePath(`/dashboard/${teamSlug}/webhooks`, 'page') - - return { success: true } - }) diff --git a/src/core/server/api/routers/index.ts b/src/core/server/api/routers/index.ts index 530503282..8c5cce9ed 100644 --- a/src/core/server/api/routers/index.ts +++ b/src/core/server/api/routers/index.ts @@ -6,6 +6,7 @@ import { sandboxesRouter } from './sandboxes' import { supportRouter } from './support' import { teamsRouter } from './teams' import { templatesRouter } from './templates' +import { webhooksRouter } from './webhooks' export const trpcAppRouter = createTRPCRouter({ sandbox: sandboxRouter, @@ -15,6 +16,7 @@ export const trpcAppRouter = createTRPCRouter({ billing: billingRouter, support: supportRouter, teams: teamsRouter, + webhooks: webhooksRouter, }) export type TRPCAppRouter = typeof trpcAppRouter diff --git a/src/core/server/api/routers/webhooks.ts b/src/core/server/api/routers/webhooks.ts new file mode 100644 index 000000000..46b511867 --- /dev/null +++ b/src/core/server/api/routers/webhooks.ts @@ -0,0 +1,248 @@ +import { createWebhooksRepository } from '@/core/modules/webhooks/repository.server' +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 WebhookDelivery = ArgusComponents['schemas']['WebhookDelivery'] +type WebhookDeliveryGroup = ArgusComponents['schemas']['WebhookDeliveryGroup'] + +// Returns the newest delivery attempt, e.g. [10:00, 10:05] -> 10:05. +const getLatestAttempt = ( + attempts: WebhookDelivery[] +): WebhookDelivery | null => { + const sortedAttempts = [...attempts].sort( + (left, right) => + new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime() + ) + + return sortedAttempts[0] ?? null +} + +const 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: getLatestAttempt(attempts), + } +} + +const webhooksRepositoryProcedure = protectedTeamProcedure.use( + withTeamAuthedRequestRepository( + createWebhooksRepository, + (webhooksRepository) => ({ webhooksRepository }) + ) +) + +export const webhooksRouter = createTRPCRouter({ + list: webhooksRepositoryProcedure.query(async ({ ctx }) => { + const result = await ctx.webhooksRepository.listWebhooks() + + if (!result.ok) { + l.error( + { + key: 'list_webhooks_trpc:error', + status: result.error.status, + error: result.error, + team_id: ctx.teamId, + user_id: ctx.session.user.id, + }, + `Failed to list webhooks: ${result.error.status}: ${result.error.message}` + ) + + throwTRPCErrorFromRepoError(result.error) + } + + 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 }) => { + const result = await ctx.webhooksRepository.upsertWebhook(input) + + if (!result.ok) { + l.error( + { + key: + input.mode === 'update' + ? 'update_webhook_trpc:error' + : 'create_webhook_trpc:error', + status: result.error.status, + error: result.error, + team_id: ctx.teamId, + user_id: ctx.session.user.id, + context: { + mode: input.mode, + name: input.name, + events: input.events, + }, + }, + `Failed to ${input.mode} webhook: ${result.error.status}: ${result.error.message}` + ) + + throwTRPCErrorFromRepoError(result.error) + } + }), + + delete: webhooksRepositoryProcedure + .input(DeleteWebhookInputSchema) + .mutation(async ({ ctx, input }) => { + const result = await ctx.webhooksRepository.deleteWebhook(input.webhookId) + + if (!result.ok) { + l.error( + { + key: 'delete_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 delete webhook: ${result.error.status}: ${result.error.message}` + ) + + throwTRPCErrorFromRepoError(result.error) + } + }), + + updateSecret: webhooksRepositoryProcedure + .input(UpdateWebhookSecretInputSchema) + .mutation(async ({ ctx, input }) => { + const result = await ctx.webhooksRepository.updateWebhookSecret( + input.webhookId, + input.signatureSecret + ) + + if (!result.ok) { + l.error( + { + key: 'update_webhook_secret_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 update webhook secret: ${result.error.status}: ${result.error.message}` + ) + + throwTRPCErrorFromRepoError(result.error) + } + }), +}) diff --git a/src/core/server/functions/webhooks/get-webhooks.ts b/src/core/server/functions/webhooks/get-webhooks.ts deleted file mode 100644 index aa5da2408..000000000 --- a/src/core/server/functions/webhooks/get-webhooks.ts +++ /dev/null @@ -1,50 +0,0 @@ -import 'server-only' - -import { z } from 'zod' -import { createWebhooksRepository } from '@/core/modules/webhooks/repository.server' -import { - authActionClient, - withTeamAuthedRequestRepository, - withTeamSlugResolution, -} from '@/core/server/actions/client' -import { handleDefaultInfraError } from '@/core/server/actions/utils' -import { l } from '@/core/shared/clients/logger/logger' -import { TeamSlugSchema } from '@/core/shared/schemas/team' - -const GetWebhooksSchema = z.object({ - teamSlug: TeamSlugSchema, -}) - -const withWebhooksRepository = withTeamAuthedRequestRepository( - createWebhooksRepository, - (webhooksRepository) => ({ webhooksRepository }) -) - -export const getWebhooks = authActionClient - .schema(GetWebhooksSchema) - .metadata({ serverFunctionName: 'getWebhooks' }) - .use(withTeamSlugResolution) - .use(withWebhooksRepository) - .action(async ({ ctx }) => { - const { session, teamId } = ctx - - const result = await ctx.webhooksRepository.listWebhooks() - - if (!result.ok) { - const status = result.error.status - l.error( - { - key: 'get_webhooks:infra_error', - status, - error: result.error, - team_id: teamId, - user_id: session.user.id, - }, - `Failed to get webhooks: ${status}: ${result.error.message}` - ) - - return handleDefaultInfraError(status, result.error) - } - - return { webhooks: result.data } - }) diff --git a/src/core/server/functions/webhooks/schema.ts b/src/core/server/functions/webhooks/schema.ts index 5d61057d1..949b7b8a5 100644 --- a/src/core/server/functions/webhooks/schema.ts +++ b/src/core/server/functions/webhooks/schema.ts @@ -1,50 +1,97 @@ import { z } from 'zod' -import { TeamSlugSchema } from '@/core/shared/schemas/team' +import { SandboxLifecycleEventTypeSchema } from '@/core/modules/sandboxes/lifecycle-event-types' const WebhookUrlSchema = z.httpUrl('Must be a valid URL').trim() const WebhookSecretSchema = z .string() - .min(32, 'Secret must be at least 32 characters') .trim() + .min(32, 'Secret must be at least 32 characters') -export const UpsertWebhookSchema = z +export const UpsertWebhookInputSchema = z .object({ - teamSlug: TeamSlugSchema, - mode: z.enum(['add', 'edit']), + mode: z.enum(['create', 'update']), webhookId: z.uuid().optional(), name: z.string().min(1, 'Name is required').trim(), url: WebhookUrlSchema, - events: z.array(z.string().min(1, 'At least one event is required')), + events: z + .array(SandboxLifecycleEventTypeSchema) + .min(1, 'At least one event is required'), signatureSecret: WebhookSecretSchema.optional(), enabled: z.boolean().optional().default(true), }) - .refine( - (data) => { - // require signatureSecret only when mode is 'add' - if (data.mode === 'add') { - return !!data.signatureSecret - } - return true - }, - { - message: 'Secret is required when creating a webhook', - path: ['signatureSecret'], + .superRefine((data, ctx) => { + if (data.mode === 'create' && !data.signatureSecret) { + ctx.addIssue({ + code: 'custom', + message: 'Secret is required when creating a webhook', + path: ['signatureSecret'], + }) + } + if (data.mode === 'update' && !data.webhookId) { + ctx.addIssue({ + code: 'custom', + message: 'webhookId is required when updating a webhook', + path: ['webhookId'], + }) } - ) + }) -export const DeleteWebhookSchema = z.object({ - teamSlug: TeamSlugSchema, +export const DeleteWebhookInputSchema = z.object({ webhookId: z.uuid(), }) -export const UpdateWebhookSecretSchema = z.object({ - teamSlug: TeamSlugSchema, +export const UpdateWebhookSecretInputSchema = z.object({ webhookId: z.uuid(), signatureSecret: WebhookSecretSchema, }) -export type UpsertWebhookSchemaType = z.input -export type DeleteWebhookSchemaType = z.input -export type UpdateWebhookSecretSchemaType = z.input< - typeof UpdateWebhookSecretSchema +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..acca58dd7 100644 --- a/src/core/shared/contracts/argus-api.types.ts +++ b/src/core/shared/contracts/argus-api.types.ts @@ -164,6 +164,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 +323,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 +627,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/layouts/header.tsx b/src/features/dashboard/layouts/header.tsx index e1a780ee7..e8c2accd8 100644 --- a/src/features/dashboard/layouts/header.tsx +++ b/src/features/dashboard/layouts/header.tsx @@ -1,10 +1,13 @@ 'use client' +import { useSuspenseQuery } from '@tanstack/react-query' import Link from 'next/link' import { usePathname } from 'next/navigation' import { Fragment } from 'react' import { getDashboardLayoutConfig, type TitleSegment } from '@/configs/layout' +import { PROTECTED_URLS } from '@/configs/urls' import { cn } from '@/lib/utils' +import { useTRPC } from '@/trpc/client' import ClientOnly from '@/ui/client-only' import CopyButton from '@/ui/copy-button' import { SidebarTrigger } from '@/ui/primitives/sidebar' @@ -22,6 +25,7 @@ export default function DashboardLayoutHeader({ const pathname = usePathname() const config = getDashboardLayoutConfig(pathname) const copyableValue = config.copyValue ?? null + const webhookRoute = getWebhookRoute(pathname) return (

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

{copyableValue && ( { + const parts = pathname.split('/') + const teamSlug = parts[2] + const resource = parts[3] + const webhookId = parts[4] + + if (resource !== 'webhooks' || !teamSlug || !webhookId) return null + return { teamSlug, webhookId } +} + +function WebhookHeaderTitle({ + teamSlug, + webhookId, +}: { + teamSlug: string + webhookId: string +}) { + const trpc = useTRPC() + const { data } = useSuspenseQuery( + trpc.webhooks.get.queryOptions({ teamSlug, webhookId }) + ) + + return ( + + + Webhooks + + / + {data.webhook.name} + + ) +} + function HeaderTitle({ title }: { title: string | TitleSegment[] }) { if (typeof title === 'string') { return title diff --git a/src/features/dashboard/members/member-table-row.tsx b/src/features/dashboard/members/member-table-row.tsx index 2834f16fa..3496ae4be 100644 --- a/src/features/dashboard/members/member-table-row.tsx +++ b/src/features/dashboard/members/member-table-row.tsx @@ -257,7 +257,7 @@ const AddedCell = ({ ) : ( )} {showRemove ? ( 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/keys/api-keys-table-row.tsx b/src/features/dashboard/settings/keys/api-keys-table-row.tsx index 919b01a80..2cb0d08a6 100644 --- a/src/features/dashboard/settings/keys/api-keys-table-row.tsx +++ b/src/features/dashboard/settings/keys/api-keys-table-row.tsx @@ -106,7 +106,7 @@ export const ApiKeysTableRow = ({ apiKey, onDelete }: ApiKeysTableRowProps) => { - + {createdByEmail} diff --git a/src/features/dashboard/settings/webhooks/add-edit-dialog.tsx b/src/features/dashboard/settings/webhooks/add-edit-dialog.tsx deleted file mode 100644 index eadd33660..000000000 --- a/src/features/dashboard/settings/webhooks/add-edit-dialog.tsx +++ /dev/null @@ -1,275 +0,0 @@ -'use client' - -import { zodResolver } from '@hookform/resolvers/zod' -import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hooks' -import { useState } from 'react' -import { SandboxLifecycleEventTypeSchema } from '@/core/modules/sandboxes/lifecycle-event-types' -import { upsertWebhookAction } from '@/core/server/actions/webhooks-actions' -import { UpsertWebhookSchema } from '@/core/server/functions/webhooks/schema' -import { - defaultErrorToast, - defaultSuccessToast, - toast, -} from '@/lib/hooks/use-toast' -import { Button } from '@/ui/primitives/button' -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@/ui/primitives/dialog' -import { Form } from '@/ui/primitives/form' -import { AddIcon, CheckIcon } from '@/ui/primitives/icons' -import { Loader } from '@/ui/primitives/loader' -import { useDashboard } from '../../context' -import { WebhookAddEditDialogSteps } from './add-edit-dialog-steps' -import type { Webhook } from './types' - -type WebhookAddEditDialogProps = - | { - children: React.ReactNode - mode: 'add' - webhook?: undefined - } - | { - children: React.ReactNode - mode: 'edit' - webhook: Webhook - } - -export default function WebhookAddEditDialog({ - children: trigger, - mode, - webhook, -}: WebhookAddEditDialogProps) { - 'use no memo' - - const { team } = useDashboard() - const [open, setOpen] = useState(false) - const [currentStep, setCurrentStep] = useState(1) - - const isEditMode = mode === 'edit' - const totalSteps = isEditMode ? 1 : 2 - - const { - form, - resetFormAndAction, - handleSubmitWithAction, - action: { isPending: isLoading }, - } = useHookFormAction(upsertWebhookAction, zodResolver(UpsertWebhookSchema), { - formProps: { - mode: 'onChange', - disabled: !team.slug, - defaultValues: { - teamSlug: team.slug, - webhookId: isEditMode ? webhook?.id : undefined, - mode, - name: webhook?.name || '', - url: webhook?.url || '', - events: webhook?.events || [], - // only include signatureSecret in add mode - ...(isEditMode ? {} : { signatureSecret: '' }), - }, - values: { - teamSlug: team.slug, - webhookId: isEditMode ? webhook?.id : undefined, - mode, - name: webhook?.name || '', - url: webhook?.url || '', - events: webhook?.events || [], - // only include signatureSecret in add mode - ...(isEditMode ? {} : { signatureSecret: '' }), - }, - }, - actionProps: { - onSuccess: () => { - toast( - defaultSuccessToast( - isEditMode - ? 'Webhook updated successfully' - : 'Webhook created successfully' - ) - ) - handleDialogChange(false) - }, - onError: ({ error }) => { - toast( - defaultErrorToast( - error.serverError || - (isEditMode - ? 'Failed to update webhook' - : 'Failed to create webhook') - ) - ) - }, - }, - }) - - const handleDialogChange = (value: boolean) => { - setOpen(value) - - if (value) return - - setCurrentStep(1) - resetFormAndAction() - } - - // watch fields to trigger reactive updates - const name = form.watch('name') - const url = form.watch('url') - const selectedEvents = form.watch('events') || [] - const signatureSecret = form.watch('signatureSecret') - - const allEventsSelected = - selectedEvents.length === SandboxLifecycleEventTypeSchema.options.length && - SandboxLifecycleEventTypeSchema.options.every((event) => - selectedEvents.includes(event) - ) - - const { errors } = form.formState - - const isStep1Valid = - !errors.name && - !errors.url && - !errors.events && - selectedEvents.length > 0 && - name.trim().length > 0 && - url.trim().length > 0 - - const isStep2Valid = - !errors.signatureSecret && signatureSecret && signatureSecret.length >= 32 - - const handleAllToggle = () => { - if (allEventsSelected) { - form.setValue('events', []) - } else { - form.setValue('events', [...SandboxLifecycleEventTypeSchema.options]) - } - } - - const handleEventToggle = (event: string) => { - const currentEvents = form.getValues('events') || [] - if (currentEvents.includes(event)) { - form.setValue( - 'events', - currentEvents.filter((eventName: string) => eventName !== event) - ) - } else { - form.setValue('events', [...currentEvents, event]) - } - } - - const handleNext = async () => { - if (currentStep === 1) { - const isNameValid = await form.trigger('name') - const isUrlValid = await form.trigger('url') - const isEventsValid = await form.trigger('events') - if (isNameValid && isUrlValid && isEventsValid) { - setCurrentStep(2) - } - } - } - - const handleBack = () => { - setCurrentStep(1) - } - - return ( - - {trigger} - - - - - {isEditMode ? 'Edit Webhook' : 'Add Webhook'} - - {/* Step Counter - only show in add mode */} - {!isEditMode && ( -
- - Step {currentStep} / {totalSteps} - -
- )} -
- -
- - {/* Hidden fields */} - - - -
- -
- - - {isLoading ? ( -
- - - {isEditMode ? 'Saving Changes...' : 'Adding Webhook...'} - -
- ) : ( - <> - {/* Edit mode: show submit button directly */} - {isEditMode ? ( - - ) : currentStep === 1 ? ( - - ) : ( - <> - - - - )} - - )} -
-
- -
-
- ) -} diff --git a/src/features/dashboard/settings/webhooks/constants.ts b/src/features/dashboard/settings/webhooks/constants.ts index 5ac0c71b6..37f36c3e3 100644 --- a/src/features/dashboard/settings/webhooks/constants.ts +++ b/src/features/dashboard/settings/webhooks/constants.ts @@ -1,4 +1,14 @@ -export const WEBHOOK_EXAMPLE_PAYLOAD = `{ +import type { SandboxLifecycleEventType } from '@/core/modules/sandboxes/lifecycle-event-types' + +const WEBHOOK_EVENT_LABELS: Record = { + 'sandbox.lifecycle.created': 'CREATE', + 'sandbox.lifecycle.paused': 'PAUSE', + 'sandbox.lifecycle.resumed': 'RESUME', + 'sandbox.lifecycle.updated': 'UPDATE', + 'sandbox.lifecycle.killed': 'KILL', +} + +const WEBHOOK_EXAMPLE_PAYLOAD = `{ "id": "", "version": "v2", "type": "sandbox.lifecycle.created", @@ -15,5 +25,14 @@ export const WEBHOOK_EXAMPLE_PAYLOAD = `{ // Payload structure may vary by event type. // See docs for full schema.` -export const WEBHOOK_SIGNATURE_VALIDATION_DOCS_URL = - 'https://e2b.dev/docs/sandbox/lifecycle-events-webhooks#webhook-verification' +const WEBHOOK_DOCS_URL = + 'https://e2b.dev/docs/sandbox/lifecycle-events-webhooks' + +const WEBHOOK_SIGNATURE_VALIDATION_DOCS_URL = `${WEBHOOK_DOCS_URL}#webhook-verification` + +export { + WEBHOOK_DOCS_URL, + WEBHOOK_EVENT_LABELS, + WEBHOOK_EXAMPLE_PAYLOAD, + WEBHOOK_SIGNATURE_VALIDATION_DOCS_URL, +} diff --git a/src/features/dashboard/settings/webhooks/delete-dialog.tsx b/src/features/dashboard/settings/webhooks/delete-dialog.tsx deleted file mode 100644 index a6a0049a2..000000000 --- a/src/features/dashboard/settings/webhooks/delete-dialog.tsx +++ /dev/null @@ -1,112 +0,0 @@ -'use client' - -import { useAction } from 'next-safe-action/hooks' -import { useState } from 'react' -import { deleteWebhookAction } from '@/core/server/actions/webhooks-actions' -import { useDashboard } from '@/features/dashboard/context' -import { - defaultErrorToast, - defaultSuccessToast, - useToast, -} from '@/lib/hooks/use-toast' -import { AlertDialog } from '@/ui/alert-dialog' -import { TrashIcon } from '@/ui/primitives/icons' -import { Input } from '@/ui/primitives/input' -import { Label } from '@/ui/primitives/label' -import { Loader } from '@/ui/primitives/loader' -import type { Webhook } from './types' - -interface WebhookDeleteDialogProps { - children: React.ReactNode - webhook: Webhook -} - -export default function WebhookDeleteDialog({ - children: trigger, - webhook, -}: WebhookDeleteDialogProps) { - const { team } = useDashboard() - const { toast } = useToast() - const [open, setOpen] = useState(false) - const [confirmationUrl, setConfirmationUrl] = useState('') - - const isUrlMatch = confirmationUrl === webhook.url - - const { execute: executeDeleteWebhook, isExecuting: isDeleting } = useAction( - deleteWebhookAction, - { - onSuccess: () => { - toast(defaultSuccessToast('Webhook deleted successfully')) - setOpen(false) - setConfirmationUrl('') - }, - onError: ({ error }) => { - toast( - defaultErrorToast(error.serverError || 'Failed to delete webhook') - ) - }, - } - ) - - const handleOpenChange = (value: boolean) => { - setOpen(value) - if (!value) { - setConfirmationUrl('') - } - } - - const webhookName = webhook.name - - return ( - - - Deleting Webhook... - - ) : ( - <> - - Delete - - ) - } - confirmProps={{ - variant: 'error', - disabled: isDeleting || !isUrlMatch, - }} - onConfirm={() => { - executeDeleteWebhook({ - teamSlug: team.slug, - webhookId: webhook.id, - }) - }} - > -
- - setConfirmationUrl(e.target.value)} - placeholder={webhook.url} - disabled={isDeleting} - autoComplete="off" - className="min-w-0" - /> - {confirmationUrl && !isUrlMatch && ( -

- URL does not match -

- )} -
-
- ) -} diff --git a/src/features/dashboard/settings/webhooks/delete-webhook-dialog.tsx b/src/features/dashboard/settings/webhooks/delete-webhook-dialog.tsx new file mode 100644 index 000000000..85c5b12e5 --- /dev/null +++ b/src/features/dashboard/settings/webhooks/delete-webhook-dialog.tsx @@ -0,0 +1,108 @@ +'use client' + +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' +import { useDashboard } from '@/features/dashboard/context' +import { + defaultErrorToast, + defaultSuccessToast, + useToast, +} from '@/lib/hooks/use-toast' +import { useTRPC } from '@/trpc/client' +import { Button } from '@/ui/primitives/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/ui/primitives/dialog' +import { TrashIcon } from '@/ui/primitives/icons' +import { Loader } from '@/ui/primitives/loader' +import type { Webhook } from './types' + +interface DeleteWebhookDialogProps { + children: React.ReactNode + webhook: Webhook +} + +export const DeleteWebhookDialog = ({ + children: trigger, + webhook, +}: DeleteWebhookDialogProps) => { + const { team } = useDashboard() + const { toast } = useToast() + const trpc = useTRPC() + const queryClient = useQueryClient() + const [open, setOpen] = useState(false) + + const listQueryKey = trpc.webhooks.list.queryOptions({ + teamSlug: team.slug, + }).queryKey + + const deleteMutation = useMutation( + trpc.webhooks.delete.mutationOptions({ + onSuccess: () => { + toast(defaultSuccessToast('Webhook deleted successfully')) + void queryClient.invalidateQueries({ queryKey: listQueryKey }) + setOpen(false) + }, + onError: (err) => { + toast(defaultErrorToast(err.message || 'Failed to delete webhook')) + }, + }) + ) + + const isDeleting = deleteMutation.isPending + + const handleDelete = () => { + deleteMutation.mutate({ + teamSlug: team.slug, + webhookId: webhook.id, + }) + } + + return ( + + {trigger} + + + Delete webhook? + + You will no longer receive events at +
+ {webhook.url} +
+
+ + + + +
+
+ ) +} 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..8e6e4ba31 --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/deliveries-content.tsx @@ -0,0 +1,573 @@ +'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 { + EventTypeFilter, + eventTypeFilterParams, + IdBadge, + SandboxEventTypeBadge, +} from '@/features/dashboard/shared' +import { defaultSuccessToast, toast } from '@/lib/hooks/use-toast' +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 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} + + + ) : ( + + )} +
+ ) +} + +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( + () => + 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 event types 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/header.tsx b/src/features/dashboard/settings/webhooks/detail/header.tsx new file mode 100644 index 000000000..2f9d4159c --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/header.tsx @@ -0,0 +1,85 @@ +'use client' + +import { useSuspenseQuery } from '@tanstack/react-query' +import { WebhookEventBadges } from '@/features/dashboard/settings/webhooks/event-badges' +import { Timestamp } from '@/features/dashboard/shared' +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 + + 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..16a8f4de6 --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/layout.tsx @@ -0,0 +1,41 @@ +'use client' + +import { PROTECTED_URLS } from '@/configs/urls' +import { DashboardTabsList } from '@/ui/dashboard-tabs' +import { ListIcon, TrendIcon } from '@/ui/primitives/icons' +import { WebhookDetailHeader } from './header' + +type WebhookDetailLayoutProps = { + children: React.ReactNode + teamSlug: string + webhookId: string +} + +export const WebhookDetailLayout = ({ + children, + teamSlug, + webhookId, +}: WebhookDetailLayoutProps) => ( +
+ + , + }, + { + id: 'deliveries', + label: '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..4644cff9a --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/overview-content.tsx @@ -0,0 +1,486 @@ +'use client' + +import { useSuspenseQuery } from '@tanstack/react-query' +import { useQueryStates } from 'nuqs' +import type { ReactNode } from 'react' +import { useMemo } from 'react' +import { type TRPCRouterOutputs, useTRPC } from '@/trpc/client' +import { WebhookRangeSelector } from './range-selector' +import { + getValidWebhookStatsBounds, + getWebhookStatsApiBounds, + getWebhookStatsRange, + getWebhookStatsRangeFromBounds, + type WebhookStatsRange, + type WebhookStatsRangeBounds, + webhookStatsTimeframeParams, +} from './stats-range' +import { + WebhookStatsChart, + type WebhookStatsChartPoint, + type WebhookStatsChartSeries, +} from './webhook-stats-chart' + +type WebhookOverviewContentProps = { + teamSlug: string + webhookId: string + initialRangeBounds: WebhookStatsRangeBounds +} + +type MetricPanelProps = { + label: string + value: string + description: string +} + +type ChartPanelProps = { + children: ReactNode + title: string +} + +type WebhookDeliveryStats = + TRPCRouterOutputs['webhooks']['getDeliveryStats']['stats'] + +type ResponseTimeTimestampStats = { + count: number + maxDurationMs: number + minDurationMs: number + timestampMs: number + totalDurationMs: number +} + +type WebhookStatsGrouping = 'day' | 'timestamp' +type WebhookDeliveryStatsBucket = WebhookDeliveryStats['buckets'][number] +type WebhookDeliveryStatus = 'failed' + +const DAY_MS = 24 * 60 * 60 * 1000 +const MINUTE_MS = 60 * 1000 + +const MetricPanel = ({ label, value, description }: MetricPanelProps) => ( +
+

{label}

+

+ {value} +

+

{description}

+
+) + +const ChartPanel = ({ children, title }: ChartPanelProps) => ( +
+
+
+ {title} +
+
+
{children}
+
+) + +const getStartOfDay = (timestampMs: number) => { + const date = new Date(timestampMs) + date.setHours(0, 0, 0, 0) + + return date.getTime() +} + +const getSeriesTimestamp = ( + timestamp: string, + grouping: WebhookStatsGrouping +) => { + const timestampMs = new Date(timestamp).getTime() + if (grouping === 'day') return getStartOfDay(timestampMs) + + return timestampMs +} + +// Groups delivery buckets by chart granularity, e.g. minute buckets from one day -> one daily count. +const getDeliveryCountSeriesData = ( + buckets: WebhookDeliveryStatsBucket[], + rangeBounds: WebhookStatsRangeBounds, + grouping: WebhookStatsGrouping, + status?: WebhookDeliveryStatus +) => { + const countByTimestamp = new Map< + number, + { count: number; timestampMs: number } + >() + + for (const bucket of buckets) { + const count = status === 'failed' ? bucket.failed : bucket.total + if (count <= 0) continue + + const timestampMs = getSeriesTimestamp(bucket.timestamp, grouping) + const bucketTimestampMs = + grouping === 'day' + ? timestampMs + : Math.floor(timestampMs / MINUTE_MS) * MINUTE_MS + const current = countByTimestamp.get(bucketTimestampMs) + + countByTimestamp.set(bucketTimestampMs, { + count: (current?.count ?? 0) + count, + timestampMs: Math.max(current?.timestampMs ?? timestampMs, timestampMs), + }) + } + + if (grouping === 'day') { + const points = [] + const start = getStartOfDay(rangeBounds.start) + const end = getStartOfDay(rangeBounds.end) + + for (let timestampMs = start; timestampMs <= end; timestampMs += DAY_MS) { + const value = countByTimestamp.get(timestampMs)?.count ?? 0 + + points.push({ + synthetic: value === 0, + timestamp: new Date(timestampMs).toISOString(), + value, + }) + } + + return points + } + + const points: WebhookStatsChartPoint[] = [ + { + synthetic: true, + timestamp: new Date(rangeBounds.start).toISOString(), + value: 0, + }, + ] + + for (const [, bucket] of Array.from(countByTimestamp).sort( + ([left], [right]) => left - right + )) { + const timestampMs = bucket.timestampMs + + points.push( + { + synthetic: true, + timestamp: new Date( + Math.max(rangeBounds.start, timestampMs - 1) + ).toISOString(), + value: 0, + }, + { + timestamp: new Date(timestampMs).toISOString(), + value: bucket.count, + }, + { + synthetic: true, + timestamp: new Date( + Math.min(rangeBounds.end, timestampMs + 1) + ).toISOString(), + value: 0, + } + ) + } + + points.push({ + synthetic: true, + timestamp: new Date(rangeBounds.end).toISOString(), + value: 0, + }) + + return points +} + +// Builds a zero-value baseline for an empty range, e.g. [May 19 10am, May 19 2pm] -> 0 deliveries line. +const getEmptyDeliveryCountSeriesData = ( + rangeBounds: WebhookStatsRangeBounds, + grouping: WebhookStatsGrouping +) => { + if (grouping === 'day') { + const points = [] + const start = getStartOfDay(rangeBounds.start) + const end = getStartOfDay(rangeBounds.end) + + for (let timestampMs = start; timestampMs <= end; timestampMs += DAY_MS) { + points.push({ + synthetic: true, + timestamp: new Date(timestampMs).toISOString(), + value: 0, + }) + } + + return points + } + + return [ + { + synthetic: true, + timestamp: new Date(rangeBounds.start).toISOString(), + value: 0, + }, + { + synthetic: true, + timestamp: new Date(rangeBounds.end).toISOString(), + value: 0, + }, + ] +} + +const hideInactiveZeroValuePoints = (points: WebhookStatsChartPoint[]) => + points.map((point, index) => { + if (point.value !== 0) return point + + const hasNearbyValue = [-2, -1, 1, 2].some( + (offset) => (points[index + offset]?.value ?? 0) > 0 + ) + if (hasNearbyValue) return point + + return { ...point, synthetic: true, value: null } + }) + +// Groups response-time buckets by chart granularity, e.g. minute buckets from one day -> one daily min/avg/max point. +const getResponseTimeSeriesData = ( + buckets: WebhookDeliveryStatsBucket[], + rangeBounds: WebhookStatsRangeBounds, + grouping: WebhookStatsGrouping, + metric: 'avg' | 'max' | 'min' +) => { + const statsByTimestamp = new Map() + + for (const bucket of buckets) { + if (bucket.total <= 0) continue + + const timestampMs = getSeriesTimestamp(bucket.timestamp, grouping) + const bucketTimestampMs = + grouping === 'day' + ? timestampMs + : Math.floor(timestampMs / MINUTE_MS) * MINUTE_MS + const currentStats = statsByTimestamp.get(bucketTimestampMs) + const durationTotal = bucket.durationMs.average * bucket.total + + statsByTimestamp.set( + bucketTimestampMs, + currentStats + ? { + count: currentStats.count + bucket.total, + maxDurationMs: Math.max( + currentStats.maxDurationMs, + bucket.durationMs.maximum + ), + minDurationMs: Math.min( + currentStats.minDurationMs, + bucket.durationMs.minimum + ), + timestampMs: Math.max(currentStats.timestampMs, timestampMs), + totalDurationMs: currentStats.totalDurationMs + durationTotal, + } + : { + count: bucket.total, + maxDurationMs: bucket.durationMs.maximum, + minDurationMs: bucket.durationMs.minimum, + timestampMs, + totalDurationMs: durationTotal, + } + ) + } + + const points: WebhookStatsChartPoint[] = [ + { + synthetic: true, + timestamp: new Date(rangeBounds.start).toISOString(), + value: 0, + }, + ] + + points.push( + ...Array.from(statsByTimestamp) + .sort(([left], [right]) => left - right) + .map(([, stats]) => { + const value = stats + ? metric === 'avg' + ? stats.totalDurationMs / stats.count + : metric === 'max' + ? stats.maxDurationMs + : stats.minDurationMs + : null + + return { + timestamp: new Date(stats.timestampMs).toISOString(), + value, + } + }) + ) + + return points +} + +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 rangeStartMs = rangeBounds.start + const rangeEndMs = rangeBounds.end + const grouping: WebhookStatsGrouping = + range === 'this-week' ? 'day' : 'timestamp' + const xAxisScale = + range === '4h' + ? 'four-hour' + : range === '12h' + ? 'twelve-hour' + : range === 'today' + ? 'today' + : 'daily' + const hasFailedDeliveries = buckets.some((bucket) => bucket.failed > 0) + const deliverySeries = [ + { + name: 'Total deliveries', + colorVar: '--accent-info-highlight', + showSymbol: true, + z: 2, + data: + buckets.length > 0 + ? getDeliveryCountSeriesData(buckets, rangeBounds, grouping) + : getEmptyDeliveryCountSeriesData(rangeBounds, grouping), + }, + { + name: 'Failed deliveries', + colorVar: '--accent-error-highlight', + showSymbol: true, + z: hasFailedDeliveries ? 3 : 1, + data: + buckets.length > 0 + ? hideInactiveZeroValuePoints( + getDeliveryCountSeriesData( + buckets, + rangeBounds, + grouping, + 'failed' + ) + ) + : [], + }, + ] satisfies WebhookStatsChartSeries[] + const latencySeries = [ + { + name: 'Min', + colorVar: '--accent-info-highlight', + connectNulls: true, + lineWidth: 2, + showSymbol: true, + z: 1, + data: getResponseTimeSeriesData(buckets, rangeBounds, grouping, 'min'), + }, + { + name: 'Avg', + colorVar: '--accent-main-highlight', + connectNulls: true, + lineWidth: 2, + showSymbol: true, + z: 3, + data: getResponseTimeSeriesData(buckets, rangeBounds, grouping, 'avg'), + }, + { + name: 'Max', + colorVar: '--accent-warning-highlight', + connectNulls: true, + lineWidth: 2, + showSymbol: true, + z: 2, + data: getResponseTimeSeriesData(buckets, rangeBounds, grouping, 'max'), + }, + ] satisfies WebhookStatsChartSeries[] + 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/range-selector.tsx b/src/features/dashboard/settings/webhooks/detail/range-selector.tsx new file mode 100644 index 000000000..681b9a8b4 --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/range-selector.tsx @@ -0,0 +1,45 @@ +'use client' + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/ui/primitives/select' +import { + isWebhookStatsRange, + WEBHOOK_STATS_RANGE_OPTIONS, + type WebhookStatsRange, +} from './stats-range' + +type WebhookRangeSelectorProps = { + value: WebhookStatsRange + onChange: (value: WebhookStatsRange) => void +} + +export const WebhookRangeSelector = ({ + value, + onChange, +}: WebhookRangeSelectorProps) => { + const handleValueChange = (nextValue: string) => { + if (!isWebhookStatsRange(nextValue)) return + + onChange(nextValue) + } + + return ( + + ) +} diff --git a/src/features/dashboard/settings/webhooks/detail/stats-range.ts b/src/features/dashboard/settings/webhooks/detail/stats-range.ts new file mode 100644 index 000000000..6f6475d32 --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/stats-range.ts @@ -0,0 +1,126 @@ +import { createLoader, parseAsInteger } from 'nuqs/server' + +type WebhookStatsRangeBounds = { + start: number + end: number +} + +type WebhookStatsApiBounds = { + start: string + end: string +} + +const webhookStatsTimeframeParams = { + start: parseAsInteger, + end: parseAsInteger, +} + +const loadWebhookStatsTimeframeParams = createLoader( + webhookStatsTimeframeParams +) + +const getStableNow = () => { + const now = Date.now() + return Math.floor(now / 1_000) * 1_000 +} + +const getStartOfDay = (timestamp: number) => { + const date = new Date(timestamp) + date.setHours(0, 0, 0, 0) + return date.getTime() +} + +const 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(), +}) + +const getWebhookStatsRangeFromBounds = ({ + start, + end, +}: WebhookStatsRangeBounds): WebhookStatsRange => { + return ( + WEBHOOK_STATS_RANGE_OPTIONS.find( + (option) => Math.abs(option.getStart(end) - start) < 60_000 + )?.value ?? DEFAULT_WEBHOOK_STATS_RANGE + ) +} + +const getValidWebhookStatsBounds = ({ + start, + end, +}: Partial): WebhookStatsRangeBounds => + start && end && end > start + ? { start, end } + : getWebhookStatsRange(DEFAULT_WEBHOOK_STATS_RANGE) + +export { + DEFAULT_WEBHOOK_STATS_RANGE, + getWebhookStatsApiBounds, + getWebhookStatsRange, + getWebhookStatsRangeFromBounds, + getValidWebhookStatsBounds, + isWebhookStatsRange, + loadWebhookStatsTimeframeParams, + webhookStatsTimeframeParams, + WEBHOOK_STATS_RANGE_OPTIONS, + WEBHOOK_STATS_RANGE_VALUES, + type WebhookStatsApiBounds, + type WebhookStatsRange, + type WebhookStatsRangeBounds, +} diff --git a/src/features/dashboard/settings/webhooks/detail/status-badge.tsx b/src/features/dashboard/settings/webhooks/detail/status-badge.tsx new file mode 100644 index 000000000..91a54a39d --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/status-badge.tsx @@ -0,0 +1,36 @@ +import { Badge } from '@/ui/primitives/badge' + +type WebhookDeliveryHealth = 'disabled' | 'failing' | 'healthy' | 'unknown' + +const statusConfigMap: Record< + WebhookDeliveryHealth, + { label: string; variant: React.ComponentProps['variant'] } +> = { + disabled: { label: 'Disabled', variant: 'warning' }, + failing: { label: 'Failing', variant: 'error' }, + healthy: { label: 'Healthy', variant: 'positive' }, + unknown: { label: 'No deliveries', variant: 'info' }, +} + +type WebhookStatusBadgeProps = { + enabled: boolean + failedCount?: number + totalCount?: number +} + +export const WebhookStatusBadge = ({ + enabled, + failedCount, + totalCount, +}: WebhookStatusBadgeProps) => { + const health: WebhookDeliveryHealth = !enabled + ? 'disabled' + : !totalCount + ? 'unknown' + : failedCount && failedCount > 0 + ? 'failing' + : 'healthy' + const config = statusConfigMap[health] + + return {config.label} +} diff --git a/src/features/dashboard/settings/webhooks/detail/webhook-stats-chart.tsx b/src/features/dashboard/settings/webhooks/detail/webhook-stats-chart.tsx new file mode 100644 index 000000000..c1ab479cb --- /dev/null +++ b/src/features/dashboard/settings/webhooks/detail/webhook-stats-chart.tsx @@ -0,0 +1,404 @@ +'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, useEffect, useMemo, useRef } from 'react' +import { useCssVars } from '@/lib/hooks/use-css-vars' +import { cn } from '@/lib/utils' +import { calculateAxisMax } from '@/lib/utils/chart' +import { formatDisplayTimestamp } from '@/lib/utils/formatting' + +echarts.use([ + LineChart, + ScatterChart, + GridComponent, + TooltipComponent, + AxisPointerComponent, + SVGRenderer, +]) + +type WebhookStatsChartPoint = { + synthetic?: boolean + timestamp: string + value: number | null +} + +type WebhookStatsChartSeries = { + name: string + data: WebhookStatsChartPoint[] + connectNulls?: boolean + lineWidth?: number + showSymbol?: boolean + z?: number + colorVar: + | '--accent-main-highlight' + | '--accent-info-highlight' + | '--accent-error-highlight' + | '--accent-positive-highlight' + | '--accent-warning-highlight' + | '--fg' + | '--fg-secondary' + | '--fg-tertiary' +} + +type WebhookStatsChartProps = { + series: WebhookStatsChartSeries[] + chartType?: 'line' | 'scatter' + className?: string + valueFormatter?: (value: number) => string + yAxisValueFormatter?: (value: number) => string + xAxisScale?: 'daily' | 'four-hour' | 'twelve-hour' | 'today' + 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 formatAxisLabel = ( + value: number, + scale: NonNullable, + bounds: Pick +) => { + const date = new Date(value) + + if (scale === 'daily') { + 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 (scale === 'twelve-hour' && 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 = ({ + scale, + xAxisMax, + xAxisMin, +}: Pick & { + scale: NonNullable +}) => { + if (scale === 'daily') return DAY_MS + if (scale === 'four-hour') return HOUR_MS + if (scale === 'twelve-hour') 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 formatTooltipTimestamp = ( + timestampMs: number, + scale: NonNullable +) => { + if (scale !== 'daily') return formatDisplayTimestamp(timestampMs) + + const date = new Date(timestampMs) + 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 getTooltipTimestampMs = (param: unknown) => { + if (!param || typeof param !== 'object') return null + if (!('value' in param)) return null + if (!Array.isArray(param.value)) return null + + const [timestamp] = param.value + return typeof timestamp === 'number' ? timestamp : null +} + +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 WebhookStatsChart = memo(function WebhookStatsChart({ + series, + chartType = 'scatter', + className, + valueFormatter = defaultValueFormatter, + yAxisValueFormatter = valueFormatter, + xAxisScale = 'daily', + xAxisMax, + xAxisMin, +}: WebhookStatsChartProps) { + const chartRef = useRef(null) + const { resolvedTheme } = useTheme() + const cssVars = useCssVars([ + '--accent-main-highlight', + '--accent-info-highlight', + '--accent-error-highlight', + '--accent-positive-highlight', + '--accent-warning-highlight', + '--fg', + '--fg-secondary', + '--fg-tertiary', + '--stroke', + '--bg-1', + '--font-mono', + ] as const) + + const stroke = cssVars['--stroke'] || '#d4d4d4' + const fgTertiary = cssVars['--fg-tertiary'] || '#666' + const bg = cssVars['--bg-1'] || '#fff' + const fontMono = cssVars['--font-mono'] || 'monospace' + + 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({ + scale: xAxisScale, + xAxisMax, + xAxisMin, + }) + + const getTooltipContent = (param: unknown) => { + if (getTooltipSyntheticValue(param)) return '' + + const timestampMs = getTooltipTimestampMs(param) + if (timestampMs === null) return '' + + const rows = series.flatMap((item) => { + const point = item.data.find( + (point) => + !point.synthetic && + point.value !== null && + new Date(point.timestamp).getTime() === timestampMs + ) + if (!point || point.value === null) return [] + + const color = cssVars[item.colorVar] || '#000' + + return [ + `
+ + + + ${item.name} + + + ${valueFormatter(point.value)} +
`, + ] + }) + + if (rows.length === 0) return '' + + return `
+
${formatTooltipTimestamp(timestampMs, xAxisScale)}
+
${rows.join('')}
+
` + } + + const chartSeries: SeriesOption[] = series.map((item) => { + const color = cssVars[item.colorVar] || '#000' + + return { + name: item.name, + type: chartType, + z: item.z, + data: item.data.map((point) => ({ + synthetic: point.synthetic, + value: [new Date(point.timestamp).getTime(), point.value], + })), + symbol: 'circle', + symbolSize: (_value: unknown, params: unknown) => + getTooltipSyntheticValue(params) ? 0 : 7, + showSymbol: item.showSymbol ?? chartType === 'scatter', + connectNulls: item.connectNulls, + itemStyle: { + color, + }, + lineStyle: { + color, + width: item.lineWidth ?? 2, + }, + emphasis: { + disabled: true, + }, + } + }) + + return { + backgroundColor: 'transparent', + animation: false, + grid: { + top: 16, + right: 16, + bottom: 28, + left: yAxisLabelGutter, + }, + tooltip: { + trigger: 'item', + confine: true, + transitionDuration: 0, + backgroundColor: bg, + borderColor: stroke, + borderWidth: 1, + textStyle: { + color: fgTertiary, + fontFamily: fontMono, + fontSize: 12, + }, + axisPointer: { + type: 'line', + lineStyle: { + color: stroke, + type: 'solid', + width: 1, + }, + label: { + show: false, + }, + }, + formatter: getTooltipContent, + }, + 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: 12, + hideOverlap: true, + formatter: (value: number) => + formatAxisLabel(value, xAxisScale, { xAxisMax, xAxisMin }), + }, + splitLine: { show: false }, + axisPointer: { + show: true, + type: 'line', + lineStyle: { + color: stroke, + type: 'solid', + width: 1, + }, + 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: 12, + interval: 0, + margin: yAxisLabelGutter, + formatter: (value: number) => yAxisValueFormatter(value), + }, + splitLine: { + show: true, + lineStyle: { + color: stroke, + type: 'dashed', + }, + interval: 0, + }, + axisPointer: { show: false }, + }, + series: chartSeries, + } + }, [ + series, + chartType, + cssVars, + stroke, + fgTertiary, + bg, + fontMono, + valueFormatter, + yAxisValueFormatter, + xAxisScale, + xAxisMax, + xAxisMin, + ]) + + useEffect(() => { + const frame = requestAnimationFrame(() => { + chartRef.current?.getEchartsInstance().resize() + }) + + return () => cancelAnimationFrame(frame) + }) + + return ( +
+ +
+ ) +}) + +export { + WebhookStatsChart, + type WebhookStatsChartPoint, + type WebhookStatsChartSeries, +} diff --git a/src/features/dashboard/settings/webhooks/discard-webhook-changes-dialog.tsx b/src/features/dashboard/settings/webhooks/discard-webhook-changes-dialog.tsx new file mode 100644 index 000000000..5b1defb7c --- /dev/null +++ b/src/features/dashboard/settings/webhooks/discard-webhook-changes-dialog.tsx @@ -0,0 +1,51 @@ +'use client' + +import { Button } from '@/ui/primitives/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/ui/primitives/dialog' +import { CloseIcon } from '@/ui/primitives/icons' + +type DiscardWebhookChangesDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + onDiscard: () => void +} + +export const DiscardWebhookChangesDialog = ({ + open, + onOpenChange, + onDiscard, +}: DiscardWebhookChangesDialogProps) => ( + + + + Discard changes? + + You have unsaved changes. If you leave now, they'll be lost. + + + + + + + + +) diff --git a/src/features/dashboard/settings/webhooks/edit-secret-dialog.tsx b/src/features/dashboard/settings/webhooks/edit-secret-dialog.tsx deleted file mode 100644 index 6e86312cc..000000000 --- a/src/features/dashboard/settings/webhooks/edit-secret-dialog.tsx +++ /dev/null @@ -1,190 +0,0 @@ -'use client' - -import { zodResolver } from '@hookform/resolvers/zod' -import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hooks' -import { useState } from 'react' -import { updateWebhookSecretAction } from '@/core/server/actions/webhooks-actions' -import { UpdateWebhookSecretSchema } from '@/core/server/functions/webhooks/schema' -import { useDashboard } from '@/features/dashboard/context' -import { - defaultErrorToast, - defaultSuccessToast, - toast, -} from '@/lib/hooks/use-toast' -import { Button } from '@/ui/primitives/button' -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@/ui/primitives/dialog' -import { - Form, - FormControl, - FormField, - FormItem, - FormMessage, -} from '@/ui/primitives/form' -import { CheckIcon } from '@/ui/primitives/icons' -import { Input } from '@/ui/primitives/input' -import { Loader } from '@/ui/primitives/loader' -import type { Webhook } from './types' - -interface WebhookEditSecretDialogProps { - children: React.ReactNode - webhook: Webhook -} - -export default function WebhookEditSecretDialog({ - children: trigger, - webhook, -}: WebhookEditSecretDialogProps) { - 'use no memo' - - const { team } = useDashboard() - const [open, setOpen] = useState(false) - - const webhookName = webhook.name - - const { - form, - resetFormAndAction, - handleSubmitWithAction, - action: { isPending: isLoading }, - } = useHookFormAction( - updateWebhookSecretAction, - zodResolver(UpdateWebhookSecretSchema), - { - formProps: { - mode: 'onChange', - defaultValues: { - teamSlug: team.slug, - webhookId: webhook.id, - signatureSecret: '', - }, - }, - actionProps: { - onSuccess: () => { - toast(defaultSuccessToast('Webhook secret rotated successfully')) - handleDialogChange(false) - }, - onError: ({ error }) => { - toast( - defaultErrorToast( - error.serverError || 'Failed to rotate webhook secret' - ) - ) - }, - }, - } - ) - - const handleDialogChange = (value: boolean) => { - setOpen(value) - - if (value) return - - resetFormAndAction() - } - - // watch field to trigger reactive updates - const signatureSecret = form.watch('signatureSecret') - - // use form state for validation - sync with zod schema - const { errors } = form.formState - const isSecretValid = - !errors.signatureSecret && signatureSecret && signatureSecret.length >= 32 - - return ( - - {trigger} - - - - - Rotate Secret for {webhookName ? `"${webhookName}"` : 'Webhook'} - -
-

- Important: E2B - sends only one signature secret at a time. Once you change it, the - old secret immediately stops working. -

-
-

- To rotate safely without downtime: -

-
    -
  1. Generate a new custom secret
  2. -
  3. - Update your endpoint to accept{' '} - both current and new - custom secrets -
  4. -
  5. Deploy your changes
  6. -
  7. - Then roll confirm your new custom secret here โ€” E2B will start - using the new secret -
  8. -
  9. Remove old secret validation from your code later
  10. -
-
-
-
- -
- - {/* Hidden fields */} - - - -
- ( - - - - -

- {'> 32 characters'} -

- -
- )} - /> -
- - - {isLoading ? ( -
- - Rotating Secret... -
- ) : ( - - )} -
-
- -
-
- ) -} diff --git a/src/features/dashboard/settings/webhooks/empty.tsx b/src/features/dashboard/settings/webhooks/empty.tsx deleted file mode 100644 index b28dbf825..000000000 --- a/src/features/dashboard/settings/webhooks/empty.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { cn } from '@/lib/utils' -import { WebhookIcon } from '@/ui/primitives/icons' - -interface WebhooksEmptyProps { - error?: string -} - -export default function WebhooksEmpty({ error }: WebhooksEmptyProps) { - return ( -
- -

- {error ? error : 'No webhooks added yet'} -

-
- ) -} 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/finish-webhook-setup-dialog.tsx b/src/features/dashboard/settings/webhooks/finish-webhook-setup-dialog.tsx new file mode 100644 index 000000000..f2ef2ed11 --- /dev/null +++ b/src/features/dashboard/settings/webhooks/finish-webhook-setup-dialog.tsx @@ -0,0 +1,157 @@ +'use client' + +import { useState } from 'react' +import { CodeBlock } from '@/ui/code-block' +import { Button } from '@/ui/primitives/button' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/ui/primitives/dialog' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/ui/primitives/tabs' +import { WEBHOOK_SIGNATURE_VALIDATION_DOCS_URL } from './constants' + +const WEBHOOK_VERIFICATION_SNIPPETS = { + javascript: `import crypto from 'crypto' + +function verifyWebhookSignature(secret : string, payload : string, payloadSignature : string) { + const expectedSignatureRaw = crypto.createHash('sha256').update(secret + payload).digest('base64'); + const expectedSignature = expectedSignatureRaw.replace(/=+$/, ''); + return expectedSignature == payloadSignature +} + +const payloadValid = verifyWebhookSignature(secret, webhookBodyRaw, webhookSignatureHeader) +if (payloadValid) { + console.log("Payload signature is valid") +} else { + console.log("Payload signature is INVALID") +}`, + python: `import hashlib +import base64 + +def verify_webhook_signature(secret: str, payload: str, payload_signature: str) -> bool: + hash_bytes = hashlib.sha256((secret + payload).encode('utf-8')).digest() + expected_signature = base64.b64encode(hash_bytes).decode('utf-8') + expected_signature = expected_signature.rstrip('=') + + return expected_signature == payload_signature + +if verify_webhook_signature(secret, webhook_body_raw, webhook_signature_header): + print("Payload signature is valid") +else: + print("Payload signature is INVALID")`, +} + +type WebhookVerificationLanguage = keyof typeof WEBHOOK_VERIFICATION_SNIPPETS + +const WEBHOOK_VERIFICATION_LANGUAGE_LABELS: Record< + WebhookVerificationLanguage, + string +> = { + javascript: 'JavaScript', + python: 'Python', +} + +// Checks if a tab value is a snippet language; "python" -> true, "go" -> false. +const isWebhookVerificationLanguage = ( + value: string +): value is WebhookVerificationLanguage => + value === 'javascript' || value === 'python' + +type FinishWebhookSetupDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void +} + +export const FinishWebhookSetupDialog = ({ + open, + onOpenChange, +}: FinishWebhookSetupDialogProps) => { + const [language, setLanguage] = + useState('javascript') + + const handleLanguageChange = (value: string) => { + if (isWebhookVerificationLanguage(value)) setLanguage(value) + } + + return ( + + + + Finish Webhook Setup + + +
+
+

+ Secret Use +

+

+ Use the snippet below to verify the secret when your webhook is + called.{' '} + + Read more in the docs. + +

+
+ + + + {Object.entries(WEBHOOK_VERIFICATION_LANGUAGE_LABELS).map( + ([value, label]) => ( + + + {value === 'javascript' ? 'JS' : 'PY'} + + {label} + + ) + )} + + + {Object.entries(WEBHOOK_VERIFICATION_SNIPPETS).map( + ([value, snippet]) => ( + + + {snippet} + + + ) + )} + +
+ + + + +
+
+ ) +} diff --git a/src/features/dashboard/settings/webhooks/table-body.tsx b/src/features/dashboard/settings/webhooks/table-body.tsx deleted file mode 100644 index fa436a298..000000000 --- a/src/features/dashboard/settings/webhooks/table-body.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { getWebhooks } from '@/core/server/functions/webhooks/get-webhooks' -import { TableCell, TableRow } from '@/ui/primitives/table' -import WebhooksEmpty from './empty' -import WebhookTableRow from './table-row' - -interface TableBodyContentProps { - params: Promise<{ - teamSlug: string - }> -} - -export default async function TableBodyContent({ - params, -}: TableBodyContentProps) { - const { teamSlug } = await params - const webhooksResult = await getWebhooks({ teamSlug }) - - // undefined data indicates execution error so we disable the controls - const hasError = webhooksResult?.data === undefined - // normalize data field, no matter the execution result - const data = webhooksResult?.data ? webhooksResult.data : { webhooks: [] } - - if (hasError || !data.webhooks.length) { - return ( - - - - - - ) - } - - return ( - <> - {data.webhooks.map((webhook, index) => ( - - ))} - - ) -} diff --git a/src/features/dashboard/settings/webhooks/table-row.tsx b/src/features/dashboard/settings/webhooks/table-row.tsx index 839599747..fcadab409 100644 --- a/src/features/dashboard/settings/webhooks/table-row.tsx +++ b/src/features/dashboard/settings/webhooks/table-row.tsx @@ -1,7 +1,12 @@ 'use client' +import { useRouter } from 'next/navigation' import { useState } from 'react' -import { Badge } from '@/ui/primitives/badge' +import { PROTECTED_URLS } from '@/configs/urls' +import { useClipboard } from '@/lib/hooks/use-clipboard' +import { defaultSuccessToast, toast } from '@/lib/hooks/use-toast' +import { cn } from '@/lib/utils' +import { Button } from '@/ui/primitives/button' import { DropdownMenu, DropdownMenuContent, @@ -11,6 +16,8 @@ import { } from '@/ui/primitives/dropdown-menu' import { IconButton } from '@/ui/primitives/icon-button' import { + CheckIcon, + CopyIcon, EditIcon, IndicatorDotsIcon, PrivateIcon, @@ -18,25 +25,128 @@ import { WebhookIcon, } from '@/ui/primitives/icons' import { TableCell, TableRow } from '@/ui/primitives/table' -import WebhookAddEditDialog from './add-edit-dialog' -import WebhookDeleteDialog from './delete-dialog' -import WebhookEditSecretDialog from './edit-secret-dialog' +import { useDashboard } from '../../context' +import { UserAvatar } from '../../shared' +import { DeleteWebhookDialog } from './delete-webhook-dialog' +import { WebhookEventBadges } from './event-badges' import type { Webhook } from './types' +import { UpdateWebhookSecretDialog } from './update-webhook-secret-dialog' +import { UpsertWebhookDialog } from './upsert-webhook-dialog' -interface WebhookTableRowProps { +type WebhookRowProps = { webhook: Webhook - index: number - className?: string } -export default function WebhookTableRow({ - webhook, - index, - className, -}: WebhookTableRowProps) { - const [hoveredRowIndex, setHoveredRowIndex] = useState(-1) +type WebhookRowActionsProps = { + webhook: Webhook +} + +type WebhookNameAndUrlProps = { + name: string + url: string +} + +type UrlIconState = 'copied' | 'hovered' | 'idle' + +const urlIconMap: Record = { + copied: CheckIcon, + hovered: CopyIcon, + idle: WebhookIcon, +} + +const WebhookNameAndUrl = ({ name, url }: WebhookNameAndUrlProps) => { + const [wasCopied, copy] = useClipboard(1500) + const [isUrlHovered, setIsUrlHovered] = useState(false) + const iconState: UrlIconState = wasCopied + ? 'copied' + : isUrlHovered + ? 'hovered' + : 'idle' + const UrlIcon = urlIconMap[iconState] + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation() + await copy(url) + toast(defaultSuccessToast('Webhook URL copied')) + } + + return ( +
+ + +
+

+ {name} +

+ +
+
+ ) +} + +const rowCellClassName = 'p-0 py-1.5 align-middle' +const rowContentClassName = 'flex items-center' +const actionIconClassName = 'size-4 text-fg-tertiary' + +const WebhookRowActions = ({ webhook }: WebhookRowActionsProps) => { const [dropDownOpen, setDropDownOpen] = useState(false) + return ( + + + e.stopPropagation()} + > + + + + + + + e.preventDefault()}> + Edit + + + + e.preventDefault()}> + + Delete + + + + e.preventDefault()}> + Edit secret + + + + + + ) +} + +export const WebhookTableRow = ({ webhook }: WebhookRowProps) => { + const { team } = useDashboard() + const router = useRouter() + const createdAt = webhook.createdAt ? new Date(webhook.createdAt).toLocaleDateString('en-US', { month: 'short', @@ -45,91 +155,40 @@ export default function WebhookTableRow({ }) : '-' + 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 ( setHoveredRowIndex(index)} - onMouseLeave={() => setHoveredRowIndex(-1)} - className={className} + className="cursor-pointer hover:bg-bg-hover" + onClick={handleRowClick} > - {/* Name & URL Column */} - -
- {/* Icon Container */} -
- -
- - {/* Name & URL */} -
-
- {webhook.name} -
-
- {webhook.url} -
-
-
+ + - {/* Events Column */} - -
-
- {webhook.events.map((event) => ( - - {event} - - ))} -
- {/* Fade out gradient overlay */} -
+ +
+
- {/* Added Column */} - - {createdAt} + +
+

+ {createdAt} +

+ +
- {/* Actions Column */} - - - - - - - - - - - e.preventDefault()}> - Edit - - - - e.preventDefault()}> - Rotate - Secret - - - - e.preventDefault()} - > - - Delete - - - - - + + ) diff --git a/src/features/dashboard/settings/webhooks/table.tsx b/src/features/dashboard/settings/webhooks/table.tsx index 4c4342413..1b8f393e0 100644 --- a/src/features/dashboard/settings/webhooks/table.tsx +++ b/src/features/dashboard/settings/webhooks/table.tsx @@ -1,40 +1,88 @@ -import { type FC, Suspense } from 'react' import { cn } from '@/lib/utils' +import { WebhookIcon } from '@/ui/primitives/icons' import { Table, TableBody, + TableEmptyState, TableHead, TableHeader, + TableLoadingState, TableRow, } from '@/ui/primitives/table' -import { TableLoader } from '@/ui/table-loader' -import TableBodyContent from './table-body' +import { WebhookTableRow } from './table-row' +import type { Webhook } from './types' interface WebhooksTableProps { - params: Promise<{ - teamSlug: string - }> + webhooks: Webhook[] + totalWebhookCount: number + isLoading?: boolean className?: string } -const WebhooksTable: FC = ({ params, className }) => { +const headerCellClassName = + '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, + totalWebhookCount, + isLoading = false, + className, +}: WebhooksTableProps) => { + const hasNoWebhooks = totalWebhookCount === 0 + const emptyMessage = hasNoWebhooks + ? 'No webhooks added yet' + : 'No webhooks match your search' + return ( - - - - Name & URL - Events - Added - +
+ + + + + + + + + NAME & URL + EVENTS + ADDED + + Actions + - - }> - - + 0 && [ + '[&_tr]:border-stroke', + '[&_tr:last-child]:border-b [&_tr:last-child]:border-stroke', + ] + )} + > + {isLoading ? ( + + ) : webhooks.length === 0 ? ( + + +

{emptyMessage}

+
+ ) : ( + webhooks.map((webhook) => ( + + )) + )}
) } - -export default WebhooksTable diff --git a/src/features/dashboard/settings/webhooks/update-webhook-secret-dialog.tsx b/src/features/dashboard/settings/webhooks/update-webhook-secret-dialog.tsx new file mode 100644 index 000000000..49eec522d --- /dev/null +++ b/src/features/dashboard/settings/webhooks/update-webhook-secret-dialog.tsx @@ -0,0 +1,176 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' +import { useForm } from 'react-hook-form' +import { + type UpdateWebhookSecretInput, + UpdateWebhookSecretInputSchema, +} from '@/core/server/functions/webhooks/schema' +import { useDashboard } from '@/features/dashboard/context' +import { + defaultErrorToast, + defaultSuccessToast, + toast, +} from '@/lib/hooks/use-toast' +import { useTRPC } from '@/trpc/client' +import { Button } from '@/ui/primitives/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/ui/primitives/dialog' +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from '@/ui/primitives/form' +import { CheckIcon } from '@/ui/primitives/icons' +import { Input } from '@/ui/primitives/input' +import { Loader } from '@/ui/primitives/loader' +import type { Webhook } from './types' + +interface UpdateWebhookSecretDialogProps { + children: React.ReactNode + webhook: Webhook +} + +export const UpdateWebhookSecretDialog = ({ + children: trigger, + webhook, +}: UpdateWebhookSecretDialogProps) => { + 'use no memo' + + const { team } = useDashboard() + const trpc = useTRPC() + const queryClient = useQueryClient() + const [open, setOpen] = useState(false) + + const listQueryKey = trpc.webhooks.list.queryOptions({ + teamSlug: team.slug, + }).queryKey + + const form = useForm({ + resolver: zodResolver(UpdateWebhookSecretInputSchema), + mode: 'onChange', + defaultValues: { + webhookId: webhook.id, + signatureSecret: '', + }, + }) + + const updateSecretMutation = useMutation( + trpc.webhooks.updateSecret.mutationOptions({ + onSuccess: () => { + toast(defaultSuccessToast('Webhook secret edited successfully')) + void queryClient.invalidateQueries({ queryKey: listQueryKey }) + handleDialogChange(false) + }, + onError: (err) => { + toast(defaultErrorToast(err.message || 'Failed to edit webhook secret')) + }, + }) + ) + + const isLoading = updateSecretMutation.isPending + + const handleDialogChange = (value: boolean) => { + setOpen(value) + if (value) return + form.reset() + updateSecretMutation.reset() + } + + const handleSubmit = form.handleSubmit((values) => { + updateSecretMutation.mutate({ + ...values, + teamSlug: team.slug, + }) + }) + + const signatureSecret = form.watch('signatureSecret') + + const { errors } = form.formState + const isSecretValid = + !errors.signatureSecret && signatureSecret && signatureSecret.length >= 32 + + return ( + + {trigger} + + + Edit '{webhook.name}' secret + + Replacing the secret will deactivate the current one. Make sure to + update any systems using it. + + + +
+ + + + ( + + + + form.setValue('signatureSecret', '', { + shouldValidate: true, + shouldDirty: true, + }) + } + {...field} + /> + + {errors.signatureSecret ? ( + + ) : ( +

+ {'> 32 characters'} +

+ )} +
+ )} + /> + + + + + + +
+
+ ) +} diff --git a/src/features/dashboard/settings/webhooks/add-edit-dialog-steps.tsx b/src/features/dashboard/settings/webhooks/upsert-webhook-dialog-steps.tsx similarity index 70% rename from src/features/dashboard/settings/webhooks/add-edit-dialog-steps.tsx rename to src/features/dashboard/settings/webhooks/upsert-webhook-dialog-steps.tsx index 0244951bb..b925040b9 100644 --- a/src/features/dashboard/settings/webhooks/add-edit-dialog-steps.tsx +++ b/src/features/dashboard/settings/webhooks/upsert-webhook-dialog-steps.tsx @@ -1,12 +1,17 @@ 'use client' import { AnimatePresence, motion } from 'motion/react' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useRef } from 'react' import type { UseFormReturn } from 'react-hook-form' import ShikiHighlighter from 'react-shiki' +import { z } from 'zod' import { useShikiTheme } from '@/configs/shiki' -import { SandboxLifecycleEventTypeSchema } from '@/core/modules/sandboxes/lifecycle-event-types' -import type { UpsertWebhookSchemaType } from '@/core/server/functions/webhooks/schema' +import { + type SandboxLifecycleEventType, + SandboxLifecycleEventTypeSchema, +} from '@/core/modules/sandboxes/lifecycle-event-types' +import type { UpsertWebhookFormInput } from '@/core/server/functions/webhooks/schema' +import { useClipboard } from '@/lib/hooks/use-clipboard' import { Button } from '@/ui/primitives/button' import { Checkbox } from '@/ui/primitives/checkbox' import { @@ -16,83 +21,97 @@ import { FormLabel, FormMessage, } from '@/ui/primitives/form' -import { CopyIcon, ExternalLinkIcon, WarningIcon } from '@/ui/primitives/icons' +import { + CheckIcon, + CopyIcon, + ExternalLinkIcon, + WarningIcon, +} from '@/ui/primitives/icons' import { Input } from '@/ui/primitives/input' import { Label } from '@/ui/primitives/label' import { ScrollArea, ScrollBar } from '@/ui/primitives/scroll-area' import { Separator } from '@/ui/primitives/separator' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/ui/primitives/tabs' import { + WEBHOOK_EVENT_LABELS, WEBHOOK_EXAMPLE_PAYLOAD, WEBHOOK_SIGNATURE_VALIDATION_DOCS_URL, } from './constants' -type WebhookAddEditDialogStepsProps = { +const SecretTypeSchema = z.enum(['pre-generated', 'custom']) + +export type SecretType = z.infer + +type UpsertWebhookDialogStepsProps = { currentStep: number - form: UseFormReturn + form: UseFormReturn isLoading: boolean selectedEvents: string[] + exampleEventType: SandboxLifecycleEventType allEventsSelected: boolean handleAllToggle: () => void - handleEventToggle: (event: string) => void - mode: 'add' | 'edit' + handleEventToggle: (event: SandboxLifecycleEventType) => void + mode: 'create' | 'update' + hasCopiedSecret: boolean + setHasCopiedSecret: (value: boolean) => void + preGeneratedSecret: string + secretType: SecretType + onSecretTypeChange: (value: SecretType) => void } -export function WebhookAddEditDialogSteps({ +export function UpsertWebhookDialogSteps({ currentStep, form, isLoading, selectedEvents, + exampleEventType, allEventsSelected, handleAllToggle, handleEventToggle, mode, -}: WebhookAddEditDialogStepsProps) { + hasCopiedSecret, + setHasCopiedSecret, + preGeneratedSecret, + secretType, + onSecretTypeChange, +}: UpsertWebhookDialogStepsProps) { const shikiTheme = useShikiTheme() - const [secretType, setSecretType] = useState<'pre-generated' | 'custom'>( - 'pre-generated' - ) + const [secretCopiedFeedback, copySecret] = useClipboard() - const preGeneratedSecret = useMemo(() => { - const chars = - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' - const array = new Uint8Array(32) - crypto.getRandomValues(array) - return Array.from(array, (byte) => chars[byte % chars.length]).join('') - }, []) + const customSecretInputRef = useRef(null) + useEffect(() => { + if (secretType !== 'custom') return + const id = window.setTimeout(() => { + customSecretInputRef.current?.focus() + }, 0) + return () => window.clearTimeout(id) + }, [secretType]) - const [copied, setCopied] = useState(false) + const handleSecretTypeChange = (value: string) => { + const parsed = SecretTypeSchema.safeParse(value) + if (!parsed.success) return - // sync secret with form state and validation - only in 'add' mode - // in 'edit' mode, we should never touch the signature secret - useEffect(() => { - if (mode !== 'add') return + onSecretTypeChange(parsed.data) - if (secretType === 'pre-generated') { - // set pre-generated secret and trigger validation to clear any errors + if (mode !== 'create') return + + if (parsed.data === 'pre-generated') { form.setValue('signatureSecret', preGeneratedSecret, { shouldValidate: true, shouldDirty: true, }) - // explicitly clear any errors since pre-generated is always valid form.clearErrors('signatureSecret') } else { - // clear for custom input form.setValue('signatureSecret', '', { - shouldValidate: false, + shouldValidate: true, shouldDirty: false, }) } - }, [mode, secretType, preGeneratedSecret, form]) + } - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(preGeneratedSecret) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } catch (err) { - console.error('Failed to copy:', err) - } + const handleCopySecret = () => { + void copySecret(preGeneratedSecret) + setHasCopiedSecret(true) } return ( @@ -118,6 +137,8 @@ export function WebhookAddEditDialogSteps({ placeholder="Example webhook" disabled={isLoading} className="min-w-0" + clearable + onClear={() => field.onChange('')} {...field} /> @@ -138,6 +159,8 @@ export function WebhookAddEditDialogSteps({ placeholder="https://example.com/postreceive" disabled={isLoading} className="min-w-0" + clearable + onClear={() => field.onChange('')} {...field} /> @@ -188,7 +211,7 @@ export function WebhookAddEditDialogSteps({ htmlFor={`event-${event}`} className="cursor-pointer select-none" > - {event} + {WEBHOOK_EVENT_LABELS[event]}
))} @@ -217,7 +240,10 @@ export function WebhookAddEditDialogSteps({ addDefaultStyles={false} showLanguage={false} > - {WEBHOOK_EXAMPLE_PAYLOAD} + {WEBHOOK_EXAMPLE_PAYLOAD.replace( + '"sandbox.lifecycle.created"', + `"${exampleEventType}"` + )} @@ -243,8 +269,9 @@ export function WebhookAddEditDialogSteps({

This secret is used to verify webhook authenticity. Each request includes an e2b-signature header - generated with HMAC SHA-256. Validate this in your endpoint to - ensure requests are from E2B and untampered. + generated with Hash-based Message Authentication Code (HMAC) + SHA-256. Validate this in your endpoint to ensure requests are + from E2B and untampered.

- setSecretType(v as 'pre-generated' | 'custom') - } + onValueChange={handleSecretTypeChange} className="min-h-0 w-full flex-1 h-full" > @@ -298,19 +323,31 @@ export function WebhookAddEditDialogSteps({
-
- +
+

- Store this secret securely. You won't be able to view it - again after creating the webhook. + Copy and store it now. You won't be able to view it + again.

@@ -329,14 +366,24 @@ export function WebhookAddEditDialogSteps({ field.onChange('')} {...field} + ref={(el) => { + field.ref(el) + customSecretInputRef.current = el + }} /> -

- {'> 32 characters'} -

- + {form.formState.errors.signatureSecret ? ( + + ) : ( +

+ {'> 32 characters'} +

+ )} )} /> diff --git a/src/features/dashboard/settings/webhooks/upsert-webhook-dialog.tsx b/src/features/dashboard/settings/webhooks/upsert-webhook-dialog.tsx new file mode 100644 index 000000000..1e8c0447c --- /dev/null +++ b/src/features/dashboard/settings/webhooks/upsert-webhook-dialog.tsx @@ -0,0 +1,367 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useMemo, useState } from 'react' +import { useForm } from 'react-hook-form' +import { + type SandboxLifecycleEventType, + SandboxLifecycleEventTypeSchema, +} from '@/core/modules/sandboxes/lifecycle-event-types' +import { + type UpsertWebhookFormInput, + UpsertWebhookInputSchema, +} from '@/core/server/functions/webhooks/schema' +import { + defaultErrorToast, + defaultSuccessToast, + toast, +} from '@/lib/hooks/use-toast' +import { useTRPC } from '@/trpc/client' +import { Button } from '@/ui/primitives/button' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/ui/primitives/dialog' +import { Form } from '@/ui/primitives/form' +import { AddIcon, CheckIcon } from '@/ui/primitives/icons' +import { Loader } from '@/ui/primitives/loader' +import { useDashboard } from '../../context' +import { DiscardWebhookChangesDialog } from './discard-webhook-changes-dialog' +import { FinishWebhookSetupDialog } from './finish-webhook-setup-dialog' +import type { Webhook } from './types' +import { + type SecretType, + UpsertWebhookDialogSteps, +} from './upsert-webhook-dialog-steps' + +type UpsertWebhookDialogProps = + | { + children: React.ReactNode + mode: 'create' + webhook?: undefined + } + | { + children: React.ReactNode + mode: 'update' + webhook: Webhook + } + +export function UpsertWebhookDialog({ + children: trigger, + mode, + webhook, +}: UpsertWebhookDialogProps) { + 'use no memo' + + const { team } = useDashboard() + const trpc = useTRPC() + const queryClient = useQueryClient() + const [open, setOpen] = useState(false) + const [currentStep, setCurrentStep] = useState(1) + const [lastSelectedEvent, setLastSelectedEvent] = + useState(null) + const [hasCopiedSecret, setHasCopiedSecret] = useState(false) + const [secretType, setSecretType] = useState('pre-generated') + const [finishSetupDialogOpen, setFinishSetupDialogOpen] = useState(false) + const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false) + + const isUpdateMode = mode === 'update' + const totalSteps = isUpdateMode ? 1 : 2 + + const listQueryKey = trpc.webhooks.list.queryOptions({ + teamSlug: team.slug, + }).queryKey + + const preGeneratedSecret = useMemo(() => { + const chars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + const array = new Uint8Array(32) + crypto.getRandomValues(array) + return Array.from(array, (byte) => chars[byte % chars.length]).join('') + }, []) + + const defaultValues: UpsertWebhookFormInput = { + webhookId: isUpdateMode ? webhook?.id : undefined, + mode, + name: webhook?.name || '', + url: webhook?.url || '', + enabled: webhook?.enabled ?? true, + events: + webhook?.events?.filter( + (event): event is SandboxLifecycleEventType => + SandboxLifecycleEventTypeSchema.safeParse(event).success + ) ?? [], + ...(isUpdateMode ? {} : { signatureSecret: preGeneratedSecret }), + } + + const form = useForm({ + resolver: zodResolver(UpsertWebhookInputSchema), + mode: 'onChange', + disabled: !team.slug, + defaultValues, + values: defaultValues, + }) + + const upsertMutation = useMutation( + trpc.webhooks.upsert.mutationOptions({ + onSuccess: () => { + toast( + defaultSuccessToast( + isUpdateMode + ? 'Webhook edited successfully' + : 'Webhook created successfully' + ) + ) + void queryClient.invalidateQueries({ queryKey: listQueryKey }) + + setOpen(false) + resetDialogState() + + if (!isUpdateMode) { + setFinishSetupDialogOpen(true) + } + }, + onError: (err) => { + toast( + defaultErrorToast( + err.message || + (isUpdateMode + ? 'Failed to edit webhook' + : 'Failed to create webhook') + ) + ) + }, + }) + ) + + const isLoading = upsertMutation.isPending + + const resetDialogState = () => { + setCurrentStep(1) + setHasCopiedSecret(false) + setSecretType('pre-generated') + form.reset() + upsertMutation.reset() + } + + const handleDialogChange = (value: boolean) => { + if (!value && isUpdateMode && hasChanges && !upsertMutation.isPending) { + setDiscardConfirmOpen(true) + return + } + + setOpen(value) + if (!value) resetDialogState() + } + + const handleConfirmDiscard = () => { + setDiscardConfirmOpen(false) + setOpen(false) + resetDialogState() + } + + const handleSubmit = form.handleSubmit((values) => { + upsertMutation.mutate({ + ...values, + teamSlug: team.slug, + }) + }) + + const name = form.watch('name') + const url = form.watch('url') + const selectedEvents = form.watch('events') || [] + const signatureSecret = form.watch('signatureSecret') + + const allEventsSelected = + selectedEvents.length === SandboxLifecycleEventTypeSchema.options.length && + SandboxLifecycleEventTypeSchema.options.every((event) => + selectedEvents.includes(event) + ) + + const hasChanges = isUpdateMode + ? name !== webhook.name || + url !== webhook.url || + selectedEvents.length !== webhook.events.length || + [...selectedEvents].sort().join('|') !== + [...webhook.events].sort().join('|') + : false + + const { errors } = form.formState + + const isStep1Valid = + !errors.name && + !errors.url && + !errors.events && + selectedEvents.length > 0 && + name.trim().length > 0 && + url.trim().length > 0 + + const isStep2Valid = + !errors.signatureSecret && signatureSecret && signatureSecret.length >= 32 + + const handleAllToggle = () => { + if (allEventsSelected) { + form.setValue('events', []) + } else { + form.setValue('events', [...SandboxLifecycleEventTypeSchema.options]) + } + } + + const handleEventToggle = (event: SandboxLifecycleEventType) => { + const currentEvents = form.getValues('events') || [] + if (currentEvents.includes(event)) { + form.setValue( + 'events', + currentEvents.filter((eventName) => eventName !== event) + ) + } else { + form.setValue('events', [...currentEvents, event]) + setLastSelectedEvent(event) + } + } + + const exampleEventType: SandboxLifecycleEventType = + lastSelectedEvent && selectedEvents.includes(lastSelectedEvent) + ? lastSelectedEvent + : (SandboxLifecycleEventTypeSchema.options.find((event) => + selectedEvents.includes(event) + ) ?? 'sandbox.lifecycle.created') + + const handleNext = async () => { + if (currentStep === 1) { + const isNameValid = await form.trigger('name') + const isUrlValid = await form.trigger('url') + const isEventsValid = await form.trigger('events') + if (isNameValid && isUrlValid && isEventsValid) { + setCurrentStep(2) + } + } + } + + const handleBack = () => { + setCurrentStep(1) + } + + return ( + <> + + {trigger} + + + + + {isUpdateMode ? 'Edit Webhook' : 'Add Webhook'} + + {!isUpdateMode && ( +
+ + Step {currentStep} / {totalSteps} + +
+ )} +
+ +
+ + + +
+ +
+ + + {isLoading ? ( +
+ + + {isUpdateMode ? 'Saving Changes...' : 'Adding Webhook...'} + +
+ ) : isUpdateMode ? ( + + ) : currentStep === 1 ? ( + + ) : ( + <> + + + + )} +
+
+ +
+
+ + + + ) +} diff --git a/src/features/dashboard/settings/webhooks/webhooks-page-content.tsx b/src/features/dashboard/settings/webhooks/webhooks-page-content.tsx new file mode 100644 index 000000000..3164343a7 --- /dev/null +++ b/src/features/dashboard/settings/webhooks/webhooks-page-content.tsx @@ -0,0 +1,174 @@ +'use client' + +import { useSuspenseQuery } from '@tanstack/react-query' +import { Suspense, useMemo, useState } from 'react' +import { useDashboard } from '@/features/dashboard/context' +import { cn } from '@/lib/utils' +import { pluralize } from '@/lib/utils/formatting' +import { useTRPC } from '@/trpc/client' +import { CatchErrorBoundary } from '@/ui/error' +import { Button } from '@/ui/primitives/button' +import { AddIcon, SearchIcon } from '@/ui/primitives/icons' +import { Input } from '@/ui/primitives/input' +import { Skeleton } from '@/ui/primitives/skeleton' +import { WebhooksTable } from './table' +import { UpsertWebhookDialog } from './upsert-webhook-dialog' + +const useWebhooksQuery = () => { + const { team } = useDashboard() + const trpc = useTRPC() + return useSuspenseQuery( + trpc.webhooks.list.queryOptions({ teamSlug: team.slug }) + ) +} + +interface WebhooksSearchFieldProps { + value: string + onChange: (next: string) => void +} + +const WebhooksSearchFieldShell = ({ + value, + onChange, + placeholder, + disabled, +}: WebhooksSearchFieldProps & { placeholder: string; disabled?: boolean }) => ( +
+ + onChange(event.target.value)} + placeholder={placeholder} + type="search" + value={value} + disabled={disabled} + /> +
+) + +const WebhooksSearchField = ({ value, onChange }: WebhooksSearchFieldProps) => { + const { data } = useWebhooksQuery() + const count = data.webhooks.length + const placeholder = + count === 0 + ? 'Add a webhook to start searching' + : 'Search by name, URL, or event' + + return ( + + ) +} + +interface WebhooksTotalLabelProps { + query: string +} + +const WebhooksTotalLabel = ({ query }: WebhooksTotalLabelProps) => { + const { data } = useWebhooksQuery() + const { webhooks } = data + const totalCount = webhooks.length + const hasActiveSearch = query.trim().length > 0 + const filteredCount = hasActiveSearch + ? webhooks.filter(({ events, name, url }) => + [name, url, ...events].some((value) => + value.toLowerCase().includes(query.trim().toLowerCase()) + ) + ).length + : totalCount + + if (totalCount === 0) return null + + const label = hasActiveSearch + ? `Showing ${filteredCount} of ${totalCount} ${pluralize(totalCount, 'webhook')}` + : `${totalCount} ${pluralize(totalCount, 'webhook')} in total` + + return

{label}

+} + +const WebhooksTableContent = ({ query }: { query: string }) => { + const { data } = useWebhooksQuery() + const { webhooks } = data + const normalizedQuery = query.trim().toLowerCase() + + const filtered = useMemo(() => { + if (!normalizedQuery) return webhooks + return webhooks.filter(({ events, name, url }) => + [name, url, ...events].some((value) => + value.toLowerCase().includes(normalizedQuery) + ) + ) + }, [normalizedQuery, webhooks]) + + return ( + + ) +} + +interface WebhooksPageContentProps { + className?: string +} + +export const WebhooksPageContent = ({ + className, +}: WebhooksPageContentProps) => { + const [query, setQuery] = useState('') + + return ( +
+
+ + } + > + + + + + + +
+ + +
+

+ Receive POST requests to your URLs when sandbox lifecycle events + occur. +

+ }> + + +
+ +
+ + } + > + + +
+
+
+ ) +} diff --git a/src/features/dashboard/shared/event-type-badge.tsx b/src/features/dashboard/shared/event-type-badge.tsx new file mode 100644 index 000000000..7f41f8b68 --- /dev/null +++ b/src/features/dashboard/shared/event-type-badge.tsx @@ -0,0 +1,24 @@ +import { SandboxLifecycleEventTypeSchema } from '@/core/modules/sandboxes/lifecycle-event-types' +import { Badge } from '@/ui/primitives/badge' +import { SANDBOX_EVENT_TYPE_MAP } from './event-type-map' + +export const SandboxEventTypeBadge = ({ type }: { type: string }) => { + 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..6d19d4df4 --- /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 { SandboxEventTypeBadge } 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..fb94cc123 --- /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, + CheckIcon, + type Icon, + PausedIcon, + RefreshIcon, + RunningIcon, +} from '@/ui/primitives/icons' + +const SANDBOX_EVENT_TYPE_MAP: Record< + SandboxLifecycleEventType, + { icon: Icon; label: string } +> = { + 'sandbox.lifecycle.created': { icon: CheckIcon, 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..d913954eb 100644 --- a/src/features/dashboard/shared/index.ts +++ b/src/features/dashboard/shared/index.ts @@ -1,2 +1,6 @@ +export { SandboxEventTypeBadge } 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/features/dashboard/shared/user-avatar.tsx b/src/features/dashboard/shared/user-avatar.tsx index 5738138ed..e17ba8825 100644 --- a/src/features/dashboard/shared/user-avatar.tsx +++ b/src/features/dashboard/shared/user-avatar.tsx @@ -4,16 +4,16 @@ import { cn } from '@/lib/utils' import { Avatar, AvatarFallback, AvatarImage } from '@/ui/primitives/avatar' interface UserAvatarProps { - email?: string | null + label?: string | null url?: string | null className?: string } -export const UserAvatar = ({ email, url, className }: UserAvatarProps) => ( +export const UserAvatar = ({ label, url, className }: UserAvatarProps) => ( - {email?.charAt(0).toUpperCase() ?? '?'} + {label?.charAt(0).toUpperCase() ?? '?'} ) diff --git a/src/lib/utils/formatting.ts b/src/lib/utils/formatting.ts index d0c2ece7c..3f609e740 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/src/ui/copy-button.tsx b/src/ui/copy-button.tsx index 825b7e346..8c8eec7df 100644 --- a/src/ui/copy-button.tsx +++ b/src/ui/copy-button.tsx @@ -1,10 +1,10 @@ 'use client' import { AnimatePresence, motion } from 'motion/react' -import { FC } from 'react' +import type { FC } from 'react' import { useClipboard } from '@/lib/hooks/use-clipboard' import { EASE_APPEAR } from '@/lib/utils/ui' -import { IconButton, IconButtonProps } from '@/ui/primitives/icon-button' +import { IconButton, type IconButtonProps } from '@/ui/primitives/icon-button' import { CheckIcon, CopyIcon } from '@/ui/primitives/icons' interface CopyButtonProps extends IconButtonProps { diff --git a/src/ui/primitives/icons.tsx b/src/ui/primitives/icons.tsx index 126e7797a..a24432e42 100644 --- a/src/ui/primitives/icons.tsx +++ b/src/ui/primitives/icons.tsx @@ -1048,8 +1048,8 @@ export const BugIcon = ({ className, ...props }: IconProps) => ( ) @@ -1065,14 +1065,14 @@ export const FeedbackIcon = ({ className, ...props }: IconProps) => ( ) @@ -1216,21 +1216,21 @@ export const UnlockIcon = ({ className, ...props }: IconProps) => ( ) @@ -1456,14 +1456,14 @@ export const ArrowRightIcon = ({ className, ...props }: IconProps) => ( ) @@ -1479,14 +1479,14 @@ export const ArrowLeftIcon = ({ className, ...props }: IconProps) => ( ) diff --git a/src/ui/primitives/input.tsx b/src/ui/primitives/input.tsx index 465d4be5e..60600e295 100644 --- a/src/ui/primitives/input.tsx +++ b/src/ui/primitives/input.tsx @@ -3,13 +3,20 @@ import * as React from 'react' import { useEffect, useState } from 'react' import { cn } from '@/lib/utils' +import { CloseIcon } from './icons' export interface InputProps - extends React.InputHTMLAttributes {} + extends React.InputHTMLAttributes { + clearable?: boolean + onClear?: () => void +} const Input = React.forwardRef( - ({ className, type, ...props }, ref) => { - return ( + ({ className, type, clearable, onClear, ...props }, ref) => { + const showClear = + clearable && !props.disabled && !props.readOnly && Boolean(props.value) + + const inputElement = ( ( 'autofill:border-accent-main-highlight autofill:border-b-accent-main-highlight autofill:border-solid autofill:shadow-[inset_0_0_0px_1000px_var(--accent-main-bg)]', 'autofill:bg-accent-main-bg autofill:text-fg', + showClear && 'pr-8', className )} ref={ref} {...props} /> ) + + if (!clearable) return inputElement + + return ( +
+ {inputElement} + {showClear && ( + + )} +
+ ) } ) Input.displayName = 'Input' diff --git a/src/ui/table-loader.tsx b/src/ui/table-loader.tsx deleted file mode 100644 index c2b081bd2..000000000 --- a/src/ui/table-loader.tsx +++ /dev/null @@ -1,24 +0,0 @@ -'use client' - -import { cn } from '@/lib/utils' -import { Loader } from '@/ui/primitives/loader' -import { TableCell, TableRow } from '@/ui/primitives/table' - -interface TableLoaderProps { - colSpan?: number - className?: string -} - -export function TableLoader({ colSpan = 100, className }: TableLoaderProps) { - return ( - - -
- -
-
-
- ) -} diff --git a/tests/unit/sandbox-monitoring-chart-model.test.ts b/tests/unit/sandbox-monitoring-chart-model.test.ts index 848a982ef..adf1e9819 100644 --- a/tests/unit/sandbox-monitoring-chart-model.test.ts +++ b/tests/unit/sandbox-monitoring-chart-model.test.ts @@ -9,6 +9,7 @@ const baseMetric = { timestamp: '1970-01-01T00:00:00.000Z', cpuCount: 2, memTotal: 1_000, + memCache: 0, diskTotal: 2_000, } satisfies Omit< SandboxMetric,