mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 11:31:13 -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user