diff --git a/dashboards/admin-web/src/app/(dashboard)/ai-diagnostics/page.tsx b/dashboards/admin-web/src/app/(dashboard)/ai-diagnostics/page.tsx new file mode 100644 index 00000000..be6ec3e4 --- /dev/null +++ b/dashboards/admin-web/src/app/(dashboard)/ai-diagnostics/page.tsx @@ -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([]); + const [alerts, setAlerts] = useState([]); + const [query, setQuery] = useState(''); + const [queryResult, setQueryResult] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedProduct, setSelectedProduct] = useState('all'); + const [selectedCluster, setSelectedCluster] = useState(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 = { + active: 'destructive', + investigating: 'default', + resolved: 'secondary', + ignored: 'outline', + }; + return {status}; + }; + + const getSeverityBadge = (severity: string) => { + const colors: Record = { + critical: 'bg-red-500', + high: 'bg-orange-500', + medium: 'bg-yellow-500', + low: 'bg-blue-500', + }; + return ( + + {severity} + + ); + }; + + // 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 ( +
+ {/* Header */} +
+
+

+ + AI Diagnostic Assistant +

+

+ AI-powered root cause analysis and error investigation +

+
+
+ +
+
+ + {/* Error Alert */} + {error && ( + + + {error} + + )} + + {/* Natural Language Query */} + + +
+
+ + setQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleQuery()} + className="pl-9" + /> +
+ +
+
+ Try: + {[ + '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) => ( + + ))} +
+
+
+ + {/* Tabs */} +
+ {[ + { 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 }) => ( + + ))} +
+ + {/* Overview Tab */} + {activeTab === 'overview' && ( +
+ {/* Stats Grid */} +
+ + + + Active Clusters + + + +
{activeClusters}
+

+ Errors requiring attention +

+
+
+ + + + Investigating + + + +
{investigatingClusters}
+

+ Under investigation +

+
+
+ + + + Resolved + + + +
{resolvedClusters}
+

+ Issues resolved this week +

+
+
+ + + + AI Insights + + + +
+ {clusters.filter(c => c.latestInsight).length} +
+

+ With AI analysis +

+
+
+
+ + {/* Recent Active Clusters */} + + + Most Critical Active Clusters + + +
+ {clusters + .filter(c => c.status === 'active') + .slice(0, 5) + .map((cluster) => ( +
{ + setSelectedCluster(cluster); + setActiveTab('clusters'); + }} + > +
+ +
+
{cluster.errorType}
+
+ {cluster.message} +
+
+
+
+ {cluster.count} occurrences + {cluster.latestInsight && ( + + + AI Analysis + + )} + +
+
+ ))} + {activeClusters === 0 && ( +
+ +

No active error clusters

+
+ )} +
+
+
+ + {/* Recent Alerts */} + {unacknowledgedAlerts > 0 && ( + + + Unacknowledged Alerts + + +
+ {alerts + .filter(a => !a.acknowledged) + .slice(0, 3) + .map((alert) => ( +
+
+ {getSeverityBadge(alert.severity)} +
+
{alert.title}
+
+ {alert.description} +
+
+
+ +
+ ))} +
+
+
+ )} +
+ )} + + {/* Clusters Tab */} + {activeTab === 'clusters' && ( + + + Error Clusters + + +
+ {clusters.map((cluster) => ( + + +
+
+ {getStatusBadge(cluster.status)} +
+
{cluster.errorType}
+
+ {cluster.message} +
+
+ {cluster.platform} + + {cluster.productId} + + Last: {new Date(cluster.lastSeen).toLocaleDateString()} +
+
+
+
+ {cluster.count} occurrences + {cluster.latestInsight && ( + + + AI + + )} +
+
+
+ + + + {cluster.errorType} + {getStatusBadge(cluster.status)} + + +
+
+

Error Message

+

{cluster.message}

+
+
+
+

Platform

+

{cluster.platform}

+
+
+

Product

+

{cluster.productId}

+
+
+

First Seen

+

+ {new Date(cluster.firstSeen).toLocaleString()} +

+
+
+

Last Seen

+

+ {new Date(cluster.lastSeen).toLocaleString()} +

+
+
+
+

Occurrences

+

{cluster.count}

+
+ {cluster.latestInsight && ( +
+

+ + AI Analysis +

+
+
+ Root Cause: + {cluster.latestInsight.rootCause} +
+
+ Confidence: + {(cluster.latestInsight.confidence * 100).toFixed(0)}% +
+
+ Impact: + {cluster.latestInsight.impact} +
+ {cluster.latestInsight.nextSteps.length > 0 && ( +
+ Next Steps: +
    + {cluster.latestInsight.nextSteps.map((step, i) => ( +
  • {step}
  • + ))} +
+
+ )} +
+
+ )} +
+ +
+
+
+
+ ))} + {clusters.length === 0 && !loading && ( +
+ +

No error clusters found

+
+ )} +
+
+
+ )} + + {/* Alerts Tab */} + {activeTab === 'alerts' && ( + + + Proactive Alerts + + +
+ {alerts.map((alert) => ( +
+
+ {getSeverityBadge(alert.severity)} +
+
{alert.title}
+
+ {alert.description} +
+
+ {alert.alertType} + + + {new Date(alert.createdAt).toLocaleString()} +
+
+
+
+ {!alert.acknowledged && ( + + )} + {alert.acknowledged && ( + + + Acknowledged + + )} +
+
+ ))} + {alerts.length === 0 && ( +
+ +

No alerts at this time

+
+ )} +
+
+
+ )} + + {/* Query Result Tab */} + {activeTab === 'query' && queryResult && ( + + + + + Query Result + + + +
+ {/* AI Response */} +
+
+ + AI Response + + Confidence: {(queryResult.confidence * 100).toFixed(0)}% + +
+

{queryResult.aiResponse}

+
+ + {/* Supporting Data */} + {queryResult.supportingData.length > 0 && ( +
+

Supporting Data

+
+ {queryResult.supportingData.map((item) => ( +
+
+ {item.type === 'cluster' && } + {item.type === 'insight' && } + {item.type === 'trend' && } + {item.title} +
+ + Relevance: {(item.relevanceScore * 100).toFixed(0)}% + +
+ ))} +
+
+ )} + + {/* Suggested Actions */} + {queryResult.suggestedActions.length > 0 && ( +
+

Suggested Actions

+
    + {queryResult.suggestedActions.map((action, i) => ( +
  • + + {action} +
  • + ))} +
+
+ )} +
+
+
+ )} +
+ ); +} diff --git a/services/platform-service/src/modules/diagnostics/auto-triggers.ts b/services/platform-service/src/modules/diagnostics/auto-triggers.ts new file mode 100644 index 00000000..14847188 --- /dev/null +++ b/services/platform-service/src/modules/diagnostics/auto-triggers.ts @@ -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; + +export const CreateTriggerConfigSchema = TriggerConfigSchema.omit({ + id: true, + lastTriggeredAt: true, + createdAt: true, + updatedAt: true, +}); + +export type CreateTriggerConfigInput = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// Repository Functions +// ───────────────────────────────────────────────────────────────────────────── + +const TRIGGER_CONTAINER = 'diagnostic_triggers'; + +export async function getTriggerContainer() { + return getRegisteredContainer(TRIGGER_CONTAINER); +} + +export async function createTriggerConfig( + input: CreateTriggerConfigInput +): Promise { + 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 { + const container = await getTriggerContainer(); + try { + const { resource } = await container.item(id, id).read(); + return resource || null; + } catch { + return null; + } +} + +export async function listTriggerConfigs(productId: string): Promise { + 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(query).fetchAll(); + return resources; +} + +export async function updateTriggerConfig( + id: string, + updates: Partial> +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + 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; +}