diff --git a/crates/api/src/dto/event.rs b/crates/api/src/dto/event.rs index 2f1311c..2042d27 100644 --- a/crates/api/src/dto/event.rs +++ b/crates/api/src/dto/event.rs @@ -144,6 +144,10 @@ pub struct EventQueryParams { #[param(example = "core.webhook")] pub trigger_ref: Option, + /// Filter by rule reference + #[param(example = "core.on_webhook")] + pub rule_ref: Option, + /// Filter by source ID #[param(example = 1)] pub source: Option, diff --git a/crates/api/src/routes/events.rs b/crates/api/src/routes/events.rs index ca71092..58b1dea 100644 --- a/crates/api/src/routes/events.rs +++ b/crates/api/src/routes/events.rs @@ -237,6 +237,16 @@ pub async fn list_events( filtered_events.retain(|e| e.source == Some(source_id)); } + if let Some(rule_ref) = &query.rule_ref { + let rule_ref_lower = rule_ref.to_lowercase(); + filtered_events.retain(|e| { + e.rule_ref + .as_ref() + .map(|r| r.to_lowercase().contains(&rule_ref_lower)) + .unwrap_or(false) + }); + } + // Calculate pagination let total = filtered_events.len() as u64; let start = query.offset() as usize; diff --git a/web/src/api/services/EventsService.ts b/web/src/api/services/EventsService.ts index c0f56bd..0c019a0 100644 --- a/web/src/api/services/EventsService.ts +++ b/web/src/api/services/EventsService.ts @@ -2,86 +2,92 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -import type { ApiResponse_EventResponse } from '../models/ApiResponse_EventResponse'; -import type { i64 } from '../models/i64'; -import type { PaginatedResponse_EventSummary } from '../models/PaginatedResponse_EventSummary'; -import type { CancelablePromise } from '../core/CancelablePromise'; -import { OpenAPI } from '../core/OpenAPI'; -import { request as __request } from '../core/request'; +import type { ApiResponse_EventResponse } from "../models/ApiResponse_EventResponse"; +import type { i64 } from "../models/i64"; +import type { PaginatedResponse_EventSummary } from "../models/PaginatedResponse_EventSummary"; +import type { CancelablePromise } from "../core/CancelablePromise"; +import { OpenAPI } from "../core/OpenAPI"; +import { request as __request } from "../core/request"; export class EventsService { + /** + * List all events with pagination and optional filters + * @returns PaginatedResponse_EventSummary List of events + * @throws ApiError + */ + public static listEvents({ + trigger, + triggerRef, + ruleRef, + source, + page, + perPage, + }: { /** - * List all events with pagination and optional filters - * @returns PaginatedResponse_EventSummary List of events - * @throws ApiError + * Filter by trigger ID */ - public static listEvents({ - trigger, - triggerRef, - source, - page, - perPage, - }: { - /** - * Filter by trigger ID - */ - trigger?: (null | i64), - /** - * Filter by trigger reference - */ - triggerRef?: string | null, - /** - * Filter by source ID - */ - source?: (null | i64), - /** - * Page number (1-indexed) - */ - page?: number, - /** - * Items per page - */ - perPage?: number, - }): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/api/v1/events', - query: { - 'trigger': trigger, - 'trigger_ref': triggerRef, - 'source': source, - 'page': page, - 'per_page': perPage, - }, - errors: { - 401: `Unauthorized`, - 500: `Internal server error`, - }, - }); - } + trigger?: null | i64; /** - * Get a single event by ID - * @returns ApiResponse_EventResponse Event details - * @throws ApiError + * Filter by trigger reference */ - public static getEvent({ - id, - }: { - /** - * Event ID - */ - id: number, - }): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/api/v1/events/{id}', - path: { - 'id': id, - }, - errors: { - 401: `Unauthorized`, - 404: `Event not found`, - 500: `Internal server error`, - }, - }); - } + triggerRef?: string | null; + /** + * Filter by rule reference + */ + ruleRef?: string | null; + /** + * Filter by source ID + */ + source?: null | i64; + /** + * Page number (1-indexed) + */ + page?: number; + /** + * Items per page + */ + perPage?: number; + }): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/api/v1/events", + query: { + trigger: trigger, + trigger_ref: triggerRef, + rule_ref: ruleRef, + source: source, + page: page, + per_page: perPage, + }, + errors: { + 401: `Unauthorized`, + 500: `Internal server error`, + }, + }); + } + /** + * Get a single event by ID + * @returns ApiResponse_EventResponse Event details + * @throws ApiError + */ + public static getEvent({ + id, + }: { + /** + * Event ID + */ + id: number; + }): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/api/v1/events/{id}", + path: { + id: id, + }, + errors: { + 401: `Unauthorized`, + 404: `Event not found`, + 500: `Internal server error`, + }, + }); + } } diff --git a/web/src/components/common/AutocompleteInput.tsx b/web/src/components/common/AutocompleteInput.tsx new file mode 100644 index 0000000..0ca0b0b --- /dev/null +++ b/web/src/components/common/AutocompleteInput.tsx @@ -0,0 +1,216 @@ +import { useState, useRef, useEffect, useCallback, memo } from "react"; + +interface AutocompleteInputProps { + label: string; + value: string; + onChange: (value: string) => void; + suggestions: string[]; + placeholder?: string; +} + +/** + * A text input with dropdown autocomplete suggestions. + * Allows free-text entry — selecting a suggestion simply fills the input. + * Memoized so it only re-renders when its own props change, not when + * sibling components (e.g. a results table) update. + */ +const AutocompleteInput = memo( + ({ + label, + value, + onChange, + suggestions, + placeholder = "", + }: AutocompleteInputProps) => { + const [isOpen, setIsOpen] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const containerRef = useRef(null); + const inputRef = useRef(null); + const listRef = useRef(null); + + // Filter suggestions based on current input value (case-insensitive substring) + const filtered = + value.length === 0 + ? suggestions + : suggestions.filter((s) => + s.toLowerCase().includes(value.toLowerCase()), + ); + + // Clamp the highlighted index when the filtered list shrinks + const safeIndex = + highlightedIndex >= filtered.length ? -1 : highlightedIndex; + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => + document.removeEventListener("mousedown", handleClickOutside); + }, []); + + // Scroll highlighted item into view + useEffect(() => { + if (safeIndex >= 0 && listRef.current) { + const items = listRef.current.children; + if (items[safeIndex]) { + (items[safeIndex] as HTMLElement).scrollIntoView({ + block: "nearest", + }); + } + } + }, [safeIndex]); + + const selectSuggestion = useCallback( + (suggestion: string) => { + onChange(suggestion); + setIsOpen(false); + setHighlightedIndex(-1); + // Keep focus on input after selection + inputRef.current?.focus(); + }, + [onChange], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!isOpen || filtered.length === 0) { + // Open dropdown on arrow down even if closed + if (e.key === "ArrowDown" && filtered.length > 0) { + e.preventDefault(); + setIsOpen(true); + setHighlightedIndex(0); + } + return; + } + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setHighlightedIndex((prev) => + prev < filtered.length - 1 ? prev + 1 : 0, + ); + break; + case "ArrowUp": + e.preventDefault(); + setHighlightedIndex((prev) => + prev > 0 ? prev - 1 : filtered.length - 1, + ); + break; + case "Enter": + e.preventDefault(); + if (safeIndex >= 0 && safeIndex < filtered.length) { + selectSuggestion(filtered[safeIndex]); + } + break; + case "Escape": + e.preventDefault(); + setIsOpen(false); + setHighlightedIndex(-1); + break; + case "Tab": + setIsOpen(false); + setHighlightedIndex(-1); + break; + } + }, + [isOpen, filtered, safeIndex, selectSuggestion], + ); + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + onChange(e.target.value); + setIsOpen(true); + setHighlightedIndex(-1); + }, + [onChange], + ); + + const handleFocus = useCallback(() => { + if (suggestions.length > 0) { + setIsOpen(true); + } + }, [suggestions.length]); + + return ( +
+ + + + {isOpen && filtered.length > 0 && ( +
    + {filtered.map((suggestion, index) => { + const isHighlighted = index === safeIndex; + // Highlight the matching substring + const matchIndex = suggestion + .toLowerCase() + .indexOf(value.toLowerCase()); + const before = + matchIndex >= 0 + ? suggestion.slice(0, matchIndex) + : suggestion; + const match = + matchIndex >= 0 + ? suggestion.slice(matchIndex, matchIndex + value.length) + : ""; + const after = + matchIndex >= 0 + ? suggestion.slice(matchIndex + value.length) + : ""; + + return ( +
  • { + // Use mousedown instead of click to fire before input blur + e.preventDefault(); + selectSuggestion(suggestion); + }} + onMouseEnter={() => setHighlightedIndex(index)} + className={`px-3 py-2 text-sm cursor-pointer ${ + isHighlighted ? "bg-blue-50 text-blue-900" : "text-gray-900" + } hover:bg-blue-50`} + > + {value.length > 0 && matchIndex >= 0 ? ( + <> + {before} + {match} + {after} + + ) : ( + suggestion + )} +
  • + ); + })} +
+ )} +
+ ); + }, +); + +AutocompleteInput.displayName = "AutocompleteInput"; + +export default AutocompleteInput; diff --git a/web/src/hooks/useEvents.ts b/web/src/hooks/useEvents.ts index aad4f6b..fe97a71 100644 --- a/web/src/hooks/useEvents.ts +++ b/web/src/hooks/useEvents.ts @@ -1,4 +1,4 @@ -import { useQuery } from "@tanstack/react-query"; +import { useQuery, keepPreviousData } from "@tanstack/react-query"; import { EventsService, EnforcementsService, EnforcementStatus } from "@/api"; import type { i64 } from "@/api"; @@ -7,6 +7,7 @@ interface EventsQueryParams { pageSize?: number; trigger?: i64 | null; triggerRef?: string | null; + ruleRef?: string | null; source?: i64 | null; } @@ -29,10 +30,12 @@ export function useEvents(params?: EventsQueryParams) { perPage: params?.pageSize || 50, trigger: params?.trigger, triggerRef: params?.triggerRef, + ruleRef: params?.ruleRef, source: params?.source, }); }, staleTime: 30000, // 30 seconds + placeholderData: keepPreviousData, }); } @@ -63,6 +66,7 @@ export function useEnforcements(params?: EnforcementsQueryParams) { }); }, staleTime: 30000, + placeholderData: keepPreviousData, }); } diff --git a/web/src/hooks/useExecutions.ts b/web/src/hooks/useExecutions.ts index ec559f9..1a49397 100644 --- a/web/src/hooks/useExecutions.ts +++ b/web/src/hooks/useExecutions.ts @@ -1,4 +1,4 @@ -import { useQuery } from "@tanstack/react-query"; +import { useQuery, keepPreviousData } from "@tanstack/react-query"; import { ExecutionsService } from "@/api"; import type { ExecutionStatus } from "@/api"; @@ -43,6 +43,8 @@ export function useExecutions(params?: ExecutionsQueryParams) { staleTime: hasFilters ? 5000 : 30000, // Refetch in background when filters change to get latest data refetchOnMount: hasFilters ? "always" : true, + // Keep previous results visible while new data loads (prevents flash of empty state) + placeholderData: keepPreviousData, }); } diff --git a/web/src/hooks/useFilterSuggestions.ts b/web/src/hooks/useFilterSuggestions.ts new file mode 100644 index 0000000..eb133c1 --- /dev/null +++ b/web/src/hooks/useFilterSuggestions.ts @@ -0,0 +1,91 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + PacksService, + RulesService, + ActionsService, + TriggersService, +} from "@/api"; +import type { + PaginatedResponse_PackSummary, + PaginatedResponse_RuleSummary, + PaginatedResponse_ActionSummary, + PaginatedResponse_TriggerSummary, +} from "@/api"; + +/** + * Fetches all packs, rules, actions, and triggers and returns sorted + * arrays of their refs for use as autocomplete suggestions. + * + * Data is cached with a long staleTime (5 minutes) since entity definitions + * change infrequently. Individual pages can augment these base suggestions + * with refs discovered via WebSocket notifications. + */ +export function useFilterSuggestions() { + const { data: packsData } = useQuery({ + queryKey: ["filter-suggestions", "packs"], + queryFn: () => PacksService.listPacks({ page: 1, pageSize: 200 }), + staleTime: 5 * 60 * 1000, + }); + + const { data: rulesData } = useQuery({ + queryKey: ["filter-suggestions", "rules"], + queryFn: () => RulesService.listRules({ page: 1, pageSize: 200 }), + staleTime: 5 * 60 * 1000, + }); + + const { data: actionsData } = useQuery({ + queryKey: ["filter-suggestions", "actions"], + queryFn: () => ActionsService.listActions({ page: 1, pageSize: 200 }), + staleTime: 5 * 60 * 1000, + }); + + const { data: triggersData } = useQuery({ + queryKey: ["filter-suggestions", "triggers"], + queryFn: () => TriggersService.listTriggers({ page: 1, pageSize: 200 }), + staleTime: 5 * 60 * 1000, + }); + + const packNames = useMemo(() => { + const refs = packsData?.data?.map((p) => p.ref) || []; + return [...new Set(refs)].sort(); + }, [packsData]); + + const ruleRefs = useMemo(() => { + const refs = rulesData?.data?.map((r) => r.ref) || []; + return [...new Set(refs)].sort(); + }, [rulesData]); + + const actionRefs = useMemo(() => { + const refs = actionsData?.data?.map((a) => a.ref) || []; + return [...new Set(refs)].sort(); + }, [actionsData]); + + const triggerRefs = useMemo(() => { + const refs = triggersData?.data?.map((t) => t.ref) || []; + return [...new Set(refs)].sort(); + }, [triggersData]); + + return { packNames, ruleRefs, actionRefs, triggerRefs }; +} + +/** + * Merge base suggestion arrays with additional refs discovered at runtime + * (e.g. from WebSocket notifications or loaded page data). + * Returns a new sorted, deduplicated array only when the inputs change. + */ +export function useMergedSuggestions( + base: string[], + ...additionalSets: string[][] +): string[] { + return useMemo(() => { + const hasAdditional = additionalSets.some((s) => s.length > 0); + if (!hasAdditional) return base; + const merged = new Set(base); + for (const set of additionalSets) { + for (const item of set) merged.add(item); + } + return [...merged].sort(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [base, ...additionalSets]); +} diff --git a/web/src/pages/enforcements/EnforcementsPage.tsx b/web/src/pages/enforcements/EnforcementsPage.tsx index 7267984..dd9030f 100644 --- a/web/src/pages/enforcements/EnforcementsPage.tsx +++ b/web/src/pages/enforcements/EnforcementsPage.tsx @@ -5,8 +5,13 @@ import { EnforcementStatus } 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. Event ID) const FilterInput = memo( ({ label, @@ -43,10 +48,287 @@ const STATUS_OPTIONS = [ { value: EnforcementStatus.DISABLED, label: "Disabled" }, ]; +const getStatusColor = (status: EnforcementStatus) => { + switch (status) { + case EnforcementStatus.PROCESSED: + return "bg-green-100 text-green-800"; + case EnforcementStatus.DISABLED: + return "bg-gray-100 text-gray-800"; + case EnforcementStatus.CREATED: + return "bg-blue-100 text-blue-800"; + default: + return "bg-gray-100 text-gray-800"; + } +}; + +const getConditionBadge = (condition: string) => { + const colors: Record = { + all: "bg-purple-100 text-purple-800", + any: "bg-indigo-100 text-indigo-800", + }; + return colors[condition] || "bg-gray-100 text-gray-800"; +}; + +const formatTime = (timestamp: string) => { + const date = new Date(timestamp); + const now = new Date(); + const diff = now.getTime() - date.getTime(); + + if (diff < 60000) return "just now"; + if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; + if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; + return date.toLocaleDateString(); +}; + +const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString(); +}; + +// Memoized results table component - only re-renders when query data changes, +// NOT when the user types in filter inputs. +const EnforcementsResultsTable = memo( + ({ + enforcements, + isLoading, + isFetching, + error, + hasActiveFilters, + clearFilters, + page, + setPage, + pageSize, + total, + }: { + enforcements: 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 && enforcements.length === 0) { + return ( +
+
+
+
+
+ ); + } + + // Error with no cached data to show + if (error && enforcements.length === 0) { + return ( +
+
+

Error: {error.message}

+
+
+ ); + } + + // Empty results + if (enforcements.length === 0) { + return ( +
+

No enforcements found

+ {hasActiveFilters && ( + + )} +
+ ); + } + + return ( +
+ {/* Inline loading overlay - shows on top of previous results while fetching */} + {isFetching && ( +
+
+
+ )} + + {/* Non-fatal error banner (data still shown from cache) */} + {error && ( +
+

Error refreshing: {error.message}

+
+ )} + +
+ + + + + + + + + + + + + + {enforcements.map((enforcement: any) => ( + + + + + + + + + + ))} + +
+ ID + + Rule + + Trigger + + Event + + Condition + + Status + + Created +
+ + #{enforcement.id} + + + {enforcement.rule ? ( + + {enforcement.rule_ref} + + ) : ( + + {enforcement.rule_ref} + + )} + + + {enforcement.trigger_ref} + + + {enforcement.event ? ( + + #{enforcement.event} + + ) : ( + - + )} + + + {enforcement.condition} + + + + {enforcement.status} + + +
+ {formatTime(enforcement.created)} +
+
+ {formatDate(enforcement.created)} +
+
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+
+ + +
+
+
+

+ Showing{" "} + + {(page - 1) * pageSize + 1} + {" "} + to{" "} + + {Math.min(page * pageSize, total)} + {" "} + of {total} enforcements +

+
+
+ +
+
+
+ )} +
+ ); + }, +); + +EnforcementsResultsTable.displayName = "EnforcementsResultsTable"; + export default function EnforcementsPage() { 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({ @@ -59,28 +341,28 @@ export default function EnforcementsPage() { 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.trigger) params.triggerRef = debouncedFilters.trigger; @@ -90,27 +372,45 @@ export default function EnforcementsPage() { params.event = eventId; } } - - // 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 EnforcementStatus; } - return params; }, [page, pageSize, debouncedFilters, debouncedStatuses]); - const { data, isLoading, error } = useEnforcements(queryParams); - - // Subscribe to real-time updates for all enforcements + const { data, isLoading, isFetching, error } = useEnforcements(queryParams); const { isConnected } = useEnforcementStream({ enabled: true }); - const enforcements = data?.data || []; + const enforcements = useMemo(() => data?.data || [], [data]); const total = data?.pagination?.total_items || 0; - const totalPages = Math.ceil(total / pageSize); - // Client-side filtering for multiple status selection and rule_ref (when > 1 status selected) + // Derive refs from currently-loaded enforcement data (no setState needed) + const loadedRefs = useMemo(() => { + const rules = new Set(); + const triggers = new Set(); + + for (const enf of enforcements) { + if (enf.rule_ref) rules.add(enf.rule_ref as string); + if (enf.trigger_ref) triggers.add(enf.trigger_ref as string); + } + + return { + rules: [...rules], + triggers: [...triggers], + }; + }, [enforcements]); + + // Merge base entity suggestions + loaded data refs + const ruleSuggestions = useMergedSuggestions( + baseSuggestions.ruleRefs, + loadedRefs.rules, + ); + const triggerSuggestions = useMergedSuggestions( + baseSuggestions.triggerRefs, + loadedRefs.triggers, + ); + + // Client-side filtering for rule_ref and multiple status selection const filteredEnforcements = useMemo(() => { let filtered = enforcements; @@ -135,87 +435,26 @@ export default function EnforcementsPage() { 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(() => { - setSearchFilters({ - rule: "", - trigger: "", - event: "", - }); + setSearchFilters({ rule: "", trigger: "", event: "" }); setSelectedStatuses([]); - setPage(1); // Reset to first page + setPage(1); }, []); const hasActiveFilters = Object.values(searchFilters).some((v) => v !== "") || selectedStatuses.length > 0; - if (isLoading) { - return ( -
-
-
-
-
- ); - } - - if (error) { - return ( -
-
-

Error: {(error as Error).message}

-
-
- ); - } - - const getStatusColor = (status: EnforcementStatus) => { - switch (status) { - case EnforcementStatus.PROCESSED: - return "bg-green-100 text-green-800"; - case EnforcementStatus.DISABLED: - return "bg-gray-100 text-gray-800"; - case EnforcementStatus.CREATED: - return "bg-blue-100 text-blue-800"; - default: - return "bg-gray-100 text-gray-800"; - } - }; - - const getConditionBadge = (condition: string) => { - const colors = { - all: "bg-purple-100 text-purple-800", - any: "bg-indigo-100 text-indigo-800", - }; - return ( - colors[condition as keyof typeof colors] || "bg-gray-100 text-gray-800" - ); - }; - - const formatTime = (timestamp: string) => { - const date = new Date(timestamp); - const now = new Date(); - const diff = now.getTime() - date.getTime(); - - if (diff < 60000) return "just now"; - if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; - if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; - return date.toLocaleDateString(); - }; - - const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleString(); - }; - return (
+ {/* Header - always visible */}

Enforcements

- {isLoading && hasActiveFilters && ( + {isFetching && hasActiveFilters && (

Searching enforcements...

@@ -229,7 +468,7 @@ export default function EnforcementsPage() { )}
- {/* Search Filters */} + {/* Filter section - always mounted, never unmounts during loading */}
@@ -247,16 +486,18 @@ export default function EnforcementsPage() { )}
- handleFilterChange("rule", value)} + suggestions={ruleSuggestions} placeholder="e.g., core.on_timer" /> - handleFilterChange("trigger", value)} + suggestions={triggerSuggestions} placeholder="e.g., core.webhook" />
- {filteredEnforcements.length === 0 ? ( -
-

- {enforcements.length === 0 - ? "No enforcements found" - : "No enforcements match the selected filters"} -

- {enforcements.length > 0 && hasActiveFilters && ( - - )} -
- ) : ( - <> -
- - - - - - - - - - - - - - {filteredEnforcements.map((enforcement: any) => ( - - - - - - - - - - ))} - -
- ID - - Rule - - Trigger - - Event - - Condition - - Status - - Created -
- - #{enforcement.id} - - - {enforcement.rule ? ( - - {enforcement.rule_ref} - - ) : ( - - {enforcement.rule_ref} - - )} - - - {enforcement.trigger_ref} - - - {enforcement.event ? ( - - #{enforcement.event} - - ) : ( - - - )} - - - {enforcement.condition} - - - - {enforcement.status} - - -
- {formatTime(enforcement.created)} -
-
- {formatDate(enforcement.created)} -
-
-
- - {/* Pagination */} - {totalPages > 1 && ( -
-
- - -
-
-
-

- Showing{" "} - - {(page - 1) * pageSize + 1} - {" "} - to{" "} - - {Math.min(page * pageSize, total)} - {" "} - of {total} enforcements -

-
-
- -
-
-
- )} - - )} + {/* Results section - isolated from filter state, only depends on query results */} +
); } diff --git a/web/src/pages/events/EventsPage.tsx b/web/src/pages/events/EventsPage.tsx index 867da9a..a896b44 100644 --- a/web/src/pages/events/EventsPage.tsx +++ b/web/src/pages/events/EventsPage.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from "react"; +import { useState, useCallback, useMemo, memo, useEffect } from "react"; import { Link, useSearchParams } from "react-router-dom"; import { useQueryClient } from "@tanstack/react-query"; import { useEvents } from "@/hooks/useEvents"; @@ -6,25 +6,322 @@ import { useEntityNotifications, Notification, } from "@/contexts/WebSocketContext"; +import { Search, X } from "lucide-react"; +import AutocompleteInput from "@/components/common/AutocompleteInput"; +import { + useFilterSuggestions, + useMergedSuggestions, +} from "@/hooks/useFilterSuggestions"; import type { EventSummary } from "@/api"; +const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString(); +}; + +const formatTime = (timestamp: string) => { + const date = new Date(timestamp); + const now = new Date(); + const diff = now.getTime() - date.getTime(); + + if (diff < 60000) return "just now"; + if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; + if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; + return date.toLocaleDateString(); +}; + +// Memoized results table component - only re-renders when query data changes, +// NOT when the user types in filter inputs. +const EventsResultsTable = memo( + ({ + events, + isLoading, + isFetching, + error, + hasActiveFilters, + clearFilters, + page, + setPage, + pageSize, + total, + }: { + events: EventSummary[]; + isLoading: boolean; + isFetching: boolean; + error: Error | null; + hasActiveFilters: boolean; + clearFilters: () => void; + page: number; + setPage: (page: number) => void; + pageSize: number; + total: number; + }) => { + const totalPages = total ? Math.ceil(total / pageSize) : 0; + + // Initial load (no cached data yet) + if (isLoading && events.length === 0) { + return ( +
+
+
+

Loading events...

+
+
+ ); + } + + // Error with no cached data to show + if (error && events.length === 0) { + return ( +
+

Failed to load events

+

{error.message}

+
+ ); + } + + // Empty results + if (events.length === 0) { + return ( +
+ + + +

No events found

+

+ {hasActiveFilters + ? "Try adjusting your filters" + : "Events will appear here when triggers fire"} +

+ {hasActiveFilters && ( + + )} +
+ ); + } + + return ( +
+ {/* Inline loading overlay - shows on top of previous results while fetching */} + {isFetching && ( +
+
+
+ )} + + {/* Non-fatal error banner (data still shown from cache) */} + {error && ( +
+

Error refreshing: {error.message}

+
+ )} + +
+
+ + + + + + + + + + + + {events.map((event) => ( + + + + + + + + ))} + +
+ ID + + Trigger + + Rule + + Source + + Created +
+ + #{event.id} + + +
+
+ {event.trigger_ref} +
+
+ ID: {event.trigger || "N/A"} +
+
+
+ {event.rule_ref ? ( +
+ + {event.rule_ref} + +
+ ID: {event.rule} +
+
+ ) : ( + + No rule + + )} +
+ {event.source_ref ? ( +
+
+ {event.source_ref} +
+
+ ID: {event.source || "N/A"} +
+
+ ) : ( + + No source + + )} +
+
+ {formatTime(event.created)} +
+
+ {formatDate(event.created)} +
+
+
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+
+ + +
+
+
+

+ Page {page} of{" "} + {totalPages} +

+
+
+ +
+
+
+ )} +
+ ); + }, +); + +EventsResultsTable.displayName = "EventsResultsTable"; + export default function EventsPage() { const [searchParams] = useSearchParams(); const queryClient = useQueryClient(); + + // --- Filter input state (updates immediately on keystroke) --- const [page, setPage] = useState(1); - const [triggerFilter, setTriggerFilter] = useState( - searchParams.get("trigger_ref") || "", - ); const pageSize = 50; + const [searchFilters, setSearchFilters] = useState({ + trigger: searchParams.get("trigger_ref") || "", + rule: searchParams.get("rule_ref") || "", + }); + + // --- Debounced filter state (drives API calls, updates after delay) --- + const [debouncedFilters, setDebouncedFilters] = useState(searchFilters); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedFilters(searchFilters); + }, 500); + return () => clearTimeout(timer); + }, [searchFilters]); + + // --- Autocomplete suggestions --- + const baseSuggestions = useFilterSuggestions(); + + // Additional refs discovered via WebSocket notifications (accumulated over time) + const [wsRefs, setWsRefs] = useState<{ + triggers: string[]; + rules: string[]; + }>({ triggers: [], rules: [] }); + + // --- Build query params from debounced state --- + const queryParams = useMemo(() => { + const params: any = { page, pageSize }; + if (debouncedFilters.trigger) params.triggerRef = debouncedFilters.trigger; + if (debouncedFilters.rule) params.ruleRef = debouncedFilters.rule; + return params; + }, [page, pageSize, debouncedFilters]); // Set up WebSocket for real-time event updates with stable callback const handleEventNotification = useCallback( (notification: Notification) => { - // Extract event data from notification payload (flat structure) if (notification.notification_type === "event_created") { const payload = notification.payload as any; - // Create EventSummary from notification data const newEvent: EventSummary = { id: payload.id, trigger: payload.trigger, @@ -38,45 +335,69 @@ export default function EventsPage() { created: payload.created, }; - // Update the query cache directly instead of invalidating - queryClient.setQueryData( - [ - "events", - { page, pageSize, triggerRef: triggerFilter || undefined }, - ], - (oldData: any) => { - if (!oldData) return oldData; + // Augment autocomplete suggestions with new refs from notification + setWsRefs((prev) => { + const newTriggers = new Set(prev.triggers); + const newRules = new Set(prev.rules); + let changed = false; - // Check if filtering and event matches filter - if (triggerFilter && newEvent.trigger_ref !== triggerFilter) { - return oldData; - } + if (newEvent.trigger_ref && !newTriggers.has(newEvent.trigger_ref)) { + newTriggers.add(newEvent.trigger_ref); + changed = true; + } + if (newEvent.rule_ref && !newRules.has(newEvent.rule_ref)) { + newRules.add(newEvent.rule_ref); + changed = true; + } - // Add new event to the beginning of the list if on first page - if (page === 1) { - return { - ...oldData, - data: [newEvent, ...oldData.data].slice(0, pageSize), - pagination: { - ...oldData.pagination, - total_items: (oldData.pagination?.total_items || 0) + 1, - }, - }; - } + if (!changed) return prev; + return { + triggers: [...newTriggers], + rules: [...newRules], + }; + }); - // For other pages, just update the total count + queryClient.setQueryData(["events", queryParams], (oldData: any) => { + if (!oldData) return oldData; + + // Check if filtering and event matches filter + if ( + debouncedFilters.trigger && + newEvent.trigger_ref !== debouncedFilters.trigger + ) { + return oldData; + } + if ( + debouncedFilters.rule && + newEvent.rule_ref !== debouncedFilters.rule + ) { + return oldData; + } + + // Add new event to the beginning of the list if on first page + if (page === 1) { return { ...oldData, + data: [newEvent, ...oldData.data].slice(0, pageSize), pagination: { ...oldData.pagination, total_items: (oldData.pagination?.total_items || 0) + 1, }, }; - }, - ); + } + + // For other pages, just update the total count + return { + ...oldData, + pagination: { + ...oldData.pagination, + total_items: (oldData.pagination?.total_items || 0) + 1, + }, + }; + }); } }, - [queryClient, page, pageSize, triggerFilter], + [queryClient, queryParams, page, pageSize, debouncedFilters], ); const { connected: wsConnected } = useEntityNotifications( @@ -84,35 +405,54 @@ export default function EventsPage() { handleEventNotification, ); - const { data, isLoading, error } = useEvents({ - page, - pageSize, - triggerRef: triggerFilter || undefined, - }); + const { data, isLoading, isFetching, error } = useEvents(queryParams); - const events = data?.data || []; + const events = useMemo(() => data?.data || [], [data]); const total = data?.pagination?.total_items || 0; - const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleString(); - }; + // Derive refs from currently-loaded event data (no setState needed) + const loadedRefs = useMemo(() => { + const triggers = new Set(); + const rules = new Set(); - const formatTime = (timestamp: string) => { - const date = new Date(timestamp); - const now = new Date(); - const diff = now.getTime() - date.getTime(); + for (const event of events) { + if (event.trigger_ref) triggers.add(event.trigger_ref); + if (event.rule_ref) rules.add(event.rule_ref); + } - if (diff < 60000) return "just now"; - if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; - if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; - return date.toLocaleDateString(); - }; + return { + triggers: [...triggers], + rules: [...rules], + }; + }, [events]); - const totalPages = total ? Math.ceil(total / pageSize) : 0; + // Merge base entity suggestions + loaded data refs + WebSocket refs + const triggerSuggestions = useMergedSuggestions( + baseSuggestions.triggerRefs, + loadedRefs.triggers, + wsRefs.triggers, + ); + const ruleSuggestions = useMergedSuggestions( + baseSuggestions.ruleRefs, + loadedRefs.rules, + wsRefs.rules, + ); + + const handleFilterChange = useCallback((field: string, value: string) => { + setSearchFilters((prev) => ({ ...prev, [field]: value })); + setPage(1); + }, []); + + const clearFilters = useCallback(() => { + setSearchFilters({ trigger: "", rule: "" }); + setPage(1); + }, []); + + const hasActiveFilters = Object.values(searchFilters).some((v) => v !== ""); return (
- {/* Header */} + {/* Header - always visible */}
@@ -120,6 +460,9 @@ export default function EventsPage() {

Event instances generated by sensors and triggers

+ {isFetching && hasActiveFilters && ( +

Searching events...

+ )}
{wsConnected && (
@@ -130,237 +473,60 @@ export default function EventsPage() {
- {/* Filters */} -
-
-
- - { - setTriggerFilter(e.target.value); - setPage(1); // Reset to first page on filter change - }} - placeholder="e.g., core.webhook" - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" - /> + {/* Filter section - always mounted, never unmounts during loading */} +
+
+
+ +

Filter Events

- - {triggerFilter && ( + {hasActiveFilters && ( )}
- +
+ handleFilterChange("trigger", value)} + suggestions={triggerSuggestions} + placeholder="e.g., core.webhook" + /> + handleFilterChange("rule", value)} + suggestions={ruleSuggestions} + placeholder="e.g., core.on_webhook" + /> +
{data && (
Showing {events.length} of {total} events - {triggerFilter && ` (filtered by "${triggerFilter}")`} + {hasActiveFilters && " (filtered)"}
)}
- {/* Events List */} -
- {isLoading ? ( -
-
-

Loading events...

-
- ) : error ? ( -
-

Failed to load events

-

- {error instanceof Error ? error.message : "Unknown error"} -

-
- ) : !events || events.length === 0 ? ( -
- - - -

No events found

-

- {triggerFilter - ? "Try adjusting your filter" - : "Events will appear here when triggers fire"} -

-
- ) : ( - <> -
- - - - - - - - - - - - - {events.map((event) => ( - - - - - - - - - ))} - -
- ID - - Trigger - - Rule - - Source - - Created - - Actions -
- - {event.id} - - -
-
- {event.trigger_ref} -
-
- ID: {event.trigger || "N/A"} -
-
-
- {event.rule_ref ? ( -
- - {event.rule_ref} - -
- ID: {event.rule} -
-
- ) : ( - - No rule - - )} -
- {event.source_ref ? ( -
-
- {event.source_ref} -
-
- ID: {event.source || "N/A"} -
-
- ) : ( - - No source - - )} -
-
- {formatTime(event.created)} -
-
- {formatDate(event.created)} -
-
- - View Details - -
-
- - {/* Pagination */} - {totalPages > 1 && ( -
-
- - -
-
-
-

- Page {page} of{" "} - {totalPages} -

-
-
- -
-
-
- )} - - )} -
+ {/* Results section - isolated from filter state, only depends on query results */} +
); } diff --git a/web/src/pages/executions/ExecutionsPage.tsx b/web/src/pages/executions/ExecutionsPage.tsx index 58b2e57..8a9c6fc 100644 --- a/web/src/pages/executions/ExecutionsPage.tsx +++ b/web/src/pages/executions/ExecutionsPage.tsx @@ -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 ( +
+
+
+
+
+ ); + } + + // Error with no cached data to show + if (error && executions.length === 0) { + return ( +
+
+

Error: {error.message}

+
+
+ ); + } + + // Empty results + if (executions.length === 0) { + return ( +
+

No executions found

+ {hasActiveFilters && ( + + )} +
+ ); + } + + return ( +
+ {/* Inline loading overlay - shows on top of previous results while fetching */} + {isFetching && ( +
+
+
+ )} + + {/* Non-fatal error banner (data still shown from cache) */} + {error && ( +
+

Error refreshing: {error.message}

+
+ )} + +
+ + + + + + + + + + + + + {executions.map((exec: any) => ( + + + + + + + + + ))} + +
+ ID + + Action + + Rule + + Trigger + + Status + + Created +
+ + #{exec.id} + + + + {exec.action_ref} + + + {exec.rule_ref ? ( + + {exec.rule_ref} + + ) : ( + - + )} + + {exec.trigger_ref ? ( + + {exec.trigger_ref} + + ) : ( + - + )} + + + {exec.status} + + + {new Date(exec.created).toLocaleString()} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+
+ + +
+
+
+

+ Showing{" "} + + {(page - 1) * pageSize + 1} + {" "} + to{" "} + + {Math.min(page * pageSize, total)} + {" "} + of {total} executions +

+
+
+ +
+
+
+ )} +
+ ); + }, +); + +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(); + const rules = new Set(); + const actions = new Set(); + const triggers = new Set(); + + 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 ( -
-
-
-
-
- ); - } - - if (error) { - return ( -
-
-

Error: {(error as Error).message}

-
-
- ); - } - - 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 (
+ {/* Header - always visible */}

Executions

- {isLoading && hasActiveFilters && ( + {isFetching && hasActiveFilters && (

Searching executions...

@@ -208,7 +443,7 @@ export default function ExecutionsPage() { )}
- {/* Search Filters */} + {/* Filter section - always mounted, never unmounts during loading */}
@@ -226,28 +461,32 @@ export default function ExecutionsPage() { )}
- handleFilterChange("pack", value)} + suggestions={packSuggestions} placeholder="e.g., core" /> - handleFilterChange("rule", value)} + suggestions={ruleSuggestions} placeholder="e.g., core.on_timer" /> - handleFilterChange("action", value)} + suggestions={actionSuggestions} placeholder="e.g., core.echo" /> - handleFilterChange("trigger", value)} + suggestions={triggerSuggestions} placeholder="e.g., core.timer" />
- {filteredExecutions.length === 0 ? ( -
-

- {executions.length === 0 - ? "No executions found" - : "No executions match the selected filters"} -

- {executions.length > 0 && hasActiveFilters && ( - - )} -
- ) : ( - <> -
- - - - - - - - - - - - - {filteredExecutions.map((exec: any) => ( - - - - - - - - - ))} - -
- ID - - Action - - Rule - - Trigger - - Status - - Created -
- - #{exec.id} - - - - {exec.action_ref} - - - {exec.rule_ref ? ( - - {exec.rule_ref} - - ) : ( - - - )} - - {exec.trigger_ref ? ( - - {exec.trigger_ref} - - ) : ( - - - )} - - - {exec.status} - - - {new Date(exec.created).toLocaleString()} -
-
- {totalPages > 1 && ( -
-
- - -
-
-
-

- Showing{" "} - - {(page - 1) * pageSize + 1} - {" "} - to - - {Math.min(page * pageSize, total)} - {" "} - of   - {total} executions -

-
-
- -
-
-
- )} - - )} + {/* Results section - isolated from filter state, only depends on query results */} +
); }