From 01aba7df90871aa495398b72c4c57ec657cb448c Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 01:51:42 +0000 Subject: [PATCH] Connect to Lovable Cloud Fix security definer view issue by enabling security_invoker on grouped_alerts_view and implement grouped alerts system with new keys, hooks, components, and monitoring overview integration. Added migration to adjust view, updated query keys, created useGroupedAlerts, useAlertGroupActions, GroupedAlertsPanel, and updated MonitoringOverview to include grouped alerts. --- src/components/admin/GroupedAlertsPanel.tsx | 237 ++++++++++++++++++ src/hooks/admin/useAlertGroupActions.ts | 110 ++++++++ src/hooks/admin/useGroupedAlerts.ts | 90 +++++++ src/lib/queryKeys.ts | 3 + src/pages/admin/MonitoringOverview.tsx | 25 +- ...6_3a7bebd3-fc29-484c-ac68-8d9c371a8b16.sql | 51 ++++ 6 files changed, 508 insertions(+), 8 deletions(-) create mode 100644 src/components/admin/GroupedAlertsPanel.tsx create mode 100644 src/hooks/admin/useAlertGroupActions.ts create mode 100644 src/hooks/admin/useGroupedAlerts.ts create mode 100644 supabase/migrations/20251111014936_3a7bebd3-fc29-484c-ac68-8d9c371a8b16.sql 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