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"; 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 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: { page: number; pageSize: number; triggerRef?: string; ruleRef?: string; } = { 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) => { if (notification.notification_type === "event_created") { const payload = notification.payload as Partial & { payload?: unknown; }; const newEvent: EventSummary = { id: payload.id ?? 0, trigger: payload.trigger ?? 0, trigger_ref: payload.trigger_ref ?? "", rule: payload.rule, rule_ref: payload.rule_ref, source: payload.source, source_ref: payload.source_ref, has_payload: payload.payload !== null && payload.payload !== undefined, created: payload.created ?? new Date().toISOString(), }; // 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; 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; } if (!changed) return prev; return { triggers: [...newTriggers], rules: [...newRules], }; }); queryClient.setQueryData( ["events", queryParams], ( oldData: | { data: EventSummary[]; pagination?: { total_items?: number }; } | undefined, ) => { 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, queryParams, page, pageSize, debouncedFilters], ); const { connected: wsConnected } = useEntityNotifications( "event", handleEventNotification, ); const { data, isLoading, isFetching, error } = useEvents(queryParams); const events = useMemo(() => data?.data || [], [data]); const total = data?.pagination?.total_items || 0; // Derive refs from currently-loaded event data (no setState needed) const loadedRefs = useMemo(() => { const triggers = new Set(); const rules = new Set(); for (const event of events) { if (event.trigger_ref) triggers.add(event.trigger_ref); if (event.rule_ref) rules.add(event.rule_ref); } return { triggers: [...triggers], rules: [...rules], }; }, [events]); // 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 - always visible */}

Events

Event instances generated by sensors and triggers

{isFetching && hasActiveFilters && (

Searching events...

)}
{wsConnected && (
Live updates
)}
{/* Filter section - always mounted, never unmounts during loading */}

Filter Events

{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 {hasActiveFilters && " (filtered)"}
)}
{/* Results section - isolated from filter state, only depends on query results */}
); }