feat(ai-diagnostics): add admin UI insights page [4.1]
This commit is contained in:
parent
458d835e5a
commit
460ed6d0c0
709
dashboards/admin-web/src/app/(dashboard)/ai-diagnostics/page.tsx
Normal file
709
dashboards/admin-web/src/app/(dashboard)/ai-diagnostics/page.tsx
Normal file
@ -0,0 +1,709 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Brain,
|
||||||
|
Search,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
TrendingUp,
|
||||||
|
MessageSquare,
|
||||||
|
Sparkles,
|
||||||
|
ChevronRight,
|
||||||
|
BarChart3,
|
||||||
|
Activity,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
interface ErrorCluster {
|
||||||
|
id: string;
|
||||||
|
errorType: string;
|
||||||
|
message: string;
|
||||||
|
status: 'active' | 'investigating' | 'resolved' | 'ignored';
|
||||||
|
count: number;
|
||||||
|
firstSeen: string;
|
||||||
|
lastSeen: string;
|
||||||
|
platform: string;
|
||||||
|
productId: string;
|
||||||
|
latestInsight?: DiagnosticInsight;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiagnosticInsight {
|
||||||
|
id: string;
|
||||||
|
rootCause: string;
|
||||||
|
confidence: number;
|
||||||
|
impact: string;
|
||||||
|
nextSteps: string[];
|
||||||
|
suggestedCodeFix?: string;
|
||||||
|
patternSummary?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QueryResult {
|
||||||
|
query: string;
|
||||||
|
aiResponse: string;
|
||||||
|
confidence: number;
|
||||||
|
supportingData: Array<{
|
||||||
|
type: string;
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
relevanceScore: number;
|
||||||
|
}>;
|
||||||
|
suggestedActions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProactiveAlert {
|
||||||
|
id: string;
|
||||||
|
alertType: string;
|
||||||
|
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
clusterId?: string;
|
||||||
|
acknowledged: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AIDiagnosticsPage() {
|
||||||
|
// State
|
||||||
|
const [clusters, setClusters] = useState<ErrorCluster[]>([]);
|
||||||
|
const [alerts, setAlerts] = useState<ProactiveAlert[]>([]);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedProduct, setSelectedProduct] = useState<string>('all');
|
||||||
|
const [selectedCluster, setSelectedCluster] = useState<ErrorCluster | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<'overview' | 'clusters' | 'alerts' | 'query'>('overview');
|
||||||
|
|
||||||
|
// Fetch clusters on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchClusters();
|
||||||
|
fetchAlerts();
|
||||||
|
}, [selectedProduct]);
|
||||||
|
|
||||||
|
const fetchClusters = async () => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (selectedProduct !== 'all') params.set('productId', selectedProduct);
|
||||||
|
params.set('limit', '50');
|
||||||
|
params.set('includeInsights', 'true');
|
||||||
|
|
||||||
|
const res = await fetch(`/api/ai-diagnostics/clusters?${params}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch clusters');
|
||||||
|
const data = await res.json();
|
||||||
|
setClusters(data.clusters || []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch clusters');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAlerts = async () => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (selectedProduct !== 'all') params.set('productId', selectedProduct);
|
||||||
|
|
||||||
|
const res = await fetch(`/api/ai-diagnostics/alerts?${params}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch alerts');
|
||||||
|
const data = await res.json();
|
||||||
|
setAlerts(data.alerts || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch alerts:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuery = async () => {
|
||||||
|
if (!query.trim()) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai-diagnostics/query', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
query,
|
||||||
|
productId: selectedProduct === 'all' ? undefined : selectedProduct,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error('Query failed');
|
||||||
|
const data = await res.json();
|
||||||
|
setQueryResult(data.result);
|
||||||
|
setActiveTab('query');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Query failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||||
|
active: 'destructive',
|
||||||
|
investigating: 'default',
|
||||||
|
resolved: 'secondary',
|
||||||
|
ignored: 'outline',
|
||||||
|
};
|
||||||
|
return <Badge variant={variants[status] || 'default'}>{status}</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSeverityBadge = (severity: string) => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
critical: 'bg-red-500',
|
||||||
|
high: 'bg-orange-500',
|
||||||
|
medium: 'bg-yellow-500',
|
||||||
|
low: 'bg-blue-500',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Badge className={colors[severity] || 'bg-gray-500'}>
|
||||||
|
{severity}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Overview stats
|
||||||
|
const activeClusters = clusters.filter(c => c.status === 'active').length;
|
||||||
|
const investigatingClusters = clusters.filter(c => c.status === 'investigating').length;
|
||||||
|
const resolvedClusters = clusters.filter(c => c.status === 'resolved').length;
|
||||||
|
const unacknowledgedAlerts = alerts.filter(a => !a.acknowledged).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||||
|
<Brain className="h-6 w-6 text-purple-500" />
|
||||||
|
AI Diagnostic Assistant
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
AI-powered root cause analysis and error investigation
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={selectedProduct} onValueChange={setSelectedProduct}>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue placeholder="All Products" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Products</SelectItem>
|
||||||
|
<SelectItem value="lysnrai">LysnrAI</SelectItem>
|
||||||
|
<SelectItem value="mindlyst">MindLyst</SelectItem>
|
||||||
|
<SelectItem value="chronomind">ChronoMind</SelectItem>
|
||||||
|
<SelectItem value="nomgap">NomGap</SelectItem>
|
||||||
|
<SelectItem value="jarvisjr">JarvisJr</SelectItem>
|
||||||
|
<SelectItem value="peakpulse">PeakPulse</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Alert */}
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Natural Language Query */}
|
||||||
|
<Card className="border-purple-200 bg-purple-50/50">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Ask AI about errors (e.g., 'Why did iOS keyboard crash yesterday?')"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleQuery()}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleQuery}
|
||||||
|
disabled={loading || !query.trim()}
|
||||||
|
className="bg-purple-600 hover:bg-purple-700"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Sparkles className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Ask AI
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-3 text-sm text-muted-foreground">
|
||||||
|
<span>Try:</span>
|
||||||
|
{[
|
||||||
|
'What are the top errors this week?',
|
||||||
|
'Compare iOS vs Android crash rates',
|
||||||
|
'Show new error types today',
|
||||||
|
'Why did auth failures spike?',
|
||||||
|
].map((suggestion) => (
|
||||||
|
<button
|
||||||
|
key={suggestion}
|
||||||
|
onClick={() => setQuery(suggestion)}
|
||||||
|
className="text-purple-600 hover:underline"
|
||||||
|
>
|
||||||
|
{suggestion}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-2 border-b">
|
||||||
|
{[
|
||||||
|
{ id: 'overview', label: 'Overview', icon: BarChart3 },
|
||||||
|
{ id: 'clusters', label: `Error Clusters (${clusters.length})`, icon: AlertTriangle },
|
||||||
|
{ id: 'alerts', label: `Alerts (${unacknowledgedAlerts})`, icon: Activity },
|
||||||
|
...(queryResult ? [{ id: 'query', label: 'Query Result', icon: MessageSquare }] : []),
|
||||||
|
].map(({ id, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => setActiveTab(id as typeof activeTab)}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 border-b-2 transition-colors ${
|
||||||
|
activeTab === id
|
||||||
|
? 'border-purple-500 text-purple-600'
|
||||||
|
: 'border-transparent hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overview Tab */}
|
||||||
|
{activeTab === 'overview' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Active Clusters
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold text-red-600">{activeClusters}</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Errors requiring attention
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Investigating
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold text-yellow-600">{investigatingClusters}</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Under investigation
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Resolved
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold text-green-600">{resolvedClusters}</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Issues resolved this week
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
AI Insights
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold text-purple-600">
|
||||||
|
{clusters.filter(c => c.latestInsight).length}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
With AI analysis
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Active Clusters */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Most Critical Active Clusters</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{clusters
|
||||||
|
.filter(c => c.status === 'active')
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((cluster) => (
|
||||||
|
<div
|
||||||
|
key={cluster.id}
|
||||||
|
className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50 cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCluster(cluster);
|
||||||
|
setActiveTab('clusters');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-red-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{cluster.errorType}</div>
|
||||||
|
<div className="text-sm text-muted-foreground line-clamp-1">
|
||||||
|
{cluster.message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Badge variant="secondary">{cluster.count} occurrences</Badge>
|
||||||
|
{cluster.latestInsight && (
|
||||||
|
<Badge className="bg-purple-100 text-purple-800">
|
||||||
|
<Sparkles className="h-3 w-3 mr-1" />
|
||||||
|
AI Analysis
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{activeClusters === 0 && (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<CheckCircle className="h-12 w-12 mx-auto mb-2 text-green-500" />
|
||||||
|
<p>No active error clusters</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Recent Alerts */}
|
||||||
|
{unacknowledgedAlerts > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Unacknowledged Alerts</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{alerts
|
||||||
|
.filter(a => !a.acknowledged)
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((alert) => (
|
||||||
|
<div
|
||||||
|
key={alert.id}
|
||||||
|
className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{getSeverityBadge(alert.severity)}
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{alert.title}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{alert.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Acknowledge
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Clusters Tab */}
|
||||||
|
{activeTab === 'clusters' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Error Clusters</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{clusters.map((cluster) => (
|
||||||
|
<Dialog key={cluster.id}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<div className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50 cursor-pointer">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{getStatusBadge(cluster.status)}
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{cluster.errorType}</div>
|
||||||
|
<div className="text-sm text-muted-foreground line-clamp-1">
|
||||||
|
{cluster.message}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-1 text-xs text-muted-foreground">
|
||||||
|
<span>{cluster.platform}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{cluster.productId}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Last: {new Date(cluster.lastSeen).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Badge variant="secondary">{cluster.count} occurrences</Badge>
|
||||||
|
{cluster.latestInsight && (
|
||||||
|
<Badge className="bg-purple-100 text-purple-800">
|
||||||
|
<Sparkles className="h-3 w-3 mr-1" />
|
||||||
|
AI
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
{cluster.errorType}
|
||||||
|
{getStatusBadge(cluster.status)}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-1">Error Message</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">{cluster.message}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-1">Platform</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">{cluster.platform}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-1">Product</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">{cluster.productId}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-1">First Seen</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{new Date(cluster.firstSeen).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-1">Last Seen</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{new Date(cluster.lastSeen).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-1">Occurrences</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">{cluster.count}</p>
|
||||||
|
</div>
|
||||||
|
{cluster.latestInsight && (
|
||||||
|
<div className="bg-purple-50 p-4 rounded-lg">
|
||||||
|
<h4 className="font-medium mb-2 flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-purple-600" />
|
||||||
|
AI Analysis
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Root Cause: </span>
|
||||||
|
{cluster.latestInsight.rootCause}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Confidence: </span>
|
||||||
|
{(cluster.latestInsight.confidence * 100).toFixed(0)}%
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Impact: </span>
|
||||||
|
{cluster.latestInsight.impact}
|
||||||
|
</div>
|
||||||
|
{cluster.latestInsight.nextSteps.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Next Steps: </span>
|
||||||
|
<ul className="list-disc list-inside mt-1">
|
||||||
|
{cluster.latestInsight.nextSteps.map((step, i) => (
|
||||||
|
<li key={i}>{step}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
// Trigger re-analysis
|
||||||
|
fetch(`/api/ai-diagnostics/clusters/${cluster.id}/analyze`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ productId: cluster.productId }),
|
||||||
|
}).then(() => fetchClusters());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Sparkles className="h-4 w-4 mr-2" />
|
||||||
|
Re-Analyze with AI
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
))}
|
||||||
|
{clusters.length === 0 && !loading && (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<CheckCircle className="h-12 w-12 mx-auto mb-2 text-green-500" />
|
||||||
|
<p>No error clusters found</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Alerts Tab */}
|
||||||
|
{activeTab === 'alerts' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Proactive Alerts</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{alerts.map((alert) => (
|
||||||
|
<div
|
||||||
|
key={alert.id}
|
||||||
|
className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{getSeverityBadge(alert.severity)}
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{alert.title}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{alert.description}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-1 text-xs text-muted-foreground">
|
||||||
|
<span>{alert.alertType}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
<span>{new Date(alert.createdAt).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!alert.acknowledged && (
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Acknowledge
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{alert.acknowledged && (
|
||||||
|
<Badge variant="outline">
|
||||||
|
<CheckCircle className="h-3 w-3 mr-1" />
|
||||||
|
Acknowledged
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{alerts.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<CheckCircle className="h-12 w-12 mx-auto mb-2 text-green-500" />
|
||||||
|
<p>No alerts at this time</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Query Result Tab */}
|
||||||
|
{activeTab === 'query' && queryResult && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<MessageSquare className="h-5 w-5" />
|
||||||
|
Query Result
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* AI Response */}
|
||||||
|
<div className="bg-purple-50 p-4 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-purple-600" />
|
||||||
|
<span className="font-medium">AI Response</span>
|
||||||
|
<Badge variant="outline">
|
||||||
|
Confidence: {(queryResult.confidence * 100).toFixed(0)}%
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{queryResult.aiResponse}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Supporting Data */}
|
||||||
|
{queryResult.supportingData.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-2">Supporting Data</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{queryResult.supportingData.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="flex items-center justify-between p-3 border rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{item.type === 'cluster' && <AlertTriangle className="h-4 w-4 text-red-500" />}
|
||||||
|
{item.type === 'insight' && <Sparkles className="h-4 w-4 text-purple-500" />}
|
||||||
|
{item.type === 'trend' && <TrendingUp className="h-4 w-4 text-blue-500" />}
|
||||||
|
<span className="font-medium">{item.title}</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline">
|
||||||
|
Relevance: {(item.relevanceScore * 100).toFixed(0)}%
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Suggested Actions */}
|
||||||
|
{queryResult.suggestedActions.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-2">Suggested Actions</h4>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{queryResult.suggestedActions.map((action, i) => (
|
||||||
|
<li key={i} className="flex items-center gap-2 text-sm">
|
||||||
|
<ChevronRight className="h-4 w-4 text-purple-500" />
|
||||||
|
{action}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,442 @@
|
|||||||
|
/**
|
||||||
|
* Automated Triggers for Remote Diagnostics
|
||||||
|
* Error-threshold triggers and crash-triggered auto sessions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { DebugSessionDoc } from './types.js';
|
||||||
|
import { createSession, updateSessionStats } from './repository.js';
|
||||||
|
import { getRegisteredContainer } from '@bytelyst/cosmos';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Trigger Configuration Types
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const TriggerConfigSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
productId: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
|
||||||
|
// Trigger conditions
|
||||||
|
condition: z.discriminatedUnion('type', [
|
||||||
|
z.object({
|
||||||
|
type: z.literal('error_rate'),
|
||||||
|
threshold: z.number().min(0).max(1), // 0.0 - 1.0 (0% to 100%)
|
||||||
|
windowMinutes: z.number().min(1).max(60),
|
||||||
|
minEvents: z.number().min(10), // Minimum events before triggering
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('crash_count'),
|
||||||
|
threshold: z.number().min(1),
|
||||||
|
windowMinutes: z.number().min(1).max(60),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('fatal_log'),
|
||||||
|
count: z.number().min(1),
|
||||||
|
windowMinutes: z.number().min(1).max(60),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
|
||||||
|
// Session to create when triggered
|
||||||
|
sessionConfig: z.object({
|
||||||
|
collectionLevel: z.enum(['standard', 'debug', 'trace']).default('debug'),
|
||||||
|
captureLogs: z.boolean().default(true),
|
||||||
|
captureNetwork: z.boolean().default(true),
|
||||||
|
captureScreenshots: z.boolean().default(true),
|
||||||
|
screenshotOnError: z.boolean().default(true),
|
||||||
|
maxDurationMinutes: z.number().min(5).max(1440).default(60),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
notifications: z.object({
|
||||||
|
slackWebhook?: z.string().optional(),
|
||||||
|
teamsWebhook?: z.string().optional(),
|
||||||
|
emailAdmins: z.boolean().default(true),
|
||||||
|
pagerDutyKey?: z.string().optional(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Cooldown to prevent spam
|
||||||
|
cooldownMinutes: z.number().min(5).max(1440).default(60),
|
||||||
|
lastTriggeredAt: z.string().datetime().optional(),
|
||||||
|
|
||||||
|
createdAt: z.string().datetime(),
|
||||||
|
updatedAt: z.string().datetime(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TriggerConfig = z.infer<typeof TriggerConfigSchema>;
|
||||||
|
|
||||||
|
export const CreateTriggerConfigSchema = TriggerConfigSchema.omit({
|
||||||
|
id: true,
|
||||||
|
lastTriggeredAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateTriggerConfigInput = z.infer<typeof CreateTriggerConfigSchema>;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Repository Functions
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TRIGGER_CONTAINER = 'diagnostic_triggers';
|
||||||
|
|
||||||
|
export async function getTriggerContainer() {
|
||||||
|
return getRegisteredContainer(TRIGGER_CONTAINER);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTriggerConfig(
|
||||||
|
input: CreateTriggerConfigInput
|
||||||
|
): Promise<TriggerConfig> {
|
||||||
|
const container = await getTriggerContainer();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const config: TriggerConfig = {
|
||||||
|
id: `trig_${crypto.randomUUID().replace(/-/g, '')}`,
|
||||||
|
...input,
|
||||||
|
lastTriggeredAt: undefined,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await container.items.create(config);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTriggerConfig(id: string): Promise<TriggerConfig | null> {
|
||||||
|
const container = await getTriggerContainer();
|
||||||
|
try {
|
||||||
|
const { resource } = await container.item(id, id).read<TriggerConfig>();
|
||||||
|
return resource || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTriggerConfigs(productId: string): Promise<TriggerConfig[]> {
|
||||||
|
const container = await getTriggerContainer();
|
||||||
|
const query = {
|
||||||
|
query: 'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.createdAt DESC',
|
||||||
|
parameters: [{ name: '@productId', value: productId }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { resources } = await container.items.query<TriggerConfig>(query).fetchAll();
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTriggerConfig(
|
||||||
|
id: string,
|
||||||
|
updates: Partial<Omit<TriggerConfig, 'id' | 'productId' | 'createdAt'>>
|
||||||
|
): Promise<TriggerConfig | null> {
|
||||||
|
const container = await getTriggerContainer();
|
||||||
|
|
||||||
|
const existing = await getTriggerConfig(id);
|
||||||
|
if (!existing) return null;
|
||||||
|
|
||||||
|
const updated: TriggerConfig = {
|
||||||
|
...existing,
|
||||||
|
...updates,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await container.item(id, id).replace(updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTriggerConfig(id: string): Promise<boolean> {
|
||||||
|
const container = await getTriggerContainer();
|
||||||
|
try {
|
||||||
|
await container.item(id, id).delete();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordTriggerExecution(
|
||||||
|
id: string,
|
||||||
|
sessionId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const container = await getTriggerContainer();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
await container.item(id, id).patch({
|
||||||
|
operations: [
|
||||||
|
{ op: 'set', path: '/lastTriggeredAt', value: now },
|
||||||
|
{ op: 'incr', path: '/triggerCount', value: 1 },
|
||||||
|
{ op: 'set', path: '/lastSessionId', value: sessionId },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Trigger Evaluation Logic
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ErrorStats {
|
||||||
|
totalEvents: number;
|
||||||
|
errorCount: number;
|
||||||
|
fatalCount: number;
|
||||||
|
crashCount: number;
|
||||||
|
errorRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query error statistics from telemetry events.
|
||||||
|
*/
|
||||||
|
export async function queryErrorStats(
|
||||||
|
productId: string,
|
||||||
|
windowMinutes: number
|
||||||
|
): Promise<ErrorStats> {
|
||||||
|
const telemetryContainer = await getRegisteredContainer('telemetry_events');
|
||||||
|
const since = new Date(Date.now() - windowMinutes * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
// Query for error events
|
||||||
|
const errorQuery = {
|
||||||
|
query: `
|
||||||
|
SELECT VALUE COUNT(1)
|
||||||
|
FROM c
|
||||||
|
WHERE c.productId = @productId
|
||||||
|
AND c.timestamp >= @since
|
||||||
|
AND c.eventType = 'error'
|
||||||
|
`,
|
||||||
|
parameters: [
|
||||||
|
{ name: '@productId', value: productId },
|
||||||
|
{ name: '@since', value: since },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Query for total events
|
||||||
|
const totalQuery = {
|
||||||
|
query: `
|
||||||
|
SELECT VALUE COUNT(1)
|
||||||
|
FROM c
|
||||||
|
WHERE c.productId = @productId
|
||||||
|
AND c.timestamp >= @since
|
||||||
|
`,
|
||||||
|
parameters: [
|
||||||
|
{ name: '@productId', value: productId },
|
||||||
|
{ name: '@since', value: since },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Query for fatal logs from diagnostics
|
||||||
|
const fatalQuery = {
|
||||||
|
query: `
|
||||||
|
SELECT VALUE COUNT(1)
|
||||||
|
FROM c
|
||||||
|
WHERE c.productId = @productId
|
||||||
|
AND c.timestamp >= @since
|
||||||
|
AND c.level = 'fatal'
|
||||||
|
`,
|
||||||
|
parameters: [
|
||||||
|
{ name: '@productId', value: productId },
|
||||||
|
{ name: '@since', value: since },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const [{ resources: errorResult }, { resources: totalResult }, { resources: fatalResult }] =
|
||||||
|
await Promise.all([
|
||||||
|
telemetryContainer.items.query(errorQuery).fetchNext(),
|
||||||
|
telemetryContainer.items.query(totalQuery).fetchNext(),
|
||||||
|
telemetryContainer.items.query(fatalQuery).fetchNext(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const errorCount = errorResult[0] || 0;
|
||||||
|
const totalEvents = totalResult[0] || 0;
|
||||||
|
const fatalCount = fatalResult[0] || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalEvents,
|
||||||
|
errorCount,
|
||||||
|
fatalCount,
|
||||||
|
crashCount: fatalCount, // Treat fatal as crashes for now
|
||||||
|
errorRate: totalEvents > 0 ? errorCount / totalEvents : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate a single trigger configuration.
|
||||||
|
*/
|
||||||
|
export async function evaluateTrigger(
|
||||||
|
trigger: TriggerConfig,
|
||||||
|
adminUserId: string
|
||||||
|
): Promise<{ triggered: boolean; reason?: string; session?: DebugSessionDoc }> {
|
||||||
|
// Check cooldown
|
||||||
|
if (trigger.lastTriggeredAt) {
|
||||||
|
const lastTrigger = new Date(trigger.lastTriggeredAt).getTime();
|
||||||
|
const cooldownMs = trigger.cooldownMinutes * 60 * 1000;
|
||||||
|
if (Date.now() - lastTrigger < cooldownMs) {
|
||||||
|
return { triggered: false, reason: 'Cooldown active' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if trigger is enabled
|
||||||
|
if (!trigger.enabled) {
|
||||||
|
return { triggered: false, reason: 'Trigger disabled' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await queryErrorStats(trigger.productId, trigger.condition.windowMinutes);
|
||||||
|
|
||||||
|
switch (trigger.condition.type) {
|
||||||
|
case 'error_rate': {
|
||||||
|
if (stats.totalEvents < trigger.condition.minEvents) {
|
||||||
|
return { triggered: false, reason: 'Insufficient events' };
|
||||||
|
}
|
||||||
|
if (stats.errorRate >= trigger.condition.threshold) {
|
||||||
|
// Trigger auto-session
|
||||||
|
const session = await createAutoSession(trigger, adminUserId);
|
||||||
|
await recordTriggerExecution(trigger.id, session.id);
|
||||||
|
await sendTriggerNotifications(trigger, session, stats);
|
||||||
|
return { triggered: true, reason: `Error rate ${(stats.errorRate * 100).toFixed(1)}% exceeded threshold ${(trigger.condition.threshold * 100).toFixed(1)}%`, session };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'crash_count': {
|
||||||
|
if (stats.crashCount >= trigger.condition.threshold) {
|
||||||
|
const session = await createAutoSession(trigger, adminUserId);
|
||||||
|
await recordTriggerExecution(trigger.id, session.id);
|
||||||
|
await sendTriggerNotifications(trigger, session, stats);
|
||||||
|
return { triggered: true, reason: `${stats.crashCount} crashes in ${trigger.condition.windowMinutes} minutes`, session };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'fatal_log': {
|
||||||
|
if (stats.fatalCount >= trigger.condition.count) {
|
||||||
|
const session = await createAutoSession(trigger, adminUserId);
|
||||||
|
await recordTriggerExecution(trigger.id, session.id);
|
||||||
|
await sendTriggerNotifications(trigger, session, stats);
|
||||||
|
return { triggered: true, reason: `${stats.fatalCount} fatal logs in ${trigger.condition.windowMinutes} minutes`, session };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { triggered: false, reason: 'Threshold not met' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an auto-triggered debug session.
|
||||||
|
*/
|
||||||
|
async function createAutoSession(
|
||||||
|
trigger: TriggerConfig,
|
||||||
|
adminUserId: string
|
||||||
|
): Promise<DebugSessionDoc> {
|
||||||
|
const session = await createSession({
|
||||||
|
productId: trigger.productId,
|
||||||
|
targetUserId: undefined, // Auto-sessions target all users
|
||||||
|
targetAnonymousId: undefined,
|
||||||
|
targetDeviceId: undefined,
|
||||||
|
targetSessionId: undefined,
|
||||||
|
status: 'active',
|
||||||
|
collectionLevel: trigger.sessionConfig.collectionLevel,
|
||||||
|
captureLogs: trigger.sessionConfig.captureLogs,
|
||||||
|
captureNetwork: trigger.sessionConfig.captureNetwork,
|
||||||
|
captureScreenshots: trigger.sessionConfig.captureScreenshots,
|
||||||
|
screenshotOnError: trigger.sessionConfig.screenshotOnError,
|
||||||
|
maxDurationMinutes: trigger.sessionConfig.maxDurationMinutes,
|
||||||
|
createdBy: adminUserId, // System/admin who created the trigger
|
||||||
|
autoTriggered: true,
|
||||||
|
triggerId: trigger.id,
|
||||||
|
triggerName: trigger.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send notifications when trigger fires.
|
||||||
|
*/
|
||||||
|
async function sendTriggerNotifications(
|
||||||
|
trigger: TriggerConfig,
|
||||||
|
session: DebugSessionDoc,
|
||||||
|
stats: ErrorStats
|
||||||
|
): Promise<void> {
|
||||||
|
const { notifications } = trigger;
|
||||||
|
|
||||||
|
// Slack webhook
|
||||||
|
if (notifications.slackWebhook) {
|
||||||
|
try {
|
||||||
|
await fetch(notifications.slackWebhook, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
text: `🚨 Auto-trigger fired: ${trigger.name}`,
|
||||||
|
attachments: [{
|
||||||
|
color: 'danger',
|
||||||
|
fields: [
|
||||||
|
{ title: 'Product', value: trigger.productId, short: true },
|
||||||
|
{ title: 'Session', value: session.id, short: true },
|
||||||
|
{ title: 'Error Rate', value: `${(stats.errorRate * 100).toFixed(1)}%`, short: true },
|
||||||
|
{ title: 'Crashes', value: stats.crashCount.toString(), short: true },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Log but don't fail
|
||||||
|
console.error('Failed to send Slack notification:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Teams webhook
|
||||||
|
if (notifications.teamsWebhook) {
|
||||||
|
try {
|
||||||
|
await fetch(notifications.teamsWebhook, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
'@type': 'MessageCard',
|
||||||
|
'@context': 'https://schema.org/extensions',
|
||||||
|
themeColor: 'FF0000',
|
||||||
|
title: `🚨 Auto-trigger fired: ${trigger.name}`,
|
||||||
|
text: `Error rate ${(stats.errorRate * 100).toFixed(1)}% exceeded threshold`,
|
||||||
|
sections: [{
|
||||||
|
facts: [
|
||||||
|
{ name: 'Product:', value: trigger.productId },
|
||||||
|
{ name: 'Session:', value: session.id },
|
||||||
|
{ name: 'Crashes:', value: stats.crashCount.toString() },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to send Teams notification:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PagerDuty (future)
|
||||||
|
if (notifications.pagerDutyKey) {
|
||||||
|
// Integration with PagerDuty Events API v2
|
||||||
|
// https://developer.pagerduty.com/docs/events-api-v2-overview/
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Background Job Runner
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run all triggers for a product.
|
||||||
|
* Called by the scheduled job every 5 minutes.
|
||||||
|
*/
|
||||||
|
export async function runAllTriggers(
|
||||||
|
productId: string,
|
||||||
|
adminUserId: string
|
||||||
|
): Promise<Array<{ triggerId: string; triggered: boolean; reason?: string }>> {
|
||||||
|
const triggers = await listTriggerConfigs(productId);
|
||||||
|
const results: Array<{ triggerId: string; triggered: boolean; reason?: string }> = [];
|
||||||
|
|
||||||
|
for (const trigger of triggers) {
|
||||||
|
const result = await evaluateTrigger(trigger, adminUserId);
|
||||||
|
results.push({
|
||||||
|
triggerId: trigger.id,
|
||||||
|
triggered: result.triggered,
|
||||||
|
reason: result.reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user