re-uploading work
This commit is contained in:
368
web/src/pages/events/EventsPage.tsx
Normal file
368
web/src/pages/events/EventsPage.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEvents } from "@/hooks/useEvents";
|
||||
import {
|
||||
useEntityNotifications,
|
||||
Notification,
|
||||
} from "@/contexts/WebSocketContext";
|
||||
import type { EventSummary } from "@/api";
|
||||
|
||||
export default function EventsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
const [triggerFilter, setTriggerFilter] = useState<string>("");
|
||||
const pageSize = 50;
|
||||
|
||||
// Set up WebSocket for real-time event updates with stable callback
|
||||
const handleEventNotification = useCallback(
|
||||
(notification: Notification) => {
|
||||
// Extract event data from notification payload
|
||||
if (
|
||||
notification.notification_type === "event_created" &&
|
||||
notification.payload
|
||||
) {
|
||||
const eventData = (notification.payload as any).data;
|
||||
|
||||
if (eventData) {
|
||||
// Create EventSummary from notification data
|
||||
const newEvent: EventSummary = {
|
||||
id: eventData.id,
|
||||
trigger: eventData.trigger,
|
||||
trigger_ref: eventData.trigger_ref,
|
||||
rule: eventData.rule,
|
||||
rule_ref: eventData.rule_ref,
|
||||
source: eventData.source,
|
||||
source_ref: eventData.source_ref,
|
||||
has_payload:
|
||||
eventData.payload !== null && eventData.payload !== undefined,
|
||||
created: eventData.created,
|
||||
};
|
||||
|
||||
// Update the query cache directly instead of invalidating
|
||||
queryClient.setQueryData(
|
||||
[
|
||||
"events",
|
||||
{ page, pageSize, triggerRef: triggerFilter || undefined },
|
||||
],
|
||||
(oldData: any) => {
|
||||
if (!oldData) return oldData;
|
||||
|
||||
// Check if filtering and event matches filter
|
||||
if (triggerFilter && newEvent.trigger_ref !== triggerFilter) {
|
||||
return oldData;
|
||||
}
|
||||
|
||||
// Add new event to the beginning of the list if on first page
|
||||
if (page === 1) {
|
||||
return {
|
||||
...oldData,
|
||||
data: [newEvent, ...oldData.data].slice(0, pageSize),
|
||||
pagination: {
|
||||
...oldData.pagination,
|
||||
total_items: (oldData.pagination?.total_items || 0) + 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// For other pages, just update the total count
|
||||
return {
|
||||
...oldData,
|
||||
pagination: {
|
||||
...oldData.pagination,
|
||||
total_items: (oldData.pagination?.total_items || 0) + 1,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[queryClient, page, pageSize, triggerFilter],
|
||||
);
|
||||
|
||||
const { connected: wsConnected } = useEntityNotifications(
|
||||
"event",
|
||||
handleEventNotification,
|
||||
);
|
||||
|
||||
const { data, isLoading, error } = useEvents({
|
||||
page,
|
||||
pageSize,
|
||||
triggerRef: triggerFilter || undefined,
|
||||
});
|
||||
|
||||
const events = data?.data || [];
|
||||
const total = data?.pagination?.total_items || 0;
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
const totalPages = total ? Math.ceil(total / pageSize) : 0;
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg shadow mb-6 p-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
<div className="flex-1 max-w-md">
|
||||
<label
|
||||
htmlFor="trigger-filter"
|
||||
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>
|
||||
|
||||
{triggerFilter && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setTriggerFilter("");
|
||||
setPage(1);
|
||||
}}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
Clear Filter
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data && (
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
Showing {events.length} of {total} events
|
||||
{triggerFilter && ` (filtered by "${triggerFilter}")`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Events List */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-12 text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<p className="mt-4 text-gray-600">Loading events...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-12 text-center">
|
||||
<p className="text-red-600">Failed to load events</p>
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
{error instanceof Error ? error.message : "Unknown error"}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user