Some checks failed
CI / Rustfmt (push) Successful in 22s
CI / Cargo Audit & Deny (push) Failing after 1m2s
CI / Web Blocking Checks (push) Failing after 35s
CI / Security Blocking Checks (push) Successful in 8s
CI / Clippy (push) Successful in 2m43s
CI / Web Advisory Checks (push) Successful in 35s
CI / Security Advisory Checks (push) Successful in 37s
CI / Tests (push) Failing after 9m28s
550 lines
19 KiB
TypeScript
550 lines
19 KiB
TypeScript
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 (
|
|
<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() {
|
|
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<EventSummary> & {
|
|
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<string>();
|
|
const rules = new Set<string>();
|
|
|
|
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 (
|
|
<div className="p-6">
|
|
{/* Header - always visible */}
|
|
<div className="mb-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-gray-900">Events</h1>
|
|
<p className="mt-2 text-gray-600">
|
|
Event instances generated by sensors and triggers
|
|
</p>
|
|
{isFetching && hasActiveFilters && (
|
|
<p className="text-sm text-gray-500 mt-1">Searching events...</p>
|
|
)}
|
|
</div>
|
|
{wsConnected && (
|
|
<div className="flex items-center gap-2 text-sm text-green-600">
|
|
<div className="w-2 h-2 bg-green-600 rounded-full animate-pulse"></div>
|
|
<span>Live updates</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filter section - always mounted, never unmounts during loading */}
|
|
<div className="bg-white shadow rounded-lg p-4 mb-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<Search className="h-5 w-5 text-gray-400" />
|
|
<h2 className="text-lg font-semibold">Filter Events</h2>
|
|
</div>
|
|
{hasActiveFilters && (
|
|
<button
|
|
onClick={clearFilters}
|
|
className="flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
Clear Filters
|
|
</button>
|
|
)}
|
|
</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 && (
|
|
<div className="mt-3 text-sm text-gray-600">
|
|
Showing {events.length} of {total} events
|
|
{hasActiveFilters && " (filtered)"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Results section - isolated from filter state, only depends on query results */}
|
|
<EventsResultsTable
|
|
events={events}
|
|
isLoading={isLoading}
|
|
isFetching={isFetching}
|
|
error={error as Error | null}
|
|
hasActiveFilters={hasActiveFilters}
|
|
clearFilters={clearFilters}
|
|
page={page}
|
|
setPage={setPage}
|
|
pageSize={pageSize}
|
|
total={total}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|