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.
This commit is contained in:
gpt-engineer-app[bot]
2025-11-11 01:51:42 +00:00
parent 97f586232f
commit 01aba7df90
6 changed files with 508 additions and 8 deletions

View File

@@ -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<Set<string>>(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 (
<Card>
<CardHeader>
<CardTitle>Critical Alerts</CardTitle>
<CardDescription>Loading alerts...</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
</CardContent>
</Card>
);
}
if (!visibleAlerts || visibleAlerts.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Critical Alerts</CardTitle>
<CardDescription>All systems operational</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<AlertCircle className="h-12 w-12 mb-2 opacity-50" />
<p>No active alerts</p>
</div>
</CardContent>
</Card>
);
}
const totalAlerts = visibleAlerts.reduce((sum, alert) => sum + alert.unresolved_count, 0);
const recurringCount = visibleAlerts.filter(a => a.is_recurring).length;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Critical Alerts</span>
<span className="text-sm font-normal text-muted-foreground">
{visibleAlerts.length} {visibleAlerts.length === 1 ? 'group' : 'groups'} {totalAlerts} total alerts
{recurringCount > 0 && `${recurringCount} recurring`}
</span>
</CardTitle>
<CardDescription>Grouped by type to reduce alert fatigue</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{visibleAlerts.map(alert => {
const config = SEVERITY_CONFIG[alert.severity];
const Icon = config.icon;
const isExpanded = expandedGroups.has(alert.group_key);
return (
<div
key={alert.group_key}
className="border rounded-lg p-4 space-y-2 bg-card hover:bg-accent/5 transition-colors"
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1">
<Icon className={`h-5 w-5 mt-0.5 ${config.color}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<span className={`text-xs font-medium px-2 py-0.5 rounded ${config.badge}`}>
{config.label}
</span>
<span className="text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground">
{alert.source === 'system' ? 'System' : 'Rate Limit'}
</span>
{alert.is_active && (
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-green-500/10 text-green-600">
<Zap className="h-3 w-3" />
Active
</span>
)}
{alert.is_recurring && (
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-amber-500/10 text-amber-600">
<RefreshCw className="h-3 w-3" />
Recurring
</span>
)}
<span className="text-xs font-semibold px-2 py-0.5 rounded bg-primary/10 text-primary">
{alert.unresolved_count} {alert.unresolved_count === 1 ? 'alert' : 'alerts'}
</span>
</div>
<p className="text-sm font-medium">
{alert.alert_type || alert.metric_type || 'Alert'}
{alert.function_name && <span className="text-muted-foreground"> {alert.function_name}</span>}
</p>
<p className="text-sm text-muted-foreground line-clamp-2">
{alert.messages[0]}
</p>
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
First: {formatDistanceToNow(new Date(alert.first_seen), { addSuffix: true })}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
Last: {formatDistanceToNow(new Date(alert.last_seen), { addSuffix: true })}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{alert.alert_count > 1 && (
<Button
variant="ghost"
size="sm"
onClick={() => toggleExpanded(alert.group_key)}
>
{isExpanded ? (
<>
<ChevronUp className="h-4 w-4 mr-1" />
Hide
</>
) : (
<>
<ChevronDown className="h-4 w-4 mr-1" />
Show all {alert.alert_count}
</>
)}
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
Snooze
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleSnooze(alert, 3600000)}>
1 hour
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSnooze(alert, 14400000)}>
4 hours
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSnooze(alert, 86400000)}>
24 hours
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="default"
size="sm"
onClick={() => handleResolveGroup(alert)}
disabled={resolveGroup.isPending}
>
Resolve All
</Button>
</div>
</div>
{isExpanded && alert.messages.length > 1 && (
<div className="mt-3 pt-3 border-t space-y-2">
<p className="text-xs font-medium text-muted-foreground">All messages in this group:</p>
<div className="space-y-1 max-h-64 overflow-y-auto">
{alert.messages.map((message, idx) => (
<div key={idx} className="text-xs p-2 rounded bg-muted/50">
{message}
</div>
))}
</div>
</div>
)}
</div>
);
})}
</CardContent>
</Card>
);
}

View File

@@ -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');
},
});
}

View File

@@ -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,
});
}

View File

@@ -89,5 +89,8 @@ export const queryKeys = {
combinedAlerts: () => ['monitoring', 'combined-alerts'] as const, combinedAlerts: () => ['monitoring', 'combined-alerts'] as const,
databaseHealth: () => ['monitoring', 'database-health'] as const, databaseHealth: () => ['monitoring', 'database-health'] as const,
moderationHealth: () => ['monitoring', 'moderation-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; } as const;

View File

@@ -3,16 +3,17 @@ import { useQueryClient } from '@tanstack/react-query';
import { AdminLayout } from '@/components/layout/AdminLayout'; import { AdminLayout } from '@/components/layout/AdminLayout';
import { RefreshButton } from '@/components/ui/refresh-button'; import { RefreshButton } from '@/components/ui/refresh-button';
import { SystemHealthStatus } from '@/components/admin/SystemHealthStatus'; 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 { MonitoringQuickStats } from '@/components/admin/MonitoringQuickStats';
import { RecentActivityTimeline } from '@/components/admin/RecentActivityTimeline'; import { RecentActivityTimeline } from '@/components/admin/RecentActivityTimeline';
import { MonitoringNavCards } from '@/components/admin/MonitoringNavCards'; import { MonitoringNavCards } from '@/components/admin/MonitoringNavCards';
import { useSystemHealth } from '@/hooks/useSystemHealth'; import { useSystemHealth } from '@/hooks/useSystemHealth';
import { useCombinedAlerts } from '@/hooks/admin/useCombinedAlerts'; import { useGroupedAlerts } from '@/hooks/admin/useGroupedAlerts';
import { useRecentActivity } from '@/hooks/admin/useRecentActivity'; import { useRecentActivity } from '@/hooks/admin/useRecentActivity';
import { useDatabaseHealth } from '@/hooks/admin/useDatabaseHealth'; import { useDatabaseHealth } from '@/hooks/admin/useDatabaseHealth';
import { useModerationHealth } from '@/hooks/admin/useModerationHealth'; import { useModerationHealth } from '@/hooks/admin/useModerationHealth';
import { useRateLimitStats } from '@/hooks/useRateLimitMetrics'; import { useRateLimitStats } from '@/hooks/useRateLimitMetrics';
import { queryKeys } from '@/lib/queryKeys';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -22,7 +23,7 @@ export default function MonitoringOverview() {
// Fetch all monitoring data // Fetch all monitoring data
const systemHealth = useSystemHealth(); const systemHealth = useSystemHealth();
const combinedAlerts = useCombinedAlerts(); const groupedAlerts = useGroupedAlerts({ includeResolved: false });
const recentActivity = useRecentActivity(3600000); // 1 hour const recentActivity = useRecentActivity(3600000); // 1 hour
const dbHealth = useDatabaseHealth(); const dbHealth = useDatabaseHealth();
const moderationHealth = useModerationHealth(); const moderationHealth = useModerationHealth();
@@ -30,7 +31,7 @@ export default function MonitoringOverview() {
const isLoading = const isLoading =
systemHealth.isLoading || systemHealth.isLoading ||
combinedAlerts.isLoading || groupedAlerts.isLoading ||
recentActivity.isLoading || recentActivity.isLoading ||
dbHealth.isLoading || dbHealth.isLoading ||
moderationHealth.isLoading || moderationHealth.isLoading ||
@@ -53,10 +54,18 @@ export default function MonitoringOverview() {
queryKey: ['rate-limit'], queryKey: ['rate-limit'],
refetchType: 'active' refetchType: 'active'
}); });
await queryClient.invalidateQueries({
queryKey: queryKeys.monitoring.groupedAlerts(),
refetchType: 'active'
});
}; };
// Calculate error count for nav card (from recent activity) // Calculate error count for nav card (from recent activity)
const errorCount = recentActivity.data?.filter(e => e.type === 'error').length || 0; 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 ( return (
<AdminLayout> <AdminLayout>
@@ -91,10 +100,10 @@ export default function MonitoringOverview() {
isLoading={systemHealth.isLoading || dbHealth.isLoading} isLoading={systemHealth.isLoading || dbHealth.isLoading}
/> />
{/* Critical Alerts */} {/* Critical Alerts - Now Grouped */}
<CriticalAlertsPanel <GroupedAlertsPanel
alerts={combinedAlerts.data} alerts={groupedAlerts.data}
isLoading={combinedAlerts.isLoading} isLoading={groupedAlerts.isLoading}
/> />
{/* Quick Stats Grid */} {/* Quick Stats Grid */}

View File

@@ -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;