mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-24 02:31:12 -05:00
Implement ML Anomaly Detection
Introduce statistical anomaly detection for metrics via edge function, hooks, and UI components. Adds detection algorithms (z-score, moving average, rate of change), anomaly storage, auto-alerts, and dashboard rendering of detected anomalies with run-once trigger and scheduling guidance.
This commit is contained in:
169
src/components/admin/AnomalyDetectionPanel.tsx
Normal file
169
src/components/admin/AnomalyDetectionPanel.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Brain, TrendingUp, TrendingDown, Activity, AlertTriangle, Play, Sparkles } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import type { AnomalyDetection } from '@/hooks/admin/useAnomalyDetection';
|
||||
import { useRunAnomalyDetection } from '@/hooks/admin/useAnomalyDetection';
|
||||
|
||||
interface AnomalyDetectionPanelProps {
|
||||
anomalies?: AnomalyDetection[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const ANOMALY_TYPE_CONFIG = {
|
||||
spike: { icon: TrendingUp, label: 'Spike', color: 'text-orange-500' },
|
||||
drop: { icon: TrendingDown, label: 'Drop', color: 'text-blue-500' },
|
||||
trend_change: { icon: Activity, label: 'Trend Change', color: 'text-purple-500' },
|
||||
outlier: { icon: AlertTriangle, label: 'Outlier', color: 'text-yellow-500' },
|
||||
pattern_break: { icon: Activity, label: 'Pattern Break', color: 'text-red-500' },
|
||||
};
|
||||
|
||||
const SEVERITY_CONFIG = {
|
||||
critical: { badge: 'destructive', label: 'Critical' },
|
||||
high: { badge: 'default', label: 'High' },
|
||||
medium: { badge: 'secondary', label: 'Medium' },
|
||||
low: { badge: 'outline', label: 'Low' },
|
||||
};
|
||||
|
||||
export function AnomalyDetectionPanel({ anomalies, isLoading }: AnomalyDetectionPanelProps) {
|
||||
const runDetection = useRunAnomalyDetection();
|
||||
|
||||
const handleRunDetection = () => {
|
||||
runDetection.mutate();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Brain className="h-5 w-5" />
|
||||
ML Anomaly Detection
|
||||
</CardTitle>
|
||||
<CardDescription>Loading anomaly data...</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>
|
||||
);
|
||||
}
|
||||
|
||||
const recentAnomalies = anomalies?.slice(0, 5) || [];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<Brain className="h-5 w-5" />
|
||||
ML Anomaly Detection
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{anomalies && anomalies.length > 0 && (
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
{anomalies.length} detected (24h)
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRunDetection}
|
||||
disabled={runDetection.isPending}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
Run Detection
|
||||
</Button>
|
||||
</div>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Statistical ML algorithms detecting unusual patterns in metrics
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{recentAnomalies.length === 0 ? (
|
||||
<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>No anomalies detected in last 24 hours</p>
|
||||
<p className="text-sm">ML models are monitoring metrics continuously</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{recentAnomalies.map((anomaly) => {
|
||||
const typeConfig = ANOMALY_TYPE_CONFIG[anomaly.anomaly_type];
|
||||
const severityConfig = SEVERITY_CONFIG[anomaly.severity];
|
||||
const TypeIcon = typeConfig.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={anomaly.id}
|
||||
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">
|
||||
<TypeIcon className={`h-5 w-5 mt-0.5 ${typeConfig.color}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<Badge variant={severityConfig.badge as any} className="text-xs">
|
||||
{severityConfig.label}
|
||||
</Badge>
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-purple-500/10 text-purple-600">
|
||||
{typeConfig.label}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{anomaly.metric_name.replace(/_/g, ' ')}
|
||||
</span>
|
||||
{anomaly.alert_created && (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-green-500/10 text-green-600">
|
||||
Alert Created
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm space-y-1">
|
||||
<div className="flex items-center gap-4 text-muted-foreground">
|
||||
<span>
|
||||
Baseline: <span className="font-medium text-foreground">{anomaly.baseline_value.toFixed(2)}</span>
|
||||
</span>
|
||||
<span>→</span>
|
||||
<span>
|
||||
Detected: <span className="font-medium text-foreground">{anomaly.anomaly_value.toFixed(2)}</span>
|
||||
</span>
|
||||
<span className="ml-2 px-2 py-0.5 rounded bg-orange-500/10 text-orange-600 text-xs font-medium">
|
||||
{anomaly.deviation_score.toFixed(2)}σ
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Brain className="h-3 w-3" />
|
||||
Algorithm: {anomaly.detection_algorithm.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<span>
|
||||
Confidence: {(anomaly.confidence_score * 100).toFixed(0)}%
|
||||
</span>
|
||||
<span>
|
||||
Detected {formatDistanceToNow(new Date(anomaly.detected_at), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{anomalies && anomalies.length > 5 && (
|
||||
<div className="text-center pt-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
+ {anomalies.length - 5} more anomalies
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
101
src/hooks/admin/useAnomalyDetection.ts
Normal file
101
src/hooks/admin/useAnomalyDetection.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export interface AnomalyDetection {
|
||||
id: string;
|
||||
metric_name: string;
|
||||
metric_category: string;
|
||||
anomaly_type: 'spike' | 'drop' | 'trend_change' | 'outlier' | 'pattern_break';
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
baseline_value: number;
|
||||
anomaly_value: number;
|
||||
deviation_score: number;
|
||||
confidence_score: number;
|
||||
detection_algorithm: string;
|
||||
time_window_start: string;
|
||||
time_window_end: string;
|
||||
detected_at: string;
|
||||
alert_created: boolean;
|
||||
alert_id?: string;
|
||||
alert_message?: string;
|
||||
alert_resolved_at?: string;
|
||||
}
|
||||
|
||||
export function useAnomalyDetections() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.monitoring.anomalyDetections(),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('recent_anomalies_view')
|
||||
.select('*')
|
||||
.order('detected_at', { ascending: false })
|
||||
.limit(50);
|
||||
|
||||
if (error) throw error;
|
||||
return (data || []) as AnomalyDetection[];
|
||||
},
|
||||
staleTime: 30000,
|
||||
refetchInterval: 60000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRunAnomalyDetection() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data, error } = await supabase.functions.invoke('detect-anomalies', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.monitoring.anomalyDetections() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.monitoring.groupedAlerts() });
|
||||
|
||||
if (data.anomalies_detected > 0) {
|
||||
toast.success(`Detected ${data.anomalies_detected} anomalies`);
|
||||
} else {
|
||||
toast.info('No anomalies detected');
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to run anomaly detection:', error);
|
||||
toast.error('Failed to run anomaly detection');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRecordMetric() {
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
metricName,
|
||||
metricCategory,
|
||||
metricValue,
|
||||
metadata,
|
||||
}: {
|
||||
metricName: string;
|
||||
metricCategory: string;
|
||||
metricValue: number;
|
||||
metadata?: any;
|
||||
}) => {
|
||||
const { error } = await supabase
|
||||
.from('metric_time_series')
|
||||
.insert({
|
||||
metric_name: metricName,
|
||||
metric_category: metricCategory,
|
||||
metric_value: metricValue,
|
||||
metadata,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to record metric:', error);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -202,6 +202,111 @@ export type Database = {
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
anomaly_detection_config: {
|
||||
Row: {
|
||||
alert_threshold_score: number
|
||||
auto_create_alert: boolean
|
||||
created_at: string
|
||||
detection_algorithms: string[]
|
||||
enabled: boolean
|
||||
id: string
|
||||
lookback_window_minutes: number
|
||||
metric_category: string
|
||||
metric_name: string
|
||||
min_data_points: number
|
||||
sensitivity: number
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
alert_threshold_score?: number
|
||||
auto_create_alert?: boolean
|
||||
created_at?: string
|
||||
detection_algorithms?: string[]
|
||||
enabled?: boolean
|
||||
id?: string
|
||||
lookback_window_minutes?: number
|
||||
metric_category: string
|
||||
metric_name: string
|
||||
min_data_points?: number
|
||||
sensitivity?: number
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
alert_threshold_score?: number
|
||||
auto_create_alert?: boolean
|
||||
created_at?: string
|
||||
detection_algorithms?: string[]
|
||||
enabled?: boolean
|
||||
id?: string
|
||||
lookback_window_minutes?: number
|
||||
metric_category?: string
|
||||
metric_name?: string
|
||||
min_data_points?: number
|
||||
sensitivity?: number
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
anomaly_detections: {
|
||||
Row: {
|
||||
alert_created: boolean
|
||||
alert_id: string | null
|
||||
anomaly_type: string
|
||||
anomaly_value: number
|
||||
baseline_value: number
|
||||
confidence_score: number
|
||||
created_at: string
|
||||
detected_at: string
|
||||
detection_algorithm: string
|
||||
deviation_score: number
|
||||
id: string
|
||||
metadata: Json | null
|
||||
metric_category: string
|
||||
metric_name: string
|
||||
severity: string
|
||||
time_window_end: string
|
||||
time_window_start: string
|
||||
}
|
||||
Insert: {
|
||||
alert_created?: boolean
|
||||
alert_id?: string | null
|
||||
anomaly_type: string
|
||||
anomaly_value: number
|
||||
baseline_value: number
|
||||
confidence_score: number
|
||||
created_at?: string
|
||||
detected_at?: string
|
||||
detection_algorithm: string
|
||||
deviation_score: number
|
||||
id?: string
|
||||
metadata?: Json | null
|
||||
metric_category: string
|
||||
metric_name: string
|
||||
severity: string
|
||||
time_window_end: string
|
||||
time_window_start: string
|
||||
}
|
||||
Update: {
|
||||
alert_created?: boolean
|
||||
alert_id?: string | null
|
||||
anomaly_type?: string
|
||||
anomaly_value?: number
|
||||
baseline_value?: number
|
||||
confidence_score?: number
|
||||
created_at?: string
|
||||
detected_at?: string
|
||||
detection_algorithm?: string
|
||||
deviation_score?: number
|
||||
id?: string
|
||||
metadata?: Json | null
|
||||
metric_category?: string
|
||||
metric_name?: string
|
||||
severity?: string
|
||||
time_window_end?: string
|
||||
time_window_start?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
approval_transaction_metrics: {
|
||||
Row: {
|
||||
created_at: string | null
|
||||
@@ -1894,6 +1999,36 @@ export type Database = {
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
metric_time_series: {
|
||||
Row: {
|
||||
created_at: string
|
||||
id: string
|
||||
metadata: Json | null
|
||||
metric_category: string
|
||||
metric_name: string
|
||||
metric_value: number
|
||||
timestamp: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
id?: string
|
||||
metadata?: Json | null
|
||||
metric_category: string
|
||||
metric_name: string
|
||||
metric_value: number
|
||||
timestamp?: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
id?: string
|
||||
metadata?: Json | null
|
||||
metric_category?: string
|
||||
metric_name?: string
|
||||
metric_value?: number
|
||||
timestamp?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
moderation_audit_log: {
|
||||
Row: {
|
||||
action: string
|
||||
@@ -6270,6 +6405,28 @@ export type Database = {
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
recent_anomalies_view: {
|
||||
Row: {
|
||||
alert_created: boolean | null
|
||||
alert_id: string | null
|
||||
alert_message: string | null
|
||||
alert_resolved_at: string | null
|
||||
anomaly_type: string | null
|
||||
anomaly_value: number | null
|
||||
baseline_value: number | null
|
||||
confidence_score: number | null
|
||||
detected_at: string | null
|
||||
detection_algorithm: string | null
|
||||
deviation_score: number | null
|
||||
id: string | null
|
||||
metric_category: string | null
|
||||
metric_name: string | null
|
||||
severity: string | null
|
||||
time_window_end: string | null
|
||||
time_window_start: string | null
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
}
|
||||
Functions: {
|
||||
anonymize_user_submissions: {
|
||||
|
||||
@@ -95,5 +95,6 @@ export const queryKeys = {
|
||||
correlatedAlerts: () => ['monitoring', 'correlated-alerts'] as const,
|
||||
incidents: (status?: string) => ['monitoring', 'incidents', status] as const,
|
||||
incidentDetails: (incidentId: string) => ['monitoring', 'incident-details', incidentId] as const,
|
||||
anomalyDetections: () => ['monitoring', 'anomaly-detections'] as const,
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SystemHealthStatus } from '@/components/admin/SystemHealthStatus';
|
||||
import { GroupedAlertsPanel } from '@/components/admin/GroupedAlertsPanel';
|
||||
import { CorrelatedAlertsPanel } from '@/components/admin/CorrelatedAlertsPanel';
|
||||
import { IncidentsPanel } from '@/components/admin/IncidentsPanel';
|
||||
import { AnomalyDetectionPanel } from '@/components/admin/AnomalyDetectionPanel';
|
||||
import { MonitoringQuickStats } from '@/components/admin/MonitoringQuickStats';
|
||||
import { RecentActivityTimeline } from '@/components/admin/RecentActivityTimeline';
|
||||
import { MonitoringNavCards } from '@/components/admin/MonitoringNavCards';
|
||||
@@ -13,6 +14,7 @@ import { useSystemHealth } from '@/hooks/useSystemHealth';
|
||||
import { useGroupedAlerts } from '@/hooks/admin/useGroupedAlerts';
|
||||
import { useCorrelatedAlerts } from '@/hooks/admin/useCorrelatedAlerts';
|
||||
import { useIncidents } from '@/hooks/admin/useIncidents';
|
||||
import { useAnomalyDetections } from '@/hooks/admin/useAnomalyDetection';
|
||||
import { useRecentActivity } from '@/hooks/admin/useRecentActivity';
|
||||
import { useDatabaseHealth } from '@/hooks/admin/useDatabaseHealth';
|
||||
import { useModerationHealth } from '@/hooks/admin/useModerationHealth';
|
||||
@@ -30,6 +32,7 @@ export default function MonitoringOverview() {
|
||||
const groupedAlerts = useGroupedAlerts({ includeResolved: false });
|
||||
const correlatedAlerts = useCorrelatedAlerts();
|
||||
const incidents = useIncidents('open');
|
||||
const anomalies = useAnomalyDetections();
|
||||
const recentActivity = useRecentActivity(3600000); // 1 hour
|
||||
const dbHealth = useDatabaseHealth();
|
||||
const moderationHealth = useModerationHealth();
|
||||
@@ -40,6 +43,7 @@ export default function MonitoringOverview() {
|
||||
groupedAlerts.isLoading ||
|
||||
correlatedAlerts.isLoading ||
|
||||
incidents.isLoading ||
|
||||
anomalies.isLoading ||
|
||||
recentActivity.isLoading ||
|
||||
dbHealth.isLoading ||
|
||||
moderationHealth.isLoading ||
|
||||
@@ -74,6 +78,10 @@ export default function MonitoringOverview() {
|
||||
queryKey: queryKeys.monitoring.incidents(),
|
||||
refetchType: 'active'
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.monitoring.anomalyDetections(),
|
||||
refetchType: 'active'
|
||||
});
|
||||
};
|
||||
|
||||
// Calculate error count for nav card (from recent activity)
|
||||
@@ -136,6 +144,12 @@ export default function MonitoringOverview() {
|
||||
isLoading={incidents.isLoading}
|
||||
/>
|
||||
|
||||
{/* ML Anomaly Detection */}
|
||||
<AnomalyDetectionPanel
|
||||
anomalies={anomalies.data}
|
||||
isLoading={anomalies.isLoading}
|
||||
/>
|
||||
|
||||
{/* Quick Stats Grid */}
|
||||
<MonitoringQuickStats
|
||||
systemHealth={systemHealth.data ?? undefined}
|
||||
|
||||
Reference in New Issue
Block a user