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/core/modules/sandboxes/lifecycle-event-types.ts b/src/core/modules/sandboxes/lifecycle-event-types.ts new file mode 100644 index 000000000..3efec7212 --- /dev/null +++ b/src/core/modules/sandboxes/lifecycle-event-types.ts @@ -0,0 +1,13 @@ +import { z } from 'zod' + +const SandboxLifecycleEventTypeSchema = z.enum([ + 'sandbox.lifecycle.created', + 'sandbox.lifecycle.updated', + 'sandbox.lifecycle.paused', + 'sandbox.lifecycle.resumed', + 'sandbox.lifecycle.killed', +]) + +type SandboxLifecycleEventType = z.infer + +export { SandboxLifecycleEventTypeSchema, type SandboxLifecycleEventType } diff --git a/src/core/modules/webhooks/repository.server.ts b/src/core/modules/webhooks/repository.server.ts index f7188d7df..4102832d1 100644 --- a/src/core/modules/webhooks/repository.server.ts +++ b/src/core/modules/webhooks/repository.server.ts @@ -15,7 +15,7 @@ type WebhooksRepositoryDeps = { export type WebhooksScope = TeamRequestScope export interface UpsertWebhookInput { - mode: 'create' | 'edit' + mode: 'create' | 'update' webhookId?: string name: string url: string @@ -68,34 +68,68 @@ export function createWebhooksRepository( 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..096e95101 --- /dev/null +++ b/src/core/server/api/routers/webhooks.ts @@ -0,0 +1,124 @@ +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, + 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' + +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 } + }), + + upsert: webhooksRepositoryProcedure + .input(UpsertWebhookInputSchema) + .mutation(async ({ ctx, input }) => { + const { mode, webhookId, name, url, events, signatureSecret, enabled } = + input + + const result = await ctx.webhooksRepository.upsertWebhook({ + mode, + webhookId: webhookId ?? undefined, + name, + url, + events, + signatureSecret: signatureSecret ?? undefined, + enabled: enabled ?? true, + }) + + if (!result.ok) { + l.error( + { + key: + 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, name, events }, + }, + `Failed to ${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..6e19e54ca 100644 --- a/src/core/server/functions/webhooks/schema.ts +++ b/src/core/server/functions/webhooks/schema.ts @@ -1,50 +1,52 @@ 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 +export type UpsertWebhookInput = z.input +export type DeleteWebhookInput = z.input +export type UpdateWebhookSecretInput = z.input< + typeof UpdateWebhookSecretInputSchema > 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/settings/keys/api-keys-table-row.tsx b/src/features/dashboard/settings/keys/api-keys-table-row.tsx index 4c82af330..59e7341ef 100644 --- a/src/features/dashboard/settings/keys/api-keys-table-row.tsx +++ b/src/features/dashboard/settings/keys/api-keys-table-row.tsx @@ -123,7 +123,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 23787a632..000000000 --- a/src/features/dashboard/settings/webhooks/add-edit-dialog.tsx +++ /dev/null @@ -1,278 +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 { 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 { WEBHOOK_EVENTS } from './constants' -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 === WEBHOOK_EVENTS.length && - WEBHOOK_EVENTS.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', [...WEBHOOK_EVENTS]) - } - } - - 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 ? ( - - ) : ( - /* Add mode: show next/back navigation */ - <> - {currentStep === 1 ? ( - - ) : ( - <> - - - - )} - - )} - - )} -
-
- -
-
- ) -} diff --git a/src/features/dashboard/settings/webhooks/constants.ts b/src/features/dashboard/settings/webhooks/constants.ts index 07a172cd8..0b08dc2b9 100644 --- a/src/features/dashboard/settings/webhooks/constants.ts +++ b/src/features/dashboard/settings/webhooks/constants.ts @@ -1,28 +1,14 @@ -export const WEBHOOK_EVENTS = [ - 'sandbox.lifecycle.created', - 'sandbox.lifecycle.paused', - 'sandbox.lifecycle.resumed', - 'sandbox.lifecycle.updated', - 'sandbox.lifecycle.killed', -] as const - -export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number] - -export const WEBHOOK_EXAMPLE_PAYLOAD = `{ - "version": "v1", - "id": "", - "type": "sandbox.lifecycle.created", - "eventData": null, - "sandboxBuildId": "", - "sandboxExecutionId": "", - "sandboxId": "", - "sandboxTeamId": "", - "sandboxTemplateId": "", - "timestamp": "" +import type { SandboxLifecycleEventType } from '@/core/modules/sandboxes/lifecycle-event-types' + +export 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', } -// Payload structure may vary by event type. -// See docs for full schema.` +export const WEBHOOK_DOCS_URL = + 'https://e2b.dev/docs/sandbox/lifecycle-events-webhooks' -export const WEBHOOK_SIGNATURE_VALIDATION_DOCS_URL = - 'https://e2b.dev/docs/sandbox/lifecycle-events-webhooks#webhook-verification' +export const WEBHOOK_SIGNATURE_VALIDATION_DOCS_URL = `${WEBHOOK_DOCS_URL}#webhook-verification` 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/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/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..c0b9c824b 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 { useState } from 'react' +import { Fragment, useState } from 'react' +import { SandboxLifecycleEventTypeSchema } from '@/core/modules/sandboxes/lifecycle-event-types' +import { useClipboard } from '@/lib/hooks/use-clipboard' +import { defaultSuccessToast, toast } from '@/lib/hooks/use-toast' +import { cn } from '@/lib/utils' import { Badge } from '@/ui/primitives/badge' +import { Button } from '@/ui/primitives/button' import { DropdownMenu, 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,169 @@ 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 { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/ui/primitives/tooltip' +import { useDashboard } from '../../context' +import { UserAvatar } from '../../shared' +import { WEBHOOK_EVENT_LABELS } from './constants' +import { DeleteWebhookDialog } from './delete-webhook-dialog' import 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 [tr:first-child>&]:pt-0' +const rowContentClassName = 'flex items-center' +const actionIconClassName = 'size-4 text-fg-tertiary' + +const getWebhookEventLabel = (event: string): string => { + const matchedEvent = SandboxLifecycleEventTypeSchema.options.find( + (webhookEvent) => webhookEvent === event + ) + if (!matchedEvent) return event + return WEBHOOK_EVENT_LABELS[matchedEvent] +} + +type WebhookEventBadgesProps = { + events: readonly string[] +} + +const WebhookEventBadges = ({ events }: WebhookEventBadgesProps) => { + const isAllEvents = + events.length === SandboxLifecycleEventTypeSchema.options.length + + if (isAllEvents) { + return ( + + + ALL ({events.length}) + + +
+ {SandboxLifecycleEventTypeSchema.options.map((event, index) => ( + + {index > 0 && ( + + )} + {WEBHOOK_EVENT_LABELS[event]} + + ))} +
+
+
+ ) + } + + return events.map((event) => ( + {getWebhookEventLabel(event)} + )) +} + +const WebhookRowActions = ({ webhook }: WebhookRowActionsProps) => { const [dropDownOpen, setDropDownOpen] = useState(false) + return ( + + + + + + + + + + e.preventDefault()}> + Edit + + + + e.preventDefault()}> + + Delete + + + + e.preventDefault()}> + Edit secret + + + + + + ) +} + +export const WebhookTableRow = ({ webhook }: WebhookRowProps) => { + const { team } = useDashboard() + const createdAt = webhook.createdAt ? new Date(webhook.createdAt).toLocaleDateString('en-US', { month: 'short', @@ -46,90 +197,28 @@ export default function WebhookTableRow({ : '-' return ( - setHoveredRowIndex(index)} - onMouseLeave={() => setHoveredRowIndex(-1)} - className={className} - > - {/* 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..b4e5e77ee 100644 --- a/src/features/dashboard/settings/webhooks/table.tsx +++ b/src/features/dashboard/settings/webhooks/table.tsx @@ -1,40 +1,83 @@ -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-2 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 63% 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 a4620b702..8c555c604 100644 --- a/src/features/dashboard/settings/webhooks/add-edit-dialog-steps.tsx +++ b/src/features/dashboard/settings/webhooks/upsert-webhook-dialog-steps.tsx @@ -1,11 +1,15 @@ 'use client' import { AnimatePresence, motion } from 'motion/react' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef } from 'react' import type { UseFormReturn } from 'react-hook-form' -import ShikiHighlighter from 'react-shiki' -import { useShikiTheme } from '@/configs/shiki' -import type { UpsertWebhookSchemaType } from '@/core/server/functions/webhooks/schema' +import { z } from 'zod' +import { + type SandboxLifecycleEventType, + SandboxLifecycleEventTypeSchema, +} from '@/core/modules/sandboxes/lifecycle-event-types' +import type { UpsertWebhookInput } 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 { @@ -15,44 +19,87 @@ import { FormLabel, FormMessage, } from '@/ui/primitives/form' -import { CopyIcon, ExternalLinkIcon, WarningIcon } from '@/ui/primitives/icons' +import { CheckIcon, CopyIcon, 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_EVENTS, - WEBHOOK_EXAMPLE_PAYLOAD, - WEBHOOK_SIGNATURE_VALIDATION_DOCS_URL, -} from './constants' +import { WEBHOOK_DOCS_URL, WEBHOOK_EVENT_LABELS } from './constants' + +const SecretTypeSchema = z.enum(['pre-generated', 'custom']) -type WebhookAddEditDialogStepsProps = { +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' + secretType: SecretType + onSecretTypeChange: (value: SecretType) => void + hasCopied: boolean + onCopied: () => void } -export function WebhookAddEditDialogSteps({ +const WebhookExamplePayload = ({ + eventType, +}: { + eventType: SandboxLifecycleEventType +}) => ( +
+
+
{'{'}
+
+ {' '} + {'"type"'} + {`: "${eventType}",`} +
+
+ {' '} + {'"sandboxId"'} + {': "",'} +
+
+ {' '} + {'"timestamp"'} + {': "",'} +
+
+ {' // ... more fields, '} + + see docs + +
+
{'}'}
+
+
+) + +export function UpsertWebhookDialogSteps({ currentStep, form, isLoading, selectedEvents, + exampleEventType, allEventsSelected, handleAllToggle, handleEventToggle, mode, -}: WebhookAddEditDialogStepsProps) { - const shikiTheme = useShikiTheme() - const [secretType, setSecretType] = useState<'pre-generated' | 'custom'>( - 'pre-generated' - ) - + secretType, + onSecretTypeChange, + hasCopied, + onCopied, +}: UpsertWebhookDialogStepsProps) { const preGeneratedSecret = useMemo(() => { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' @@ -61,12 +108,21 @@ export function WebhookAddEditDialogSteps({ return Array.from(array, (byte) => chars[byte % chars.length]).join('') }, []) - const [copied, setCopied] = useState(false) + const [copied, copy] = useClipboard() - // sync secret with form state and validation - only in 'add' mode - // in 'edit' mode, we should never touch the signature secret + const customSecretInputRef = useRef(null) useEffect(() => { - if (mode !== 'add') return + if (secretType !== 'custom') return + const id = window.setTimeout(() => { + customSecretInputRef.current?.focus() + }, 0) + return () => window.clearTimeout(id) + }, [secretType]) + + // sync secret with form state and validation - only in create mode + // in update mode, we should never touch the signature secret + useEffect(() => { + if (mode !== 'create') return if (secretType === 'pre-generated') { // set pre-generated secret and trigger validation to clear any errors @@ -77,23 +133,16 @@ export function WebhookAddEditDialogSteps({ // 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) - } - } + useEffect(() => { + if (copied) onCopied() + }, [copied, onCopied]) return ( @@ -118,6 +167,8 @@ export function WebhookAddEditDialogSteps({ placeholder="Example webhook" disabled={isLoading} className="min-w-0" + clearable + onClear={() => field.onChange('')} {...field} /> @@ -138,6 +189,8 @@ export function WebhookAddEditDialogSteps({ placeholder="https://example.com/postreceive" disabled={isLoading} className="min-w-0" + clearable + onClear={() => field.onChange('')} {...field} /> @@ -176,7 +229,7 @@ export function WebhookAddEditDialogSteps({ {/* Individual event checkboxes */} - {WEBHOOK_EVENTS.map((event) => ( + {SandboxLifecycleEventTypeSchema.options.map((event) => (
- {event} + {WEBHOOK_EVENT_LABELS[event]}
))} @@ -201,27 +254,14 @@ export function WebhookAddEditDialogSteps({ {/* Description */}
-

+

We'll send a POST request with a JSON payload to{' '} - + {form.watch('url') || 'https://example.com/postreceive'} {' '} for each event. Example:

-
- - - {WEBHOOK_EXAMPLE_PAYLOAD} - - - -
+
)} @@ -237,32 +277,20 @@ export function WebhookAddEditDialogSteps({ > {/* Section Title and Description */}
-

- Signature Secret -

+

Secret

- 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. + A secret verifies that webhooks are from us and untampered. Use + our pre-generated one or add your own.

- - View validation examples - -
{/* Tabs */} - setSecretType(v as 'pre-generated' | 'custom') - } + onValueChange={(v) => { + const parsed = SecretTypeSchema.safeParse(v) + if (parsed.success) onSecretTypeChange(parsed.data) + }} className="min-h-0 w-full flex-1 h-full" > @@ -298,19 +326,27 @@ 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 +365,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..a4c43fc74 --- /dev/null +++ b/src/features/dashboard/settings/webhooks/upsert-webhook-dialog.tsx @@ -0,0 +1,354 @@ +'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 SandboxLifecycleEventType, + SandboxLifecycleEventTypeSchema, +} from '@/core/modules/sandboxes/lifecycle-event-types' +import { + type UpsertWebhookInput, + 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 [hasCopied, setHasCopied] = 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 defaultValues: UpsertWebhookInput = { + 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: '' }), + } + + 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) + setHasCopied(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} + +
+ )} +
+ +
+ + + +
+ setHasCopied(true)} + /> +
+ + + {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/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/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,