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

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