[WIP] Workflows
This commit is contained in:
@@ -2,7 +2,16 @@ import { Link, useParams, useNavigate } from "react-router-dom";
|
||||
import { useActions, useAction, useDeleteAction } from "@/hooks/useActions";
|
||||
import { useExecutions } from "@/hooks/useExecutions";
|
||||
import { useState, useMemo } from "react";
|
||||
import { ChevronDown, ChevronRight, Search, X, Play, Plus } from "lucide-react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Search,
|
||||
X,
|
||||
Play,
|
||||
Plus,
|
||||
GitBranch,
|
||||
Pencil,
|
||||
} from "lucide-react";
|
||||
import ExecuteActionModal from "@/components/common/ExecuteActionModal";
|
||||
import ErrorDisplay from "@/components/common/ErrorDisplay";
|
||||
import { extractProperties } from "@/components/common/ParamSchemaForm";
|
||||
@@ -177,7 +186,12 @@ export default function ActionsPage() {
|
||||
: "border-2 border-transparent hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-sm text-gray-900 truncate">
|
||||
<div className="font-medium text-sm text-gray-900 truncate flex items-center gap-1.5">
|
||||
{action.workflow_def && (
|
||||
<span title="Workflow">
|
||||
<GitBranch className="w-3.5 h-3.5 text-purple-500 flex-shrink-0" />
|
||||
</span>
|
||||
)}
|
||||
{action.label}
|
||||
</div>
|
||||
<div className="font-mono text-xs text-gray-500 mt-1 truncate">
|
||||
@@ -236,6 +250,7 @@ export default function ActionsPage() {
|
||||
}
|
||||
|
||||
function ActionDetail({ actionRef }: { actionRef: string }) {
|
||||
const navigate = useNavigate();
|
||||
const { data: action, isLoading, error } = useAction(actionRef);
|
||||
const { data: executionsData } = useExecutions({
|
||||
actionRef: actionRef,
|
||||
@@ -290,6 +305,17 @@ function ActionDetail({ actionRef }: { actionRef: string }) {
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{action.data?.workflow_def && (
|
||||
<button
|
||||
onClick={() =>
|
||||
navigate(`/actions/workflows/${action.data!.ref}/edit`)
|
||||
}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700 flex items-center gap-2"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
Edit Workflow
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowExecuteModal(true)}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 flex items-center gap-2"
|
||||
|
||||
@@ -457,7 +457,7 @@ export default function WorkflowBuilderPage() {
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await saveWorkflowFile.mutateAsync({
|
||||
const fileData = {
|
||||
name: state.name,
|
||||
label: state.label,
|
||||
description: state.description || undefined,
|
||||
@@ -472,7 +472,30 @@ export default function WorkflowBuilderPage() {
|
||||
Object.keys(state.output).length > 0 ? state.output : undefined,
|
||||
tags: state.tags.length > 0 ? state.tags : undefined,
|
||||
enabled: state.enabled,
|
||||
});
|
||||
};
|
||||
try {
|
||||
await saveWorkflowFile.mutateAsync(fileData);
|
||||
} catch (createErr: unknown) {
|
||||
const apiErr = createErr as { status?: number };
|
||||
if (apiErr?.status === 409) {
|
||||
// Workflow already exists — fall back to update
|
||||
const workflowRef = `${state.packRef}.${state.name}`;
|
||||
await updateWorkflowFile.mutateAsync({
|
||||
workflowRef,
|
||||
data: fileData,
|
||||
});
|
||||
} else {
|
||||
throw createErr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// After a successful first save, navigate to the edit URL so the
|
||||
// page transitions into edit mode (locks ref, uses update on next save).
|
||||
if (!isEditing) {
|
||||
const newRef = `${state.packRef}.${state.name}`;
|
||||
navigate(`/actions/workflows/${newRef}/edit`, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
setSaveSuccess(true);
|
||||
@@ -490,6 +513,7 @@ export default function WorkflowBuilderPage() {
|
||||
saveWorkflowFile,
|
||||
updateWorkflowFile,
|
||||
actionSchemaMap,
|
||||
navigate,
|
||||
]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
@@ -540,9 +564,11 @@ export default function WorkflowBuilderPage() {
|
||||
{/* Left section: Back + metadata */}
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<button
|
||||
onClick={() => navigate("/actions")}
|
||||
onClick={() =>
|
||||
navigate(isEditing ? `/actions/${editRef}` : "/actions")
|
||||
}
|
||||
className="p-1.5 rounded hover:bg-gray-100 text-gray-500 hover:text-gray-700 transition-colors flex-shrink-0"
|
||||
title="Back to Actions"
|
||||
title={isEditing ? "Back to Workflow" : "Back to Actions"}
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
@@ -558,6 +584,7 @@ export default function WorkflowBuilderPage() {
|
||||
}))}
|
||||
placeholder="Pack..."
|
||||
className="max-w-[140px]"
|
||||
disabled={isEditing}
|
||||
/>
|
||||
|
||||
<span className="text-gray-400 text-lg font-light">/</span>
|
||||
@@ -571,8 +598,9 @@ export default function WorkflowBuilderPage() {
|
||||
name: e.target.value.replace(/[^a-zA-Z0-9_-]/g, "_"),
|
||||
})
|
||||
}
|
||||
className="px-2 py-1.5 border border-gray-300 rounded text-sm font-mono focus:ring-2 focus:ring-blue-500 focus:border-blue-500 w-48"
|
||||
className={`px-2 py-1.5 border border-gray-300 rounded text-sm font-mono w-48 ${isEditing ? "bg-gray-100 cursor-not-allowed text-gray-500" : "focus:ring-2 focus:ring-blue-500 focus:border-blue-500"}`}
|
||||
placeholder="workflow_name"
|
||||
disabled={isEditing}
|
||||
/>
|
||||
|
||||
<span className="text-gray-400 text-lg font-light">—</span>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { useEnforcement } from "@/hooks/useEvents";
|
||||
import { EnforcementStatus, EnforcementCondition } from "@/api";
|
||||
import EntityHistoryPanel from "@/components/common/EntityHistoryPanel";
|
||||
|
||||
export default function EnforcementDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -189,6 +188,18 @@ export default function EnforcementDetailPage() {
|
||||
{formatDate(enforcement.created)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">
|
||||
Resolved At
|
||||
</dt>
|
||||
<dd className="mt-1 text-gray-900">
|
||||
{enforcement.resolved_at ? (
|
||||
formatDate(enforcement.resolved_at)
|
||||
) : (
|
||||
<span className="text-gray-500">Pending</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
@@ -331,6 +342,14 @@ export default function EnforcementDetailPage() {
|
||||
{formatDate(enforcement.created)}
|
||||
</dd>
|
||||
</div>
|
||||
{enforcement.resolved_at && (
|
||||
<div>
|
||||
<dt className="text-gray-500">Resolved</dt>
|
||||
<dd className="text-gray-900">
|
||||
{formatDate(enforcement.resolved_at)}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
@@ -377,15 +396,6 @@ export default function EnforcementDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Change History */}
|
||||
<div className="mt-6">
|
||||
<EntityHistoryPanel
|
||||
entityType="enforcement"
|
||||
entityId={enforcement.id}
|
||||
title="Enforcement History"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { useEvent } from "@/hooks/useEvents";
|
||||
import EntityHistoryPanel from "@/components/common/EntityHistoryPanel";
|
||||
|
||||
export default function EventDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -259,15 +258,6 @@ export default function EventDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Change History */}
|
||||
<div className="mt-6">
|
||||
<EntityHistoryPanel
|
||||
entityType="event"
|
||||
entityId={event.id}
|
||||
title="Event History"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useState, useMemo } from "react";
|
||||
import { RotateCcw, Loader2 } from "lucide-react";
|
||||
import ExecuteActionModal from "@/components/common/ExecuteActionModal";
|
||||
import EntityHistoryPanel from "@/components/common/EntityHistoryPanel";
|
||||
import WorkflowTasksPanel from "@/components/common/WorkflowTasksPanel";
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
@@ -116,6 +117,9 @@ export default function ExecutionDetailPage() {
|
||||
// Fetch the action so we can get param_schema for the re-run modal
|
||||
const { data: actionData } = useAction(execution?.action_ref || "");
|
||||
|
||||
// Determine if this execution is a workflow (action has workflow_def)
|
||||
const isWorkflow = !!actionData?.data?.workflow_def;
|
||||
|
||||
const [showRerunModal, setShowRerunModal] = useState(false);
|
||||
|
||||
// Fetch status history for the timeline
|
||||
@@ -207,6 +211,11 @@ export default function ExecutionDetailPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-3xl font-bold">Execution #{execution.id}</h1>
|
||||
{isWorkflow && (
|
||||
<span className="px-3 py-1 text-sm rounded-full bg-indigo-100 text-indigo-800">
|
||||
Workflow
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`px-3 py-1 text-sm rounded-full ${getStatusColor(execution.status)}`}
|
||||
>
|
||||
@@ -247,6 +256,25 @@ export default function ExecutionDetailPage() {
|
||||
{execution.action_ref}
|
||||
</Link>
|
||||
</p>
|
||||
{execution.workflow_task && (
|
||||
<p className="text-sm text-indigo-600 mt-1 flex items-center gap-1.5">
|
||||
<span className="text-gray-500">Task</span>{" "}
|
||||
<span className="font-medium">
|
||||
{execution.workflow_task.task_name}
|
||||
</span>
|
||||
{execution.parent && (
|
||||
<>
|
||||
<span className="text-gray-500">in workflow</span>
|
||||
<Link
|
||||
to={`/executions/${execution.parent}`}
|
||||
className="text-indigo-600 hover:text-indigo-800 font-medium"
|
||||
>
|
||||
Execution #{execution.parent}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Re-Run Modal */}
|
||||
@@ -504,6 +532,13 @@ export default function ExecutionDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow Tasks (shown only for workflow executions) */}
|
||||
{isWorkflow && (
|
||||
<div className="mt-6">
|
||||
<WorkflowTasksPanel parentExecutionId={execution.id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Change History */}
|
||||
<div className="mt-6">
|
||||
<EntityHistoryPanel
|
||||
|
||||
@@ -3,13 +3,19 @@ import { useExecutions } from "@/hooks/useExecutions";
|
||||
import { useExecutionStream } from "@/hooks/useExecutionStream";
|
||||
import { ExecutionStatus } from "@/api";
|
||||
import { useState, useMemo, memo, useCallback, useEffect } from "react";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { Search, X, List, GitBranch } from "lucide-react";
|
||||
import MultiSelect from "@/components/common/MultiSelect";
|
||||
import AutocompleteInput from "@/components/common/AutocompleteInput";
|
||||
import {
|
||||
useFilterSuggestions,
|
||||
useMergedSuggestions,
|
||||
} from "@/hooks/useFilterSuggestions";
|
||||
import WorkflowExecutionTree from "@/components/executions/WorkflowExecutionTree";
|
||||
import ExecutionPreviewPanel from "@/components/executions/ExecutionPreviewPanel";
|
||||
|
||||
type ViewMode = "all" | "workflow";
|
||||
|
||||
const VIEW_MODE_STORAGE_KEY = "attune:executions:viewMode";
|
||||
|
||||
// Memoized filter input component for non-ref fields (e.g. Executor ID)
|
||||
const FilterInput = memo(
|
||||
@@ -87,6 +93,8 @@ const ExecutionsResultsTable = memo(
|
||||
setPage,
|
||||
pageSize,
|
||||
total,
|
||||
selectedExecutionId,
|
||||
onSelectExecution,
|
||||
}: {
|
||||
executions: any[];
|
||||
isLoading: boolean;
|
||||
@@ -98,6 +106,8 @@ const ExecutionsResultsTable = memo(
|
||||
setPage: (page: number) => void;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
selectedExecutionId: number | null;
|
||||
onSelectExecution: (id: number) => void;
|
||||
}) => {
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
@@ -182,11 +192,20 @@ const ExecutionsResultsTable = memo(
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{executions.map((exec: any) => (
|
||||
<tr key={exec.id} className="hover:bg-gray-50">
|
||||
<tr
|
||||
key={exec.id}
|
||||
className={`hover:bg-gray-50 cursor-pointer ${
|
||||
selectedExecutionId === exec.id
|
||||
? "bg-blue-50 hover:bg-blue-50"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => onSelectExecution(exec.id)}
|
||||
>
|
||||
<td className="px-6 py-4 font-mono text-sm">
|
||||
<Link
|
||||
to={`/executions/${exec.id}`}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
#{exec.id}
|
||||
</Link>
|
||||
@@ -294,6 +313,15 @@ ExecutionsResultsTable.displayName = "ExecutionsResultsTable";
|
||||
export default function ExecutionsPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// --- View mode toggle ---
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(() => {
|
||||
const stored = localStorage.getItem(VIEW_MODE_STORAGE_KEY);
|
||||
if (stored === "all" || stored === "workflow") return stored;
|
||||
const param = searchParams.get("view");
|
||||
if (param === "all" || param === "workflow") return param;
|
||||
return "all";
|
||||
});
|
||||
|
||||
// --- Filter input state (updates immediately on keystroke) ---
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
@@ -342,8 +370,11 @@ export default function ExecutionsPage() {
|
||||
if (debouncedStatuses.length === 1) {
|
||||
params.status = debouncedStatuses[0] as ExecutionStatus;
|
||||
}
|
||||
if (viewMode === "workflow") {
|
||||
params.topLevelOnly = true;
|
||||
}
|
||||
return params;
|
||||
}, [page, pageSize, debouncedFilters, debouncedStatuses]);
|
||||
}, [page, pageSize, debouncedFilters, debouncedStatuses, viewMode]);
|
||||
|
||||
const { data, isLoading, isFetching, error } = useExecutions(queryParams);
|
||||
const { isConnected } = useExecutionStream({ enabled: true });
|
||||
@@ -423,103 +454,181 @@ export default function ExecutionsPage() {
|
||||
Object.values(searchFilters).some((v) => v !== "") ||
|
||||
selectedStatuses.length > 0;
|
||||
|
||||
const [selectedExecutionId, setSelectedExecutionId] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const handleSelectExecution = useCallback((id: number) => {
|
||||
setSelectedExecutionId((prev) => (prev === id ? null : id));
|
||||
}, []);
|
||||
|
||||
const handleClosePreview = useCallback(() => {
|
||||
setSelectedExecutionId(null);
|
||||
}, []);
|
||||
|
||||
const handleViewModeChange = useCallback((mode: ViewMode) => {
|
||||
setViewMode(mode);
|
||||
localStorage.setItem(VIEW_MODE_STORAGE_KEY, mode);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header - always visible */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Executions</h1>
|
||||
{isFetching && hasActiveFilters && (
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Searching executions...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isConnected && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600">
|
||||
<div className="h-2 w-2 rounded-full bg-green-600 animate-pulse" />
|
||||
<span>Live Updates</span>
|
||||
<div className="flex h-[calc(100vh-4rem)]">
|
||||
{/* Main content area */}
|
||||
<div
|
||||
className={`flex-1 min-w-0 overflow-y-auto p-6 ${selectedExecutionId ? "mr-0" : ""}`}
|
||||
>
|
||||
{/* Header - always visible */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold">Executions</h1>
|
||||
{isConnected && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-green-600 bg-green-50 border border-green-200 rounded-full px-2.5 py-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||
<span>Live</span>
|
||||
</div>
|
||||
)}
|
||||
{isFetching && hasActiveFilters && (
|
||||
<p className="text-sm text-gray-500">Searching executions...</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* View mode toggle */}
|
||||
<div className="inline-flex rounded-lg border border-gray-300 bg-white shadow-sm">
|
||||
<button
|
||||
onClick={() => handleViewModeChange("all")}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-l-lg transition-colors ${
|
||||
viewMode === "all"
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleViewModeChange("workflow")}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-r-lg transition-colors ${
|
||||
viewMode === "workflow"
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<GitBranch className="h-4 w-4" />
|
||||
By Workflow
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter section - always mounted, never unmounts during loading */}
|
||||
<div className="bg-white shadow rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="h-5 w-5 text-gray-400" />
|
||||
<h2 className="text-lg font-semibold">Filter Executions</h2>
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
Clear Filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4">
|
||||
<AutocompleteInput
|
||||
label="Pack"
|
||||
value={searchFilters.pack}
|
||||
onChange={(value) => handleFilterChange("pack", value)}
|
||||
suggestions={packSuggestions}
|
||||
placeholder="e.g., core"
|
||||
/>
|
||||
<AutocompleteInput
|
||||
label="Rule"
|
||||
value={searchFilters.rule}
|
||||
onChange={(value) => handleFilterChange("rule", value)}
|
||||
suggestions={ruleSuggestions}
|
||||
placeholder="e.g., core.on_timer"
|
||||
/>
|
||||
<AutocompleteInput
|
||||
label="Action"
|
||||
value={searchFilters.action}
|
||||
onChange={(value) => handleFilterChange("action", value)}
|
||||
suggestions={actionSuggestions}
|
||||
placeholder="e.g., core.echo"
|
||||
/>
|
||||
<AutocompleteInput
|
||||
label="Trigger"
|
||||
value={searchFilters.trigger}
|
||||
onChange={(value) => handleFilterChange("trigger", value)}
|
||||
suggestions={triggerSuggestions}
|
||||
placeholder="e.g., core.timer"
|
||||
/>
|
||||
<FilterInput
|
||||
label="Executor ID"
|
||||
value={searchFilters.executor}
|
||||
onChange={(value) => handleFilterChange("executor", value)}
|
||||
placeholder="e.g., 1"
|
||||
/>
|
||||
<div>
|
||||
<MultiSelect
|
||||
label="Status"
|
||||
options={STATUS_OPTIONS}
|
||||
value={selectedStatuses}
|
||||
onChange={setSelectedStatuses}
|
||||
placeholder="All Statuses"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results section - isolated from filter state, only depends on query results */}
|
||||
{viewMode === "all" ? (
|
||||
<ExecutionsResultsTable
|
||||
executions={filteredExecutions}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
error={error as Error | null}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
clearFilters={clearFilters}
|
||||
page={page}
|
||||
setPage={setPage}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
selectedExecutionId={selectedExecutionId}
|
||||
onSelectExecution={handleSelectExecution}
|
||||
/>
|
||||
) : (
|
||||
<WorkflowExecutionTree
|
||||
executions={filteredExecutions}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
error={error as Error | null}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
clearFilters={clearFilters}
|
||||
page={page}
|
||||
setPage={setPage}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
workflowActionRefs={baseSuggestions.workflowActionRefs}
|
||||
selectedExecutionId={selectedExecutionId}
|
||||
onSelectExecution={handleSelectExecution}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter section - always mounted, never unmounts during loading */}
|
||||
<div className="bg-white shadow rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="h-5 w-5 text-gray-400" />
|
||||
<h2 className="text-lg font-semibold">Filter Executions</h2>
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
Clear Filters
|
||||
</button>
|
||||
)}
|
||||
{/* Right-side preview panel */}
|
||||
{selectedExecutionId && (
|
||||
<div className="w-[400px] flex-shrink-0 h-full">
|
||||
<ExecutionPreviewPanel
|
||||
executionId={selectedExecutionId}
|
||||
onClose={handleClosePreview}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4">
|
||||
<AutocompleteInput
|
||||
label="Pack"
|
||||
value={searchFilters.pack}
|
||||
onChange={(value) => handleFilterChange("pack", value)}
|
||||
suggestions={packSuggestions}
|
||||
placeholder="e.g., core"
|
||||
/>
|
||||
<AutocompleteInput
|
||||
label="Rule"
|
||||
value={searchFilters.rule}
|
||||
onChange={(value) => handleFilterChange("rule", value)}
|
||||
suggestions={ruleSuggestions}
|
||||
placeholder="e.g., core.on_timer"
|
||||
/>
|
||||
<AutocompleteInput
|
||||
label="Action"
|
||||
value={searchFilters.action}
|
||||
onChange={(value) => handleFilterChange("action", value)}
|
||||
suggestions={actionSuggestions}
|
||||
placeholder="e.g., core.echo"
|
||||
/>
|
||||
<AutocompleteInput
|
||||
label="Trigger"
|
||||
value={searchFilters.trigger}
|
||||
onChange={(value) => handleFilterChange("trigger", value)}
|
||||
suggestions={triggerSuggestions}
|
||||
placeholder="e.g., core.timer"
|
||||
/>
|
||||
<FilterInput
|
||||
label="Executor ID"
|
||||
value={searchFilters.executor}
|
||||
onChange={(value) => handleFilterChange("executor", value)}
|
||||
placeholder="e.g., 1"
|
||||
/>
|
||||
<div>
|
||||
<MultiSelect
|
||||
label="Status"
|
||||
options={STATUS_OPTIONS}
|
||||
value={selectedStatuses}
|
||||
onChange={setSelectedStatuses}
|
||||
placeholder="All Statuses"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results section - isolated from filter state, only depends on query results */}
|
||||
<ExecutionsResultsTable
|
||||
executions={filteredExecutions}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
error={error as Error | null}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
clearFilters={clearFilters}
|
||||
page={page}
|
||||
setPage={setPage}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user