mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 12:31:26 -05:00
Implement alert correlation UI
- Add hooks and components for correlated alerts and incidents - Integrate panels into MonitoringOverview - Extend query keys for correlation and incidents - Implement incident actions (create, acknowledge, resolve) and wiring
This commit is contained in:
175
src/components/admin/CorrelatedAlertsPanel.tsx
Normal file
175
src/components/admin/CorrelatedAlertsPanel.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { AlertTriangle, AlertCircle, Link2, Clock, Sparkles } from 'lucide-react';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import type { CorrelatedAlert } from '@/hooks/admin/useCorrelatedAlerts';
|
||||||
|
import { useCreateIncident } from '@/hooks/admin/useIncidents';
|
||||||
|
|
||||||
|
interface CorrelatedAlertsPanelProps {
|
||||||
|
correlations?: CorrelatedAlert[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEVERITY_CONFIG = {
|
||||||
|
critical: { color: 'text-destructive', icon: AlertCircle, badge: 'bg-destructive/10 text-destructive' },
|
||||||
|
high: { color: 'text-orange-500', icon: AlertTriangle, badge: 'bg-orange-500/10 text-orange-500' },
|
||||||
|
medium: { color: 'text-yellow-500', icon: AlertTriangle, badge: 'bg-yellow-500/10 text-yellow-500' },
|
||||||
|
low: { color: 'text-blue-500', icon: AlertTriangle, badge: 'bg-blue-500/10 text-blue-500' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CorrelatedAlertsPanel({ correlations, isLoading }: CorrelatedAlertsPanelProps) {
|
||||||
|
const createIncident = useCreateIncident();
|
||||||
|
|
||||||
|
const handleCreateIncident = (correlation: CorrelatedAlert) => {
|
||||||
|
createIncident.mutate({
|
||||||
|
ruleId: correlation.rule_id,
|
||||||
|
title: correlation.incident_title_template,
|
||||||
|
description: correlation.rule_description,
|
||||||
|
severity: correlation.incident_severity,
|
||||||
|
alertIds: correlation.alert_ids,
|
||||||
|
alertSources: correlation.alert_sources as ('system' | 'rate_limit')[],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Link2 className="h-5 w-5" />
|
||||||
|
Correlated Alerts
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Loading correlation patterns...</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 (!correlations || correlations.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Link2 className="h-5 w-5" />
|
||||||
|
Correlated Alerts
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>No correlated alert patterns detected</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||||
|
<Sparkles className="h-12 w-12 mb-2 opacity-50" />
|
||||||
|
<p>Alert correlation engine is active</p>
|
||||||
|
<p className="text-sm">Incidents will be auto-detected when patterns match</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Link2 className="h-5 w-5" />
|
||||||
|
Correlated Alerts
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-normal text-muted-foreground">
|
||||||
|
{correlations.length} {correlations.length === 1 ? 'pattern' : 'patterns'} detected
|
||||||
|
</span>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Multiple related alerts indicating potential incidents
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{correlations.map((correlation) => {
|
||||||
|
const config = SEVERITY_CONFIG[correlation.incident_severity];
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={correlation.rule_id}
|
||||||
|
className="border rounded-lg p-4 space-y-3 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.badge.split(' ')[1].split('-')[0].toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-purple-500/10 text-purple-600">
|
||||||
|
<Link2 className="h-3 w-3" />
|
||||||
|
Correlated
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-semibold px-2 py-0.5 rounded bg-primary/10 text-primary">
|
||||||
|
{correlation.matching_alerts_count} alerts
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium mb-1">
|
||||||
|
{correlation.rule_name}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{correlation.rule_description}
|
||||||
|
</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" />
|
||||||
|
Window: {correlation.time_window_minutes}m
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
First: {formatDistanceToNow(new Date(correlation.first_alert_at), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
Last: {formatDistanceToNow(new Date(correlation.last_alert_at), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{correlation.can_create_incident ? (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleCreateIncident(correlation)}
|
||||||
|
disabled={createIncident.isPending}
|
||||||
|
>
|
||||||
|
<Sparkles className="h-4 w-4 mr-1" />
|
||||||
|
Create Incident
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground px-3 py-1.5 bg-muted rounded">
|
||||||
|
Incident exists
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{correlation.alert_messages.length > 0 && (
|
||||||
|
<div className="pt-3 border-t">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-2">Sample alerts:</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{correlation.alert_messages.slice(0, 3).map((message, idx) => (
|
||||||
|
<div key={idx} className="text-xs p-2 rounded bg-muted/50 truncate">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
218
src/components/admin/IncidentsPanel.tsx
Normal file
218
src/components/admin/IncidentsPanel.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { AlertCircle, AlertTriangle, CheckCircle2, Clock, Eye } from 'lucide-react';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import type { Incident } from '@/hooks/admin/useIncidents';
|
||||||
|
import { useAcknowledgeIncident, useResolveIncident } from '@/hooks/admin/useIncidents';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface IncidentsPanelProps {
|
||||||
|
incidents?: Incident[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEVERITY_CONFIG = {
|
||||||
|
critical: { color: 'text-destructive', icon: AlertCircle, badge: 'destructive' },
|
||||||
|
high: { color: 'text-orange-500', icon: AlertTriangle, badge: 'default' },
|
||||||
|
medium: { color: 'text-yellow-500', icon: AlertTriangle, badge: 'secondary' },
|
||||||
|
low: { color: 'text-blue-500', icon: AlertTriangle, badge: 'outline' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_CONFIG = {
|
||||||
|
open: { label: 'Open', color: 'bg-red-500/10 text-red-600' },
|
||||||
|
investigating: { label: 'Investigating', color: 'bg-yellow-500/10 text-yellow-600' },
|
||||||
|
resolved: { label: 'Resolved', color: 'bg-green-500/10 text-green-600' },
|
||||||
|
closed: { label: 'Closed', color: 'bg-gray-500/10 text-gray-600' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function IncidentsPanel({ incidents, isLoading }: IncidentsPanelProps) {
|
||||||
|
const acknowledgeIncident = useAcknowledgeIncident();
|
||||||
|
const resolveIncident = useResolveIncident();
|
||||||
|
const [resolutionNotes, setResolutionNotes] = useState('');
|
||||||
|
const [selectedIncident, setSelectedIncident] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleAcknowledge = (incidentId: string) => {
|
||||||
|
acknowledgeIncident.mutate(incidentId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResolve = () => {
|
||||||
|
if (selectedIncident) {
|
||||||
|
resolveIncident.mutate({
|
||||||
|
incidentId: selectedIncident,
|
||||||
|
resolutionNotes,
|
||||||
|
resolveAlerts: true,
|
||||||
|
});
|
||||||
|
setResolutionNotes('');
|
||||||
|
setSelectedIncident(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Active Incidents</CardTitle>
|
||||||
|
<CardDescription>Loading incidents...</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 (!incidents || incidents.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Active Incidents</CardTitle>
|
||||||
|
<CardDescription>No active incidents</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||||
|
<CheckCircle2 className="h-12 w-12 mb-2 opacity-50" />
|
||||||
|
<p>All clear - no incidents detected</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const openIncidents = incidents.filter(i => i.status === 'open' || i.status === 'investigating');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
<span>Active Incidents</span>
|
||||||
|
<span className="text-sm font-normal text-muted-foreground">
|
||||||
|
{openIncidents.length} active • {incidents.length} total
|
||||||
|
</span>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Automatically detected incidents from correlated alerts
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{incidents.map((incident) => {
|
||||||
|
const severityConfig = SEVERITY_CONFIG[incident.severity];
|
||||||
|
const statusConfig = STATUS_CONFIG[incident.status];
|
||||||
|
const Icon = severityConfig.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={incident.id}
|
||||||
|
className="border rounded-lg p-4 space-y-3 bg-card"
|
||||||
|
>
|
||||||
|
<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 ${severityConfig.color}`} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||||
|
<span className="text-xs font-mono font-medium px-2 py-0.5 rounded bg-muted">
|
||||||
|
{incident.incident_number}
|
||||||
|
</span>
|
||||||
|
<Badge variant={severityConfig.badge as any} className="text-xs">
|
||||||
|
{incident.severity.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
<span className={`text-xs font-medium px-2 py-0.5 rounded ${statusConfig.color}`}>
|
||||||
|
{statusConfig.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded bg-primary/10 text-primary">
|
||||||
|
{incident.alert_count} alerts
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium mb-1">{incident.title}</p>
|
||||||
|
{incident.description && (
|
||||||
|
<p className="text-sm text-muted-foreground">{incident.description}</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" />
|
||||||
|
Detected: {formatDistanceToNow(new Date(incident.detected_at), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
{incident.acknowledged_at && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Eye className="h-3 w-3" />
|
||||||
|
Acknowledged: {formatDistanceToNow(new Date(incident.acknowledged_at), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{incident.status === 'open' && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAcknowledge(incident.id)}
|
||||||
|
disabled={acknowledgeIncident.isPending}
|
||||||
|
>
|
||||||
|
Acknowledge
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{(incident.status === 'open' || incident.status === 'investigating') && (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedIncident(incident.id)}
|
||||||
|
>
|
||||||
|
Resolve
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Resolve Incident {incident.incident_number}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Add resolution notes and close this incident. All linked alerts will be automatically resolved.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="resolution-notes">Resolution Notes</Label>
|
||||||
|
<Textarea
|
||||||
|
id="resolution-notes"
|
||||||
|
placeholder="Describe how this incident was resolved..."
|
||||||
|
value={resolutionNotes}
|
||||||
|
onChange={(e) => setResolutionNotes(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={handleResolve}
|
||||||
|
disabled={resolveIncident.isPending}
|
||||||
|
>
|
||||||
|
Resolve Incident
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/hooks/admin/useCorrelatedAlerts.ts
Normal file
38
src/hooks/admin/useCorrelatedAlerts.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { supabase } from '@/lib/supabaseClient';
|
||||||
|
import { queryKeys } from '@/lib/queryKeys';
|
||||||
|
|
||||||
|
export interface CorrelatedAlert {
|
||||||
|
rule_id: string;
|
||||||
|
rule_name: string;
|
||||||
|
rule_description: string;
|
||||||
|
incident_severity: 'critical' | 'high' | 'medium' | 'low';
|
||||||
|
incident_title_template: string;
|
||||||
|
time_window_minutes: number;
|
||||||
|
min_alerts_required: number;
|
||||||
|
matching_alerts_count: number;
|
||||||
|
alert_ids: string[];
|
||||||
|
alert_sources: string[];
|
||||||
|
alert_messages: string[];
|
||||||
|
first_alert_at: string;
|
||||||
|
last_alert_at: string;
|
||||||
|
can_create_incident: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCorrelatedAlerts() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.monitoring.correlatedAlerts(),
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('alert_correlations_view')
|
||||||
|
.select('*')
|
||||||
|
.order('incident_severity', { ascending: true })
|
||||||
|
.order('matching_alerts_count', { ascending: false });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return (data || []) as CorrelatedAlert[];
|
||||||
|
},
|
||||||
|
staleTime: 15000,
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
}
|
||||||
197
src/hooks/admin/useIncidents.ts
Normal file
197
src/hooks/admin/useIncidents.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { supabase } from '@/lib/supabaseClient';
|
||||||
|
import { queryKeys } from '@/lib/queryKeys';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export interface Incident {
|
||||||
|
id: string;
|
||||||
|
incident_number: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||||
|
status: 'open' | 'investigating' | 'resolved' | 'closed';
|
||||||
|
correlation_rule_id?: string;
|
||||||
|
detected_at: string;
|
||||||
|
acknowledged_at?: string;
|
||||||
|
acknowledged_by?: string;
|
||||||
|
resolved_at?: string;
|
||||||
|
resolved_by?: string;
|
||||||
|
resolution_notes?: string;
|
||||||
|
alert_count: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useIncidents(status?: 'open' | 'investigating' | 'resolved' | 'closed') {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.monitoring.incidents(status),
|
||||||
|
queryFn: async () => {
|
||||||
|
let query = supabase
|
||||||
|
.from('incidents')
|
||||||
|
.select('*')
|
||||||
|
.order('detected_at', { ascending: false });
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
query = query.eq('status', status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await query;
|
||||||
|
if (error) throw error;
|
||||||
|
return (data || []) as Incident[];
|
||||||
|
},
|
||||||
|
staleTime: 15000,
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateIncident() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
ruleId,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
severity,
|
||||||
|
alertIds,
|
||||||
|
alertSources,
|
||||||
|
}: {
|
||||||
|
ruleId?: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||||
|
alertIds: string[];
|
||||||
|
alertSources: ('system' | 'rate_limit')[];
|
||||||
|
}) => {
|
||||||
|
// Create the incident (incident_number is auto-generated by trigger)
|
||||||
|
const { data: incident, error: incidentError } = await supabase
|
||||||
|
.from('incidents')
|
||||||
|
.insert([{
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
severity,
|
||||||
|
correlation_rule_id: ruleId,
|
||||||
|
status: 'open' as const,
|
||||||
|
} as any])
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (incidentError) throw incidentError;
|
||||||
|
|
||||||
|
// Link alerts to the incident
|
||||||
|
const incidentAlerts = alertIds.map((alertId, index) => ({
|
||||||
|
incident_id: incident.id,
|
||||||
|
alert_source: alertSources[index] || 'system',
|
||||||
|
alert_id: alertId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { error: linkError } = await supabase
|
||||||
|
.from('incident_alerts')
|
||||||
|
.insert(incidentAlerts);
|
||||||
|
|
||||||
|
if (linkError) throw linkError;
|
||||||
|
|
||||||
|
return incident as Incident;
|
||||||
|
},
|
||||||
|
onSuccess: (incident) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.monitoring.incidents() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.monitoring.correlatedAlerts() });
|
||||||
|
toast.success(`Incident ${incident.incident_number} created`);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Failed to create incident:', error);
|
||||||
|
toast.error('Failed to create incident');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAcknowledgeIncident() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (incidentId: string) => {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('incidents')
|
||||||
|
.update({
|
||||||
|
status: 'investigating',
|
||||||
|
acknowledged_at: new Date().toISOString(),
|
||||||
|
acknowledged_by: (await supabase.auth.getUser()).data.user?.id,
|
||||||
|
})
|
||||||
|
.eq('id', incidentId)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return data as Incident;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.monitoring.incidents() });
|
||||||
|
toast.success('Incident acknowledged');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Failed to acknowledge incident:', error);
|
||||||
|
toast.error('Failed to acknowledge incident');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResolveIncident() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
incidentId,
|
||||||
|
resolutionNotes,
|
||||||
|
resolveAlerts = true,
|
||||||
|
}: {
|
||||||
|
incidentId: string;
|
||||||
|
resolutionNotes?: string;
|
||||||
|
resolveAlerts?: boolean;
|
||||||
|
}) => {
|
||||||
|
const userId = (await supabase.auth.getUser()).data.user?.id;
|
||||||
|
|
||||||
|
// Update incident
|
||||||
|
const { error: incidentError } = await supabase
|
||||||
|
.from('incidents')
|
||||||
|
.update({
|
||||||
|
status: 'resolved',
|
||||||
|
resolved_at: new Date().toISOString(),
|
||||||
|
resolved_by: userId,
|
||||||
|
resolution_notes: resolutionNotes,
|
||||||
|
})
|
||||||
|
.eq('id', incidentId);
|
||||||
|
|
||||||
|
if (incidentError) throw incidentError;
|
||||||
|
|
||||||
|
// Optionally resolve all linked alerts
|
||||||
|
if (resolveAlerts) {
|
||||||
|
const { data: linkedAlerts } = await supabase
|
||||||
|
.from('incident_alerts')
|
||||||
|
.select('alert_source, alert_id')
|
||||||
|
.eq('incident_id', incidentId);
|
||||||
|
|
||||||
|
if (linkedAlerts) {
|
||||||
|
for (const alert of linkedAlerts) {
|
||||||
|
const table = alert.alert_source === 'system' ? 'system_alerts' : 'rate_limit_alerts';
|
||||||
|
await supabase
|
||||||
|
.from(table)
|
||||||
|
.update({ resolved_at: new Date().toISOString() })
|
||||||
|
.eq('id', alert.alert_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { incidentId };
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.monitoring.incidents() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.monitoring.groupedAlerts() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.monitoring.combinedAlerts() });
|
||||||
|
toast.success('Incident resolved');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Failed to resolve incident:', error);
|
||||||
|
toast.error('Failed to resolve incident');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -92,5 +92,8 @@ export const queryKeys = {
|
|||||||
groupedAlerts: (options?: { includeResolved?: boolean; minCount?: number; severity?: string }) =>
|
groupedAlerts: (options?: { includeResolved?: boolean; minCount?: number; severity?: string }) =>
|
||||||
['monitoring', 'grouped-alerts', options] as const,
|
['monitoring', 'grouped-alerts', options] as const,
|
||||||
alertGroupDetails: (groupKey: string) => ['monitoring', 'alert-group-details', groupKey] as const,
|
alertGroupDetails: (groupKey: string) => ['monitoring', 'alert-group-details', groupKey] as const,
|
||||||
|
correlatedAlerts: () => ['monitoring', 'correlated-alerts'] as const,
|
||||||
|
incidents: (status?: string) => ['monitoring', 'incidents', status] as const,
|
||||||
|
incidentDetails: (incidentId: string) => ['monitoring', 'incident-details', incidentId] as const,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ 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 { GroupedAlertsPanel } from '@/components/admin/GroupedAlertsPanel';
|
import { GroupedAlertsPanel } from '@/components/admin/GroupedAlertsPanel';
|
||||||
|
import { CorrelatedAlertsPanel } from '@/components/admin/CorrelatedAlertsPanel';
|
||||||
|
import { IncidentsPanel } from '@/components/admin/IncidentsPanel';
|
||||||
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 { useGroupedAlerts } from '@/hooks/admin/useGroupedAlerts';
|
import { useGroupedAlerts } from '@/hooks/admin/useGroupedAlerts';
|
||||||
|
import { useCorrelatedAlerts } from '@/hooks/admin/useCorrelatedAlerts';
|
||||||
|
import { useIncidents } from '@/hooks/admin/useIncidents';
|
||||||
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';
|
||||||
@@ -24,6 +28,8 @@ export default function MonitoringOverview() {
|
|||||||
// Fetch all monitoring data
|
// Fetch all monitoring data
|
||||||
const systemHealth = useSystemHealth();
|
const systemHealth = useSystemHealth();
|
||||||
const groupedAlerts = useGroupedAlerts({ includeResolved: false });
|
const groupedAlerts = useGroupedAlerts({ includeResolved: false });
|
||||||
|
const correlatedAlerts = useCorrelatedAlerts();
|
||||||
|
const incidents = useIncidents('open');
|
||||||
const recentActivity = useRecentActivity(3600000); // 1 hour
|
const recentActivity = useRecentActivity(3600000); // 1 hour
|
||||||
const dbHealth = useDatabaseHealth();
|
const dbHealth = useDatabaseHealth();
|
||||||
const moderationHealth = useModerationHealth();
|
const moderationHealth = useModerationHealth();
|
||||||
@@ -32,6 +38,8 @@ export default function MonitoringOverview() {
|
|||||||
const isLoading =
|
const isLoading =
|
||||||
systemHealth.isLoading ||
|
systemHealth.isLoading ||
|
||||||
groupedAlerts.isLoading ||
|
groupedAlerts.isLoading ||
|
||||||
|
correlatedAlerts.isLoading ||
|
||||||
|
incidents.isLoading ||
|
||||||
recentActivity.isLoading ||
|
recentActivity.isLoading ||
|
||||||
dbHealth.isLoading ||
|
dbHealth.isLoading ||
|
||||||
moderationHealth.isLoading ||
|
moderationHealth.isLoading ||
|
||||||
@@ -58,14 +66,24 @@ export default function MonitoringOverview() {
|
|||||||
queryKey: queryKeys.monitoring.groupedAlerts(),
|
queryKey: queryKeys.monitoring.groupedAlerts(),
|
||||||
refetchType: 'active'
|
refetchType: 'active'
|
||||||
});
|
});
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.monitoring.correlatedAlerts(),
|
||||||
|
refetchType: 'active'
|
||||||
|
});
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.monitoring.incidents(),
|
||||||
|
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
|
// Calculate stats from grouped alerts and incidents
|
||||||
const totalGroupedAlerts = groupedAlerts.data?.reduce((sum, g) => sum + g.unresolved_count, 0) || 0;
|
const totalGroupedAlerts = groupedAlerts.data?.reduce((sum, g) => sum + g.unresolved_count, 0) || 0;
|
||||||
const recurringIssues = groupedAlerts.data?.filter(g => g.is_recurring).length || 0;
|
const recurringIssues = groupedAlerts.data?.filter(g => g.is_recurring).length || 0;
|
||||||
|
const activeIncidents = incidents.data?.length || 0;
|
||||||
|
const criticalIncidents = incidents.data?.filter(i => i.severity === 'critical').length || 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminLayout>
|
<AdminLayout>
|
||||||
@@ -106,6 +124,18 @@ export default function MonitoringOverview() {
|
|||||||
isLoading={groupedAlerts.isLoading}
|
isLoading={groupedAlerts.isLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Correlated Alerts - Potential Incidents */}
|
||||||
|
<CorrelatedAlertsPanel
|
||||||
|
correlations={correlatedAlerts.data}
|
||||||
|
isLoading={correlatedAlerts.isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Active Incidents */}
|
||||||
|
<IncidentsPanel
|
||||||
|
incidents={incidents.data}
|
||||||
|
isLoading={incidents.isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Quick Stats Grid */}
|
{/* Quick Stats Grid */}
|
||||||
<MonitoringQuickStats
|
<MonitoringQuickStats
|
||||||
systemHealth={systemHealth.data ?? undefined}
|
systemHealth={systemHealth.data ?? undefined}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
-- Fix search_path security warnings - drop triggers first, then recreate functions
|
||||||
|
|
||||||
|
-- Drop triggers
|
||||||
|
DROP TRIGGER IF EXISTS trigger_set_incident_number ON incidents;
|
||||||
|
DROP TRIGGER IF EXISTS trigger_update_incident_alert_count ON incident_alerts;
|
||||||
|
|
||||||
|
-- Drop functions
|
||||||
|
DROP FUNCTION IF EXISTS generate_incident_number();
|
||||||
|
DROP FUNCTION IF EXISTS set_incident_number();
|
||||||
|
DROP FUNCTION IF EXISTS update_incident_alert_count();
|
||||||
|
|
||||||
|
-- Recreate functions with proper search_path
|
||||||
|
CREATE OR REPLACE FUNCTION generate_incident_number()
|
||||||
|
RETURNS TEXT
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN 'INC-' || LPAD(nextval('incident_number_seq')::TEXT, 6, '0');
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION set_incident_number()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.incident_number IS NULL THEN
|
||||||
|
NEW.incident_number := generate_incident_number();
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION update_incident_alert_count()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'INSERT' THEN
|
||||||
|
UPDATE incidents
|
||||||
|
SET alert_count = alert_count + 1,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = NEW.incident_id;
|
||||||
|
ELSIF TG_OP = 'DELETE' THEN
|
||||||
|
UPDATE incidents
|
||||||
|
SET alert_count = alert_count - 1,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = OLD.incident_id;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Recreate triggers
|
||||||
|
CREATE TRIGGER trigger_set_incident_number
|
||||||
|
BEFORE INSERT ON incidents
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION set_incident_number();
|
||||||
|
|
||||||
|
CREATE TRIGGER trigger_update_incident_alert_count
|
||||||
|
AFTER INSERT OR DELETE ON incident_alerts
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_incident_alert_count();
|
||||||
Reference in New Issue
Block a user