Watch
1
0
Fork
You've already forked RedFlag
0

global events: notification bell feed + exclude client_error from operator alerts

This commit is contained in:
Fimeg 2026-06-11 13:38:31 -04:00
commit 44d7e3dd66
3 changed files with 93 additions and 5 deletions

View file

@ -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" />;
}
};

View 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]);
};