global events: notification bell feed + exclude client_error from operator alerts
This commit is contained in:
parent
34d4a02826
commit
44d7e3dd66
3 changed files with 93 additions and 5 deletions
|
|
@ -16,10 +16,15 @@ import {
|
|||
Container,
|
||||
Bell,
|
||||
BookOpen,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import { useUIStore, useAuthStore, useRealtimeStore } from '@/lib/store';
|
||||
import { cn, formatRelativeTime } from '@/lib/utils';
|
||||
import AdvisoryBanner from '@/components/AdvisoryBanner';
|
||||
import { useGlobalEvents } from '@/hooks/useGlobalEvents';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
|
|
@ -36,6 +41,9 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||
const [isNotificationDropdownOpen, setIsNotificationDropdownOpen] = useState(false);
|
||||
const [serverVersion, setServerVersion] = useState<string | null>(null);
|
||||
|
||||
// Global event feed — powers the notification bell across all pages.
|
||||
useGlobalEvents();
|
||||
|
||||
const unreadCount = notifications.filter(n => !n.read).length;
|
||||
|
||||
// Fetch server version from health endpoint
|
||||
|
|
@ -116,13 +124,13 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||
const getNotificationIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return '✅';
|
||||
return <CheckCircle className="w-4 h-4 text-green-600" />;
|
||||
case 'error':
|
||||
return '❌';
|
||||
return <XCircle className="w-4 h-4 text-red-600" />;
|
||||
case 'warning':
|
||||
return '⚠️';
|
||||
return <AlertTriangle className="w-4 h-4 text-amber-600" />;
|
||||
default:
|
||||
return 'ℹ️';
|
||||
return <Info className="w-4 h-4 text-blue-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
77
web/src/hooks/useGlobalEvents.ts
Normal file
77
web/src/hooks/useGlobalEvents.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { useRealtimeStore } from '@/lib/store';
|
||||
import { POLL } from '@/lib/polling';
|
||||
|
||||
// SystemEvent interface matching the backend model
|
||||
interface SystemEvent {
|
||||
id: string;
|
||||
agent_id?: string;
|
||||
event_type: string;
|
||||
event_subtype: string;
|
||||
severity: 'info' | 'warning' | 'error' | 'critical';
|
||||
component: string;
|
||||
message: string;
|
||||
metadata?: Record<string, any>;
|
||||
created_at: string;
|
||||
narrative?: string;
|
||||
}
|
||||
|
||||
export interface UseGlobalEventsOptions {
|
||||
severity?: string; // comma-separated: error,critical,warning,info
|
||||
limit?: number; // default 50, max 200
|
||||
pollingInterval?: number; // milliseconds, default 30000
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls the global event feed and feeds new events into the notification bell.
|
||||
* Mount once in Layout — the bell is global, not per-page.
|
||||
* Tracks seen event IDs to avoid re-notifying on every poll cycle.
|
||||
*/
|
||||
export const useGlobalEvents = (options: UseGlobalEventsOptions = {}) => {
|
||||
const { addNotification } = useRealtimeStore();
|
||||
const seenRef = useRef<Set<string>>(new Set());
|
||||
const {
|
||||
severity = 'error,critical,warning',
|
||||
limit = 50,
|
||||
pollingInterval = POLL.DETAIL,
|
||||
} = options;
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['global-events', severity, limit],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (severity) params.append('severity', severity);
|
||||
if (limit) params.append('limit', limit.toString());
|
||||
|
||||
const response = await api.get(`/events?${params.toString()}`);
|
||||
return response.data as { events: SystemEvent[]; total: number };
|
||||
},
|
||||
refetchInterval: pollingInterval,
|
||||
staleTime: pollingInterval / 2,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.events && data.events.length > 0) {
|
||||
data.events.forEach((event) => {
|
||||
// Only push events we haven't seen this session.
|
||||
if (seenRef.current.has(event.id)) return;
|
||||
seenRef.current.add(event.id);
|
||||
|
||||
addNotification({
|
||||
type:
|
||||
event.severity === 'critical'
|
||||
? 'error'
|
||||
: event.severity === 'error'
|
||||
? 'error'
|
||||
: event.severity === 'warning'
|
||||
? 'warning'
|
||||
: 'info',
|
||||
title: `${event.component}: ${event.event_type}`,
|
||||
message: event.narrative || event.message,
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [data?.events, addNotification]);
|
||||
};
|
||||
Loading…
Reference in a new issue