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 (
);
}
// 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}
)}
|
ID
|
Trigger
|
Rule
|
Source
|
Created
|
{events.map((event) => (
|
#{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 && (
)}
{/* 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 */}
);
}