From 82e5b28840c8649b23b94e34237249165b5ad0b0 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 21 Dec 2025 00:14:20 +0000 Subject: [PATCH] feat: enhance AI baseline context visibility and incident timeline improvements Backend: - Enhanced buildEnrichedResourceContext to ALWAYS show learned baselines with status indicators (normal/elevated/anomaly) instead of only when anomalous - This makes Pulse Pro's 'moat' visible - users can see the AI understands their infrastructure's normal behavior patterns - Added baseline import to service.go Frontend (user changes): - Added incident event type filtering with toggle buttons - Added resource incident panel to view all incidents for a resource - Added timeline expand/collapse functionality in alert history - Added incident note saving with proper incidentId tracking - Added startedAt parameter for proper incident timeline loading --- frontend-modern/src/api/alerts.ts | 33 +- frontend-modern/src/pages/Alerts.tsx | 932 +++++++++++++++++++++++---- frontend-modern/src/types/api.ts | 28 + internal/ai/alert_triggered.go | 11 + internal/ai/memory/incidents.go | 848 ++++++++++++++++++++++++ internal/ai/memory/incidents_test.go | 108 ++++ internal/ai/patrol.go | 27 + internal/ai/runbooks.go | 4 + internal/ai/service.go | 259 ++++++-- internal/alerts/alerts.go | 105 ++- internal/api/ai_handlers.go | 24 + internal/api/alerts.go | 135 ++++ internal/api/router.go | 3 + internal/monitoring/monitor.go | 106 ++- 14 files changed, 2397 insertions(+), 226 deletions(-) create mode 100644 internal/ai/memory/incidents.go create mode 100644 internal/ai/memory/incidents_test.go diff --git a/frontend-modern/src/api/alerts.ts b/frontend-modern/src/api/alerts.ts index 0e92d39..efd6df7 100644 --- a/frontend-modern/src/api/alerts.ts +++ b/frontend-modern/src/api/alerts.ts @@ -1,4 +1,4 @@ -import type { Alert } from '@/types/api'; +import type { Alert, Incident } from '@/types/api'; import type { AlertConfig } from '@/types/alerts'; import { apiFetchJSON } from '@/utils/apiClient'; @@ -29,6 +29,37 @@ export class AlertsAPI { return apiFetchJSON(`${this.baseUrl}/history?${queryParams}`); } + static async getIncidentTimeline(alertId: string, startedAt?: string): Promise { + const query = new URLSearchParams({ alert_id: alertId }); + if (startedAt) { + query.set('started_at', startedAt); + } + return apiFetchJSON(`${this.baseUrl}/incidents?${query.toString()}`) as Promise; + } + + static async getIncidentsForResource(resourceId: string, limit?: number): Promise { + const query = new URLSearchParams({ resource_id: resourceId }); + if (limit) query.set('limit', String(limit)); + return apiFetchJSON(`${this.baseUrl}/incidents?${query.toString()}`) as Promise; + } + + static async addIncidentNote(params: { + alertId?: string; + incidentId?: string; + note: string; + user?: string; + }): Promise<{ success: boolean }> { + return apiFetchJSON(`${this.baseUrl}/incidents/note`, { + method: 'POST', + body: JSON.stringify({ + alert_id: params.alertId, + incident_id: params.incidentId, + note: params.note, + user: params.user, + }), + }) as Promise<{ success: boolean }>; + } + static async acknowledge(alertId: string, user?: string): Promise<{ success: boolean }> { return apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(alertId)}/acknowledge`, { method: 'POST', diff --git a/frontend-modern/src/pages/Alerts.tsx b/frontend-modern/src/pages/Alerts.tsx index ab2d0be..73a22af 100644 --- a/frontend-modern/src/pages/Alerts.tsx +++ b/frontend-modern/src/pages/Alerts.tsx @@ -22,7 +22,7 @@ import { AIAPI } from '@/api/ai'; import { LicenseAPI, type LicenseFeatureStatus } from '@/api/license'; import type { EmailConfig, AppriseConfig } from '@/api/notifications'; import type { HysteresisThreshold } from '@/types/alerts'; -import type { Alert, State, VM, Container, DockerHost, DockerContainer, Host } from '@/types/api'; +import type { Alert, Incident, IncidentEvent, State, VM, Container, DockerHost, DockerContainer, Host } from '@/types/api'; import type { RemediationRecord } from '@/types/aiIntelligence'; import { useNavigate, useLocation } from '@solidjs/router'; import { useAlertsActivation } from '@/stores/alertsActivation'; @@ -106,6 +106,93 @@ export const tabFromPath = ( return 'overview'; }; +const INCIDENT_EVENT_TYPES = [ + 'alert_fired', + 'alert_acknowledged', + 'alert_unacknowledged', + 'alert_resolved', + 'ai_analysis', + 'command', + 'runbook', + 'note', +] as const; + +const INCIDENT_EVENT_LABELS: Record<(typeof INCIDENT_EVENT_TYPES)[number], string> = { + alert_fired: 'Fired', + alert_acknowledged: 'Ack', + alert_unacknowledged: 'Unack', + alert_resolved: 'Resolved', + ai_analysis: 'AI', + command: 'Cmd', + runbook: 'Runbook', + note: 'Note', +}; + +const filterIncidentEvents = ( + events: IncidentEvent[] | undefined, + filters: Set, +): IncidentEvent[] => { + if (!events || events.length === 0) { + return []; + } + if (filters.size === 0 || filters.size === INCIDENT_EVENT_TYPES.length) { + return events; + } + return events.filter((event) => filters.has(event.type)); +}; + +function IncidentEventFilters(props: { + filters: () => Set; + setFilters: (next: Set) => void; +}) { + const toggleFilter = (type: (typeof INCIDENT_EVENT_TYPES)[number]) => { + const next = new Set(props.filters()); + if (next.has(type)) { + next.delete(type); + } else { + next.add(type); + } + props.setFilters(next); + }; + + return ( +
+ Filters + + + + {(type) => { + const selected = () => props.filters().has(type); + return ( + + ); + }} + +
+ ); +} + // Store reference interfaces interface DestinationsRef { emailConfig?: () => EmailConfig; @@ -2062,6 +2149,14 @@ function OverviewTab(props: { }) { // Loading states for buttons const [processingAlerts, setProcessingAlerts] = createSignal>(new Set()); + const [incidentTimelines, setIncidentTimelines] = createSignal>({}); + const [incidentLoading, setIncidentLoading] = createSignal>({}); + const [expandedIncidents, setExpandedIncidents] = createSignal>(new Set()); + const [incidentNoteDrafts, setIncidentNoteDrafts] = createSignal>({}); + const [incidentNoteSaving, setIncidentNoteSaving] = createSignal>(new Set()); + const [incidentEventFilters, setIncidentEventFilters] = createSignal>( + new Set(INCIDENT_EVENT_TYPES), + ); // AI Patrol findings state const [aiFindings, setAiFindings] = createSignal([]); @@ -2142,6 +2237,58 @@ function OverviewTab(props: { return 'AI Patrol insights require Pulse Pro.'; }); + const loadIncidentTimeline = async (alertId: string, startedAt?: string) => { + setIncidentLoading((prev) => ({ ...prev, [alertId]: true })); + try { + const timeline = await AlertsAPI.getIncidentTimeline(alertId, startedAt); + setIncidentTimelines((prev) => ({ ...prev, [alertId]: timeline })); + } catch (error) { + logger.error('Failed to load incident timeline', error); + showError('Failed to load incident timeline'); + } finally { + setIncidentLoading((prev) => ({ ...prev, [alertId]: false })); + } + }; + + const toggleIncidentTimeline = async (alertId: string, startedAt?: string) => { + const expanded = expandedIncidents(); + const next = new Set(expanded); + if (next.has(alertId)) { + next.delete(alertId); + setExpandedIncidents(next); + return; + } + next.add(alertId); + setExpandedIncidents(next); + if (!(alertId in incidentTimelines())) { + await loadIncidentTimeline(alertId, startedAt); + } + }; + + const saveIncidentNote = async (alertId: string, startedAt?: string) => { + const note = (incidentNoteDrafts()[alertId] || '').trim(); + if (!note) { + return; + } + setIncidentNoteSaving((prev) => new Set(prev).add(alertId)); + try { + const incidentId = incidentTimelines()[alertId]?.id; + await AlertsAPI.addIncidentNote({ alertId, incidentId, note }); + setIncidentNoteDrafts((prev) => ({ ...prev, [alertId]: '' })); + await loadIncidentTimeline(alertId, startedAt); + showSuccess('Incident note saved'); + } catch (error) { + logger.error('Failed to save incident note', error); + showError('Failed to save incident note'); + } finally { + setIncidentNoteSaving((prev) => { + const next = new Set(prev); + next.delete(alertId); + return next; + }); + } + }; + // Effect to manage live stream subscription when expanded createEffect(() => { const isExpanded = expandedLiveStream(); @@ -4058,6 +4205,14 @@ function OverviewTab(props: { ? 'Unacknowledge' : 'Acknowledge'} + + +
+ +

Loading timeline...

+
+ + + {(timeline) => ( +
+
+ Incident + {timeline().status} + + + acknowledged + + + + opened {new Date(timeline().openedAt).toLocaleString()} + + + closed {new Date(timeline().closedAt as string).toLocaleString()} + +
+ {(() => { + const events = timeline().events || []; + const filteredEvents = filterIncidentEvents(events, incidentEventFilters()); + return ( + <> + 0}> + + + 0}> +
+ + {(event) => ( +
+
+ + {event.summary} + + {new Date(event.timestamp).toLocaleString()} +
+ +

+ {(event.details as { note?: string }).note} +

+
+ +

+ {(event.details as { command?: string }).command} +

+
+ +

+ {(event.details as { output_excerpt?: string }).output_excerpt} +

+
+
+ )} +
+
+
+ 0 && filteredEvents.length === 0}> +

+ No timeline events match the selected filters. +

+
+ +

No timeline events yet.

+
+ + ); + })()} +
+