UNPKG

@kinvolk/headlamp-plugin

Version:

The needed infrastructure for building Headlamp plugins.

553 lines (501 loc) 19.5 kB
/** * ProactiveDiagnosisManager * * Manages proactive AI diagnosis of recent Warning/Error Kubernetes events. * - Periodically (every 5 minutes) fetches the top 15 warning/error events. * - Caches diagnoses keyed by event UID to avoid redundant AI calls. * - Emits events so React components can subscribe to updates. * - Runs diagnoses sequentially (server aborts concurrent SSE streams). */ import { EventEmitter } from 'events'; import { basePrompt } from '../ai/prompts'; export interface EventDigest { /** The event UID (metadata.uid) – primary cache key */ uid: string; /** Event name */ name: string; /** Warning / Error */ type: string; /** Reason field */ reason: string; /** Human-readable message */ message: string; /** Involved object kind */ objectKind: string; /** Involved object name */ objectName: string; /** Involved object namespace */ objectNamespace: string; /** Last timestamp of the event */ lastTimestamp: string; /** The raw event JSON for context */ rawEvent: any; } export interface DiagnosisThinkingStep { id: string; content: string; type: 'tool-start' | 'tool-result' | 'intermediate-text' | 'todo-update'; timestamp: number; } export interface DiagnosisResult { /** Matches EventDigest.uid */ eventUid: string; /** The event digest this diagnosis is for */ event: EventDigest; /** The AI-generated diagnosis text */ diagnosis: string; /** Timestamp when the diagnosis was generated */ diagnosedAt: number; /** Whether diagnosis is currently loading (actively being processed) */ loading: boolean; /** Whether this event is queued and waiting to be processed */ pending?: boolean; /** Error message if diagnosis failed */ error?: string; /** Intermediate thinking steps accumulated during diagnosis */ thinkingSteps?: DiagnosisThinkingStep[]; } /** Callback to report intermediate thinking steps during diagnosis */ export type DiagnosisStepCallback = (step: DiagnosisThinkingStep) => void; type DiagnoseFn = (prompt: string, onStep?: DiagnosisStepCallback) => Promise<string>; export class ProactiveDiagnosisManager extends EventEmitter { /** uid → DiagnosisResult */ private cache = new Map<string, DiagnosisResult>(); /** Interval handle */ private intervalHandle: ReturnType<typeof setInterval> | null = null; /** Whether a diagnosis cycle is currently running */ private running = false; /** Queue for single-event diagnosis requests that arrive while a cycle is running */ private singleEventQueue: Array<{ event: EventDigest; resolve: (r: DiagnosisResult) => void; reject: (e: any) => void; }> = []; /** The function to call to get a diagnosis from the AI */ private diagnoseFn: DiagnoseFn | null = null; /** Interval in ms (default 5 minutes) */ private intervalMs = 5 * 60 * 1000; /** Whether proactive diagnosis is enabled */ private enabled = false; /** The event UID the user wants to scroll to */ private _scrollToEventUid: string | null = null; /** * Persistent set of resource keys (kind/ns/name) that have been * successfully diagnosed. Survives cache changes so that a CronJob * re-emitting events under new UIDs won't be re-diagnosed. */ private diagnosedResourceKeys = new Set<string>(); /** * Build a unique key for the involved resource of an event. * Strips Kubernetes-generated hash suffixes and ignores objectKind so * that events across the Deployment → ReplicaSet → Pod hierarchy all * map to the same key. */ private static resourceKey(event: EventDigest): string { const baseName = ProactiveDiagnosisManager.stripHashSuffixes(event.objectName); return `${event.objectNamespace || '_'}/${baseName}`; } /** * Set the diagnosis function. This is called by the modal/AI manager * when the AI infrastructure is ready. */ setDiagnoseFn(fn: DiagnoseFn | null) { this.diagnoseFn = fn; } /** * Check whether a diagnosis (cycle or single-event) is currently running. * Used by the UI to block chat input during active diagnosis. */ isRunning(): boolean { return this.running; } /** * Start proactive diagnosis polling. * Safe to call multiple times – will not create duplicate intervals. */ start() { if (this.enabled) return; this.enabled = true; // Run immediately on start, then every intervalMs this.emit('status-change', { enabled: true }); } /** * Stop proactive diagnosis polling. */ stop() { this.enabled = false; if (this.intervalHandle) { clearInterval(this.intervalHandle); this.intervalHandle = null; } this.emit('status-change', { enabled: false }); } /** * Get all cached diagnosis results (most recent first). */ getAllDiagnoses(): DiagnosisResult[] { return Array.from(this.cache.values()).sort((a, b) => b.diagnosedAt - a.diagnosedAt); } /** * Get a single diagnosis by event UID. */ getDiagnosis(eventUid: string): DiagnosisResult | undefined { return this.cache.get(eventUid); } /** * Check if a diagnosis is already cached for the given event UID. */ hasDiagnosis(eventUid: string): boolean { const result = this.cache.get(eventUid); return !!result && !result.loading && !result.error; } /** * Check if a diagnosis (successful, loading, or pending) already exists * for the same involved resource (kind/namespace/name), regardless of event UID. * Also checks the persistent diagnosedResourceKeys set so that resources * are never re-diagnosed even if they generate new event UIDs. */ hasDiagnosisForResource(event: EventDigest): boolean { const key = ProactiveDiagnosisManager.resourceKey(event); // Fast path: already in the persistent "done" set if (this.diagnosedResourceKeys.has(key)) return true; // Slower check: still loading / pending in the cache for (const cached of this.cache.values()) { if (ProactiveDiagnosisManager.resourceKey(cached.event) === key) { if (!cached.error) return true; } } return false; } /** * Set the UID of the event the user wants to scroll to. * The UI component should consume this and clear it after scrolling. */ setScrollToEventUid(uid: string | null) { this._scrollToEventUid = uid; if (uid) { this.emit('scroll-to-event', uid); } } getScrollToEventUid(): string | null { return this._scrollToEventUid; } clearScrollToEventUid() { this._scrollToEventUid = null; } /** * Diagnose a list of events. Skips events that are already cached. * Runs diagnoses sequentially — the Holmes ag-ui server aborts previous * SSE connections when concurrent requests arrive, so we must process * one at a time (same pattern as normal chat). */ async diagnoseEvents(events: EventDigest[]): Promise<void> { if (this.running) return; if (!this.diagnoseFn) return; this.running = true; this.emit('cycle-start'); try { // Filter out events that already have a successful diagnosis // or that target a resource already being diagnosed / diagnosed const toDiagnose = events.filter( event => !this.hasDiagnosis(event.uid) && !this.hasDiagnosisForResource(event) ); if (toDiagnose.length === 0) { return; } // Mark all as pending first (queued, not yet processing) for (const event of toDiagnose) { const pendingResult: DiagnosisResult = { eventUid: event.uid, event, diagnosis: '', diagnosedAt: Date.now(), loading: false, pending: true, }; this.cache.set(event.uid, pendingResult); this.emit('diagnosis-update', pendingResult); } // Process sequentially — the Holmes server can only handle one // SSE stream at a time (concurrent requests cause AbortError). const diagnoseFn = this.diagnoseFn; for (const event of toDiagnose) { // Mark the current event as loading (actively processing) const loadingResult: DiagnosisResult = { eventUid: event.uid, event, diagnosis: '', diagnosedAt: Date.now(), loading: true, pending: false, thinkingSteps: [], }; this.cache.set(event.uid, loadingResult); this.emit('diagnosis-update', loadingResult); try { const prompt = this.buildPrompt(event); // onStep callback: accumulates thinking steps and emits updates const onStep: DiagnosisStepCallback = step => { const current = this.cache.get(event.uid); if (current) { const updated: DiagnosisResult = { ...current, thinkingSteps: [...(current.thinkingSteps || []), step], }; this.cache.set(event.uid, updated); this.emit('diagnosis-update', updated); } }; const diagnosis = await diagnoseFn(prompt, onStep); const result: DiagnosisResult = { eventUid: event.uid, event, diagnosis, diagnosedAt: Date.now(), loading: false, pending: false, thinkingSteps: this.cache.get(event.uid)?.thinkingSteps || [], }; this.cache.set(event.uid, result); // Mark resource as permanently diagnosed so future events for the // same resource (under new UIDs) are skipped. this.diagnosedResourceKeys.add(ProactiveDiagnosisManager.resourceKey(event)); this.emit('diagnosis-update', result); } catch (err: any) { const errorResult: DiagnosisResult = { eventUid: event.uid, event, diagnosis: '', diagnosedAt: Date.now(), loading: false, pending: false, error: err?.message || 'Diagnosis failed', }; this.cache.set(event.uid, errorResult); this.emit('diagnosis-update', errorResult); } } } finally { this.running = false; this.emit('cycle-end'); // Process any queued single-event requests this._drainSingleEventQueue(); } } /** * Force a diagnosis for a single event (used when clicking from the table). * If a diagnosis cycle is already running, queues this request and processes it * after the current cycle completes. */ async diagnoseSingleEvent(event: EventDigest): Promise<DiagnosisResult> { // Return cached result if available const cached = this.cache.get(event.uid); if (cached && !cached.loading && !cached.error) { return cached; } if (!this.diagnoseFn) { throw new Error('Diagnosis function not available'); } // If a cycle is already running, queue this request if (this.running) { return new Promise<DiagnosisResult>((resolve, reject) => { this.singleEventQueue.push({ event, resolve, reject }); }); } return this._executeSingleDiagnosis(event); } /** * Internal: execute a single-event diagnosis. Sets the running flag and * emits cycle events so the UI can block chat input. */ private async _executeSingleDiagnosis(event: EventDigest): Promise<DiagnosisResult> { this.running = true; this.emit('cycle-start'); // Mark as loading (actively processing) const loadingResult: DiagnosisResult = { eventUid: event.uid, event, diagnosis: '', diagnosedAt: Date.now(), loading: true, pending: false, thinkingSteps: [], }; this.cache.set(event.uid, loadingResult); this.emit('diagnosis-update', loadingResult); try { const prompt = this.buildPrompt(event); // onStep callback: accumulates thinking steps and emits updates const onStep: DiagnosisStepCallback = step => { const current = this.cache.get(event.uid); if (current) { const updated: DiagnosisResult = { ...current, thinkingSteps: [...(current.thinkingSteps || []), step], }; this.cache.set(event.uid, updated); this.emit('diagnosis-update', updated); } }; const diagnosis = await this.diagnoseFn!(prompt, onStep); const result: DiagnosisResult = { eventUid: event.uid, event, diagnosis, diagnosedAt: Date.now(), loading: false, pending: false, thinkingSteps: this.cache.get(event.uid)?.thinkingSteps || [], }; this.cache.set(event.uid, result); this.diagnosedResourceKeys.add(ProactiveDiagnosisManager.resourceKey(event)); this.emit('diagnosis-update', result); return result; } catch (err: any) { const errorResult: DiagnosisResult = { eventUid: event.uid, event, diagnosis: '', diagnosedAt: Date.now(), loading: false, pending: false, error: err?.message || 'Diagnosis failed', }; this.cache.set(event.uid, errorResult); this.emit('diagnosis-update', errorResult); return errorResult; } finally { this.running = false; this.emit('cycle-end'); // Process queued single-event requests this._drainSingleEventQueue(); } } /** * Process queued single-event diagnosis requests one by one. */ private _drainSingleEventQueue() { if (this.singleEventQueue.length === 0) return; const next = this.singleEventQueue.shift()!; this._executeSingleDiagnosis(next.event).then(next.resolve).catch(next.reject); } /** * Build a diagnosis prompt for a single event. */ private buildPrompt(event: EventDigest): string { return ( `## System Context\n` + `${basePrompt}\n\n` + `---\n\n` + `A Kubernetes ${event.type} event has been detected that requires investigation.\n\n` + `## Event Details\n` + `- **Event UID:** ${event.uid}\n` + `- **Event Name:** ${event.name}\n` + `- **Type:** ${event.type}\n` + `- **Reason:** ${event.reason}\n` + `- **Message:** ${event.message}\n` + `- **Involved Object:** ${event.objectKind}/${event.objectName}` + (event.objectNamespace ? ` in namespace \`${event.objectNamespace}\`` : '') + `\n- **Last Seen:** ${event.lastTimestamp}\n` + `\nPlease provide a thorough and detailed diagnosis by covering the following:\n\n` + `1. **What happened:** Clearly explain what this event means and why it occurred. ` + `Describe the issue in plain language so that someone unfamiliar with the internals can understand it.\n` + `2. **Root cause analysis:** Investigate and identify the most likely root cause. ` + `Examine the involved resource and any related objects (parent Deployments, ReplicaSets, ConfigMaps, Secrets, etc.) to trace the origin of the problem.\n` + `3. **Impact:** Explain what effect this issue has on the cluster, the workload, or end users.\n` + `4. **Remediation steps:** Provide specific, actionable steps to resolve the issue. ` + `Include exact kubectl commands or manifest changes where applicable.\n` + `5. **Prevention:** Suggest best practices or configuration changes to prevent this from recurring.\n\n` + `## Formatting Instructions\n` + `- Format your response in clear Markdown with headers and bullet points for readability.\n` + `- When including any YAML (Kubernetes manifests, configuration snippets, etc.), ` + `always wrap it in a markdown code block with the \`yaml\` language tag (e.g. \`\`\`yaml ... \`\`\`). ` + `The UI automatically parses YAML code blocks and renders them in an interactive editor, ` + `so never paste raw YAML outside of a code block.` ); } /** * Clear all cached diagnoses. */ clearCache() { this.cache.clear(); this.diagnosedResourceKeys.clear(); this.emit('cache-cleared'); } /** * Strip Kubernetes-generated hash suffixes from a resource name to find * the "root" workload name. Handles chained suffixes like those on Pods * created by ReplicaSets created by Deployments: * myapp-7b8d9c5f6d-xk4z2 → myapp * myapp-7b8d9c5f6d → myapp * myapp → myapp * * Heuristic: repeatedly strip a trailing `-<segment>` when the segment * looks like a generated hash. A segment is considered a hash only if it * is 4-10 lowercase-alphanumeric chars AND contains at least one digit. * This avoids stripping meaningful name parts like `-server`, `-proxy`, * or `-manager` which are purely alphabetic. */ private static stripHashSuffixes(name: string): string { let stripped = name; // Keep stripping trailing hash-like segments (e.g. -xk4z2, -7b8d9c5f6d) // The segment must contain at least one digit to qualify as a hash. while (true) { const match = stripped.match(/^(.+)-([a-z0-9]{4,10})$/); if (match && match[1] && match[2] && /\d/.test(match[2])) { stripped = match[1]; } else { break; } } return stripped; } /** * Extract top N Warning/Error events from a list, sorted by lastTimestamp (most recent first). * Deduplicates by the root workload name so that events across Deployment → * ReplicaSet → Pod hierarchies only consume one slot. */ static extractTopEvents(events: any[], limit = 15): EventDigest[] { const warningOrError = events.filter((e: any) => { const type = e?.jsonData?.type || e?.type || ''; return type === 'Warning' || type === 'Error'; }); // Sort by lastTimestamp descending warningOrError.sort((a: any, b: any) => { const tsA = new Date(a?.jsonData?.lastTimestamp || a?.lastTimestamp || 0).getTime(); const tsB = new Date(b?.jsonData?.lastTimestamp || b?.lastTimestamp || 0).getTime(); return tsB - tsA; }); // Deduplicate by root workload — strip generated hash suffixes and // ignore objectKind so that Pod / ReplicaSet / Deployment events for the // same workload collapse into a single slot (the most recent one). const seen = new Set<string>(); const deduped = warningOrError.filter((e: any) => { const data = e?.jsonData || e; const io = data?.involvedObject || {}; const baseName = ProactiveDiagnosisManager.stripHashSuffixes(io?.name || ''); const key = `${io?.namespace || '_'}/${baseName}`; if (seen.has(key)) return false; seen.add(key); return true; }); return deduped.slice(0, limit).map((e: any) => { const data = e?.jsonData || e; const involvedObject = data?.involvedObject || {}; return { uid: data?.metadata?.uid || `${data?.metadata?.name}-${data?.metadata?.namespace}`, name: data?.metadata?.name || 'unknown', type: data?.type || 'Warning', reason: data?.reason || '', message: data?.message || '', objectKind: involvedObject?.kind || '', objectName: involvedObject?.name || '', objectNamespace: involvedObject?.namespace || '', lastTimestamp: data?.lastTimestamp || data?.metadata?.creationTimestamp || '', rawEvent: data, }; }); } } // Singleton instance export const proactiveDiagnosisManager = new ProactiveDiagnosisManager();