filters with search autofill

This commit is contained in:
2026-02-12 20:44:39 -06:00
parent aa1425365e
commit f52320f889
10 changed files with 1510 additions and 823 deletions

View File

@@ -5,8 +5,13 @@ import { ExecutionStatus } from "@/api";
import { useState, useMemo, memo, useCallback, useEffect } from "react";
import { Search, X } from "lucide-react";
import MultiSelect from "@/components/common/MultiSelect";
import AutocompleteInput from "@/components/common/AutocompleteInput";
import {
useFilterSuggestions,
useMergedSuggestions,
} from "@/hooks/useFilterSuggestions";
// Memoized filter input component to prevent re-render on WebSocket updates
// Memoized filter input component for non-ref fields (e.g. Executor ID)
const FilterInput = memo(
({
label,
@@ -50,10 +55,246 @@ const STATUS_OPTIONS = [
{ value: ExecutionStatus.ABANDONED, label: "Abandoned" },
];
const getStatusColor = (status: ExecutionStatus) => {
switch (status) {
case ExecutionStatus.COMPLETED:
return "bg-green-100 text-green-800";
case ExecutionStatus.FAILED:
case ExecutionStatus.TIMEOUT:
return "bg-red-100 text-red-800";
case ExecutionStatus.RUNNING:
return "bg-blue-100 text-blue-800";
case ExecutionStatus.SCHEDULED:
case ExecutionStatus.SCHEDULING:
case ExecutionStatus.REQUESTED:
return "bg-yellow-100 text-yellow-800";
default:
return "bg-gray-100 text-gray-800";
}
};
// Memoized results table component - only re-renders when query data changes,
// NOT when the user types in filter inputs.
const ExecutionsResultsTable = memo(
({
executions,
isLoading,
isFetching,
error,
hasActiveFilters,
clearFilters,
page,
setPage,
pageSize,
total,
}: {
executions: any[];
isLoading: boolean;
isFetching: boolean;
error: Error | null;
hasActiveFilters: boolean;
clearFilters: () => void;
page: number;
setPage: (page: number) => void;
pageSize: number;
total: number;
}) => {
const totalPages = Math.ceil(total / pageSize);
// Initial load (no cached data yet)
if (isLoading && executions.length === 0) {
return (
<div className="bg-white shadow rounded-lg">
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
</div>
</div>
);
}
// Error with no cached data to show
if (error && executions.length === 0) {
return (
<div className="bg-white shadow rounded-lg">
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
<p>Error: {error.message}</p>
</div>
</div>
);
}
// Empty results
if (executions.length === 0) {
return (
<div className="bg-white p-12 text-center rounded-lg shadow">
<p>No executions found</p>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="mt-3 text-sm text-blue-600 hover:text-blue-800"
>
Clear filters
</button>
)}
</div>
);
}
return (
<div className="relative">
{/* Inline loading overlay - shows on top of previous results while fetching */}
{isFetching && (
<div className="absolute inset-0 bg-white/60 z-10 flex items-center justify-center rounded-lg">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
)}
{/* Non-fatal error banner (data still shown from cache) */}
{error && (
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
<p>Error refreshing: {error.message}</p>
</div>
)}
<div className="bg-white shadow rounded-lg overflow-hidden">
<table className="min-w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Action
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Rule
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Trigger
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Created
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{executions.map((exec: any) => (
<tr key={exec.id} className="hover:bg-gray-50">
<td className="px-6 py-4 font-mono text-sm">
<Link
to={`/executions/${exec.id}`}
className="text-blue-600 hover:text-blue-800"
>
#{exec.id}
</Link>
</td>
<td className="px-6 py-4">
<span className="text-sm text-gray-900">
{exec.action_ref}
</span>
</td>
<td className="px-6 py-4">
{exec.rule_ref ? (
<span className="text-sm text-gray-700">
{exec.rule_ref}
</span>
) : (
<span className="text-sm text-gray-400 italic">-</span>
)}
</td>
<td className="px-6 py-4">
{exec.trigger_ref ? (
<span className="text-sm text-gray-700">
{exec.trigger_ref}
</span>
) : (
<span className="text-sm text-gray-400 italic">-</span>
)}
</td>
<td className="px-6 py-4">
<span
className={`px-2 py-1 text-xs rounded ${getStatusColor(exec.status)}`}
>
{exec.status}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{new Date(exec.created).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="bg-gray-50 px-6 py-4 flex items-center justify-between border-t border-gray-200">
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={() => setPage(page - 1)}
disabled={page === 1}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => setPage(page + 1)}
disabled={page === totalPages}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Showing{" "}
<span className="font-medium">
{(page - 1) * pageSize + 1}
</span>{" "}
to{" "}
<span className="font-medium">
{Math.min(page * pageSize, total)}
</span>{" "}
of <span className="font-medium">{total}</span> executions
</p>
</div>
<div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
<button
onClick={() => setPage(page - 1)}
disabled={page === 1}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => setPage(page + 1)}
disabled={page === totalPages}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</nav>
</div>
</div>
</div>
)}
</div>
);
},
);
ExecutionsResultsTable.displayName = "ExecutionsResultsTable";
export default function ExecutionsPage() {
const [searchParams] = useSearchParams();
// Initialize filters from URL query parameters
// --- Filter input state (updates immediately on keystroke) ---
const [page, setPage] = useState(1);
const pageSize = 50;
const [searchFilters, setSearchFilters] = useState({
@@ -68,28 +309,28 @@ export default function ExecutionsPage() {
return status ? [status] : [];
});
// Debounced filter state for API calls
// --- Debounced filter state (drives API calls, updates after delay) ---
const [debouncedFilters, setDebouncedFilters] = useState(searchFilters);
const [debouncedStatuses, setDebouncedStatuses] = useState(selectedStatuses);
// Debounce filter changes (500ms delay)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedFilters(searchFilters);
}, 500);
return () => clearTimeout(timer);
}, [searchFilters]);
// Debounce status changes (300ms delay - shorter since it's a selection)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedStatuses(selectedStatuses);
}, 300);
return () => clearTimeout(timer);
}, [selectedStatuses]);
// --- Autocomplete suggestions ---
const baseSuggestions = useFilterSuggestions();
// --- Build query params from debounced state ---
const queryParams = useMemo(() => {
const params: any = { page, pageSize };
if (debouncedFilters.pack) params.packName = debouncedFilters.pack;
@@ -98,33 +339,64 @@ export default function ExecutionsPage() {
if (debouncedFilters.trigger) params.triggerRef = debouncedFilters.trigger;
if (debouncedFilters.executor)
params.executor = parseInt(debouncedFilters.executor, 10);
// Include status filter if exactly one status is selected
// API only supports single status, so we use the first one for filtering
// and show all results if multiple are selected
if (debouncedStatuses.length === 1) {
params.status = debouncedStatuses[0] as ExecutionStatus;
}
return params;
}, [page, pageSize, debouncedFilters, debouncedStatuses]);
const { data, isLoading, error } = useExecutions(queryParams);
// Subscribe to real-time updates for all executions
const { data, isLoading, isFetching, error } = useExecutions(queryParams);
const { isConnected } = useExecutionStream({ enabled: true });
const executions = data?.data || [];
const executions = useMemo(() => data?.data || [], [data]);
const total = data?.pagination?.total_items || 0;
const totalPages = Math.ceil(total / pageSize);
// Derive refs from currently-loaded execution data (no setState needed)
const loadedRefs = useMemo(() => {
const packs = new Set<string>();
const rules = new Set<string>();
const actions = new Set<string>();
const triggers = new Set<string>();
for (const exec of executions) {
if (exec.action_ref) {
const pack = (exec.action_ref as string).split(".")[0];
if (pack) packs.add(pack);
actions.add(exec.action_ref as string);
}
if (exec.rule_ref) rules.add(exec.rule_ref as string);
if (exec.trigger_ref) triggers.add(exec.trigger_ref as string);
}
return {
packs: [...packs],
rules: [...rules],
actions: [...actions],
triggers: [...triggers],
};
}, [executions]);
// Merge base entity suggestions + loaded data refs
const packSuggestions = useMergedSuggestions(
baseSuggestions.packNames,
loadedRefs.packs,
);
const ruleSuggestions = useMergedSuggestions(
baseSuggestions.ruleRefs,
loadedRefs.rules,
);
const actionSuggestions = useMergedSuggestions(
baseSuggestions.actionRefs,
loadedRefs.actions,
);
const triggerSuggestions = useMergedSuggestions(
baseSuggestions.triggerRefs,
loadedRefs.triggers,
);
// Client-side filtering for multiple status selection (when > 1 selected)
const filteredExecutions = useMemo(() => {
// If no statuses selected or only one (already filtered by API), show all
if (debouncedStatuses.length <= 1) {
return executions;
}
// If multiple statuses selected, filter client-side
if (debouncedStatuses.length <= 1) return executions;
return executions.filter((exec: any) =>
debouncedStatuses.includes(exec.status),
);
@@ -132,7 +404,7 @@ export default function ExecutionsPage() {
const handleFilterChange = useCallback((field: string, value: string) => {
setSearchFilters((prev) => ({ ...prev, [field]: value }));
setPage(1); // Reset to first page on filter change
setPage(1);
}, []);
const clearFilters = useCallback(() => {
@@ -144,57 +416,20 @@ export default function ExecutionsPage() {
executor: "",
});
setSelectedStatuses([]);
setPage(1); // Reset to first page
setPage(1);
}, []);
const hasActiveFilters =
Object.values(searchFilters).some((v) => v !== "") ||
selectedStatuses.length > 0;
if (isLoading) {
return (
<div className="p-6">
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
</div>
</div>
);
}
if (error) {
return (
<div className="p-6">
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
<p>Error: {(error as Error).message}</p>
</div>
</div>
);
}
const getStatusColor = (status: ExecutionStatus) => {
switch (status) {
case ExecutionStatus.COMPLETED:
return "bg-green-100 text-green-800";
case ExecutionStatus.FAILED:
case ExecutionStatus.TIMEOUT:
return "bg-red-100 text-red-800";
case ExecutionStatus.RUNNING:
return "bg-blue-100 text-blue-800";
case ExecutionStatus.SCHEDULED:
case ExecutionStatus.SCHEDULING:
case ExecutionStatus.REQUESTED:
return "bg-yellow-100 text-yellow-800";
default:
return "bg-gray-100 text-gray-800";
}
};
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>
{isLoading && hasActiveFilters && (
{isFetching && hasActiveFilters && (
<p className="text-sm text-gray-500 mt-1">
Searching executions...
</p>
@@ -208,7 +443,7 @@ export default function ExecutionsPage() {
)}
</div>
{/* Search Filters */}
{/* 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">
@@ -226,28 +461,32 @@ export default function ExecutionsPage() {
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4">
<FilterInput
<AutocompleteInput
label="Pack"
value={searchFilters.pack}
onChange={(value) => handleFilterChange("pack", value)}
suggestions={packSuggestions}
placeholder="e.g., core"
/>
<FilterInput
<AutocompleteInput
label="Rule"
value={searchFilters.rule}
onChange={(value) => handleFilterChange("rule", value)}
suggestions={ruleSuggestions}
placeholder="e.g., core.on_timer"
/>
<FilterInput
<AutocompleteInput
label="Action"
value={searchFilters.action}
onChange={(value) => handleFilterChange("action", value)}
suggestions={actionSuggestions}
placeholder="e.g., core.echo"
/>
<FilterInput
<AutocompleteInput
label="Trigger"
value={searchFilters.trigger}
onChange={(value) => handleFilterChange("trigger", value)}
suggestions={triggerSuggestions}
placeholder="e.g., core.timer"
/>
<FilterInput
@@ -268,153 +507,19 @@ export default function ExecutionsPage() {
</div>
</div>
{filteredExecutions.length === 0 ? (
<div className="bg-white p-12 text-center rounded-lg shadow">
<p>
{executions.length === 0
? "No executions found"
: "No executions match the selected filters"}
</p>
{executions.length > 0 && hasActiveFilters && (
<button
onClick={clearFilters}
className="mt-3 text-sm text-blue-600 hover:text-blue-800"
>
Clear filters
</button>
)}
</div>
) : (
<>
<div className="bg-white shadow rounded-lg overflow-hidden">
<table className="min-w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Action
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Rule
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Trigger
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Created
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredExecutions.map((exec: any) => (
<tr key={exec.id} className="hover:bg-gray-50">
<td className="px-6 py-4 font-mono text-sm">
<Link
to={`/executions/${exec.id}`}
className="text-blue-600 hover:text-blue-800"
>
#{exec.id}
</Link>
</td>
<td className="px-6 py-4">
<span className="text-sm text-gray-900">
{exec.action_ref}
</span>
</td>
<td className="px-6 py-4">
{exec.rule_ref ? (
<span className="text-sm text-gray-700">
{exec.rule_ref}
</span>
) : (
<span className="text-sm text-gray-400 italic">-</span>
)}
</td>
<td className="px-6 py-4">
{exec.trigger_ref ? (
<span className="text-sm text-gray-700">
{exec.trigger_ref}
</span>
) : (
<span className="text-sm text-gray-400 italic">-</span>
)}
</td>
<td className="px-6 py-4">
<span
className={`px-2 py-1 text-xs rounded ${getStatusColor(exec.status)}`}
>
{exec.status}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{new Date(exec.created).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="bg-gray-50 px-6 py-4 flex items-center justify-between border-t border-gray-200">
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={() => setPage(page - 1)}
disabled={page === 1}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => setPage(page + 1)}
disabled={page === totalPages}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Showing{" "}
<span className="font-medium">
{(page - 1) * pageSize + 1}
</span>{" "}
to
<span className="font-medium">
{Math.min(page * pageSize, total)}
</span>{" "}
of &nbsp;
<span className="font-medium">{total}</span> executions
</p>
</div>
<div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
<button
onClick={() => setPage(page - 1)}
disabled={page === 1}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => setPage(page + 1)}
disabled={page === totalPages}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</nav>
</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>
);
}