http_request action working nicely

This commit is contained in:
2026-02-09 23:21:23 -06:00
parent e31ecb781b
commit 966a5af188
18 changed files with 720 additions and 395 deletions

View File

@@ -0,0 +1,273 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { OpenAPI } from "@/api";
import { Play, X } from "lucide-react";
import ParamSchemaForm, {
validateParamSchema,
type ParamSchema,
} from "@/components/common/ParamSchemaForm";
interface ExecuteActionModalProps {
action: any;
onClose: () => void;
initialParameters?: Record<string, any>;
}
/**
* Shared modal for executing an action with a dynamic parameter form.
*
* Used from:
* - ActionDetail page (Execute button)
* - ExecutionDetailPage (Re-Run button, with initialParameters pre-filled from previous execution config)
*/
export default function ExecuteActionModal({
action,
onClose,
initialParameters,
}: ExecuteActionModalProps) {
const queryClient = useQueryClient();
const paramSchema: ParamSchema = (action.param_schema as ParamSchema) || {};
// If initialParameters are provided, use them (stripping out any keys not in the schema)
const buildInitialValues = (): Record<string, any> => {
if (!initialParameters) return {};
const properties = paramSchema.properties || {};
const values: Record<string, any> = {};
// Include all initial parameters - even those not in the schema
// so users can see exactly what was run before
for (const [key, value] of Object.entries(initialParameters)) {
if (value !== undefined && value !== null) {
values[key] = value;
}
}
// Also fill in defaults for any schema properties not covered
for (const [key, param] of Object.entries(properties)) {
if (values[key] === undefined && param?.default !== undefined) {
values[key] = param.default;
}
}
return values;
};
const [parameters, setParameters] = useState<Record<string, any>>(
buildInitialValues,
);
const [paramErrors, setParamErrors] = useState<Record<string, string>>({});
const [envVars, setEnvVars] = useState<Array<{ key: string; value: string }>>(
[{ key: "", value: "" }],
);
const executeAction = useMutation({
mutationFn: async (params: {
parameters: Record<string, any>;
envVars: Array<{ key: string; value: string }>;
}) => {
const token =
typeof OpenAPI.TOKEN === "function"
? await OpenAPI.TOKEN({} as any)
: OpenAPI.TOKEN;
const response = await fetch(
`${OpenAPI.BASE}/api/v1/executions/execute`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
action_ref: action.ref,
parameters: params.parameters,
env_vars: params.envVars
.filter((ev) => ev.key.trim() !== "")
.reduce(
(acc, ev) => {
acc[ev.key] = ev.value;
return acc;
},
{} as Record<string, string>,
),
}),
},
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "Failed to execute action");
}
return response.json();
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["executions"] });
onClose();
if (data?.data?.id) {
window.location.href = `/executions/${data.data.id}`;
}
},
});
const validateForm = (): boolean => {
const errors = validateParamSchema(paramSchema, parameters);
setParamErrors(errors);
return Object.keys(errors).length === 0;
};
const handleExecute = async () => {
if (!validateForm()) {
return;
}
try {
await executeAction.mutateAsync({ parameters, envVars });
} catch (err) {
console.error("Failed to execute action:", err);
}
};
const addEnvVar = () => {
setEnvVars([...envVars, { key: "", value: "" }]);
};
const removeEnvVar = (index: number) => {
if (envVars.length > 1) {
setEnvVars(envVars.filter((_, i) => i !== index));
}
};
const updateEnvVar = (
index: number,
field: "key" | "value",
value: string,
) => {
const updated = [...envVars];
updated[index][field] = value;
setEnvVars(updated);
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-bold">
{initialParameters ? "Re-Run Action" : "Execute Action"}
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<X className="h-6 w-6" />
</button>
</div>
<div className="mb-4">
<p className="text-sm text-gray-600">
Action:{" "}
<span className="font-mono text-gray-900">{action.ref}</span>
</p>
{action.description && (
<p className="text-sm text-gray-600 mt-1">{action.description}</p>
)}
{initialParameters && (
<p className="text-xs text-blue-600 mt-2 bg-blue-50 px-3 py-1.5 rounded">
Parameters pre-filled from previous execution. Modify as needed
before re-running.
</p>
)}
</div>
{executeAction.error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
{(executeAction.error as Error).message}
</div>
)}
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 mb-2">
Parameters
</h4>
<ParamSchemaForm
schema={paramSchema}
values={parameters}
onChange={setParameters}
errors={paramErrors}
/>
</div>
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 mb-2">
Environment Variables
</h4>
<p className="text-xs text-gray-500 mb-3">
Optional environment variables for this execution (e.g., DEBUG,
LOG_LEVEL)
</p>
<div className="space-y-2">
{envVars.map((envVar, index) => (
<div key={index} className="flex gap-2 items-start">
<input
type="text"
placeholder="Key"
value={envVar.key}
onChange={(e) => updateEnvVar(index, "key", e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<input
type="text"
placeholder="Value"
value={envVar.value}
onChange={(e) => updateEnvVar(index, "value", e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="button"
onClick={() => removeEnvVar(index)}
disabled={envVars.length === 1}
className="px-3 py-2 text-red-600 hover:text-red-700 disabled:text-gray-300 disabled:cursor-not-allowed"
title="Remove"
>
<X className="h-5 w-5" />
</button>
</div>
))}
</div>
<button
type="button"
onClick={addEnvVar}
className="mt-2 text-sm text-blue-600 hover:text-blue-700"
>
+ Add Environment Variable
</button>
</div>
<div className="flex justify-end gap-3">
<button
onClick={onClose}
disabled={executeAction.isPending}
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleExecute}
disabled={executeAction.isPending}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 flex items-center gap-2"
>
{executeAction.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
Executing...
</>
) : (
<>
<Play className="h-4 w-4" />
{initialParameters ? "Re-Run" : "Execute"}
</>
)}
</button>
</div>
</div>
</div>
);
}

View File

@@ -3,12 +3,7 @@ import { useActions, useAction, useDeleteAction } from "@/hooks/useActions";
import { useExecutions } from "@/hooks/useExecutions";
import { useState, useMemo } from "react";
import { ChevronDown, ChevronRight, Search, X, Play } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { OpenAPI } from "@/api";
import ParamSchemaForm, {
validateParamSchema,
type ParamSchema,
} from "@/components/common/ParamSchemaForm";
import ExecuteActionModal from "@/components/common/ExecuteActionModal";
import ErrorDisplay from "@/components/common/ErrorDisplay";
export default function ActionsPage() {
@@ -573,229 +568,3 @@ function ActionDetail({ actionRef }: { actionRef: string }) {
</div>
);
}
function ExecuteActionModal({
action,
onClose,
}: {
action: any;
onClose: () => void;
}) {
const queryClient = useQueryClient();
// Initialize parameters with default values from schema
const paramSchema: ParamSchema = (action.param_schema as ParamSchema) || {};
const [parameters, setParameters] = useState<Record<string, any>>({});
const [paramErrors, setParamErrors] = useState<Record<string, string>>({});
const [envVars, setEnvVars] = useState<Array<{ key: string; value: string }>>(
[{ key: "", value: "" }],
);
const executeAction = useMutation({
mutationFn: async (params: {
parameters: Record<string, any>;
envVars: Array<{ key: string; value: string }>;
}) => {
// Get the token by calling the TOKEN function
const token =
typeof OpenAPI.TOKEN === "function"
? await OpenAPI.TOKEN({} as any)
: OpenAPI.TOKEN;
const response = await fetch(
`${OpenAPI.BASE}/api/v1/executions/execute`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
action_ref: action.ref,
parameters: params.parameters,
env_vars: params.envVars
.filter((ev) => ev.key.trim() !== "")
.reduce(
(acc, ev) => {
acc[ev.key] = ev.value;
return acc;
},
{} as Record<string, string>,
),
}),
},
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "Failed to execute action");
}
return response.json();
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["executions"] });
onClose();
// Redirect to execution detail page
if (data?.data?.id) {
window.location.href = `/executions/${data.data.id}`;
}
},
});
const validateForm = (): boolean => {
const errors = validateParamSchema(paramSchema, parameters);
setParamErrors(errors);
return Object.keys(errors).length === 0;
};
const handleExecute = async () => {
if (!validateForm()) {
return;
}
try {
await executeAction.mutateAsync({ parameters, envVars });
} catch (err) {
console.error("Failed to execute action:", err);
}
};
const addEnvVar = () => {
setEnvVars([...envVars, { key: "", value: "" }]);
};
const removeEnvVar = (index: number) => {
if (envVars.length > 1) {
setEnvVars(envVars.filter((_, i) => i !== index));
}
};
const updateEnvVar = (
index: number,
field: "key" | "value",
value: string,
) => {
const updated = [...envVars];
updated[index][field] = value;
setEnvVars(updated);
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-bold">Execute Action</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<X className="h-6 w-6" />
</button>
</div>
<div className="mb-4">
<p className="text-sm text-gray-600">
Action:{" "}
<span className="font-mono text-gray-900">{action.ref}</span>
</p>
{action.description && (
<p className="text-sm text-gray-600 mt-1">{action.description}</p>
)}
</div>
{executeAction.error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
{(executeAction.error as Error).message}
</div>
)}
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 mb-2">
Parameters
</h4>
<ParamSchemaForm
schema={paramSchema}
values={parameters}
onChange={setParameters}
errors={paramErrors}
/>
</div>
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 mb-2">
Environment Variables
</h4>
<p className="text-xs text-gray-500 mb-3">
Optional environment variables for this execution (e.g., DEBUG,
LOG_LEVEL)
</p>
<div className="space-y-2">
{envVars.map((envVar, index) => (
<div key={index} className="flex gap-2 items-start">
<input
type="text"
placeholder="Key"
value={envVar.key}
onChange={(e) => updateEnvVar(index, "key", e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<input
type="text"
placeholder="Value"
value={envVar.value}
onChange={(e) => updateEnvVar(index, "value", e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="button"
onClick={() => removeEnvVar(index)}
disabled={envVars.length === 1}
className="px-3 py-2 text-red-600 hover:text-red-700 disabled:text-gray-300 disabled:cursor-not-allowed"
title="Remove"
>
<X className="h-5 w-5" />
</button>
</div>
))}
</div>
<button
type="button"
onClick={addEnvVar}
className="mt-2 text-sm text-blue-600 hover:text-blue-700"
>
+ Add Environment Variable
</button>
</div>
<div className="flex justify-end gap-3">
<button
onClick={onClose}
disabled={executeAction.isPending}
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleExecute}
disabled={executeAction.isPending}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 flex items-center gap-2"
>
{executeAction.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
Executing...
</>
) : (
<>
<Play className="h-4 w-4" />
Execute
</>
)}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,8 +1,12 @@
import { useParams, Link } from "react-router-dom";
import { useExecution } from "@/hooks/useExecutions";
import { useAction } from "@/hooks/useActions";
import { useExecutionStream } from "@/hooks/useExecutionStream";
import { formatDistanceToNow } from "date-fns";
import { ExecutionStatus } from "@/api";
import { useState } from "react";
import { RotateCcw } from "lucide-react";
import ExecuteActionModal from "@/components/common/ExecuteActionModal";
const getStatusColor = (status: string) => {
switch (status) {
@@ -31,6 +35,11 @@ export default function ExecutionDetailPage() {
const { data: executionData, isLoading, error } = useExecution(Number(id));
const execution = executionData?.data;
// Fetch the action so we can get param_schema for the re-run modal
const { data: actionData } = useAction(execution?.action_ref || "");
const [showRerunModal, setShowRerunModal] = useState(false);
// Subscribe to real-time updates for this execution
const { isConnected } = useExecutionStream({
executionId: Number(id),
@@ -102,6 +111,19 @@ export default function ExecutionDetailPage() {
</div>
)}
</div>
<button
onClick={() => setShowRerunModal(true)}
disabled={!actionData?.data}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
title={
!actionData?.data
? "Loading action details..."
: "Re-run this action with the same parameters"
}
>
<RotateCcw className="h-4 w-4" />
Re-Run
</button>
</div>
<p className="text-gray-600 mt-2">
<Link
@@ -113,6 +135,15 @@ export default function ExecutionDetailPage() {
</p>
</div>
{/* Re-Run Modal */}
{showRerunModal && actionData?.data && (
<ExecuteActionModal
action={actionData.data}
onClose={() => setShowRerunModal(false)}
initialParameters={execution.config}
/>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
@@ -295,6 +326,13 @@ export default function ExecutionDetailPage() {
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2>
<div className="space-y-2">
<button
onClick={() => setShowRerunModal(true)}
disabled={!actionData?.data}
className="block w-full px-4 py-2 text-sm text-center bg-blue-50 hover:bg-blue-100 text-blue-700 rounded disabled:opacity-50 disabled:cursor-not-allowed"
>
Re-Run with Same Parameters
</button>
<Link
to={`/actions/${execution.action_ref}`}
className="block w-full px-4 py-2 text-sm text-center bg-gray-100 hover:bg-gray-200 rounded"