proper sql filtering

This commit is contained in:
2026-03-01 20:43:48 -06:00
parent 6b9d7d6cf2
commit bbe94d75f8
54 changed files with 6692 additions and 928 deletions

View File

@@ -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
*/

View File

@@ -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
*/

View File

@@ -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)
*/

View File

@@ -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)
*/

View File

@@ -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
*/

View File

@@ -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)

View File

@@ -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>

View File

@@ -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"

View 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>
);
}

View File

@@ -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 }],

View File

@@ -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 {

View File

@@ -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 */}