diff --git a/skyvern-frontend/src/components/ApiWebhookActionsMenu.tsx b/skyvern-frontend/src/components/ApiWebhookActionsMenu.tsx new file mode 100644 index 00000000..3c696f7f --- /dev/null +++ b/skyvern-frontend/src/components/ApiWebhookActionsMenu.tsx @@ -0,0 +1,85 @@ +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { toast } from "@/components/ui/use-toast"; +import { copyText } from "@/util/copyText"; +import { + generateApiCommands, + type ApiCommandOptions, +} from "@/util/apiCommands"; + +type ApiWebhookActionsMenuProps = { + getOptions: () => ApiCommandOptions; + runId?: string; + webhookDisabled?: boolean; + onTestWebhook: () => void; +}; + +export function ApiWebhookActionsMenu({ + getOptions, + webhookDisabled = false, + onTestWebhook, +}: ApiWebhookActionsMenuProps) { + return ( + + + + + + + Re-run via API + + { + const { curl } = generateApiCommands(getOptions()); + copyText(curl).then(() => { + toast({ + variant: "success", + title: "Copied to Clipboard", + description: + "The cURL command has been copied to your clipboard.", + }); + }); + }} + > + Copy cURL (Unix/Linux/macOS) + + { + const { powershell } = generateApiCommands(getOptions()); + copyText(powershell).then(() => { + toast({ + variant: "success", + title: "Copied to Clipboard", + description: + "The PowerShell command has been copied to your clipboard.", + }); + }); + }} + > + Copy PowerShell (Windows) + + + + Webhooks + + { + setTimeout(() => onTestWebhook(), 0); + }} + > + Test Webhook + + + + ); +} diff --git a/skyvern-frontend/src/components/WebhookReplayDialog.tsx b/skyvern-frontend/src/components/WebhookReplayDialog.tsx new file mode 100644 index 00000000..2c65737d --- /dev/null +++ b/skyvern-frontend/src/components/WebhookReplayDialog.tsx @@ -0,0 +1,308 @@ +import { useEffect, useMemo, useState } from "react"; +import type { AxiosError } from "axios"; +import { getClient } from "@/api/AxiosClient"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { toast } from "@/components/ui/use-toast"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { CodeEditor } from "@/routes/workflows/components/CodeEditor"; +import { ReloadIcon } from "@radix-ui/react-icons"; +import { useMutation, useQuery } from "@tanstack/react-query"; + +type WebhookPreview = { + run_id: string; + run_type: string; + default_webhook_url: string | null; + payload: string; + headers: Record; +}; + +type WebhookReplayResult = { + run_id: string; + run_type: string; + default_webhook_url: string | null; + target_webhook_url: string | null; + payload: string; + headers: Record; + status_code: number | null; + latency_ms: number | null; + response_body: string | null; + error: string | null; +}; + +export type WebhookReplayDialogProps = { + runId: string; + disabled?: boolean; + triggerLabel?: string; + open?: boolean; + onOpenChange?: (nextOpen: boolean) => void; + hideTrigger?: boolean; +}; + +const formatJson = (raw: string | undefined) => { + if (!raw) { + return ""; + } + try { + return JSON.stringify(JSON.parse(raw), null, 2); + } catch { + return raw; + } +}; + +export function WebhookReplayDialog({ + runId, + disabled = false, + triggerLabel = "Replay Webhook", + open: controlledOpen, + onOpenChange, + hideTrigger = false, +}: WebhookReplayDialogProps) { + const [uncontrolledOpen, setUncontrolledOpen] = useState(false); + const isControlled = controlledOpen !== undefined; + const open = isControlled ? (controlledOpen as boolean) : uncontrolledOpen; + const [targetUrl, setTargetUrl] = useState(""); + const [formError, setFormError] = useState(null); + const [replayResult, setReplayResult] = useState( + null, + ); + + const credentialGetter = useCredentialGetter(); + + const previewQuery = useQuery< + WebhookPreview, + AxiosError<{ detail?: string }> + >({ + queryKey: ["webhookReplayPreview", runId], + enabled: open && runId.length > 0, + queryFn: async () => { + const client = await getClient(credentialGetter); + const response = await client.get(`/internal/runs/${runId}/test-webhook`); + return response.data as WebhookPreview; + }, + }); + + useEffect(() => { + if (previewQuery.data) { + setTargetUrl(previewQuery.data.default_webhook_url ?? ""); + setFormError(null); + setReplayResult(null); + } + }, [previewQuery.data]); + + const replayMutation = useMutation< + WebhookReplayResult, + AxiosError<{ detail?: string }>, + string | null + >({ + mutationFn: async (overrideUrl: string | null) => { + const client = await getClient(credentialGetter); + const response = await client.post( + `/internal/runs/${runId}/test-webhook`, + { + override_webhook_url: overrideUrl, + }, + ); + return response.data as WebhookReplayResult; + }, + onSuccess: (data) => { + setReplayResult(data); + setFormError(null); + const isSuccessful = + data.error === null && + data.status_code !== null && + data.status_code >= 200 && + data.status_code < 300; + + toast({ + variant: isSuccessful ? "success" : "destructive", + title: isSuccessful + ? "Webhook replay sent" + : "Replay completed with issues", + description: + data.status_code !== null + ? `Received status ${data.status_code}${ + data.latency_ms !== null ? ` in ${data.latency_ms} ms` : "" + }.` + : "Replay request dispatched.", + }); + }, + onError: (error) => { + const detail = error.response?.data?.detail ?? error.message; + setFormError(detail); + toast({ + variant: "destructive", + title: "Replay failed", + description: detail, + }); + }, + }); + + const previewErrorMessage = + previewQuery.error?.response?.data?.detail ?? + previewQuery.error?.message ?? + null; + + const payloadText = useMemo(() => { + const payload = + replayResult?.payload ?? previewQuery.data?.payload ?? undefined; + return formatJson(payload); + }, [replayResult?.payload, previewQuery.data?.payload]); + const defaultUrl = previewQuery.data?.default_webhook_url?.trim() ?? ""; + + const handleSend = () => { + if (!previewQuery.data || replayMutation.isPending) { + return; + } + const trimmed = targetUrl.trim(); + + if (!trimmed && !defaultUrl) { + setFormError("Provide a webhook URL before sending."); + return; + } + + const override = + trimmed.length > 0 && trimmed !== defaultUrl ? trimmed : null; + + setFormError(null); + replayMutation.mutate(override); + }; + + const handleOpenChange = (nextOpen: boolean) => { + if (isControlled) { + onOpenChange?.(nextOpen); + } else { + setUncontrolledOpen(nextOpen); + } + if (!nextOpen) { + setReplayResult(null); + setFormError(null); + setTargetUrl(""); + replayMutation.reset(); + } + }; + + return ( + + {!hideTrigger && ( + + + + )} + + + Test Webhook + + Resend the payload generated for this run or override the + destination URL before sending. + + + {previewQuery.isError && ( +
+ {previewErrorMessage ?? + "Unable to load webhook payload for this run."} +
+ )} + +
+
+ + setTargetUrl(event.target.value)} + disabled={replayMutation.isPending} + /> + {formError ? ( +

{formError}

+ ) : null} +
+ + {replayResult?.target_webhook_url && ( + + Last sent to {replayResult.target_webhook_url} + + )} +
+
+ +
+
+ + {previewQuery.isLoading && !replayResult && ( + + Loading preview… + + )} +
+ {previewQuery.isLoading && !replayResult ? null : ( + + )} +
+ + {replayResult && ( +
+
+ + Status:{" "} + {replayResult.status_code ?? "No response"} + + + Latency:{" "} + {replayResult.latency_ms !== null + ? `${replayResult.latency_ms} ms` + : "—"} + +
+ {replayResult.error && ( +
+ {replayResult.error} +
+ )} + {replayResult.response_body && ( +
+ + +
+ )} +
+ )} +
+
+
+ ); +} diff --git a/skyvern-frontend/src/routes/tasks/detail/TaskDetails.tsx b/skyvern-frontend/src/routes/tasks/detail/TaskDetails.tsx index 31d99eef..f8667175 100644 --- a/skyvern-frontend/src/routes/tasks/detail/TaskDetails.tsx +++ b/skyvern-frontend/src/routes/tasks/detail/TaskDetails.tsx @@ -1,4 +1,5 @@ import { getClient } from "@/api/AxiosClient"; +import { useState } from "react"; import { Status, TaskApiResponse, @@ -25,7 +26,8 @@ import { useCredentialGetter } from "@/hooks/useCredentialGetter"; import { CodeEditor } from "@/routes/workflows/components/CodeEditor"; import { WorkflowApiResponse } from "@/routes/workflows/types/workflowTypes"; import { apiBaseUrl } from "@/util/env"; -import { CopyApiCommandDropdown } from "@/components/CopyApiCommandDropdown"; +import { ApiWebhookActionsMenu } from "@/components/ApiWebhookActionsMenu"; +import { WebhookReplayDialog } from "@/components/WebhookReplayDialog"; import { type ApiCommandOptions } from "@/util/apiCommands"; import { PlayIcon, ReloadIcon } from "@radix-ui/react-icons"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -124,6 +126,8 @@ function TaskDetails() { }, }); + const [replayOpen, setReplayOpen] = useState(false); + if (taskIsError) { return
Error: {taskError?.message}
; } @@ -189,7 +193,8 @@ function TaskDetails() { )}
- { if (!task) { return { @@ -211,6 +216,15 @@ function TaskDetails() { }, } satisfies ApiCommandOptions; }} + webhookDisabled={taskIsLoading || !taskHasTerminalState} + onTestWebhook={() => setReplayOpen(true)} + /> + {taskIsRunningOrQueued && ( diff --git a/skyvern-frontend/src/routes/workflows/WorkflowRun.tsx b/skyvern-frontend/src/routes/workflows/WorkflowRun.tsx index 9be8e5e0..2e8ab1b5 100644 --- a/skyvern-frontend/src/routes/workflows/WorkflowRun.tsx +++ b/skyvern-frontend/src/routes/workflows/WorkflowRun.tsx @@ -41,7 +41,8 @@ import { Label } from "@/components/ui/label"; import { CodeEditor } from "./components/CodeEditor"; import { cn } from "@/util/utils"; import { ScrollArea, ScrollAreaViewport } from "@/components/ui/scroll-area"; -import { CopyApiCommandDropdown } from "@/components/CopyApiCommandDropdown"; +import { ApiWebhookActionsMenu } from "@/components/ApiWebhookActionsMenu"; +import { WebhookReplayDialog } from "@/components/WebhookReplayDialog"; import { type ApiCommandOptions } from "@/util/apiCommands"; import { useBlockScriptsQuery } from "@/routes/workflows/hooks/useBlockScriptsQuery"; import { constructCacheKeyValue } from "@/routes/workflows/editor/utils"; @@ -108,6 +109,7 @@ function WorkflowRun() { }, [blockScriptsPublished, setHasPublishedCode]); const { data: workflowRunTimeline } = useWorkflowRunTimelineQuery(); + const [replayOpen, setReplayOpen] = useState(false); const cancelWorkflowMutation = useMutation({ mutationFn: async () => { @@ -304,7 +306,7 @@ function WorkflowRun() {
- ({ method: "POST", @@ -319,6 +321,15 @@ function WorkflowRun() { }, }) satisfies ApiCommandOptions } + webhookDisabled={workflowRunIsLoading || !workflowRunIsFinalized} + onTestWebhook={() => setReplayOpen(true)} + /> +