proper sql filtering
This commit is contained in:
@@ -47,6 +47,11 @@ export type ApiResponse_ExecutionResponse = {
|
||||
* Execution result/output
|
||||
*/
|
||||
result: Record<string, any>;
|
||||
/**
|
||||
* When the execution actually started running (worker picked it up).
|
||||
* Null if the execution hasn't started running yet.
|
||||
*/
|
||||
started_at?: string | null;
|
||||
/**
|
||||
* Execution status
|
||||
*/
|
||||
|
||||
@@ -43,6 +43,11 @@ export type ExecutionResponse = {
|
||||
* Execution result/output
|
||||
*/
|
||||
result: Record<string, any>;
|
||||
/**
|
||||
* When the execution actually started running (worker picked it up).
|
||||
* Null if the execution hasn't started running yet.
|
||||
*/
|
||||
started_at?: string | null;
|
||||
/**
|
||||
* Execution status
|
||||
*/
|
||||
|
||||
@@ -35,6 +35,11 @@ export type ExecutionSummary = {
|
||||
* Execution status
|
||||
*/
|
||||
status: ExecutionStatus;
|
||||
/**
|
||||
* When the execution actually started running (worker picked it up).
|
||||
* Null if the execution hasn't started running yet.
|
||||
*/
|
||||
started_at?: string | null;
|
||||
/**
|
||||
* Trigger reference (if triggered by a trigger)
|
||||
*/
|
||||
|
||||
@@ -40,6 +40,11 @@ export type PaginatedResponse_ExecutionSummary = {
|
||||
* Execution status
|
||||
*/
|
||||
status: ExecutionStatus;
|
||||
/**
|
||||
* When the execution actually started running (worker picked it up).
|
||||
* Null if the execution hasn't started running yet.
|
||||
*/
|
||||
started_at?: string | null;
|
||||
/**
|
||||
* Trigger reference (if triggered by a trigger)
|
||||
*/
|
||||
|
||||
@@ -239,6 +239,11 @@ export class ExecutionsService {
|
||||
* Execution result/output
|
||||
*/
|
||||
result: Record<string, any>;
|
||||
/**
|
||||
* When the execution actually started running (worker picked it up).
|
||||
* Null if the execution hasn't started running yet.
|
||||
*/
|
||||
started_at?: string | null;
|
||||
/**
|
||||
* Execution status
|
||||
*/
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { useChildExecutions } from "@/hooks/useExecutions";
|
||||
import { useExecutionStream } from "@/hooks/useExecutionStream";
|
||||
|
||||
interface WorkflowTasksPanelProps {
|
||||
/** The parent (workflow) execution ID */
|
||||
@@ -95,6 +96,11 @@ export default function WorkflowTasksPanel({
|
||||
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
|
||||
const { data, isLoading, error } = useChildExecutions(parentExecutionId);
|
||||
|
||||
// Subscribe to the unfiltered execution stream so that child execution
|
||||
// WebSocket notifications update the ["executions", { parent }] query cache
|
||||
// in real-time (the detail page only subscribes filtered by its own ID).
|
||||
useExecutionStream({ enabled: true });
|
||||
|
||||
const tasks = useMemo(() => {
|
||||
if (!data?.data) return [];
|
||||
return data.data;
|
||||
@@ -211,15 +217,20 @@ export default function WorkflowTasksPanel({
|
||||
const maxRetries = wt?.max_retries ?? 0;
|
||||
const timedOut = wt?.timed_out ?? false;
|
||||
|
||||
// Compute duration from created → updated (best available)
|
||||
// Compute duration from started_at → updated (actual run time)
|
||||
const startedAt = task.started_at
|
||||
? new Date(task.started_at)
|
||||
: null;
|
||||
const created = new Date(task.created);
|
||||
const updated = new Date(task.updated);
|
||||
const isTerminal =
|
||||
task.status === "completed" ||
|
||||
task.status === "failed" ||
|
||||
task.status === "timeout";
|
||||
const durationMs =
|
||||
wt?.duration_ms ??
|
||||
(task.status === "completed" ||
|
||||
task.status === "failed" ||
|
||||
task.status === "timeout"
|
||||
? updated.getTime() - created.getTime()
|
||||
(isTerminal && startedAt
|
||||
? updated.getTime() - startedAt.getTime()
|
||||
: null);
|
||||
|
||||
return (
|
||||
@@ -277,7 +288,10 @@ export default function WorkflowTasksPanel({
|
||||
<div className="col-span-2 text-sm text-gray-500">
|
||||
{task.status === "running" ? (
|
||||
<span className="text-blue-600">
|
||||
{formatDistanceToNow(created, { addSuffix: false })}…
|
||||
{formatDistanceToNow(startedAt ?? created, {
|
||||
addSuffix: false,
|
||||
})}
|
||||
…
|
||||
</span>
|
||||
) : durationMs != null && durationMs > 0 ? (
|
||||
formatDuration(durationMs)
|
||||
|
||||
@@ -70,11 +70,14 @@ const ExecutionPreviewPanel = memo(function ExecutionPreviewPanel({
|
||||
execution?.status === "scheduled" ||
|
||||
execution?.status === "requested";
|
||||
|
||||
const startedAt = execution?.started_at
|
||||
? new Date(execution.started_at)
|
||||
: null;
|
||||
const created = execution ? new Date(execution.created) : null;
|
||||
const updated = execution ? new Date(execution.updated) : null;
|
||||
const durationMs =
|
||||
created && updated && !isRunning
|
||||
? updated.getTime() - created.getTime()
|
||||
startedAt && updated && !isRunning
|
||||
? updated.getTime() - startedAt.getTime()
|
||||
: null;
|
||||
|
||||
return (
|
||||
@@ -175,9 +178,9 @@ const ExecutionPreviewPanel = memo(function ExecutionPreviewPanel({
|
||||
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
Elapsed
|
||||
</dt>
|
||||
<dd className="mt-0.5 text-sm text-blue-600 flex items-center gap-1.5">
|
||||
<dd className="mt-0.5 text-blue-600 flex items-center gap-1.5">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
{formatDistanceToNow(created!)}
|
||||
{formatDistanceToNow(startedAt ?? created!)}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
@@ -240,41 +243,39 @@ const ExecutionPreviewPanel = memo(function ExecutionPreviewPanel({
|
||||
</div>
|
||||
|
||||
{/* Config / Parameters */}
|
||||
{execution.config &&
|
||||
Object.keys(execution.config).length > 0 && (
|
||||
<div className="px-4 py-3">
|
||||
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1.5">
|
||||
Parameters
|
||||
</dt>
|
||||
<dd>
|
||||
<pre className="bg-gray-50 border border-gray-200 rounded p-3 text-xs overflow-x-auto max-h-48 overflow-y-auto">
|
||||
{JSON.stringify(execution.config, null, 2)}
|
||||
</pre>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{execution.config && Object.keys(execution.config).length > 0 && (
|
||||
<div className="px-4 py-3">
|
||||
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1.5">
|
||||
Parameters
|
||||
</dt>
|
||||
<dd>
|
||||
<pre className="bg-gray-50 border border-gray-200 rounded p-3 text-xs overflow-x-auto max-h-48 overflow-y-auto">
|
||||
{JSON.stringify(execution.config, null, 2)}
|
||||
</pre>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{execution.result &&
|
||||
Object.keys(execution.result).length > 0 && (
|
||||
<div className="px-4 py-3">
|
||||
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1.5">
|
||||
Result
|
||||
</dt>
|
||||
<dd>
|
||||
<pre
|
||||
className={`border rounded p-3 text-xs overflow-x-auto max-h-64 overflow-y-auto ${
|
||||
execution.status === ("failed" as ExecutionStatus) ||
|
||||
execution.status === ("timeout" as ExecutionStatus)
|
||||
? "bg-red-50 border-red-200"
|
||||
: "bg-gray-50 border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{JSON.stringify(execution.result, null, 2)}
|
||||
</pre>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{execution.result && Object.keys(execution.result).length > 0 && (
|
||||
<div className="px-4 py-3">
|
||||
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1.5">
|
||||
Result
|
||||
</dt>
|
||||
<dd>
|
||||
<pre
|
||||
className={`border rounded p-3 text-xs overflow-x-auto max-h-64 overflow-y-auto ${
|
||||
execution.status === ("failed" as ExecutionStatus) ||
|
||||
execution.status === ("timeout" as ExecutionStatus)
|
||||
? "bg-red-50 border-red-200"
|
||||
: "bg-gray-50 border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{JSON.stringify(execution.result, null, 2)}
|
||||
</pre>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -128,6 +128,7 @@ const ChildExecutionRow = memo(function ChildExecutionRow({
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
data-execution-id={execution.id}
|
||||
className={`hover:bg-gray-50/80 group border-t border-gray-100 cursor-pointer ${
|
||||
selectedExecutionId === execution.id
|
||||
? "bg-blue-50 hover:bg-blue-50"
|
||||
@@ -314,6 +315,7 @@ const WorkflowExecutionRow = memo(function WorkflowExecutionRow({
|
||||
<>
|
||||
{/* Main execution row */}
|
||||
<tr
|
||||
data-execution-id={execution.id}
|
||||
className={`hover:bg-gray-50 border-b border-gray-200 cursor-pointer ${
|
||||
selectedExecutionId === execution.id
|
||||
? "bg-blue-50 hover:bg-blue-50"
|
||||
|
||||
195
web/src/components/workflows/RunWorkflowModal.tsx
Normal file
195
web/src/components/workflows/RunWorkflowModal.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Play, X, ExternalLink } from "lucide-react";
|
||||
import ParamSchemaForm, {
|
||||
validateParamSchema,
|
||||
extractProperties,
|
||||
type ParamSchema,
|
||||
} from "@/components/common/ParamSchemaForm";
|
||||
import { useRequestExecution } from "@/hooks/useExecutions";
|
||||
|
||||
interface RunWorkflowModalProps {
|
||||
/** The workflow's action ref (e.g., "examples.hello_workflow") */
|
||||
actionRef: string;
|
||||
/** The workflow's param_schema in flat format */
|
||||
paramSchema: ParamSchema;
|
||||
/** Called before executing — should save the workflow. Return true if save succeeded. */
|
||||
onSave: () => Promise<boolean>;
|
||||
/** Called when the modal is closed (cancel or after successful execution) */
|
||||
onClose: () => void;
|
||||
/** Optional label for display */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for running a workflow with optional parameter overrides.
|
||||
*
|
||||
* Shown from the workflow builder's "Run" button when the workflow has
|
||||
* parameters defined. Displays a ParamSchemaForm pre-populated with
|
||||
* default values, saves the workflow first, then creates an execution
|
||||
* and opens the execution detail page in a new tab.
|
||||
*/
|
||||
export default function RunWorkflowModal({
|
||||
actionRef,
|
||||
paramSchema,
|
||||
onSave,
|
||||
onClose,
|
||||
label,
|
||||
}: RunWorkflowModalProps) {
|
||||
const requestExecution = useRequestExecution();
|
||||
|
||||
const paramProperties = extractProperties(paramSchema);
|
||||
|
||||
// Build initial values from schema defaults
|
||||
const buildInitialValues = (): Record<string, unknown> => {
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const [key, prop] of Object.entries(paramProperties)) {
|
||||
if (prop?.default !== undefined) {
|
||||
values[key] = prop.default;
|
||||
}
|
||||
}
|
||||
return values;
|
||||
};
|
||||
|
||||
const [parameters, setParameters] =
|
||||
useState<Record<string, unknown>>(buildInitialValues);
|
||||
const [paramErrors, setParamErrors] = useState<Record<string, string>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [phase, setPhase] = useState<"idle" | "saving" | "executing">("idle");
|
||||
|
||||
const isSubmitting = phase !== "idle";
|
||||
|
||||
const handleExecute = useCallback(async () => {
|
||||
// Validate parameters against schema
|
||||
const errors = validateParamSchema(paramSchema, parameters);
|
||||
setParamErrors(errors);
|
||||
if (Object.keys(errors).length > 0) return;
|
||||
|
||||
setError(null);
|
||||
|
||||
// Phase 1: Save the workflow
|
||||
setPhase("saving");
|
||||
try {
|
||||
const saved = await onSave();
|
||||
if (!saved) {
|
||||
setPhase("idle");
|
||||
return; // save failed — error shown by parent
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to save workflow");
|
||||
setPhase("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 2: Execute
|
||||
setPhase("executing");
|
||||
try {
|
||||
// Strip out empty-string values so the backend applies schema defaults
|
||||
// for parameters the user left blank.
|
||||
const cleanedParams: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(parameters)) {
|
||||
if (value !== "" && value !== undefined) {
|
||||
cleanedParams[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await requestExecution.mutateAsync({
|
||||
actionRef,
|
||||
parameters: cleanedParams,
|
||||
});
|
||||
const executionId = response.data.id;
|
||||
|
||||
// Open execution in new tab and close the modal
|
||||
window.open(`/executions/${executionId}`, "_blank");
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
const e = err as { body?: { message?: string }; message?: string };
|
||||
const message =
|
||||
e?.body?.message || e?.message || "Failed to start execution";
|
||||
setError(message);
|
||||
setPhase("idle");
|
||||
}
|
||||
}, [paramSchema, parameters, onSave, actionRef, requestExecution, onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl border border-gray-200 max-w-lg w-full max-h-[85vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 flex-shrink-0">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-base font-semibold text-gray-900 truncate">
|
||||
Run Workflow
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 font-mono mt-0.5 truncate">
|
||||
{label || actionRef}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className="p-1 rounded hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors disabled:opacity-50 flex-shrink-0"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-1.5">
|
||||
Parameters
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
Override default values or leave as-is to use the schema defaults.
|
||||
</p>
|
||||
<ParamSchemaForm
|
||||
schema={paramSchema}
|
||||
values={parameters}
|
||||
onChange={setParameters}
|
||||
errors={paramErrors}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2.5 px-5 py-3 border-t border-gray-200 bg-gray-50 rounded-b-lg flex-shrink-0">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-1.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={isSubmitting}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium text-white bg-green-600 rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
|
||||
>
|
||||
{phase === "saving" ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Saving…
|
||||
</>
|
||||
) : phase === "executing" ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Executing…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4" />
|
||||
Run
|
||||
<ExternalLink className="w-3 h-3 opacity-60" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
||||
import {
|
||||
useQuery,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
keepPreviousData,
|
||||
} from "@tanstack/react-query";
|
||||
import { ExecutionsService } from "@/api";
|
||||
import type { ExecutionStatus } from "@/api";
|
||||
import { OpenAPI } from "@/api/core/OpenAPI";
|
||||
import { request as __request } from "@/api/core/request";
|
||||
|
||||
interface ExecutionsQueryParams {
|
||||
page?: number;
|
||||
@@ -69,6 +76,42 @@ export function useExecution(id: number) {
|
||||
* Enabled only when `parentId` is provided. Polls every 5 seconds while any
|
||||
* child execution is still in a running/pending state so the UI stays current.
|
||||
*/
|
||||
/**
|
||||
* Request a manual execution of an action (or workflow).
|
||||
*
|
||||
* Calls POST /api/v1/executions/execute and returns the created execution,
|
||||
* including its `id` which callers can use to navigate to the detail page.
|
||||
*/
|
||||
export function useRequestExecution() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
actionRef,
|
||||
parameters,
|
||||
}: {
|
||||
actionRef: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
}) => {
|
||||
const response = await __request(OpenAPI, {
|
||||
method: "POST",
|
||||
url: "/api/v1/executions/execute",
|
||||
body: {
|
||||
action_ref: actionRef,
|
||||
parameters: parameters ?? null,
|
||||
},
|
||||
mediaType: "application/json",
|
||||
});
|
||||
return response as {
|
||||
data: { id: number; status: string; action_ref: string };
|
||||
};
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["executions"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useChildExecutions(parentId: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["executions", { parent: parentId }],
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useQueries } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Play,
|
||||
AlertTriangle,
|
||||
FileCode,
|
||||
Code,
|
||||
@@ -11,6 +12,9 @@ import {
|
||||
X,
|
||||
Zap,
|
||||
Settings2,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import SearchableSelect from "@/components/common/SearchableSelect";
|
||||
import yaml from "js-yaml";
|
||||
@@ -23,6 +27,9 @@ import TaskInspector from "@/components/workflows/TaskInspector";
|
||||
import { useActions } from "@/hooks/useActions";
|
||||
import { ActionsService } from "@/api";
|
||||
import { usePacks } from "@/hooks/usePacks";
|
||||
import { useRequestExecution } from "@/hooks/useExecutions";
|
||||
import RunWorkflowModal from "@/components/workflows/RunWorkflowModal";
|
||||
import type { ParamSchema } from "@/components/common/ParamSchemaForm";
|
||||
import { useWorkflow } from "@/hooks/useWorkflows";
|
||||
import {
|
||||
useSaveWorkflowFile,
|
||||
@@ -77,6 +84,7 @@ export default function WorkflowBuilderPage() {
|
||||
// Mutations
|
||||
const saveWorkflowFile = useSaveWorkflowFile();
|
||||
const updateWorkflowFile = useUpdateWorkflowFile();
|
||||
const requestExecution = useRequestExecution();
|
||||
|
||||
// Builder state
|
||||
const [state, setState] = useState<WorkflowBuilderState>(INITIAL_STATE);
|
||||
@@ -85,6 +93,9 @@ export default function WorkflowBuilderPage() {
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [runError, setRunError] = useState<string | null>(null);
|
||||
const [showRunModal, setShowRunModal] = useState(false);
|
||||
const [yamlCopied, setYamlCopied] = useState(false);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [showYamlPreview, setShowYamlPreview] = useState(false);
|
||||
const [sidebarTab, setSidebarTab] = useState<"actions" | "inputs">("actions");
|
||||
@@ -428,7 +439,7 @@ export default function WorkflowBuilderPage() {
|
||||
|
||||
if (errors.length > 0) {
|
||||
setShowErrors(true);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const definition = builderStateToDefinition(state, actionSchemaMap);
|
||||
@@ -495,16 +506,19 @@ export default function WorkflowBuilderPage() {
|
||||
if (!isEditing) {
|
||||
const newRef = `${state.packRef}.${state.name}`;
|
||||
navigate(`/actions/workflows/${newRef}/edit`, { replace: true });
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 3000);
|
||||
|
||||
return true; // indicate success
|
||||
} catch (err: unknown) {
|
||||
const error = err as { body?: { message?: string }; message?: string };
|
||||
const message =
|
||||
error?.body?.message || error?.message || "Failed to save workflow";
|
||||
setSaveError(message);
|
||||
return false; // indicate failure
|
||||
}
|
||||
}, [
|
||||
state,
|
||||
@@ -516,6 +530,49 @@ export default function WorkflowBuilderPage() {
|
||||
navigate,
|
||||
]);
|
||||
|
||||
// Check whether the workflow has any parameters defined
|
||||
const hasParameters = useMemo(
|
||||
() => Object.keys(state.parameters).length > 0,
|
||||
[state.parameters],
|
||||
);
|
||||
|
||||
const handleRun = useCallback(async () => {
|
||||
setRunError(null);
|
||||
|
||||
if (hasParameters) {
|
||||
// Open the modal so the user can review / override parameter values
|
||||
setShowRunModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// No parameters — save and execute immediately
|
||||
const saved = await doSave();
|
||||
if (!saved) return; // save failed — error already shown
|
||||
|
||||
const actionRef = editRef || `${state.packRef}.${state.name}`;
|
||||
|
||||
try {
|
||||
const response = await requestExecution.mutateAsync({
|
||||
actionRef,
|
||||
parameters: {},
|
||||
});
|
||||
const executionId = response.data.id;
|
||||
window.open(`/executions/${executionId}`, "_blank");
|
||||
} catch (err: unknown) {
|
||||
const error = err as { body?: { message?: string }; message?: string };
|
||||
const message =
|
||||
error?.body?.message || error?.message || "Failed to start execution";
|
||||
setRunError(message);
|
||||
}
|
||||
}, [
|
||||
hasParameters,
|
||||
doSave,
|
||||
editRef,
|
||||
state.packRef,
|
||||
state.name,
|
||||
requestExecution,
|
||||
]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
// If there's a start-node problem, show the toast immediately and
|
||||
// require confirmation before saving
|
||||
@@ -547,6 +604,7 @@ export default function WorkflowBuilderPage() {
|
||||
}, [state, showYamlPreview, actionSchemaMap]);
|
||||
|
||||
const isSaving = saveWorkflowFile.isPending || updateWorkflowFile.isPending;
|
||||
const isExecuting = requestExecution.isPending;
|
||||
|
||||
if (isEditing && workflowLoading) {
|
||||
return (
|
||||
@@ -684,6 +742,16 @@ export default function WorkflowBuilderPage() {
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Run error indicator */}
|
||||
{runError && (
|
||||
<span
|
||||
className="text-xs text-red-600 font-medium max-w-[200px] truncate"
|
||||
title={runError}
|
||||
>
|
||||
✗ {runError}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Save button */}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
@@ -693,6 +761,31 @@ export default function WorkflowBuilderPage() {
|
||||
<Save className="w-4 h-4" />
|
||||
{isSaving ? "Saving..." : isEditing ? "Update" : "Save"}
|
||||
</button>
|
||||
|
||||
{/* Run button */}
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={!isEditing || isSaving || isExecuting}
|
||||
title={
|
||||
!isEditing
|
||||
? "Save the workflow first to enable execution"
|
||||
: "Save & run this workflow"
|
||||
}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 bg-green-600 text-white text-sm font-medium rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
|
||||
>
|
||||
{isExecuting ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Running...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4" />
|
||||
Run
|
||||
<ExternalLink className="w-3 h-3 opacity-60" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -771,6 +864,30 @@ export default function WorkflowBuilderPage() {
|
||||
<span className="text-[10px] text-gray-500 ml-1">
|
||||
(read-only preview of the generated YAML)
|
||||
</span>
|
||||
<div className="ml-auto">
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(yamlPreview).then(() => {
|
||||
setYamlCopied(true);
|
||||
setTimeout(() => setYamlCopied(false), 2000);
|
||||
});
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded transition-colors text-gray-400 hover:text-gray-200 hover:bg-gray-700"
|
||||
title="Copy YAML to clipboard"
|
||||
>
|
||||
{yamlCopied ? (
|
||||
<>
|
||||
<Check className="w-3.5 h-3.5 text-green-400" />
|
||||
<span className="text-green-400">Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="flex-1 overflow-auto p-6 text-sm font-mono text-green-400 whitespace-pre leading-relaxed">
|
||||
{yamlPreview}
|
||||
@@ -965,6 +1082,17 @@ export default function WorkflowBuilderPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Run workflow modal (shown when workflow has parameters) */}
|
||||
{showRunModal && (
|
||||
<RunWorkflowModal
|
||||
actionRef={editRef || `${state.packRef}.${state.name}`}
|
||||
paramSchema={state.parameters as unknown as ParamSchema}
|
||||
label={state.label || undefined}
|
||||
onSave={doSave}
|
||||
onClose={() => setShowRunModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Inline style for fade-in animation */}
|
||||
<style>{`
|
||||
@keyframes fadeInDown {
|
||||
|
||||
@@ -194,6 +194,7 @@ const ExecutionsResultsTable = memo(
|
||||
{executions.map((exec: any) => (
|
||||
<tr
|
||||
key={exec.id}
|
||||
data-execution-id={exec.id}
|
||||
className={`hover:bg-gray-50 cursor-pointer ${
|
||||
selectedExecutionId === exec.id
|
||||
? "bg-blue-50 hover:bg-blue-50"
|
||||
@@ -472,6 +473,66 @@ export default function ExecutionsPage() {
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
// --- Keyboard arrow-key navigation for execution list ---
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
|
||||
|
||||
// Don't interfere with inputs, selects, textareas
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return;
|
||||
|
||||
const list = filteredExecutions;
|
||||
if (!list || list.length === 0) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
setSelectedExecutionId((prevId) => {
|
||||
if (prevId == null) {
|
||||
// Nothing selected — pick first or last depending on direction
|
||||
const nextId =
|
||||
e.key === "ArrowDown" ? list[0].id : list[list.length - 1].id;
|
||||
requestAnimationFrame(() => {
|
||||
document
|
||||
.querySelector(`[data-execution-id="${nextId}"]`)
|
||||
?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
});
|
||||
return nextId;
|
||||
}
|
||||
|
||||
const currentIndex = list.findIndex((ex: any) => ex.id === prevId);
|
||||
if (currentIndex === -1) {
|
||||
const nextId = list[0].id;
|
||||
requestAnimationFrame(() => {
|
||||
document
|
||||
.querySelector(`[data-execution-id="${nextId}"]`)
|
||||
?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
});
|
||||
return nextId;
|
||||
}
|
||||
|
||||
let nextIndex: number;
|
||||
if (e.key === "ArrowDown") {
|
||||
nextIndex =
|
||||
currentIndex < list.length - 1 ? currentIndex + 1 : currentIndex;
|
||||
} else {
|
||||
nextIndex = currentIndex > 0 ? currentIndex - 1 : currentIndex;
|
||||
}
|
||||
|
||||
const nextId = list[nextIndex].id;
|
||||
requestAnimationFrame(() => {
|
||||
document
|
||||
.querySelector(`[data-execution-id="${nextId}"]`)
|
||||
?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
});
|
||||
return nextId;
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [filteredExecutions]);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)]">
|
||||
{/* Main content area */}
|
||||
|
||||
Reference in New Issue
Block a user