Allow testing webhook response in setup flow (#3768)
This commit is contained in:
320
skyvern-frontend/src/components/TestWebhookDialog.tsx
Normal file
320
skyvern-frontend/src/components/TestWebhookDialog.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ReloadIcon, CopyIcon, CheckIcon } from "@radix-ui/react-icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
|
||||
import { getClient } from "@/api/AxiosClient";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { copyText } from "@/util/copyText";
|
||||
|
||||
type TestWebhookRequest = {
|
||||
webhook_url: string;
|
||||
run_type: "task" | "workflow_run";
|
||||
run_id: string | null;
|
||||
};
|
||||
|
||||
type TestWebhookResponse = {
|
||||
status_code: number | null;
|
||||
latency_ms: number;
|
||||
response_body: string;
|
||||
headers_sent: Record<string, string>;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
type TestWebhookDialogProps = {
|
||||
runType: "task" | "workflow_run";
|
||||
runId?: string | null;
|
||||
initialWebhookUrl?: string;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
function TestWebhookDialog({
|
||||
runType,
|
||||
runId,
|
||||
initialWebhookUrl,
|
||||
trigger,
|
||||
}: TestWebhookDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [targetUrl, setTargetUrl] = useState(initialWebhookUrl || "");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<TestWebhookResponse | null>(null);
|
||||
const [signatureOpen, setSignatureOpen] = useState(false);
|
||||
const [responseOpen, setResponseOpen] = useState(false);
|
||||
const [copiedResponse, setCopiedResponse] = useState(false);
|
||||
const credentialGetter = useCredentialGetter();
|
||||
|
||||
const runTest = async (url: string) => {
|
||||
setTargetUrl(url);
|
||||
if (!url.trim()) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Enter a webhook URL before testing.",
|
||||
});
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
setSignatureOpen(false);
|
||||
setResponseOpen(false);
|
||||
setCopiedResponse(false);
|
||||
|
||||
try {
|
||||
const client = await getClient(credentialGetter);
|
||||
const response = await client.post<TestWebhookResponse>(
|
||||
"/internal/test-webhook",
|
||||
{
|
||||
webhook_url: url,
|
||||
run_type: runType,
|
||||
run_id: runId ?? null,
|
||||
} satisfies TestWebhookRequest,
|
||||
);
|
||||
|
||||
setResult(response.data);
|
||||
|
||||
if (response.data.error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Webhook Test Failed",
|
||||
description: response.data.error,
|
||||
});
|
||||
} else if (
|
||||
response.data.status_code &&
|
||||
response.data.status_code >= 200 &&
|
||||
response.data.status_code < 300
|
||||
) {
|
||||
toast({
|
||||
variant: "success",
|
||||
title: "Webhook Test Successful",
|
||||
description: `Received ${response.data.status_code} response in ${response.data.latency_ms}ms`,
|
||||
});
|
||||
} else if (response.data.status_code) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Webhook Test Failed",
|
||||
description: `Received ${response.data.status_code} response`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description:
|
||||
error instanceof Error ? error.message : "Failed to test webhook",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextUrl = initialWebhookUrl || "";
|
||||
setTargetUrl(nextUrl);
|
||||
void runTest(nextUrl);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, initialWebhookUrl]);
|
||||
|
||||
const handleCopyResponse = async () => {
|
||||
if (!result?.response_body) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await copyText(result.response_body);
|
||||
setCopiedResponse(true);
|
||||
setTimeout(() => setCopiedResponse(false), 2000);
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to copy response",
|
||||
description:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Clipboard permissions are required.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadgeClass = (statusCode: number | null) => {
|
||||
if (!statusCode) return "bg-slate-500";
|
||||
if (statusCode >= 200 && statusCode < 300) return "bg-green-600";
|
||||
if (statusCode >= 400 && statusCode < 500) return "bg-orange-600";
|
||||
if (statusCode >= 500) return "bg-red-600";
|
||||
return "bg-blue-600";
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<Button type="button" variant="secondary">
|
||||
Test Webhook
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Test Webhook URL</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="test-webhook-url">Testing URL</Label>
|
||||
<Input
|
||||
id="test-webhook-url"
|
||||
value={targetUrl}
|
||||
onChange={(event) => setTargetUrl(event.target.value)}
|
||||
placeholder="https://your-endpoint.com/webhook"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading && !result ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<ReloadIcon className="h-4 w-4 animate-spin" />
|
||||
Sending test webhook…
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{result && (
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
{result.error ? (
|
||||
<div className="rounded-md border border-red-600 bg-red-50 p-4 dark:bg-red-950">
|
||||
<p className="text-sm font-medium text-red-900 dark:text-red-100">
|
||||
Error
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-red-700 dark:text-red-200">
|
||||
{result.error}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Status
|
||||
</Label>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium text-white ${getStatusBadgeClass(result.status_code)}`}
|
||||
>
|
||||
{result.status_code || "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Latency
|
||||
</Label>
|
||||
<p className="mt-1 text-sm font-medium">
|
||||
{result.latency_ms}ms
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Collapsible
|
||||
open={responseOpen}
|
||||
onOpenChange={setResponseOpen}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="outline" className="w-full">
|
||||
{responseOpen
|
||||
? "Hide Response Body"
|
||||
: "Show Response Body"}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Response Body</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopyResponse}
|
||||
>
|
||||
{copiedResponse ? (
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<CodeEditor
|
||||
language="json"
|
||||
value={result.response_body || "Empty response"}
|
||||
readOnly
|
||||
minHeight="100px"
|
||||
maxHeight="300px"
|
||||
className="w-full"
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible
|
||||
open={signatureOpen}
|
||||
onOpenChange={setSignatureOpen}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="outline" className="w-full">
|
||||
{signatureOpen
|
||||
? "Hide Headers Sent"
|
||||
: "Show Headers Sent"}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Headers Sent</Label>
|
||||
<div className="space-y-1 rounded-md border bg-slate-50 p-3 font-mono text-sm dark:bg-slate-950">
|
||||
{Object.entries(result.headers_sent).map(
|
||||
([key, value]) => (
|
||||
<div key={key}>
|
||||
<span className="text-slate-600 dark:text-slate-400">
|
||||
{key}:
|
||||
</span>{" "}
|
||||
{value}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void runTest(targetUrl)}
|
||||
disabled={loading || !targetUrl}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="self-start"
|
||||
>
|
||||
Retest
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { TestWebhookDialog };
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
import { ProxySelector } from "@/components/ProxySelector";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { MAX_SCREENSHOT_SCROLLS_DEFAULT } from "@/routes/workflows/editor/nodes/Taskv2Node/types";
|
||||
import { TestWebhookDialog } from "@/components/TestWebhookDialog";
|
||||
type Props = {
|
||||
initialValues: CreateNewTaskFormValues;
|
||||
};
|
||||
@@ -527,11 +528,31 @@ function CreateNewTaskForm({ initialValues }: Props) {
|
||||
</FormLabel>
|
||||
<div className="w-full">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="https://"
|
||||
value={field.value === null ? "" : field.value}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
className="w-full"
|
||||
{...field}
|
||||
placeholder="https://"
|
||||
value={field.value === null ? "" : field.value}
|
||||
/>
|
||||
<TestWebhookDialog
|
||||
runType="task"
|
||||
runId={null}
|
||||
initialWebhookUrl={
|
||||
field.value === null ? undefined : field.value
|
||||
}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="self-start"
|
||||
disabled={!field.value}
|
||||
>
|
||||
Test Webhook
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { InboxIcon } from "@/components/icons/InboxIcon";
|
||||
import { MessageIcon } from "@/components/icons/MessageIcon";
|
||||
import { TrophyIcon } from "@/components/icons/TrophyIcon";
|
||||
import { ProxySelector } from "@/components/ProxySelector";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { KeyValueInput } from "@/components/KeyValueInput";
|
||||
import {
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
MAX_STEPS_DEFAULT,
|
||||
} from "@/routes/workflows/editor/nodes/Taskv2Node/types";
|
||||
import { useAutoplayStore } from "@/store/useAutoplayStore";
|
||||
import { TestWebhookDialog } from "@/components/TestWebhookDialog";
|
||||
|
||||
const exampleCases = [
|
||||
{
|
||||
@@ -352,12 +354,30 @@ function PromptBox() {
|
||||
information
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
value={webhookCallbackUrl ?? ""}
|
||||
onChange={(event) => {
|
||||
setWebhookCallbackUrl(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
className="w-full"
|
||||
value={webhookCallbackUrl ?? ""}
|
||||
onChange={(event) => {
|
||||
setWebhookCallbackUrl(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<TestWebhookDialog
|
||||
runType="task"
|
||||
runId={null}
|
||||
initialWebhookUrl={webhookCallbackUrl ?? undefined}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="self-start"
|
||||
disabled={!webhookCallbackUrl}
|
||||
>
|
||||
Test Webhook
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-16">
|
||||
<div className="w-48 shrink-0">
|
||||
|
||||
@@ -33,6 +33,7 @@ import { TaskFormSection } from "./TaskFormSection";
|
||||
import { savedTaskFormSchema, SavedTaskFormValues } from "./taskFormTypes";
|
||||
import { OrganizationApiResponse, ProxyLocation } from "@/api/types";
|
||||
import { ProxySelector } from "@/components/ProxySelector";
|
||||
import { TestWebhookDialog } from "@/components/TestWebhookDialog";
|
||||
|
||||
type Props = {
|
||||
initialValues: SavedTaskFormValues;
|
||||
@@ -627,11 +628,31 @@ function SavedTaskForm({ initialValues }: Props) {
|
||||
</FormLabel>
|
||||
<div className="w-full">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="https://"
|
||||
value={field.value === null ? "" : field.value}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
className="w-full"
|
||||
{...field}
|
||||
placeholder="https://"
|
||||
value={field.value === null ? "" : field.value}
|
||||
/>
|
||||
<TestWebhookDialog
|
||||
runType="task"
|
||||
runId={null}
|
||||
initialWebhookUrl={
|
||||
field.value === null ? undefined : field.value
|
||||
}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="self-start"
|
||||
disabled={!field.value}
|
||||
>
|
||||
Test Webhook
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</div>
|
||||
|
||||
@@ -48,6 +48,7 @@ import { MAX_SCREENSHOT_SCROLLS_DEFAULT } from "./editor/nodes/Taskv2Node/types"
|
||||
import { getLabelForWorkflowParameterType } from "./editor/workflowEditorUtils";
|
||||
import { WorkflowParameter } from "./types/workflowTypes";
|
||||
import { WorkflowParameterInput } from "./WorkflowParameterInput";
|
||||
import { TestWebhookDialog } from "@/components/TestWebhookDialog";
|
||||
|
||||
// Utility function to omit specified keys from an object
|
||||
function omit<T extends Record<string, unknown>, K extends keyof T>(
|
||||
@@ -461,13 +462,37 @@ function RunWorkflowForm({
|
||||
</FormLabel>
|
||||
<div className="w-full space-y-2">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="https://"
|
||||
value={
|
||||
field.value === null ? "" : (field.value as string)
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
className="w-full"
|
||||
{...field}
|
||||
placeholder="https://"
|
||||
value={
|
||||
field.value === null
|
||||
? ""
|
||||
: (field.value as string)
|
||||
}
|
||||
/>
|
||||
<TestWebhookDialog
|
||||
runType="workflow_run"
|
||||
runId={null}
|
||||
initialWebhookUrl={
|
||||
field.value === null
|
||||
? undefined
|
||||
: (field.value as string)
|
||||
}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="self-start"
|
||||
disabled={!field.value}
|
||||
>
|
||||
Test Webhook
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</div>
|
||||
|
||||
@@ -39,6 +39,8 @@ import { useBlockScriptStore } from "@/store/BlockScriptStore";
|
||||
import { BlockCodeEditor } from "@/routes/workflows/components/BlockCodeEditor";
|
||||
import { useUpdate } from "@/routes/workflows/editor/useUpdate";
|
||||
import { cn } from "@/util/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TestWebhookDialog } from "@/components/TestWebhookDialog";
|
||||
|
||||
interface StartSettings {
|
||||
webhookCallbackUrl: string;
|
||||
@@ -170,15 +172,35 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
|
||||
<Label>Webhook Callback URL</Label>
|
||||
<HelpTooltip content="The URL of a webhook endpoint to send the workflow results" />
|
||||
</div>
|
||||
<Input
|
||||
value={data.webhookCallbackUrl}
|
||||
placeholder="https://"
|
||||
onChange={(event) => {
|
||||
update({
|
||||
webhookCallbackUrl: event.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
className="w-full"
|
||||
value={data.webhookCallbackUrl}
|
||||
placeholder="https://"
|
||||
onChange={(event) => {
|
||||
update({
|
||||
webhookCallbackUrl: event.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<TestWebhookDialog
|
||||
runType="workflow_run"
|
||||
runId={null}
|
||||
initialWebhookUrl={
|
||||
data.webhookCallbackUrl || undefined
|
||||
}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="self-start"
|
||||
disabled={!data.webhookCallbackUrl}
|
||||
>
|
||||
Test Webhook
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
|
||||
Reference in New Issue
Block a user