diff --git a/src/components/admin/GroupedAlertsPanel.tsx b/src/components/admin/GroupedAlertsPanel.tsx new file mode 100644 index 00000000..e79f37d8 --- /dev/null +++ b/src/components/admin/GroupedAlertsPanel.tsx @@ -0,0 +1,237 @@ +import { useState } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { AlertCircle, AlertTriangle, Info, ChevronDown, ChevronUp, Clock, Zap, RefreshCw } from 'lucide-react'; +import { formatDistanceToNow } from 'date-fns'; +import type { GroupedAlert } from '@/hooks/admin/useGroupedAlerts'; +import { useResolveAlertGroup, useSnoozeAlertGroup } from '@/hooks/admin/useAlertGroupActions'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; + +interface GroupedAlertsPanelProps { + alerts?: GroupedAlert[]; + isLoading: boolean; +} + +const SEVERITY_CONFIG = { + critical: { color: 'text-destructive', icon: AlertCircle, label: 'Critical', badge: 'bg-destructive/10 text-destructive' }, + high: { color: 'text-orange-500', icon: AlertTriangle, label: 'High', badge: 'bg-orange-500/10 text-orange-500' }, + medium: { color: 'text-yellow-500', icon: AlertTriangle, label: 'Medium', badge: 'bg-yellow-500/10 text-yellow-500' }, + low: { color: 'text-blue-500', icon: Info, label: 'Low', badge: 'bg-blue-500/10 text-blue-500' }, +}; + +export function GroupedAlertsPanel({ alerts, isLoading }: GroupedAlertsPanelProps) { + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + const resolveGroup = useResolveAlertGroup(); + const snoozeGroup = useSnoozeAlertGroup(); + + // Filter out snoozed alerts + const snoozedAlerts = JSON.parse(localStorage.getItem('snoozed_alerts') || '{}'); + const visibleAlerts = alerts?.filter(alert => { + const snoozeUntil = snoozedAlerts[alert.group_key]; + return !snoozeUntil || Date.now() > snoozeUntil; + }); + + const handleResolveGroup = (alert: GroupedAlert) => { + resolveGroup.mutate({ + alertIds: alert.alert_ids, + source: alert.source, + }); + }; + + const handleSnooze = (alert: GroupedAlert, durationMs: number) => { + snoozeGroup.mutate({ + groupKey: alert.group_key, + duration: durationMs, + }); + }; + + const toggleExpanded = (groupKey: string) => { + setExpandedGroups(prev => { + const next = new Set(prev); + if (next.has(groupKey)) { + next.delete(groupKey); + } else { + next.add(groupKey); + } + return next; + }); + }; + + if (isLoading) { + return ( + + + Critical Alerts + Loading alerts... + + +
+
+
+
+
+ ); + } + + if (!visibleAlerts || visibleAlerts.length === 0) { + return ( + + + Critical Alerts + All systems operational + + +
+ +

No active alerts

+
+
+
+ ); + } + + const totalAlerts = visibleAlerts.reduce((sum, alert) => sum + alert.unresolved_count, 0); + const recurringCount = visibleAlerts.filter(a => a.is_recurring).length; + + return ( + + + + Critical Alerts + + {visibleAlerts.length} {visibleAlerts.length === 1 ? 'group' : 'groups'} • {totalAlerts} total alerts + {recurringCount > 0 && ` • ${recurringCount} recurring`} + + + Grouped by type to reduce alert fatigue + + + {visibleAlerts.map(alert => { + const config = SEVERITY_CONFIG[alert.severity]; + const Icon = config.icon; + const isExpanded = expandedGroups.has(alert.group_key); + + return ( +
+
+
+ +
+
+ + {config.label} + + + {alert.source === 'system' ? 'System' : 'Rate Limit'} + + {alert.is_active && ( + + + Active + + )} + {alert.is_recurring && ( + + + Recurring + + )} + + {alert.unresolved_count} {alert.unresolved_count === 1 ? 'alert' : 'alerts'} + +
+

+ {alert.alert_type || alert.metric_type || 'Alert'} + {alert.function_name && • {alert.function_name}} +

+

+ {alert.messages[0]} +

+
+ + + First: {formatDistanceToNow(new Date(alert.first_seen), { addSuffix: true })} + + + + Last: {formatDistanceToNow(new Date(alert.last_seen), { addSuffix: true })} + +
+
+
+
+ {alert.alert_count > 1 && ( + + )} + + + + + + handleSnooze(alert, 3600000)}> + 1 hour + + handleSnooze(alert, 14400000)}> + 4 hours + + handleSnooze(alert, 86400000)}> + 24 hours + + + + +
+
+ + {isExpanded && alert.messages.length > 1 && ( +
+

All messages in this group:

+
+ {alert.messages.map((message, idx) => ( +
+ {message} +
+ ))} +
+
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/src/hooks/admin/useAlertGroupActions.ts b/src/hooks/admin/useAlertGroupActions.ts new file mode 100644 index 00000000..97158102 --- /dev/null +++ b/src/hooks/admin/useAlertGroupActions.ts @@ -0,0 +1,110 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { supabase } from '@/lib/supabaseClient'; +import { queryKeys } from '@/lib/queryKeys'; +import { toast } from 'sonner'; +import type { GroupedAlert } from './useGroupedAlerts'; + +export function useResolveAlertGroup() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + alertIds, + source + }: { + alertIds: string[]; + source: 'system' | 'rate_limit'; + }) => { + const table = source === 'system' ? 'system_alerts' : 'rate_limit_alerts'; + const { error } = await supabase + .from(table) + .update({ resolved_at: new Date().toISOString() }) + .in('id', alertIds); + + if (error) throw error; + return { count: alertIds.length }; + }, + onMutate: async ({ alertIds }) => { + // Cancel any outgoing refetches + await queryClient.cancelQueries({ + queryKey: queryKeys.monitoring.groupedAlerts() + }); + + const previousData = queryClient.getQueryData( + queryKeys.monitoring.groupedAlerts() + ); + + // Optimistically update to the new value + queryClient.setQueryData( + queryKeys.monitoring.groupedAlerts(), + (old: GroupedAlert[] | undefined) => { + if (!old) return old; + return old.map(alert => { + const hasMatchingIds = alert.alert_ids.some(id => + alertIds.includes(id) + ); + if (hasMatchingIds) { + return { + ...alert, + unresolved_count: 0, + has_resolved: true, + }; + } + return alert; + }); + } + ); + + return { previousData }; + }, + onSuccess: (data) => { + toast.success(`Resolved ${data.count} alert${data.count > 1 ? 's' : ''}`); + }, + onError: (error, variables, context) => { + // Rollback on error + if (context?.previousData) { + queryClient.setQueryData( + queryKeys.monitoring.groupedAlerts(), + context.previousData + ); + } + toast.error('Failed to resolve alerts'); + console.error('Error resolving alert group:', error); + }, + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.monitoring.groupedAlerts() + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.monitoring.combinedAlerts() + }); + }, + }); +} + +export function useSnoozeAlertGroup() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + groupKey, + duration + }: { + groupKey: string; + duration: number; + }) => { + const snoozedAlerts = JSON.parse( + localStorage.getItem('snoozed_alerts') || '{}' + ); + snoozedAlerts[groupKey] = Date.now() + duration; + localStorage.setItem('snoozed_alerts', JSON.stringify(snoozedAlerts)); + return { groupKey, until: snoozedAlerts[groupKey] }; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.monitoring.groupedAlerts() + }); + toast.success('Alert group snoozed'); + }, + }); +} diff --git a/src/hooks/admin/useGroupedAlerts.ts b/src/hooks/admin/useGroupedAlerts.ts new file mode 100644 index 00000000..5be14318 --- /dev/null +++ b/src/hooks/admin/useGroupedAlerts.ts @@ -0,0 +1,90 @@ +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/lib/supabaseClient'; +import { queryKeys } from '@/lib/queryKeys'; + +export interface GroupedAlert { + group_key: string; + alert_type?: string; + severity: 'critical' | 'high' | 'medium' | 'low'; + source: 'system' | 'rate_limit'; + function_name?: string; + metric_type?: string; + alert_count: number; + unresolved_count: number; + first_seen: string; + last_seen: string; + alert_ids: string[]; + messages: string[]; + has_resolved: boolean; + is_recurring: boolean; + is_active: boolean; +} + +interface GroupedAlertsOptions { + includeResolved?: boolean; + minCount?: number; + severity?: 'critical' | 'high' | 'medium' | 'low'; +} + +export function useGroupedAlerts(options?: GroupedAlertsOptions) { + return useQuery({ + queryKey: queryKeys.monitoring.groupedAlerts(options), + queryFn: async () => { + let query = supabase + .from('grouped_alerts_view') + .select('*') + .order('last_seen', { ascending: false }); + + if (!options?.includeResolved) { + query = query.gt('unresolved_count', 0); + } + + if (options?.minCount) { + query = query.gte('alert_count', options.minCount); + } + + if (options?.severity) { + query = query.eq('severity', options.severity); + } + + const { data, error } = await query; + if (error) throw error; + + return (data || []).map(alert => ({ + ...alert, + is_recurring: (alert.alert_count ?? 0) > 3, + is_active: new Date(alert.last_seen ?? new Date()).getTime() > Date.now() - 3600000, + })) as GroupedAlert[]; + }, + staleTime: 15000, + refetchInterval: 30000, + }); +} + +export function useAlertGroupDetails(groupKey: string, source: 'system' | 'rate_limit', alertIds: string[]) { + return useQuery({ + queryKey: queryKeys.monitoring.alertGroupDetails(groupKey), + queryFn: async () => { + if (source === 'system') { + const { data, error } = await supabase + .from('system_alerts') + .select('*') + .in('id', alertIds) + .order('created_at', { ascending: false }); + + if (error) throw error; + return data || []; + } else { + const { data, error } = await supabase + .from('rate_limit_alerts') + .select('*') + .in('id', alertIds) + .order('created_at', { ascending: false }); + + if (error) throw error; + return data || []; + } + }, + enabled: alertIds.length > 0, + }); +} diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index 7f6ed5bd..52326aa9 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -89,5 +89,8 @@ export const queryKeys = { combinedAlerts: () => ['monitoring', 'combined-alerts'] as const, databaseHealth: () => ['monitoring', 'database-health'] as const, moderationHealth: () => ['monitoring', 'moderation-health'] as const, + groupedAlerts: (options?: { includeResolved?: boolean; minCount?: number; severity?: string }) => + ['monitoring', 'grouped-alerts', options] as const, + alertGroupDetails: (groupKey: string) => ['monitoring', 'alert-group-details', groupKey] as const, }, } as const; diff --git a/src/pages/admin/MonitoringOverview.tsx b/src/pages/admin/MonitoringOverview.tsx index 0e03732c..0faa682a 100644 --- a/src/pages/admin/MonitoringOverview.tsx +++ b/src/pages/admin/MonitoringOverview.tsx @@ -3,16 +3,17 @@ import { useQueryClient } from '@tanstack/react-query'; import { AdminLayout } from '@/components/layout/AdminLayout'; import { RefreshButton } from '@/components/ui/refresh-button'; import { SystemHealthStatus } from '@/components/admin/SystemHealthStatus'; -import { CriticalAlertsPanel } from '@/components/admin/CriticalAlertsPanel'; +import { GroupedAlertsPanel } from '@/components/admin/GroupedAlertsPanel'; import { MonitoringQuickStats } from '@/components/admin/MonitoringQuickStats'; import { RecentActivityTimeline } from '@/components/admin/RecentActivityTimeline'; import { MonitoringNavCards } from '@/components/admin/MonitoringNavCards'; import { useSystemHealth } from '@/hooks/useSystemHealth'; -import { useCombinedAlerts } from '@/hooks/admin/useCombinedAlerts'; +import { useGroupedAlerts } from '@/hooks/admin/useGroupedAlerts'; import { useRecentActivity } from '@/hooks/admin/useRecentActivity'; import { useDatabaseHealth } from '@/hooks/admin/useDatabaseHealth'; import { useModerationHealth } from '@/hooks/admin/useModerationHealth'; import { useRateLimitStats } from '@/hooks/useRateLimitMetrics'; +import { queryKeys } from '@/lib/queryKeys'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; @@ -22,7 +23,7 @@ export default function MonitoringOverview() { // Fetch all monitoring data const systemHealth = useSystemHealth(); - const combinedAlerts = useCombinedAlerts(); + const groupedAlerts = useGroupedAlerts({ includeResolved: false }); const recentActivity = useRecentActivity(3600000); // 1 hour const dbHealth = useDatabaseHealth(); const moderationHealth = useModerationHealth(); @@ -30,7 +31,7 @@ export default function MonitoringOverview() { const isLoading = systemHealth.isLoading || - combinedAlerts.isLoading || + groupedAlerts.isLoading || recentActivity.isLoading || dbHealth.isLoading || moderationHealth.isLoading || @@ -53,10 +54,18 @@ export default function MonitoringOverview() { queryKey: ['rate-limit'], refetchType: 'active' }); + await queryClient.invalidateQueries({ + queryKey: queryKeys.monitoring.groupedAlerts(), + refetchType: 'active' + }); }; // Calculate error count for nav card (from recent activity) const errorCount = recentActivity.data?.filter(e => e.type === 'error').length || 0; + + // Calculate stats from grouped alerts + const totalGroupedAlerts = groupedAlerts.data?.reduce((sum, g) => sum + g.unresolved_count, 0) || 0; + const recurringIssues = groupedAlerts.data?.filter(g => g.is_recurring).length || 0; return ( @@ -91,10 +100,10 @@ export default function MonitoringOverview() { isLoading={systemHealth.isLoading || dbHealth.isLoading} /> - {/* Critical Alerts */} - {/* Quick Stats Grid */} diff --git a/supabase/migrations/20251111014936_3a7bebd3-fc29-484c-ac68-8d9c371a8b16.sql b/supabase/migrations/20251111014936_3a7bebd3-fc29-484c-ac68-8d9c371a8b16.sql new file mode 100644 index 00000000..c6af3789 --- /dev/null +++ b/supabase/migrations/20251111014936_3a7bebd3-fc29-484c-ac68-8d9c371a8b16.sql @@ -0,0 +1,51 @@ +-- Fix security definer view issue by enabling security_invoker +-- This ensures the view respects RLS policies and runs with the querying user's permissions +DROP VIEW IF EXISTS grouped_alerts_view; + +CREATE OR REPLACE VIEW grouped_alerts_view +WITH (security_invoker=on) +AS +WITH system_alerts_grouped AS ( + SELECT + alert_type AS group_key, + alert_type, + severity, + 'system'::text AS source, + NULL::text AS function_name, + NULL::text AS metric_type, + COUNT(*) AS alert_count, + MIN(created_at) AS first_seen, + MAX(created_at) AS last_seen, + ARRAY_AGG(id::text ORDER BY created_at DESC) AS alert_ids, + ARRAY_AGG(message ORDER BY created_at DESC) AS messages, + BOOL_OR(resolved_at IS NOT NULL) AS has_resolved, + COUNT(*) FILTER (WHERE resolved_at IS NULL) AS unresolved_count + FROM system_alerts + WHERE created_at > NOW() - INTERVAL '7 days' + GROUP BY alert_type, severity +), +rate_limit_alerts_grouped AS ( + SELECT + CONCAT(metric_type, ':', COALESCE(function_name, 'global')) AS group_key, + NULL::text AS alert_type, + 'high'::text AS severity, + 'rate_limit'::text AS source, + function_name, + metric_type, + COUNT(*) AS alert_count, + MIN(created_at) AS first_seen, + MAX(created_at) AS last_seen, + ARRAY_AGG(id::text ORDER BY created_at DESC) AS alert_ids, + ARRAY_AGG(alert_message ORDER BY created_at DESC) AS messages, + BOOL_OR(resolved_at IS NOT NULL) AS has_resolved, + COUNT(*) FILTER (WHERE resolved_at IS NULL) AS unresolved_count + FROM rate_limit_alerts + WHERE created_at > NOW() - INTERVAL '7 days' + GROUP BY metric_type, function_name +) +SELECT * FROM system_alerts_grouped +UNION ALL +SELECT * FROM rate_limit_alerts_grouped; + +-- Grant access to authenticated users +GRANT SELECT ON grouped_alerts_view TO authenticated; \ No newline at end of file