@kinvolk/headlamp-plugin
Version:
The needed infrastructure for building Headlamp plugins.
553 lines (501 loc) • 19.5 kB
text/typescript
/**
* 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();