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

@@ -144,6 +144,10 @@ pub struct EventQueryParams {
#[param(example = "core.webhook")] #[param(example = "core.webhook")]
pub trigger_ref: Option<String>, pub trigger_ref: Option<String>,
/// Filter by rule reference
#[param(example = "core.on_webhook")]
pub rule_ref: Option<String>,
/// Filter by source ID /// Filter by source ID
#[param(example = 1)] #[param(example = 1)]
pub source: Option<Id>, pub source: Option<Id>,

View File

@@ -237,6 +237,16 @@ pub async fn list_events(
filtered_events.retain(|e| e.source == Some(source_id)); 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 // Calculate pagination
let total = filtered_events.len() as u64; let total = filtered_events.len() as u64;
let start = query.offset() as usize; let start = query.offset() as usize;

View File

@@ -2,86 +2,92 @@
/* istanbul ignore file */ /* istanbul ignore file */
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
import type { ApiResponse_EventResponse } from '../models/ApiResponse_EventResponse'; import type { ApiResponse_EventResponse } from "../models/ApiResponse_EventResponse";
import type { i64 } from '../models/i64'; import type { i64 } from "../models/i64";
import type { PaginatedResponse_EventSummary } from '../models/PaginatedResponse_EventSummary'; import type { PaginatedResponse_EventSummary } from "../models/PaginatedResponse_EventSummary";
import type { CancelablePromise } from '../core/CancelablePromise'; import type { CancelablePromise } from "../core/CancelablePromise";
import { OpenAPI } from '../core/OpenAPI'; import { OpenAPI } from "../core/OpenAPI";
import { request as __request } from '../core/request'; import { request as __request } from "../core/request";
export class EventsService { 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 * Filter by trigger ID
* @returns PaginatedResponse_EventSummary List of events
* @throws ApiError
*/ */
public static listEvents({ trigger?: null | i64;
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<PaginatedResponse_EventSummary> {
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`,
},
});
}
/** /**
* Get a single event by ID * Filter by trigger reference
* @returns ApiResponse_EventResponse Event details
* @throws ApiError
*/ */
public static getEvent({ triggerRef?: string | null;
id, /**
}: { * Filter by rule reference
/** */
* Event ID ruleRef?: string | null;
*/ /**
id: number, * Filter by source ID
}): CancelablePromise<ApiResponse_EventResponse> { */
return __request(OpenAPI, { source?: null | i64;
method: 'GET', /**
url: '/api/v1/events/{id}', * Page number (1-indexed)
path: { */
'id': id, page?: number;
}, /**
errors: { * Items per page
401: `Unauthorized`, */
404: `Event not found`, perPage?: number;
500: `Internal server error`, }): CancelablePromise<PaginatedResponse_EventSummary> {
}, 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<ApiResponse_EventResponse> {
return __request(OpenAPI, {
method: "GET",
url: "/api/v1/events/{id}",
path: {
id: id,
},
errors: {
401: `Unauthorized`,
404: `Event not found`,
500: `Internal server error`,
},
});
}
} }

View File

@@ -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<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(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<HTMLInputElement>) => {
onChange(e.target.value);
setIsOpen(true);
setHighlightedIndex(-1);
},
[onChange],
);
const handleFocus = useCallback(() => {
if (suggestions.length > 0) {
setIsOpen(true);
}
}, [suggestions.length]);
return (
<div ref={containerRef} className="relative">
<label className="block text-sm font-medium text-gray-700 mb-1">
{label}
</label>
<input
ref={inputRef}
type="text"
value={value}
onChange={handleInputChange}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
placeholder={placeholder}
autoComplete="off"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{isOpen && filtered.length > 0 && (
<ul
ref={listRef}
className="absolute z-50 mt-1 w-full bg-white border border-gray-300 rounded-md shadow-lg max-h-48 overflow-y-auto"
>
{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 (
<li
key={suggestion}
onMouseDown={(e) => {
// 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}
<span className="font-semibold">{match}</span>
{after}
</>
) : (
suggestion
)}
</li>
);
})}
</ul>
)}
</div>
);
},
);
AutocompleteInput.displayName = "AutocompleteInput";
export default AutocompleteInput;

View File

@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { EventsService, EnforcementsService, EnforcementStatus } from "@/api"; import { EventsService, EnforcementsService, EnforcementStatus } from "@/api";
import type { i64 } from "@/api"; import type { i64 } from "@/api";
@@ -7,6 +7,7 @@ interface EventsQueryParams {
pageSize?: number; pageSize?: number;
trigger?: i64 | null; trigger?: i64 | null;
triggerRef?: string | null; triggerRef?: string | null;
ruleRef?: string | null;
source?: i64 | null; source?: i64 | null;
} }
@@ -29,10 +30,12 @@ export function useEvents(params?: EventsQueryParams) {
perPage: params?.pageSize || 50, perPage: params?.pageSize || 50,
trigger: params?.trigger, trigger: params?.trigger,
triggerRef: params?.triggerRef, triggerRef: params?.triggerRef,
ruleRef: params?.ruleRef,
source: params?.source, source: params?.source,
}); });
}, },
staleTime: 30000, // 30 seconds staleTime: 30000, // 30 seconds
placeholderData: keepPreviousData,
}); });
} }
@@ -63,6 +66,7 @@ export function useEnforcements(params?: EnforcementsQueryParams) {
}); });
}, },
staleTime: 30000, staleTime: 30000,
placeholderData: keepPreviousData,
}); });
} }

View File

@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { ExecutionsService } from "@/api"; import { ExecutionsService } from "@/api";
import type { ExecutionStatus } from "@/api"; import type { ExecutionStatus } from "@/api";
@@ -43,6 +43,8 @@ export function useExecutions(params?: ExecutionsQueryParams) {
staleTime: hasFilters ? 5000 : 30000, staleTime: hasFilters ? 5000 : 30000,
// Refetch in background when filters change to get latest data // Refetch in background when filters change to get latest data
refetchOnMount: hasFilters ? "always" : true, refetchOnMount: hasFilters ? "always" : true,
// Keep previous results visible while new data loads (prevents flash of empty state)
placeholderData: keepPreviousData,
}); });
} }

View File

@@ -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<PaginatedResponse_PackSummary>({
queryKey: ["filter-suggestions", "packs"],
queryFn: () => PacksService.listPacks({ page: 1, pageSize: 200 }),
staleTime: 5 * 60 * 1000,
});
const { data: rulesData } = useQuery<PaginatedResponse_RuleSummary>({
queryKey: ["filter-suggestions", "rules"],
queryFn: () => RulesService.listRules({ page: 1, pageSize: 200 }),
staleTime: 5 * 60 * 1000,
});
const { data: actionsData } = useQuery<PaginatedResponse_ActionSummary>({
queryKey: ["filter-suggestions", "actions"],
queryFn: () => ActionsService.listActions({ page: 1, pageSize: 200 }),
staleTime: 5 * 60 * 1000,
});
const { data: triggersData } = useQuery<PaginatedResponse_TriggerSummary>({
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]);
}

View File

@@ -5,8 +5,13 @@ import { EnforcementStatus } from "@/api";
import { useState, useMemo, memo, useCallback, useEffect } from "react"; import { useState, useMemo, memo, useCallback, useEffect } from "react";
import { Search, X } from "lucide-react"; import { Search, X } from "lucide-react";
import MultiSelect from "@/components/common/MultiSelect"; 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( const FilterInput = memo(
({ ({
label, label,
@@ -43,10 +48,287 @@ const STATUS_OPTIONS = [
{ value: EnforcementStatus.DISABLED, label: "Disabled" }, { 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<string, string> = {
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 (
<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 && enforcements.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 (enforcements.length === 0) {
return (
<div className="bg-white p-12 text-center rounded-lg shadow">
<p>No enforcements 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">
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">
Event
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Condition
</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">
{enforcements.map((enforcement: any) => (
<tr key={enforcement.id} className="hover:bg-gray-50">
<td className="px-6 py-4 font-mono text-sm">
<Link
to={`/enforcements/${enforcement.id}`}
className="text-blue-600 hover:text-blue-800"
>
#{enforcement.id}
</Link>
</td>
<td className="px-6 py-4">
{enforcement.rule ? (
<Link
to={`/rules/${enforcement.rule}`}
className="text-sm text-blue-600 hover:text-blue-800"
>
{enforcement.rule_ref}
</Link>
) : (
<span className="text-sm text-gray-900">
{enforcement.rule_ref}
</span>
)}
</td>
<td className="px-6 py-4">
<span className="text-sm text-gray-700">
{enforcement.trigger_ref}
</span>
</td>
<td className="px-6 py-4">
{enforcement.event ? (
<Link
to={`/events/${enforcement.event}`}
className="text-sm font-mono text-blue-600 hover:text-blue-800"
>
#{enforcement.event}
</Link>
) : (
<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 ${getConditionBadge(enforcement.condition)}`}
>
{enforcement.condition}
</span>
</td>
<td className="px-6 py-4">
<span
className={`px-2 py-1 text-xs rounded ${getStatusColor(enforcement.status)}`}
>
{enforcement.status}
</span>
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-900">
{formatTime(enforcement.created)}
</div>
<div className="text-xs text-gray-500">
{formatDate(enforcement.created)}
</div>
</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> enforcements
</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>
);
},
);
EnforcementsResultsTable.displayName = "EnforcementsResultsTable";
export default function EnforcementsPage() { export default function EnforcementsPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
// Initialize filters from URL query parameters // --- Filter input state (updates immediately on keystroke) ---
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const pageSize = 50; const pageSize = 50;
const [searchFilters, setSearchFilters] = useState({ const [searchFilters, setSearchFilters] = useState({
@@ -59,28 +341,28 @@ export default function EnforcementsPage() {
return status ? [status] : []; return status ? [status] : [];
}); });
// Debounced filter state for API calls // --- Debounced filter state (drives API calls, updates after delay) ---
const [debouncedFilters, setDebouncedFilters] = useState(searchFilters); const [debouncedFilters, setDebouncedFilters] = useState(searchFilters);
const [debouncedStatuses, setDebouncedStatuses] = useState(selectedStatuses); const [debouncedStatuses, setDebouncedStatuses] = useState(selectedStatuses);
// Debounce filter changes (500ms delay)
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setDebouncedFilters(searchFilters); setDebouncedFilters(searchFilters);
}, 500); }, 500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [searchFilters]); }, [searchFilters]);
// Debounce status changes (300ms delay - shorter since it's a selection)
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setDebouncedStatuses(selectedStatuses); setDebouncedStatuses(selectedStatuses);
}, 300); }, 300);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [selectedStatuses]); }, [selectedStatuses]);
// --- Autocomplete suggestions ---
const baseSuggestions = useFilterSuggestions();
// --- Build query params from debounced state ---
const queryParams = useMemo(() => { const queryParams = useMemo(() => {
const params: any = { page, pageSize }; const params: any = { page, pageSize };
if (debouncedFilters.trigger) params.triggerRef = debouncedFilters.trigger; if (debouncedFilters.trigger) params.triggerRef = debouncedFilters.trigger;
@@ -90,27 +372,45 @@ export default function EnforcementsPage() {
params.event = eventId; 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) { if (debouncedStatuses.length === 1) {
params.status = debouncedStatuses[0] as EnforcementStatus; params.status = debouncedStatuses[0] as EnforcementStatus;
} }
return params; return params;
}, [page, pageSize, debouncedFilters, debouncedStatuses]); }, [page, pageSize, debouncedFilters, debouncedStatuses]);
const { data, isLoading, error } = useEnforcements(queryParams); const { data, isLoading, isFetching, error } = useEnforcements(queryParams);
// Subscribe to real-time updates for all enforcements
const { isConnected } = useEnforcementStream({ enabled: true }); const { isConnected } = useEnforcementStream({ enabled: true });
const enforcements = data?.data || []; const enforcements = useMemo(() => data?.data || [], [data]);
const total = data?.pagination?.total_items || 0; 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<string>();
const triggers = new Set<string>();
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(() => { const filteredEnforcements = useMemo(() => {
let filtered = enforcements; let filtered = enforcements;
@@ -135,87 +435,26 @@ export default function EnforcementsPage() {
const handleFilterChange = useCallback((field: string, value: string) => { const handleFilterChange = useCallback((field: string, value: string) => {
setSearchFilters((prev) => ({ ...prev, [field]: value })); setSearchFilters((prev) => ({ ...prev, [field]: value }));
setPage(1); // Reset to first page on filter change setPage(1);
}, []); }, []);
const clearFilters = useCallback(() => { const clearFilters = useCallback(() => {
setSearchFilters({ setSearchFilters({ rule: "", trigger: "", event: "" });
rule: "",
trigger: "",
event: "",
});
setSelectedStatuses([]); setSelectedStatuses([]);
setPage(1); // Reset to first page setPage(1);
}, []); }, []);
const hasActiveFilters = const hasActiveFilters =
Object.values(searchFilters).some((v) => v !== "") || Object.values(searchFilters).some((v) => v !== "") ||
selectedStatuses.length > 0; 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: 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 ( return (
<div className="p-6"> <div className="p-6">
{/* Header - always visible */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div> <div>
<h1 className="text-3xl font-bold">Enforcements</h1> <h1 className="text-3xl font-bold">Enforcements</h1>
{isLoading && hasActiveFilters && ( {isFetching && hasActiveFilters && (
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">
Searching enforcements... Searching enforcements...
</p> </p>
@@ -229,7 +468,7 @@ export default function EnforcementsPage() {
)} )}
</div> </div>
{/* Search Filters */} {/* Filter section - always mounted, never unmounts during loading */}
<div className="bg-white shadow rounded-lg p-4 mb-6"> <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 justify-between mb-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -247,16 +486,18 @@ export default function EnforcementsPage() {
)} )}
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<FilterInput <AutocompleteInput
label="Rule" label="Rule"
value={searchFilters.rule} value={searchFilters.rule}
onChange={(value) => handleFilterChange("rule", value)} onChange={(value) => handleFilterChange("rule", value)}
suggestions={ruleSuggestions}
placeholder="e.g., core.on_timer" placeholder="e.g., core.on_timer"
/> />
<FilterInput <AutocompleteInput
label="Trigger" label="Trigger"
value={searchFilters.trigger} value={searchFilters.trigger}
onChange={(value) => handleFilterChange("trigger", value)} onChange={(value) => handleFilterChange("trigger", value)}
suggestions={triggerSuggestions}
placeholder="e.g., core.webhook" placeholder="e.g., core.webhook"
/> />
<FilterInput <FilterInput
@@ -277,177 +518,19 @@ export default function EnforcementsPage() {
</div> </div>
</div> </div>
{filteredEnforcements.length === 0 ? ( {/* Results section - isolated from filter state, only depends on query results */}
<div className="bg-white p-12 text-center rounded-lg shadow"> <EnforcementsResultsTable
<p> enforcements={filteredEnforcements}
{enforcements.length === 0 isLoading={isLoading}
? "No enforcements found" isFetching={isFetching}
: "No enforcements match the selected filters"} error={error as Error | null}
</p> hasActiveFilters={hasActiveFilters}
{enforcements.length > 0 && hasActiveFilters && ( clearFilters={clearFilters}
<button page={page}
onClick={clearFilters} setPage={setPage}
className="mt-3 text-sm text-blue-600 hover:text-blue-800" pageSize={pageSize}
> total={total}
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">
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">
Event
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Condition
</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">
{filteredEnforcements.map((enforcement: any) => (
<tr key={enforcement.id} className="hover:bg-gray-50">
<td className="px-6 py-4 font-mono text-sm">
<Link
to={`/enforcements/${enforcement.id}`}
className="text-blue-600 hover:text-blue-800"
>
#{enforcement.id}
</Link>
</td>
<td className="px-6 py-4">
{enforcement.rule ? (
<Link
to={`/rules/${enforcement.rule}`}
className="text-sm text-blue-600 hover:text-blue-800"
>
{enforcement.rule_ref}
</Link>
) : (
<span className="text-sm text-gray-900">
{enforcement.rule_ref}
</span>
)}
</td>
<td className="px-6 py-4">
<span className="text-sm text-gray-700">
{enforcement.trigger_ref}
</span>
</td>
<td className="px-6 py-4">
{enforcement.event ? (
<Link
to={`/events/${enforcement.event}`}
className="text-sm font-mono text-blue-600 hover:text-blue-800"
>
#{enforcement.event}
</Link>
) : (
<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 ${getConditionBadge(enforcement.condition)}`}
>
{enforcement.condition}
</span>
</td>
<td className="px-6 py-4">
<span
className={`px-2 py-1 text-xs rounded ${getStatusColor(enforcement.status)}`}
>
{enforcement.status}
</span>
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-900">
{formatTime(enforcement.created)}
</div>
<div className="text-xs text-gray-500">
{formatDate(enforcement.created)}
</div>
</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> enforcements
</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> </div>
); );
} }

View File

@@ -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 { Link, useSearchParams } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useEvents } from "@/hooks/useEvents"; import { useEvents } from "@/hooks/useEvents";
@@ -6,25 +6,322 @@ import {
useEntityNotifications, useEntityNotifications,
Notification, Notification,
} from "@/contexts/WebSocketContext"; } 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"; 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 (
<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" />
<p className="ml-4 text-gray-600">Loading events...</p>
</div>
</div>
);
}
// Error with no cached data to show
if (error && events.length === 0) {
return (
<div className="bg-white shadow rounded-lg p-12 text-center">
<p className="text-red-600">Failed to load events</p>
<p className="text-sm text-gray-600 mt-2">{error.message}</p>
</div>
);
}
// Empty results
if (events.length === 0) {
return (
<div className="bg-white shadow rounded-lg p-12 text-center">
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<p className="mt-4 text-gray-600">No events found</p>
<p className="text-sm text-gray-500 mt-1">
{hasActiveFilters
? "Try adjusting your filters"
: "Events will appear here when triggers fire"}
</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">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Trigger
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Rule
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Source
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{events.map((event) => (
<tr key={event.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<Link
to={`/events/${event.id}`}
className="text-sm font-mono text-blue-600 hover:text-blue-800"
>
#{event.id}
</Link>
</td>
<td className="px-6 py-4">
<div className="text-sm">
<div className="font-medium text-gray-900">
{event.trigger_ref}
</div>
<div className="text-gray-500 text-xs">
ID: {event.trigger || "N/A"}
</div>
</div>
</td>
<td className="px-6 py-4">
{event.rule_ref ? (
<div className="text-sm">
<Link
to={`/rules/${event.rule}`}
className="font-medium text-blue-600 hover:text-blue-900"
>
{event.rule_ref}
</Link>
<div className="text-gray-500 text-xs">
ID: {event.rule}
</div>
</div>
) : (
<span className="text-sm text-gray-400 italic">
No rule
</span>
)}
</td>
<td className="px-6 py-4">
{event.source_ref ? (
<div className="text-sm">
<div className="font-medium text-gray-900">
{event.source_ref}
</div>
<div className="text-gray-500 text-xs">
ID: {event.source || "N/A"}
</div>
</div>
) : (
<span className="text-sm text-gray-400 italic">
No source
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{formatTime(event.created)}
</div>
<div className="text-xs text-gray-500">
{formatDate(event.created)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</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">
Page <span className="font-medium">{page}</span> of{" "}
<span className="font-medium">{totalPages}</span>
</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>
);
},
);
EventsResultsTable.displayName = "EventsResultsTable";
export default function EventsPage() { export default function EventsPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// --- Filter input state (updates immediately on keystroke) ---
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [triggerFilter, setTriggerFilter] = useState<string>(
searchParams.get("trigger_ref") || "",
);
const pageSize = 50; 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 // Set up WebSocket for real-time event updates with stable callback
const handleEventNotification = useCallback( const handleEventNotification = useCallback(
(notification: Notification) => { (notification: Notification) => {
// Extract event data from notification payload (flat structure)
if (notification.notification_type === "event_created") { if (notification.notification_type === "event_created") {
const payload = notification.payload as any; const payload = notification.payload as any;
// Create EventSummary from notification data
const newEvent: EventSummary = { const newEvent: EventSummary = {
id: payload.id, id: payload.id,
trigger: payload.trigger, trigger: payload.trigger,
@@ -38,45 +335,69 @@ export default function EventsPage() {
created: payload.created, created: payload.created,
}; };
// Update the query cache directly instead of invalidating // Augment autocomplete suggestions with new refs from notification
queryClient.setQueryData( setWsRefs((prev) => {
[ const newTriggers = new Set(prev.triggers);
"events", const newRules = new Set(prev.rules);
{ page, pageSize, triggerRef: triggerFilter || undefined }, let changed = false;
],
(oldData: any) => {
if (!oldData) return oldData;
// Check if filtering and event matches filter if (newEvent.trigger_ref && !newTriggers.has(newEvent.trigger_ref)) {
if (triggerFilter && newEvent.trigger_ref !== triggerFilter) { newTriggers.add(newEvent.trigger_ref);
return oldData; 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 (!changed) return prev;
if (page === 1) { return {
return { triggers: [...newTriggers],
...oldData, rules: [...newRules],
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 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 { return {
...oldData, ...oldData,
data: [newEvent, ...oldData.data].slice(0, pageSize),
pagination: { pagination: {
...oldData.pagination, ...oldData.pagination,
total_items: (oldData.pagination?.total_items || 0) + 1, 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( const { connected: wsConnected } = useEntityNotifications(
@@ -84,35 +405,54 @@ export default function EventsPage() {
handleEventNotification, handleEventNotification,
); );
const { data, isLoading, error } = useEvents({ const { data, isLoading, isFetching, error } = useEvents(queryParams);
page,
pageSize,
triggerRef: triggerFilter || undefined,
});
const events = data?.data || []; const events = useMemo(() => data?.data || [], [data]);
const total = data?.pagination?.total_items || 0; const total = data?.pagination?.total_items || 0;
const formatDate = (dateString: string) => { // Derive refs from currently-loaded event data (no setState needed)
return new Date(dateString).toLocaleString(); const loadedRefs = useMemo(() => {
}; const triggers = new Set<string>();
const rules = new Set<string>();
const formatTime = (timestamp: string) => { for (const event of events) {
const date = new Date(timestamp); if (event.trigger_ref) triggers.add(event.trigger_ref);
const now = new Date(); if (event.rule_ref) rules.add(event.rule_ref);
const diff = now.getTime() - date.getTime(); }
if (diff < 60000) return "just now"; return {
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; triggers: [...triggers],
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; rules: [...rules],
return date.toLocaleDateString(); };
}; }, [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 ( return (
<div className="p-6"> <div className="p-6">
{/* Header */} {/* Header - always visible */}
<div className="mb-6"> <div className="mb-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
@@ -120,6 +460,9 @@ export default function EventsPage() {
<p className="mt-2 text-gray-600"> <p className="mt-2 text-gray-600">
Event instances generated by sensors and triggers Event instances generated by sensors and triggers
</p> </p>
{isFetching && hasActiveFilters && (
<p className="text-sm text-gray-500 mt-1">Searching events...</p>
)}
</div> </div>
{wsConnected && ( {wsConnected && (
<div className="flex items-center gap-2 text-sm text-green-600"> <div className="flex items-center gap-2 text-sm text-green-600">
@@ -130,237 +473,60 @@ export default function EventsPage() {
</div> </div>
</div> </div>
{/* Filters */} {/* Filter section - always mounted, never unmounts during loading */}
<div className="bg-white rounded-lg shadow mb-6 p-4"> <div className="bg-white shadow rounded-lg p-4 mb-6">
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between"> <div className="flex items-center justify-between mb-4">
<div className="flex-1 max-w-md"> <div className="flex items-center gap-2">
<label <Search className="h-5 w-5 text-gray-400" />
htmlFor="trigger-filter" <h2 className="text-lg font-semibold">Filter Events</h2>
className="block text-sm font-medium text-gray-700 mb-1"
>
Filter by Trigger
</label>
<input
id="trigger-filter"
type="text"
value={triggerFilter}
onChange={(e) => {
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"
/>
</div> </div>
{hasActiveFilters && (
{triggerFilter && (
<button <button
onClick={() => { onClick={clearFilters}
setTriggerFilter(""); className="flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900"
setPage(1);
}}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900"
> >
Clear Filter <X className="h-4 w-4" />
Clear Filters
</button> </button>
)} )}
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<AutocompleteInput
label="Trigger"
value={searchFilters.trigger}
onChange={(value) => handleFilterChange("trigger", value)}
suggestions={triggerSuggestions}
placeholder="e.g., core.webhook"
/>
<AutocompleteInput
label="Rule"
value={searchFilters.rule}
onChange={(value) => handleFilterChange("rule", value)}
suggestions={ruleSuggestions}
placeholder="e.g., core.on_webhook"
/>
</div>
{data && ( {data && (
<div className="mt-3 text-sm text-gray-600"> <div className="mt-3 text-sm text-gray-600">
Showing {events.length} of {total} events Showing {events.length} of {total} events
{triggerFilter && ` (filtered by "${triggerFilter}")`} {hasActiveFilters && " (filtered)"}
</div> </div>
)} )}
</div> </div>
{/* Events List */} {/* Results section - isolated from filter state, only depends on query results */}
<div className="bg-white rounded-lg shadow overflow-hidden"> <EventsResultsTable
{isLoading ? ( events={events}
<div className="p-12 text-center"> isLoading={isLoading}
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div> isFetching={isFetching}
<p className="mt-4 text-gray-600">Loading events...</p> error={error as Error | null}
</div> hasActiveFilters={hasActiveFilters}
) : error ? ( clearFilters={clearFilters}
<div className="p-12 text-center"> page={page}
<p className="text-red-600">Failed to load events</p> setPage={setPage}
<p className="text-sm text-gray-600 mt-2"> pageSize={pageSize}
{error instanceof Error ? error.message : "Unknown error"} total={total}
</p> />
</div>
) : !events || events.length === 0 ? (
<div className="p-12 text-center">
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<p className="mt-4 text-gray-600">No events found</p>
<p className="text-sm text-gray-500 mt-1">
{triggerFilter
? "Try adjusting your filter"
: "Events will appear here when triggers fire"}
</p>
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Trigger
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Rule
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Source
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{events.map((event) => (
<tr key={event.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm font-mono text-gray-900">
{event.id}
</span>
</td>
<td className="px-6 py-4">
<div className="text-sm">
<div className="font-medium text-gray-900">
{event.trigger_ref}
</div>
<div className="text-gray-500 text-xs">
ID: {event.trigger || "N/A"}
</div>
</div>
</td>
<td className="px-6 py-4">
{event.rule_ref ? (
<div className="text-sm">
<Link
to={`/rules/${event.rule}`}
className="font-medium text-blue-600 hover:text-blue-900"
>
{event.rule_ref}
</Link>
<div className="text-gray-500 text-xs">
ID: {event.rule}
</div>
</div>
) : (
<span className="text-sm text-gray-400 italic">
No rule
</span>
)}
</td>
<td className="px-6 py-4">
{event.source_ref ? (
<div className="text-sm">
<div className="font-medium text-gray-900">
{event.source_ref}
</div>
<div className="text-gray-500 text-xs">
ID: {event.source || "N/A"}
</div>
</div>
) : (
<span className="text-sm text-gray-400 italic">
No source
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{formatTime(event.created)}
</div>
<div className="text-xs text-gray-500">
{formatDate(event.created)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<Link
to={`/events/${event.id}`}
className="text-blue-600 hover:text-blue-900"
>
View Details
</Link>
</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">
Page <span className="font-medium">{page}</span> of{" "}
<span className="font-medium">{totalPages}</span>
</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>
</div> </div>
); );
} }

View File

@@ -5,8 +5,13 @@ import { ExecutionStatus } from "@/api";
import { useState, useMemo, memo, useCallback, useEffect } from "react"; import { useState, useMemo, memo, useCallback, useEffect } from "react";
import { Search, X } from "lucide-react"; import { Search, X } from "lucide-react";
import MultiSelect from "@/components/common/MultiSelect"; 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( const FilterInput = memo(
({ ({
label, label,
@@ -50,10 +55,246 @@ const STATUS_OPTIONS = [
{ value: ExecutionStatus.ABANDONED, label: "Abandoned" }, { 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() { export default function ExecutionsPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
// Initialize filters from URL query parameters // --- Filter input state (updates immediately on keystroke) ---
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const pageSize = 50; const pageSize = 50;
const [searchFilters, setSearchFilters] = useState({ const [searchFilters, setSearchFilters] = useState({
@@ -68,28 +309,28 @@ export default function ExecutionsPage() {
return status ? [status] : []; return status ? [status] : [];
}); });
// Debounced filter state for API calls // --- Debounced filter state (drives API calls, updates after delay) ---
const [debouncedFilters, setDebouncedFilters] = useState(searchFilters); const [debouncedFilters, setDebouncedFilters] = useState(searchFilters);
const [debouncedStatuses, setDebouncedStatuses] = useState(selectedStatuses); const [debouncedStatuses, setDebouncedStatuses] = useState(selectedStatuses);
// Debounce filter changes (500ms delay)
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setDebouncedFilters(searchFilters); setDebouncedFilters(searchFilters);
}, 500); }, 500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [searchFilters]); }, [searchFilters]);
// Debounce status changes (300ms delay - shorter since it's a selection)
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setDebouncedStatuses(selectedStatuses); setDebouncedStatuses(selectedStatuses);
}, 300); }, 300);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [selectedStatuses]); }, [selectedStatuses]);
// --- Autocomplete suggestions ---
const baseSuggestions = useFilterSuggestions();
// --- Build query params from debounced state ---
const queryParams = useMemo(() => { const queryParams = useMemo(() => {
const params: any = { page, pageSize }; const params: any = { page, pageSize };
if (debouncedFilters.pack) params.packName = debouncedFilters.pack; if (debouncedFilters.pack) params.packName = debouncedFilters.pack;
@@ -98,33 +339,64 @@ export default function ExecutionsPage() {
if (debouncedFilters.trigger) params.triggerRef = debouncedFilters.trigger; if (debouncedFilters.trigger) params.triggerRef = debouncedFilters.trigger;
if (debouncedFilters.executor) if (debouncedFilters.executor)
params.executor = parseInt(debouncedFilters.executor, 10); 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) { if (debouncedStatuses.length === 1) {
params.status = debouncedStatuses[0] as ExecutionStatus; params.status = debouncedStatuses[0] as ExecutionStatus;
} }
return params; return params;
}, [page, pageSize, debouncedFilters, debouncedStatuses]); }, [page, pageSize, debouncedFilters, debouncedStatuses]);
const { data, isLoading, error } = useExecutions(queryParams); const { data, isLoading, isFetching, error } = useExecutions(queryParams);
// Subscribe to real-time updates for all executions
const { isConnected } = useExecutionStream({ enabled: true }); const { isConnected } = useExecutionStream({ enabled: true });
const executions = data?.data || []; const executions = useMemo(() => data?.data || [], [data]);
const total = data?.pagination?.total_items || 0; 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) // Client-side filtering for multiple status selection (when > 1 selected)
const filteredExecutions = useMemo(() => { const filteredExecutions = useMemo(() => {
// If no statuses selected or only one (already filtered by API), show all if (debouncedStatuses.length <= 1) return executions;
if (debouncedStatuses.length <= 1) {
return executions;
}
// If multiple statuses selected, filter client-side
return executions.filter((exec: any) => return executions.filter((exec: any) =>
debouncedStatuses.includes(exec.status), debouncedStatuses.includes(exec.status),
); );
@@ -132,7 +404,7 @@ export default function ExecutionsPage() {
const handleFilterChange = useCallback((field: string, value: string) => { const handleFilterChange = useCallback((field: string, value: string) => {
setSearchFilters((prev) => ({ ...prev, [field]: value })); setSearchFilters((prev) => ({ ...prev, [field]: value }));
setPage(1); // Reset to first page on filter change setPage(1);
}, []); }, []);
const clearFilters = useCallback(() => { const clearFilters = useCallback(() => {
@@ -144,57 +416,20 @@ export default function ExecutionsPage() {
executor: "", executor: "",
}); });
setSelectedStatuses([]); setSelectedStatuses([]);
setPage(1); // Reset to first page setPage(1);
}, []); }, []);
const hasActiveFilters = const hasActiveFilters =
Object.values(searchFilters).some((v) => v !== "") || Object.values(searchFilters).some((v) => v !== "") ||
selectedStatuses.length > 0; 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 ( return (
<div className="p-6"> <div className="p-6">
{/* Header - always visible */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div> <div>
<h1 className="text-3xl font-bold">Executions</h1> <h1 className="text-3xl font-bold">Executions</h1>
{isLoading && hasActiveFilters && ( {isFetching && hasActiveFilters && (
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">
Searching executions... Searching executions...
</p> </p>
@@ -208,7 +443,7 @@ export default function ExecutionsPage() {
)} )}
</div> </div>
{/* Search Filters */} {/* Filter section - always mounted, never unmounts during loading */}
<div className="bg-white shadow rounded-lg p-4 mb-6"> <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 justify-between mb-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -226,28 +461,32 @@ export default function ExecutionsPage() {
)} )}
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4">
<FilterInput <AutocompleteInput
label="Pack" label="Pack"
value={searchFilters.pack} value={searchFilters.pack}
onChange={(value) => handleFilterChange("pack", value)} onChange={(value) => handleFilterChange("pack", value)}
suggestions={packSuggestions}
placeholder="e.g., core" placeholder="e.g., core"
/> />
<FilterInput <AutocompleteInput
label="Rule" label="Rule"
value={searchFilters.rule} value={searchFilters.rule}
onChange={(value) => handleFilterChange("rule", value)} onChange={(value) => handleFilterChange("rule", value)}
suggestions={ruleSuggestions}
placeholder="e.g., core.on_timer" placeholder="e.g., core.on_timer"
/> />
<FilterInput <AutocompleteInput
label="Action" label="Action"
value={searchFilters.action} value={searchFilters.action}
onChange={(value) => handleFilterChange("action", value)} onChange={(value) => handleFilterChange("action", value)}
suggestions={actionSuggestions}
placeholder="e.g., core.echo" placeholder="e.g., core.echo"
/> />
<FilterInput <AutocompleteInput
label="Trigger" label="Trigger"
value={searchFilters.trigger} value={searchFilters.trigger}
onChange={(value) => handleFilterChange("trigger", value)} onChange={(value) => handleFilterChange("trigger", value)}
suggestions={triggerSuggestions}
placeholder="e.g., core.timer" placeholder="e.g., core.timer"
/> />
<FilterInput <FilterInput
@@ -268,153 +507,19 @@ export default function ExecutionsPage() {
</div> </div>
</div> </div>
{filteredExecutions.length === 0 ? ( {/* Results section - isolated from filter state, only depends on query results */}
<div className="bg-white p-12 text-center rounded-lg shadow"> <ExecutionsResultsTable
<p> executions={filteredExecutions}
{executions.length === 0 isLoading={isLoading}
? "No executions found" isFetching={isFetching}
: "No executions match the selected filters"} error={error as Error | null}
</p> hasActiveFilters={hasActiveFilters}
{executions.length > 0 && hasActiveFilters && ( clearFilters={clearFilters}
<button page={page}
onClick={clearFilters} setPage={setPage}
className="mt-3 text-sm text-blue-600 hover:text-blue-800" pageSize={pageSize}
> total={total}
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>
)}
</>
)}
</div> </div>
); );
} }