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
This commit is contained in:
rcourtman 2025-12-21 00:14:20 +00:00
parent f0b983667c
commit 82e5b28840
14 changed files with 2397 additions and 226 deletions

View file

@ -1,4 +1,4 @@
import type { Alert } from '@/types/api'; import type { Alert, Incident } from '@/types/api';
import type { AlertConfig } from '@/types/alerts'; import type { AlertConfig } from '@/types/alerts';
import { apiFetchJSON } from '@/utils/apiClient'; import { apiFetchJSON } from '@/utils/apiClient';
@ -29,6 +29,37 @@ export class AlertsAPI {
return apiFetchJSON(`${this.baseUrl}/history?${queryParams}`); return apiFetchJSON(`${this.baseUrl}/history?${queryParams}`);
} }
static async getIncidentTimeline(alertId: string, startedAt?: string): Promise<Incident | null> {
const query = new URLSearchParams({ alert_id: alertId });
if (startedAt) {
query.set('started_at', startedAt);
}
return apiFetchJSON(`${this.baseUrl}/incidents?${query.toString()}`) as Promise<Incident | null>;
}
static async getIncidentsForResource(resourceId: string, limit?: number): Promise<Incident[]> {
const query = new URLSearchParams({ resource_id: resourceId });
if (limit) query.set('limit', String(limit));
return apiFetchJSON(`${this.baseUrl}/incidents?${query.toString()}`) as Promise<Incident[]>;
}
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 }> { static async acknowledge(alertId: string, user?: string): Promise<{ success: boolean }> {
return apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(alertId)}/acknowledge`, { return apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(alertId)}/acknowledge`, {
method: 'POST', method: 'POST',

View file

@ -22,7 +22,7 @@ import { AIAPI } from '@/api/ai';
import { LicenseAPI, type LicenseFeatureStatus } from '@/api/license'; import { LicenseAPI, type LicenseFeatureStatus } from '@/api/license';
import type { EmailConfig, AppriseConfig } from '@/api/notifications'; import type { EmailConfig, AppriseConfig } from '@/api/notifications';
import type { HysteresisThreshold } from '@/types/alerts'; 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 type { RemediationRecord } from '@/types/aiIntelligence';
import { useNavigate, useLocation } from '@solidjs/router'; import { useNavigate, useLocation } from '@solidjs/router';
import { useAlertsActivation } from '@/stores/alertsActivation'; import { useAlertsActivation } from '@/stores/alertsActivation';
@ -106,6 +106,93 @@ export const tabFromPath = (
return 'overview'; 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<string>,
): 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<string>;
setFilters: (next: Set<string>) => 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 (
<div class="flex flex-wrap items-center gap-2 text-[10px] text-gray-500 dark:text-gray-400">
<span class="uppercase tracking-wide text-[9px] text-gray-400 dark:text-gray-500">Filters</span>
<button
type="button"
class="px-2 py-0.5 rounded border border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
onClick={() => props.setFilters(new Set(INCIDENT_EVENT_TYPES))}
>
All
</button>
<button
type="button"
class="px-2 py-0.5 rounded border border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
onClick={() => props.setFilters(new Set())}
>
None
</button>
<For each={INCIDENT_EVENT_TYPES}>
{(type) => {
const selected = () => props.filters().has(type);
return (
<button
type="button"
class={`px-2 py-0.5 rounded border text-[10px] ${selected()
? 'border-blue-300 bg-blue-100 text-blue-700 dark:border-blue-800 dark:bg-blue-900/40 dark:text-blue-300'
: 'border-gray-300 text-gray-500 dark:border-gray-600 dark:text-gray-300'
}`}
onClick={() => toggleFilter(type)}
>
{INCIDENT_EVENT_LABELS[type]}
</button>
);
}}
</For>
</div>
);
}
// Store reference interfaces // Store reference interfaces
interface DestinationsRef { interface DestinationsRef {
emailConfig?: () => EmailConfig; emailConfig?: () => EmailConfig;
@ -2062,6 +2149,14 @@ function OverviewTab(props: {
}) { }) {
// Loading states for buttons // Loading states for buttons
const [processingAlerts, setProcessingAlerts] = createSignal<Set<string>>(new Set()); const [processingAlerts, setProcessingAlerts] = createSignal<Set<string>>(new Set());
const [incidentTimelines, setIncidentTimelines] = createSignal<Record<string, Incident | null>>({});
const [incidentLoading, setIncidentLoading] = createSignal<Record<string, boolean>>({});
const [expandedIncidents, setExpandedIncidents] = createSignal<Set<string>>(new Set());
const [incidentNoteDrafts, setIncidentNoteDrafts] = createSignal<Record<string, string>>({});
const [incidentNoteSaving, setIncidentNoteSaving] = createSignal<Set<string>>(new Set());
const [incidentEventFilters, setIncidentEventFilters] = createSignal<Set<string>>(
new Set(INCIDENT_EVENT_TYPES),
);
// AI Patrol findings state // AI Patrol findings state
const [aiFindings, setAiFindings] = createSignal<Finding[]>([]); const [aiFindings, setAiFindings] = createSignal<Finding[]>([]);
@ -2142,6 +2237,58 @@ function OverviewTab(props: {
return 'AI Patrol insights require Pulse Pro.'; 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 // Effect to manage live stream subscription when expanded
createEffect(() => { createEffect(() => {
const isExpanded = expandedLiveStream(); const isExpanded = expandedLiveStream();
@ -4058,6 +4205,14 @@ function OverviewTab(props: {
? 'Unacknowledge' ? 'Unacknowledge'
: 'Acknowledge'} : 'Acknowledge'}
</button> </button>
<button
class="px-3 py-1.5 text-xs font-medium border rounded-lg transition-all bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600"
onClick={() => {
void toggleIncidentTimeline(alert.id, alert.startTime);
}}
>
{expandedIncidents().has(alert.id) ? 'Hide Timeline' : 'Timeline'}
</button>
<InvestigateAlertButton <InvestigateAlertButton
alert={alert} alert={alert}
variant="text" variant="text"
@ -4066,6 +4221,115 @@ function OverviewTab(props: {
/> />
</div> </div>
</div> </div>
<Show when={expandedIncidents().has(alert.id)}>
<div class="mt-3 border-t border-gray-200 dark:border-gray-700 pt-3">
<Show when={incidentLoading()[alert.id]}>
<p class="text-xs text-gray-500 dark:text-gray-400">Loading timeline...</p>
</Show>
<Show when={!incidentLoading()[alert.id]}>
<Show when={incidentTimelines()[alert.id]}>
{(timeline) => (
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<span class="font-medium text-gray-700 dark:text-gray-200">Incident</span>
<span>{timeline().status}</span>
<Show when={timeline().acknowledged}>
<span class="px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
acknowledged
</span>
</Show>
<Show when={timeline().openedAt}>
<span>opened {new Date(timeline().openedAt).toLocaleString()}</span>
</Show>
<Show when={timeline().closedAt}>
<span>closed {new Date(timeline().closedAt as string).toLocaleString()}</span>
</Show>
</div>
{(() => {
const events = timeline().events || [];
const filteredEvents = filterIncidentEvents(events, incidentEventFilters());
return (
<>
<Show when={events.length > 0}>
<IncidentEventFilters
filters={incidentEventFilters}
setFilters={setIncidentEventFilters}
/>
</Show>
<Show when={filteredEvents.length > 0}>
<div class="space-y-2">
<For each={filteredEvents}>
{(event) => (
<div class="rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/30 p-2">
<div class="flex flex-wrap items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<span class="font-medium text-gray-800 dark:text-gray-200">
{event.summary}
</span>
<span>{new Date(event.timestamp).toLocaleString()}</span>
</div>
<Show when={event.details && (event.details as { note?: string }).note}>
<p class="text-xs text-gray-700 dark:text-gray-300 mt-1">
{(event.details as { note?: string }).note}
</p>
</Show>
<Show when={event.details && (event.details as { command?: string }).command}>
<p class="text-xs text-gray-700 dark:text-gray-300 mt-1 font-mono">
{(event.details as { command?: string }).command}
</p>
</Show>
<Show when={event.details && (event.details as { output_excerpt?: string }).output_excerpt}>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
{(event.details as { output_excerpt?: string }).output_excerpt}
</p>
</Show>
</div>
)}
</For>
</div>
</Show>
<Show when={events.length > 0 && filteredEvents.length === 0}>
<p class="text-xs text-gray-500 dark:text-gray-400">
No timeline events match the selected filters.
</p>
</Show>
<Show when={events.length === 0}>
<p class="text-xs text-gray-500 dark:text-gray-400">No timeline events yet.</p>
</Show>
</>
);
})()}
<div class="flex flex-col gap-2">
<textarea
class="w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 p-2 text-xs text-gray-800 dark:text-gray-200"
rows={2}
placeholder="Add a note for this incident..."
value={incidentNoteDrafts()[alert.id] || ''}
onInput={(e) => {
const value = e.currentTarget.value;
setIncidentNoteDrafts((prev) => ({ ...prev, [alert.id]: value }));
}}
/>
<div class="flex justify-end">
<button
class="px-3 py-1.5 text-xs font-medium border rounded-lg transition-all bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={incidentNoteSaving().has(alert.id) || !(incidentNoteDrafts()[alert.id] || '').trim()}
onClick={() => {
void saveIncidentNote(alert.id, alert.startTime);
}}
>
{incidentNoteSaving().has(alert.id) ? 'Saving...' : 'Save Note'}
</button>
</div>
</div>
</div>
)}
</Show>
<Show when={!incidentTimelines()[alert.id]}>
<p class="text-xs text-gray-500 dark:text-gray-400">No incident timeline available.</p>
</Show>
</Show>
</div>
</Show>
</div> </div>
)} )}
</For> </For>
@ -5601,6 +5865,21 @@ function HistoryTab() {
const [aiFindingsHistory, setAiFindingsHistory] = createSignal<Finding[]>([]); const [aiFindingsHistory, setAiFindingsHistory] = createSignal<Finding[]>([]);
const [loading, setLoading] = createSignal(true); const [loading, setLoading] = createSignal(true);
const [selectedBarIndex, setSelectedBarIndex] = createSignal<number | null>(null); const [selectedBarIndex, setSelectedBarIndex] = createSignal<number | null>(null);
const [resourceIncidentPanel, setResourceIncidentPanel] = createSignal<{ resourceId: string; resourceName: string } | null>(null);
const [resourceIncidents, setResourceIncidents] = createSignal<Record<string, Incident[]>>({});
const [resourceIncidentLoading, setResourceIncidentLoading] = createSignal<Record<string, boolean>>({});
const [expandedResourceIncidentIds, setExpandedResourceIncidentIds] = createSignal<Set<string>>(new Set());
const [historyIncidentEventFilters, setHistoryIncidentEventFilters] = createSignal<Set<string>>(
new Set(INCIDENT_EVENT_TYPES),
);
const [resourceIncidentEventFilters, setResourceIncidentEventFilters] = createSignal<Set<string>>(
new Set(INCIDENT_EVENT_TYPES),
);
const [incidentTimelines, setIncidentTimelines] = createSignal<Record<string, Incident | null>>({});
const [incidentLoading, setIncidentLoading] = createSignal<Record<string, boolean>>({});
const [expandedIncidents, setExpandedIncidents] = createSignal<Set<string>>(new Set());
const [incidentNoteDrafts, setIncidentNoteDrafts] = createSignal<Record<string, string>>({});
const [incidentNoteSaving, setIncidentNoteSaving] = createSignal<Set<string>>(new Set());
const MS_PER_HOUR = 60 * 60 * 1000; const MS_PER_HOUR = 60 * 60 * 1000;
const userLocale = const userLocale =
Intl.DateTimeFormat().resolvedOptions().locale || Intl.DateTimeFormat().resolvedOptions().locale ||
@ -5770,6 +6049,53 @@ function HistoryTab() {
return `${minutes}m`; return `${minutes}m`;
}; };
const loadResourceIncidents = async (resourceId: string, limit = 10) => {
if (!resourceId) {
return;
}
setResourceIncidentLoading((prev) => ({ ...prev, [resourceId]: true }));
try {
const incidents = await AlertsAPI.getIncidentsForResource(resourceId, limit);
setResourceIncidents((prev) => ({ ...prev, [resourceId]: incidents }));
} catch (error) {
logger.error('Failed to load resource incidents', error);
showError('Failed to load resource incidents');
} finally {
setResourceIncidentLoading((prev) => ({ ...prev, [resourceId]: false }));
}
};
const openResourceIncidentPanel = async (resourceId: string, resourceName: string) => {
if (!resourceId) {
return;
}
setResourceIncidentPanel({ resourceId, resourceName });
setExpandedResourceIncidentIds(new Set());
if (!(resourceId in resourceIncidents())) {
await loadResourceIncidents(resourceId);
}
};
const refreshResourceIncidentPanel = async () => {
const selection = resourceIncidentPanel();
if (!selection) {
return;
}
await loadResourceIncidents(selection.resourceId);
};
const toggleResourceIncidentDetails = (incidentId: string) => {
setExpandedResourceIncidentIds((prev) => {
const next = new Set(prev);
if (next.has(incidentId)) {
next.delete(incidentId);
} else {
next.add(incidentId);
}
return next;
});
};
const formatBucketRange = (startMs: number, endMs: number) => { const formatBucketRange = (startMs: number, endMs: number) => {
const start = new Date(startMs); const start = new Date(startMs);
const end = new Date(endMs); const end = new Date(endMs);
@ -6103,6 +6429,7 @@ function HistoryTab() {
}; };
type AlertHistoryRow = ReturnType<typeof alertData>[number]; type AlertHistoryRow = ReturnType<typeof alertData>[number];
const getIncidentRowKey = (alert: AlertHistoryRow) => `${alert.id}::${alert.startTime}`;
// Group alerts by day for display // Group alerts by day for display
const groupedAlerts = createMemo(() => { const groupedAlerts = createMemo(() => {
@ -6238,6 +6565,58 @@ function HistoryTab() {
}; };
}); });
const loadIncidentTimeline = async (rowKey: string, alertId: string, startedAt?: string) => {
setIncidentLoading((prev) => ({ ...prev, [rowKey]: true }));
try {
const timeline = await AlertsAPI.getIncidentTimeline(alertId, startedAt);
setIncidentTimelines((prev) => ({ ...prev, [rowKey]: timeline }));
} catch (error) {
logger.error('Failed to load incident timeline', error);
showError('Failed to load incident timeline');
} finally {
setIncidentLoading((prev) => ({ ...prev, [rowKey]: false }));
}
};
const toggleIncidentTimeline = async (rowKey: string, alertId: string, startedAt?: string) => {
const expanded = expandedIncidents();
const next = new Set(expanded);
if (next.has(rowKey)) {
next.delete(rowKey);
setExpandedIncidents(next);
return;
}
next.add(rowKey);
setExpandedIncidents(next);
if (!(rowKey in incidentTimelines())) {
await loadIncidentTimeline(rowKey, alertId, startedAt);
}
};
const saveIncidentNote = async (rowKey: string, alertId: string, startedAt?: string) => {
const note = (incidentNoteDrafts()[rowKey] || '').trim();
if (!note) {
return;
}
setIncidentNoteSaving((prev) => new Set(prev).add(rowKey));
try {
const incidentId = incidentTimelines()[rowKey]?.id;
await AlertsAPI.addIncidentNote({ alertId, incidentId, note });
setIncidentNoteDrafts((prev) => ({ ...prev, [rowKey]: '' }));
await loadIncidentTimeline(rowKey, 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(rowKey);
return next;
});
}
};
const bucketDurationLabel = createMemo(() => { const bucketDurationLabel = createMemo(() => {
const bucketHours = alertTrends().bucketSize; const bucketHours = alertTrends().bucketSize;
if (!Number.isFinite(bucketHours) || bucketHours <= 0) { if (!Number.isFinite(bucketHours) || bucketHours <= 0) {
@ -6602,6 +6981,190 @@ function HistoryTab() {
</div> </div>
</div> </div>
<Show when={resourceIncidentPanel()}>
{(selection) => {
const resourceId = selection().resourceId;
const incidents = () => resourceIncidents()[resourceId] || [];
const isLoading = () => resourceIncidentLoading()[resourceId];
return (
<Card padding="md">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Resource incidents</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">
{selection().resourceName}
<Show when={incidents().length > 0}>
<span> · {incidents().length} incident{incidents().length === 1 ? '' : 's'}</span>
</Show>
</p>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="px-2 py-1 text-xs border rounded-md border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
disabled={isLoading()}
onClick={() => {
void refreshResourceIncidentPanel();
}}
>
{isLoading() ? 'Refreshing...' : 'Refresh'}
</button>
<button
type="button"
class="px-2 py-1 text-xs border rounded-md border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
onClick={() => setResourceIncidentPanel(null)}
>
Close
</button>
</div>
</div>
<Show when={isLoading()}>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">Loading incidents...</p>
</Show>
<Show when={!isLoading()}>
<Show when={incidents().length > 0}>
<div class="mt-2">
<IncidentEventFilters
filters={resourceIncidentEventFilters}
setFilters={setResourceIncidentEventFilters}
/>
</div>
</Show>
<Show
when={incidents().length > 0}
fallback={
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
No incidents recorded for this resource yet.
</p>
}
>
<div class="mt-3 space-y-3">
<For each={incidents()}>
{(incident) => {
const statusLabel =
incident.status === 'open' && incident.acknowledged
? 'acknowledged'
: incident.status;
const statusClasses =
statusLabel === 'acknowledged'
? 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300'
: statusLabel === 'open'
? 'bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300';
const levelClasses =
incident.level === 'critical'
? 'bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300'
: 'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-300';
const isExpanded = expandedResourceIncidentIds().has(incident.id);
const events = incident.events || [];
const filteredEvents = filterIncidentEvents(events, resourceIncidentEventFilters());
const recentEvents =
filteredEvents.length > 6 ? filteredEvents.slice(filteredEvents.length - 6) : filteredEvents;
const lastEvent =
filteredEvents.length > 0 ? filteredEvents[filteredEvents.length - 1] : undefined;
const filteredLabel =
filteredEvents.length !== events.length
? `${filteredEvents.length}/${events.length}`
: `${events.length}`;
return (
<div class="rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900/40 p-3">
<div class="flex flex-wrap items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<span class="font-medium text-gray-800 dark:text-gray-200">
{incident.alertType}
</span>
<span class={`px-2 py-0.5 rounded ${levelClasses}`}>{incident.level}</span>
<span class={`px-2 py-0.5 rounded ${statusClasses}`}>{statusLabel}</span>
<span>opened {new Date(incident.openedAt).toLocaleString()}</span>
<Show when={incident.closedAt}>
<span>closed {new Date(incident.closedAt as string).toLocaleString()}</span>
</Show>
</div>
<Show when={incident.message}>
<p class="mt-1 text-xs text-gray-600 dark:text-gray-300">
{incident.message}
</p>
</Show>
<Show when={incident.acknowledged && incident.ackUser}>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Acknowledged by {incident.ackUser}
</p>
</Show>
<Show when={events.length > 0}>
<div class="mt-2 flex flex-wrap items-center justify-between gap-2 text-xs text-gray-500 dark:text-gray-400">
<span>
<Show
when={filteredEvents.length > 0}
fallback={<span>No events match filters</span>}
>
Last event: {lastEvent?.summary}
</Show>
</span>
<button
type="button"
class="px-2 py-1 text-[10px] border rounded-md border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
onClick={() => toggleResourceIncidentDetails(incident.id)}
>
{isExpanded ? 'Hide events' : `Events (${filteredLabel})`}
</button>
</div>
</Show>
<Show when={isExpanded}>
<div class="mt-2 space-y-2">
<Show
when={filteredEvents.length > 0}
fallback={
<p class="text-[10px] text-gray-400 dark:text-gray-500">
No events match the selected filters.
</p>
}
>
<For each={recentEvents}>
{(event) => (
<div class="rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/40 p-2">
<div class="flex flex-wrap items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<span class="font-medium text-gray-800 dark:text-gray-200">
{event.summary}
</span>
<span>{new Date(event.timestamp).toLocaleString()}</span>
</div>
<Show when={event.details && (event.details as { note?: string }).note}>
<p class="text-xs text-gray-700 dark:text-gray-300 mt-1">
{(event.details as { note?: string }).note}
</p>
</Show>
<Show when={event.details && (event.details as { command?: string }).command}>
<p class="text-xs text-gray-700 dark:text-gray-300 mt-1 font-mono">
{(event.details as { command?: string }).command}
</p>
</Show>
<Show when={event.details && (event.details as { output_excerpt?: string }).output_excerpt}>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
{(event.details as { output_excerpt?: string }).output_excerpt}
</p>
</Show>
</div>
)}
</For>
<Show when={filteredEvents.length > recentEvents.length}>
<p class="text-[10px] text-gray-400 dark:text-gray-500">
Showing last {recentEvents.length} events
</p>
</Show>
</Show>
</div>
</Show>
</div>
);
}}
</For>
</div>
</Show>
</Show>
</Card>
);
}}
</Show>
{/* Alert History Table */} {/* Alert History Table */}
<Show <Show
when={loading()} when={loading()}
@ -6683,7 +7246,10 @@ function HistoryTab() {
{/* Alerts for this day */} {/* Alerts for this day */}
<For each={group.alerts}> <For each={group.alerts}>
{(alert) => ( {(alert) => {
const rowKey = getIncidentRowKey(alert);
return (
<>
<tr <tr
class={`border-b border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 ${alert.status === 'active' ? 'bg-red-50 dark:bg-red-900/10' : '' class={`border-b border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 ${alert.status === 'active' ? 'bg-red-50 dark:bg-red-900/10' : ''
}`} }`}
@ -6777,7 +7343,31 @@ function HistoryTab() {
{/* Actions */} {/* Actions */}
<td class="p-1 px-2 text-center"> <td class="p-1 px-2 text-center">
<Show when={alert.status === 'active' || alert.status === 'acknowledged'}> <div class="flex items-center justify-center gap-1">
<Show when={alert.source === 'alert'}>
<button
type="button"
class="px-2 py-1 text-[10px] border rounded-md border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
onClick={() => {
void toggleIncidentTimeline(rowKey, alert.id, alert.startTime);
}}
>
{expandedIncidents().has(rowKey) ? 'Hide' : 'Timeline'}
</button>
</Show>
<Show when={alert.source === 'alert' && alert.resourceId}>
<button
type="button"
class="px-2 py-1 text-[10px] border rounded-md border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
title="View incidents for this resource"
onClick={() => {
void openResourceIncidentPanel(alert.resourceId as string, alert.resourceName);
}}
>
Resource
</button>
</Show>
<Show when={alert.source === 'alert' && (alert.status === 'active' || alert.status === 'acknowledged')}>
<InvestigateAlertButton <InvestigateAlertButton
alert={{ alert={{
id: alert.id, id: alert.id,
@ -6799,10 +7389,124 @@ function HistoryTab() {
licenseLocked={!hasAIAlertsFeature() && !licenseLoading()} licenseLocked={!hasAIAlertsFeature() && !licenseLoading()}
/> />
</Show> </Show>
</div>
</td> </td>
</tr> </tr>
<Show when={alert.source === 'alert' && expandedIncidents().has(rowKey)}>
<tr class="bg-gray-50 dark:bg-gray-900/40 border-b border-gray-200 dark:border-gray-700">
<td colspan="11" class="p-3">
<Show when={incidentLoading()[rowKey]}>
<p class="text-xs text-gray-500 dark:text-gray-400">Loading timeline...</p>
</Show>
<Show when={!incidentLoading()[rowKey]}>
<Show when={incidentTimelines()[rowKey]}>
{(timeline) => (
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<span class="font-medium text-gray-700 dark:text-gray-200">Incident</span>
<span>{timeline().status}</span>
<Show when={timeline().acknowledged}>
<span class="px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
acknowledged
</span>
</Show>
<Show when={timeline().openedAt}>
<span>opened {new Date(timeline().openedAt).toLocaleString()}</span>
</Show>
<Show when={timeline().closedAt}>
<span>closed {new Date(timeline().closedAt as string).toLocaleString()}</span>
</Show>
</div>
{(() => {
const events = timeline().events || [];
const filteredEvents = filterIncidentEvents(events, historyIncidentEventFilters());
return (
<>
<Show when={events.length > 0}>
<IncidentEventFilters
filters={historyIncidentEventFilters}
setFilters={setHistoryIncidentEventFilters}
/>
</Show>
<Show when={filteredEvents.length > 0}>
<div class="space-y-2">
<For each={filteredEvents}>
{(event) => (
<div class="rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900/30 p-2">
<div class="flex flex-wrap items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<span class="font-medium text-gray-800 dark:text-gray-200">
{event.summary}
</span>
<span>{new Date(event.timestamp).toLocaleString()}</span>
</div>
<Show when={event.details && (event.details as { note?: string }).note}>
<p class="text-xs text-gray-700 dark:text-gray-300 mt-1">
{(event.details as { note?: string }).note}
</p>
</Show>
<Show when={event.details && (event.details as { command?: string }).command}>
<p class="text-xs text-gray-700 dark:text-gray-300 mt-1 font-mono">
{(event.details as { command?: string }).command}
</p>
</Show>
<Show when={event.details && (event.details as { output_excerpt?: string }).output_excerpt}>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
{(event.details as { output_excerpt?: string }).output_excerpt}
</p>
</Show>
</div>
)} )}
</For> </For>
</div>
</Show>
<Show when={events.length > 0 && filteredEvents.length === 0}>
<p class="text-xs text-gray-500 dark:text-gray-400">
No timeline events match the selected filters.
</p>
</Show>
<Show when={events.length === 0}>
<p class="text-xs text-gray-500 dark:text-gray-400">No timeline events yet.</p>
</Show>
</>
);
})()}
<div class="flex flex-col gap-2">
<textarea
class="w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 p-2 text-xs text-gray-800 dark:text-gray-200"
rows={2}
placeholder="Add a note for this incident..."
value={incidentNoteDrafts()[rowKey] || ''}
onInput={(e) => {
const value = e.currentTarget.value;
setIncidentNoteDrafts((prev) => ({ ...prev, [rowKey]: value }));
}}
/>
<div class="flex justify-end">
<button
class="px-3 py-1.5 text-xs font-medium border rounded-lg transition-all bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={incidentNoteSaving().has(rowKey) || !(incidentNoteDrafts()[rowKey] || '').trim()}
onClick={() => {
void saveIncidentNote(rowKey, alert.id, alert.startTime);
}}
>
{incidentNoteSaving().has(rowKey) ? 'Saving...' : 'Save Note'}
</button>
</div>
</div>
</div>
)}
</Show>
<Show when={!incidentTimelines()[rowKey]}>
<p class="text-xs text-gray-500 dark:text-gray-400">No incident timeline available.</p>
</Show>
</Show>
</td>
</tr>
</Show>
</>
);
}}
</For>
</> </>
)} )}
</For> </For>

View file

@ -1019,6 +1019,34 @@ export interface ResolvedAlert extends Alert {
resolvedTime: string; resolvedTime: string;
} }
export interface IncidentEvent {
id: string;
type: string;
timestamp: string;
summary: string;
details?: Record<string, unknown>;
}
export interface Incident {
id: string;
alertId: string;
alertType: string;
level: string;
resourceId: string;
resourceName: string;
resourceType?: string;
node?: string;
instance?: string;
message?: string;
status: string;
openedAt: string;
closedAt?: string;
acknowledged: boolean;
ackUser?: string;
ackTime?: string;
events?: IncidentEvent[];
}
// WebSocket message types // WebSocket message types
export type WSMessage = export type WSMessage =
| { type: 'initialState'; data: State } | { type: 'initialState'; data: State }

View file

@ -161,6 +161,17 @@ func (a *AlertTriggeredAnalyzer) analyzeResource(alert *alerts.Alert, resourceKe
Dur("duration", duration). Dur("duration", duration).
Msg("Alert-triggered AI analysis completed with no additional findings") Msg("Alert-triggered AI analysis completed with no additional findings")
} }
if a.patrolService != nil && a.patrolService.aiService != nil {
summary := "Alert-triggered AI analysis completed"
if len(findings) > 0 {
summary = fmt.Sprintf("Alert-triggered AI analysis found %d findings", len(findings))
}
a.patrolService.aiService.RecordIncidentAnalysis(alert.ID, summary, map[string]interface{}{
"findings": len(findings),
"duration": duration.String(),
})
}
} }
// analyzeResourceByAlert determines the resource type from the alert and analyzes it // analyzeResourceByAlert determines the resource type from the alert and analyzes it

View file

@ -0,0 +1,848 @@
package memory
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rs/zerolog/log"
)
// IncidentStatus represents the current state of an incident.
type IncidentStatus string
const (
IncidentStatusOpen IncidentStatus = "open"
IncidentStatusResolved IncidentStatus = "resolved"
)
// IncidentEventType describes a timeline event type.
type IncidentEventType string
const (
IncidentEventAlertFired IncidentEventType = "alert_fired"
IncidentEventAlertAcknowledged IncidentEventType = "alert_acknowledged"
IncidentEventAlertUnacknowledged IncidentEventType = "alert_unacknowledged"
IncidentEventAlertResolved IncidentEventType = "alert_resolved"
IncidentEventAnalysis IncidentEventType = "ai_analysis"
IncidentEventCommand IncidentEventType = "command"
IncidentEventRunbook IncidentEventType = "runbook"
IncidentEventNote IncidentEventType = "note"
)
// IncidentEvent represents a single timeline entry for an incident.
type IncidentEvent struct {
ID string `json:"id"`
Type IncidentEventType `json:"type"`
Timestamp time.Time `json:"timestamp"`
Summary string `json:"summary"`
Details map[string]interface{} `json:"details,omitempty"`
}
// Incident captures an alert occurrence and its timeline.
type Incident struct {
ID string `json:"id"`
AlertID string `json:"alertId"`
AlertType string `json:"alertType"`
Level string `json:"level"`
ResourceID string `json:"resourceId"`
ResourceName string `json:"resourceName"`
ResourceType string `json:"resourceType,omitempty"`
Node string `json:"node,omitempty"`
Instance string `json:"instance,omitempty"`
Message string `json:"message,omitempty"`
Status IncidentStatus `json:"status"`
OpenedAt time.Time `json:"openedAt"`
ClosedAt *time.Time `json:"closedAt,omitempty"`
Acknowledged bool `json:"acknowledged"`
AckUser string `json:"ackUser,omitempty"`
AckTime *time.Time `json:"ackTime,omitempty"`
Events []IncidentEvent `json:"events,omitempty"`
}
// IncidentStoreConfig configures incident retention and persistence.
type IncidentStoreConfig struct {
DataDir string
MaxIncidents int
MaxEventsPerIncident int
MaxAgeDays int
}
// IncidentStore maintains incident timelines and persistence.
type IncidentStore struct {
mu sync.RWMutex
saveMu sync.Mutex
incidents []*Incident
maxIncidents int
maxEvents int
maxAge time.Duration
dataDir string
filePath string
}
const (
defaultIncidentMaxIncidents = 500
defaultIncidentMaxEvents = 120
defaultIncidentMaxAgeDays = 90
incidentFileName = "ai_incidents.json"
maxIncidentFileSize = 20 * 1024 * 1024 // 20MB
incidentStartMatchTolerance = 10 * time.Minute
)
// NewIncidentStore creates a new incident store with persistence.
func NewIncidentStore(cfg IncidentStoreConfig) *IncidentStore {
maxIncidents := cfg.MaxIncidents
if maxIncidents <= 0 {
maxIncidents = defaultIncidentMaxIncidents
}
maxEvents := cfg.MaxEventsPerIncident
if maxEvents <= 0 {
maxEvents = defaultIncidentMaxEvents
}
maxAgeDays := cfg.MaxAgeDays
if maxAgeDays <= 0 {
maxAgeDays = defaultIncidentMaxAgeDays
}
store := &IncidentStore{
incidents: make([]*Incident, 0),
maxIncidents: maxIncidents,
maxEvents: maxEvents,
maxAge: time.Duration(maxAgeDays) * 24 * time.Hour,
dataDir: cfg.DataDir,
}
if store.dataDir != "" {
store.filePath = filepath.Join(store.dataDir, incidentFileName)
if err := store.loadFromDisk(); err != nil {
log.Warn().Err(err).Msg("Failed to load incident history from disk")
} else if len(store.incidents) > 0 {
log.Info().Int("count", len(store.incidents)).Msg("Loaded incident history from disk")
}
}
return store
}
// RecordAlertFired opens or updates an incident for a fired alert.
func (s *IncidentStore) RecordAlertFired(alert *alerts.Alert) {
if alert == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
incident := s.findOpenIncidentByAlertIDLocked(alert.ID)
if incident == nil {
incident = newIncidentFromAlert(alert)
s.incidents = append(s.incidents, incident)
s.addEventLocked(incident, IncidentEventAlertFired, formatAlertSummary(alert), map[string]interface{}{
"type": alert.Type,
"level": string(alert.Level),
"value": alert.Value,
"threshold": alert.Threshold,
})
} else {
updateIncidentFromAlert(incident, alert)
}
s.trimLocked()
s.saveAsync()
}
// RecordAlertAcknowledged records an acknowledgement event for an alert.
func (s *IncidentStore) RecordAlertAcknowledged(alert *alerts.Alert, user string) {
if alert == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
incident := s.ensureIncidentForAlertLocked(alert)
if incident == nil {
return
}
incident.Acknowledged = true
if alert.AckTime != nil {
incident.AckTime = alert.AckTime
} else {
now := time.Now()
incident.AckTime = &now
}
incident.AckUser = user
s.addEventLocked(incident, IncidentEventAlertAcknowledged, "Alert acknowledged", map[string]interface{}{
"user": user,
})
s.trimLocked()
s.saveAsync()
}
// RecordAlertUnacknowledged records an unacknowledge event for an alert.
func (s *IncidentStore) RecordAlertUnacknowledged(alert *alerts.Alert, user string) {
if alert == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
incident := s.ensureIncidentForAlertLocked(alert)
if incident == nil {
return
}
incident.Acknowledged = false
incident.AckTime = nil
incident.AckUser = ""
s.addEventLocked(incident, IncidentEventAlertUnacknowledged, "Alert unacknowledged", map[string]interface{}{
"user": user,
})
s.trimLocked()
s.saveAsync()
}
// RecordAlertResolved records a resolved event and closes the incident.
func (s *IncidentStore) RecordAlertResolved(alert *alerts.Alert, resolvedAt time.Time) {
if alert == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
incident := s.findOpenIncidentByAlertIDLocked(alert.ID)
if incident == nil {
incident = newIncidentFromAlert(alert)
s.incidents = append(s.incidents, incident)
}
incident.Status = IncidentStatusResolved
if resolvedAt.IsZero() {
now := time.Now()
resolvedAt = now
}
incident.ClosedAt = &resolvedAt
s.addEventLocked(incident, IncidentEventAlertResolved, "Alert resolved", map[string]interface{}{
"resolved_at": resolvedAt.Format(time.RFC3339),
})
s.trimLocked()
s.saveAsync()
}
// RecordAnalysis adds an AI analysis event to the incident for an alert.
func (s *IncidentStore) RecordAnalysis(alertID, summary string, details map[string]interface{}) {
if alertID == "" {
return
}
s.mu.Lock()
defer s.mu.Unlock()
incident := s.findLatestIncidentByAlertIDLocked(alertID)
if incident == nil {
incident = &Incident{
ID: generateIncidentID(),
AlertID: alertID,
Status: IncidentStatusOpen,
OpenedAt: time.Now(),
}
s.incidents = append(s.incidents, incident)
}
if summary == "" {
summary = "AI analysis completed"
}
s.addEventLocked(incident, IncidentEventAnalysis, summary, details)
s.trimLocked()
s.saveAsync()
}
// RecordCommand adds a command execution event to the incident for an alert.
func (s *IncidentStore) RecordCommand(alertID, command string, success bool, output string, details map[string]interface{}) {
if alertID == "" || command == "" {
return
}
s.mu.Lock()
defer s.mu.Unlock()
incident := s.findLatestIncidentByAlertIDLocked(alertID)
if incident == nil {
incident = &Incident{
ID: generateIncidentID(),
AlertID: alertID,
Status: IncidentStatusOpen,
OpenedAt: time.Now(),
}
s.incidents = append(s.incidents, incident)
}
if details == nil {
details = make(map[string]interface{})
}
details["command"] = command
details["success"] = success
if output != "" {
details["output_excerpt"] = truncateOutput(output, 500)
}
status := "failed"
if success {
status = "succeeded"
}
summary := fmt.Sprintf("Command %s: %s", status, command)
s.addEventLocked(incident, IncidentEventCommand, summary, details)
s.trimLocked()
s.saveAsync()
}
// RecordRunbook adds a runbook execution event to the incident for an alert.
func (s *IncidentStore) RecordRunbook(alertID, runbookID, title string, outcome string, automatic bool, message string) {
if alertID == "" || runbookID == "" {
return
}
s.mu.Lock()
defer s.mu.Unlock()
incident := s.findLatestIncidentByAlertIDLocked(alertID)
if incident == nil {
incident = &Incident{
ID: generateIncidentID(),
AlertID: alertID,
Status: IncidentStatusOpen,
OpenedAt: time.Now(),
}
s.incidents = append(s.incidents, incident)
}
summary := fmt.Sprintf("Runbook %s (%s)", title, outcome)
details := map[string]interface{}{
"runbook_id": runbookID,
"outcome": outcome,
"automatic": automatic,
}
if message != "" {
details["message"] = message
}
s.addEventLocked(incident, IncidentEventRunbook, summary, details)
s.trimLocked()
s.saveAsync()
}
// RecordNote appends a user note to an incident identified by alert ID or incident ID.
func (s *IncidentStore) RecordNote(alertID, incidentID, note, user string) bool {
note = strings.TrimSpace(note)
if note == "" {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
var incident *Incident
if incidentID != "" {
incident = s.findIncidentByIDLocked(incidentID)
} else if alertID != "" {
incident = s.findLatestIncidentByAlertIDLocked(alertID)
}
if incident == nil {
return false
}
summary := "Note added"
if user != "" {
summary = fmt.Sprintf("Note added by %s", user)
}
s.addEventLocked(incident, IncidentEventNote, summary, map[string]interface{}{
"note": note,
"user": user,
})
s.trimLocked()
s.saveAsync()
return true
}
// GetTimelineByAlertID returns the most recent incident for the alert.
func (s *IncidentStore) GetTimelineByAlertID(alertID string) *Incident {
if alertID == "" {
return nil
}
s.mu.RLock()
defer s.mu.RUnlock()
incident := s.findLatestIncidentByAlertIDLocked(alertID)
if incident == nil {
return nil
}
return cloneIncident(incident)
}
// GetTimelineByAlertAt returns the incident closest to the provided start time for an alert.
func (s *IncidentStore) GetTimelineByAlertAt(alertID string, startedAt time.Time) *Incident {
if alertID == "" {
return nil
}
if startedAt.IsZero() {
return s.GetTimelineByAlertID(alertID)
}
s.mu.RLock()
defer s.mu.RUnlock()
var best *Incident
var bestDelta time.Duration
for _, incident := range s.incidents {
if incident == nil || incident.AlertID != alertID {
continue
}
delta := incident.OpenedAt.Sub(startedAt)
if delta < 0 {
delta = -delta
}
if best == nil || delta < bestDelta {
best = incident
bestDelta = delta
}
}
if best == nil || bestDelta > incidentStartMatchTolerance {
return nil
}
return cloneIncident(best)
}
// ListIncidentsByResource returns recent incidents for a resource.
func (s *IncidentStore) ListIncidentsByResource(resourceID string, limit int) []*Incident {
if resourceID == "" {
return nil
}
s.mu.RLock()
defer s.mu.RUnlock()
var matches []*Incident
for i := len(s.incidents) - 1; i >= 0; i-- {
incident := s.incidents[i]
if incident != nil && incident.ResourceID == resourceID {
matches = append(matches, cloneIncident(incident))
if limit > 0 && len(matches) >= limit {
break
}
}
}
return matches
}
// FormatForAlert returns a condensed incident timeline for prompt injection.
func (s *IncidentStore) FormatForAlert(alertID string, maxEvents int) string {
incident := s.GetTimelineByAlertID(alertID)
if incident == nil {
return ""
}
var b strings.Builder
b.WriteString("\n\n## Incident Memory\n")
b.WriteString(fmt.Sprintf("Alert incident for %s (%s, %s)\n",
incident.ResourceName, incident.AlertType, incident.Level))
b.WriteString(fmt.Sprintf("Status: %s\n", incident.Status))
events := incident.Events
if maxEvents > 0 && len(events) > maxEvents {
events = events[len(events)-maxEvents:]
}
for _, evt := range events {
b.WriteString("- ")
b.WriteString(evt.Timestamp.Format(time.RFC3339))
b.WriteString(": ")
b.WriteString(evt.Summary)
b.WriteString("\n")
}
return b.String()
}
// FormatForResource returns a condensed incident summary for a resource.
func (s *IncidentStore) FormatForResource(resourceID string, limit int) string {
incidents := s.ListIncidentsByResource(resourceID, limit)
if len(incidents) == 0 {
return ""
}
var b strings.Builder
b.WriteString("\n\n## Incident Memory\n")
b.WriteString("Recent incidents for this resource:\n")
for _, incident := range incidents {
status := string(incident.Status)
if incident.Acknowledged && incident.Status == IncidentStatusOpen {
status = "acknowledged"
}
b.WriteString("- ")
b.WriteString(incident.OpenedAt.Format(time.RFC3339))
b.WriteString(": ")
b.WriteString(incident.AlertType)
if incident.Level != "" {
b.WriteString(" (")
b.WriteString(incident.Level)
b.WriteString(")")
}
b.WriteString(" - ")
b.WriteString(status)
b.WriteString("\n")
}
return b.String()
}
// FormatForPatrol returns a condensed incident summary for infrastructure-wide patrol analysis.
func (s *IncidentStore) FormatForPatrol(limit int) string {
if limit <= 0 {
limit = 8
}
s.mu.RLock()
defer s.mu.RUnlock()
if len(s.incidents) == 0 {
return ""
}
var b strings.Builder
b.WriteString("\n\n## Incident Memory\n")
b.WriteString("Recent incidents across infrastructure:\n")
count := 0
for i := len(s.incidents) - 1; i >= 0 && count < limit; i-- {
incident := s.incidents[i]
if incident == nil {
continue
}
status := string(incident.Status)
if incident.Acknowledged && incident.Status == IncidentStatusOpen {
status = "acknowledged"
}
lastSummary := ""
if len(incident.Events) > 0 {
lastSummary = incident.Events[len(incident.Events)-1].Summary
}
b.WriteString("- ")
b.WriteString(incident.OpenedAt.Format(time.RFC3339))
b.WriteString(": ")
if incident.ResourceName != "" {
b.WriteString(incident.ResourceName)
b.WriteString(" - ")
}
if incident.AlertType != "" {
b.WriteString(incident.AlertType)
}
if incident.Level != "" {
b.WriteString(" (")
b.WriteString(incident.Level)
b.WriteString(")")
}
b.WriteString(" - ")
b.WriteString(status)
if lastSummary != "" {
b.WriteString(" - last: ")
b.WriteString(truncateOutput(lastSummary, 80))
} else if incident.Message != "" {
b.WriteString(" - ")
b.WriteString(truncateOutput(incident.Message, 80))
}
b.WriteString("\n")
count++
}
return b.String()
}
func newIncidentFromAlert(alert *alerts.Alert) *Incident {
openedAt := alert.StartTime
if openedAt.IsZero() {
openedAt = time.Now()
}
return &Incident{
ID: generateIncidentID(),
AlertID: alert.ID,
AlertType: alert.Type,
Level: string(alert.Level),
ResourceID: alert.ResourceID,
ResourceName: alert.ResourceName,
Node: alert.Node,
Instance: alert.Instance,
Message: alert.Message,
Status: IncidentStatusOpen,
OpenedAt: openedAt,
Acknowledged: alert.Acknowledged,
AckUser: alert.AckUser,
AckTime: alert.AckTime,
Events: make([]IncidentEvent, 0),
}
}
func updateIncidentFromAlert(incident *Incident, alert *alerts.Alert) {
if incident == nil || alert == nil {
return
}
incident.AlertType = alert.Type
incident.Level = string(alert.Level)
incident.ResourceID = alert.ResourceID
incident.ResourceName = alert.ResourceName
incident.Node = alert.Node
incident.Instance = alert.Instance
incident.Message = alert.Message
incident.Acknowledged = alert.Acknowledged
incident.AckUser = alert.AckUser
incident.AckTime = alert.AckTime
}
func (s *IncidentStore) ensureIncidentForAlertLocked(alert *alerts.Alert) *Incident {
incident := s.findLatestIncidentByAlertIDLocked(alert.ID)
if incident == nil {
incident = newIncidentFromAlert(alert)
s.incidents = append(s.incidents, incident)
}
updateIncidentFromAlert(incident, alert)
return incident
}
func (s *IncidentStore) addEventLocked(incident *Incident, eventType IncidentEventType, summary string, details map[string]interface{}) {
if incident == nil {
return
}
if summary == "" {
summary = string(eventType)
}
event := IncidentEvent{
ID: generateIncidentEventID(),
Type: eventType,
Timestamp: time.Now(),
Summary: summary,
Details: details,
}
incident.Events = append(incident.Events, event)
if s.maxEvents > 0 && len(incident.Events) > s.maxEvents {
incident.Events = incident.Events[len(incident.Events)-s.maxEvents:]
}
}
func (s *IncidentStore) findOpenIncidentByAlertIDLocked(alertID string) *Incident {
if alertID == "" {
return nil
}
for i := len(s.incidents) - 1; i >= 0; i-- {
incident := s.incidents[i]
if incident != nil && incident.AlertID == alertID && incident.Status == IncidentStatusOpen {
return incident
}
}
return nil
}
func (s *IncidentStore) findLatestIncidentByAlertIDLocked(alertID string) *Incident {
if alertID == "" {
return nil
}
for i := len(s.incidents) - 1; i >= 0; i-- {
incident := s.incidents[i]
if incident != nil && incident.AlertID == alertID {
return incident
}
}
return nil
}
func (s *IncidentStore) findIncidentByIDLocked(incidentID string) *Incident {
if incidentID == "" {
return nil
}
for i := len(s.incidents) - 1; i >= 0; i-- {
incident := s.incidents[i]
if incident != nil && incident.ID == incidentID {
return incident
}
}
return nil
}
func (s *IncidentStore) trimLocked() {
if s.maxAge > 0 {
cutoff := time.Now().Add(-s.maxAge)
filtered := make([]*Incident, 0, len(s.incidents))
for _, incident := range s.incidents {
if incident == nil {
continue
}
compareTime := incident.OpenedAt
if incident.ClosedAt != nil {
compareTime = *incident.ClosedAt
}
if compareTime.After(cutoff) {
filtered = append(filtered, incident)
}
}
s.incidents = filtered
}
if s.maxIncidents > 0 && len(s.incidents) > s.maxIncidents {
sort.Slice(s.incidents, func(i, j int) bool {
return s.incidents[i].OpenedAt.Before(s.incidents[j].OpenedAt)
})
if len(s.incidents) > s.maxIncidents {
s.incidents = s.incidents[len(s.incidents)-s.maxIncidents:]
}
}
}
func (s *IncidentStore) saveAsync() {
if s.dataDir == "" || s.filePath == "" {
return
}
go func() {
if err := s.saveToDisk(); err != nil {
log.Warn().Err(err).Msg("Failed to save incident history")
}
}()
}
func (s *IncidentStore) saveToDisk() error {
s.saveMu.Lock()
defer s.saveMu.Unlock()
if s.dataDir == "" || s.filePath == "" {
return nil
}
if err := os.MkdirAll(s.dataDir, 0755); err != nil {
return err
}
s.mu.RLock()
snapshot := make([]*Incident, 0, len(s.incidents))
for _, incident := range s.incidents {
snapshot = append(snapshot, cloneIncident(incident))
}
s.mu.RUnlock()
data, err := json.Marshal(snapshot)
if err != nil {
return err
}
tmpFile := s.filePath + ".tmp"
if err := os.WriteFile(tmpFile, data, 0644); err != nil {
return err
}
if err := os.Rename(tmpFile, s.filePath); err != nil {
return err
}
return nil
}
func (s *IncidentStore) loadFromDisk() error {
if s.filePath == "" {
return nil
}
info, err := os.Stat(s.filePath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if info.Size() > maxIncidentFileSize {
return fmt.Errorf("incident history file too large (%d bytes)", info.Size())
}
data, err := os.ReadFile(s.filePath)
if err != nil {
return err
}
var incidents []*Incident
if err := json.Unmarshal(data, &incidents); err != nil {
return err
}
s.incidents = incidents
s.trimLocked()
return nil
}
func cloneIncident(src *Incident) *Incident {
if src == nil {
return nil
}
clone := *src
if src.AckTime != nil {
t := *src.AckTime
clone.AckTime = &t
}
if src.ClosedAt != nil {
t := *src.ClosedAt
clone.ClosedAt = &t
}
if len(src.Events) > 0 {
clone.Events = make([]IncidentEvent, len(src.Events))
for i, event := range src.Events {
cloneEvent := event
if event.Details != nil {
detailsCopy := make(map[string]interface{}, len(event.Details))
for key, value := range event.Details {
detailsCopy[key] = value
}
cloneEvent.Details = detailsCopy
}
clone.Events[i] = cloneEvent
}
}
return &clone
}
var incidentCounter int64
func generateIncidentID() string {
incidentCounter++
return "inc-" + time.Now().Format("20060102150405") + "-" + intToString(int(incidentCounter%1000))
}
var incidentEventCounter int64
func generateIncidentEventID() string {
incidentEventCounter++
return "inc-evt-" + time.Now().Format("20060102150405") + "-" + intToString(int(incidentEventCounter%1000))
}
func formatAlertSummary(alert *alerts.Alert) string {
if alert == nil {
return "Alert triggered"
}
summary := fmt.Sprintf("Alert triggered: %s (%s)", alert.Type, alert.Level)
if alert.Value > 0 || alert.Threshold > 0 {
summary = fmt.Sprintf("Alert triggered: %s (%s %.1f >= %.1f)", alert.Type, alert.Level, alert.Value, alert.Threshold)
}
return summary
}

View file

@ -0,0 +1,108 @@
package memory
import (
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
)
func TestIncidentStore_RecordTimeline(t *testing.T) {
store := NewIncidentStore(IncidentStoreConfig{
DataDir: t.TempDir(),
MaxIncidents: 10,
MaxEventsPerIncident: 10,
MaxAgeDays: 30,
})
alert := &alerts.Alert{
ID: "alert-1",
Type: "cpu",
Level: alerts.AlertLevelWarning,
ResourceID: "res-1",
ResourceName: "vm-1",
StartTime: time.Now().Add(-5 * time.Minute),
Value: 92,
Threshold: 85,
}
store.RecordAlertFired(alert)
store.RecordAlertAcknowledged(alert, "admin")
store.RecordAnalysis(alert.ID, "analysis complete", map[string]interface{}{
"findings": 1,
})
store.RecordCommand(alert.ID, "systemctl restart nginx", true, "ok", nil)
store.RecordAlertResolved(alert, time.Now())
timeline := store.GetTimelineByAlertID(alert.ID)
if timeline == nil {
t.Fatalf("expected timeline, got nil")
}
if timeline.Status != IncidentStatusResolved {
t.Fatalf("expected status %q, got %q", IncidentStatusResolved, timeline.Status)
}
if timeline.AckUser != "admin" {
t.Fatalf("expected ack user admin, got %q", timeline.AckUser)
}
if len(timeline.Events) < 4 {
t.Fatalf("expected events recorded, got %d", len(timeline.Events))
}
if ok := store.RecordNote(alert.ID, "", "note text", ""); !ok {
t.Fatalf("expected note to be saved")
}
}
func TestIncidentStore_GetTimelineByAlertAt(t *testing.T) {
store := NewIncidentStore(IncidentStoreConfig{
DataDir: t.TempDir(),
MaxIncidents: 10,
MaxEventsPerIncident: 10,
MaxAgeDays: 30,
})
base := time.Now().UTC()
first := &alerts.Alert{
ID: "alert-2",
Type: "cpu",
Level: alerts.AlertLevelWarning,
ResourceID: "res-2",
ResourceName: "vm-2",
StartTime: base.Add(-2 * time.Hour),
}
second := &alerts.Alert{
ID: "alert-2",
Type: "cpu",
Level: alerts.AlertLevelWarning,
ResourceID: "res-2",
ResourceName: "vm-2",
StartTime: base.Add(-10 * time.Minute),
}
store.RecordAlertFired(first)
store.RecordAlertResolved(first, base.Add(-90*time.Minute))
store.RecordAlertFired(second)
timeline := store.GetTimelineByAlertAt(first.ID, first.StartTime)
if timeline == nil {
t.Fatalf("expected timeline for first incident, got nil")
}
if !timeline.OpenedAt.Equal(first.StartTime) {
t.Fatalf("expected openedAt %s, got %s", first.StartTime, timeline.OpenedAt)
}
timeline = store.GetTimelineByAlertAt(second.ID, second.StartTime)
if timeline == nil {
t.Fatalf("expected timeline for second incident, got nil")
}
if !timeline.OpenedAt.Equal(second.StartTime) {
t.Fatalf("expected openedAt %s, got %s", second.StartTime, timeline.OpenedAt)
}
timeline = store.GetTimelineByAlertAt(second.ID, base.Add(-45*time.Minute))
if timeline != nil {
t.Fatalf("expected no timeline for mismatched start time")
}
}

View file

@ -13,6 +13,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/ai/baseline" "github.com/rcourtman/pulse-go-rewrite/internal/ai/baseline"
aicontext "github.com/rcourtman/pulse-go-rewrite/internal/ai/context" aicontext "github.com/rcourtman/pulse-go-rewrite/internal/ai/context"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/knowledge" "github.com/rcourtman/pulse-go-rewrite/internal/ai/knowledge"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/memory"
"github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@ -217,6 +218,7 @@ type PatrolService struct {
remediationLog *RemediationLog // For tracking remediation actions remediationLog *RemediationLog // For tracking remediation actions
patternDetector *PatternDetector // For failure prediction from historical patterns patternDetector *PatternDetector // For failure prediction from historical patterns
correlationDetector *CorrelationDetector // For multi-resource correlation correlationDetector *CorrelationDetector // For multi-resource correlation
incidentStore *memory.IncidentStore // For incident timeline capture
// Cached thresholds (recalculated when thresholdProvider changes) // Cached thresholds (recalculated when thresholdProvider changes)
thresholds PatrolThresholds thresholds PatrolThresholds
@ -263,6 +265,20 @@ func NewPatrolService(aiService *Service, stateProvider StateProvider) *PatrolSe
} }
} }
// SetIncidentStore attaches an incident store for alert timeline capture.
func (p *PatrolService) SetIncidentStore(store *memory.IncidentStore) {
p.mu.Lock()
defer p.mu.Unlock()
p.incidentStore = store
}
// GetIncidentStore returns the incident store if configured.
func (p *PatrolService) GetIncidentStore() *memory.IncidentStore {
p.mu.RLock()
defer p.mu.RUnlock()
return p.incidentStore
}
// SetConfig updates the patrol configuration // SetConfig updates the patrol configuration
func (p *PatrolService) SetConfig(cfg PatrolConfig) { func (p *PatrolService) SetConfig(cfg PatrolConfig) {
p.mu.Lock() p.mu.Lock()
@ -2185,12 +2201,17 @@ func (p *PatrolService) buildPatrolPrompt(summary string) string {
// Get resource notes from knowledge store (per-resource user notes) // Get resource notes from knowledge store (per-resource user notes)
var knowledgeContext string var knowledgeContext string
var incidentContext string
p.mu.RLock() p.mu.RLock()
knowledgeStore := p.knowledgeStore knowledgeStore := p.knowledgeStore
incidentStore := p.incidentStore
p.mu.RUnlock() p.mu.RUnlock()
if knowledgeStore != nil { if knowledgeStore != nil {
knowledgeContext = knowledgeStore.FormatAllForContext() knowledgeContext = knowledgeStore.FormatAllForContext()
} }
if incidentStore != nil {
incidentContext = incidentStore.FormatForPatrol(8)
}
basePrompt := fmt.Sprintf(`Please perform a comprehensive analysis of the following infrastructure and identify any issues, potential problems, or optimization opportunities. basePrompt := fmt.Sprintf(`Please perform a comprehensive analysis of the following infrastructure and identify any issues, potential problems, or optimization opportunities.
@ -2235,6 +2256,12 @@ IMPORTANT: Respect the user's feedback above. Do NOT re-raise findings that are:
Only report NEW issues or issues where the severity has clearly escalated.`) Only report NEW issues or issues where the severity has clearly escalated.`)
} }
if incidentContext != "" {
contextAdditions.WriteString("\n\n")
contextAdditions.WriteString(incidentContext)
contextAdditions.WriteString("\nIMPORTANT: Use incident memory to avoid repeating known issues and to build on successful past investigations.")
}
if contextAdditions.Len() > 0 { if contextAdditions.Len() > 0 {
return basePrompt + contextAdditions.String() return basePrompt + contextAdditions.String()
} }

View file

@ -323,6 +323,10 @@ func (p *PatrolService) logRunbookExecution(finding *Finding, runbook Runbook, s
if err := p.remediationLog.Log(record); err != nil { if err := p.remediationLog.Log(record); err != nil {
log.Warn().Err(err).Msg("Failed to log runbook execution") log.Warn().Err(err).Msg("Failed to log runbook execution")
} }
if p.aiService != nil && finding != nil && finding.AlertID != "" {
p.aiService.RecordIncidentRunbook(finding.AlertID, runbook.ID, runbook.Title, outcome, automatic, message)
}
} }
type runbookContext struct { type runbookContext struct {

View file

@ -18,8 +18,10 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec" "github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/baseline"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/cost" "github.com/rcourtman/pulse-go-rewrite/internal/ai/cost"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/knowledge" "github.com/rcourtman/pulse-go-rewrite/internal/ai/knowledge"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/memory"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers" "github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
"github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/models"
@ -76,6 +78,7 @@ type Service struct {
resourceProvider ResourceProvider // Unified resource model provider (Phase 2) resourceProvider ResourceProvider // Unified resource model provider (Phase 2)
patrolService *PatrolService // Background AI monitoring service patrolService *PatrolService // Background AI monitoring service
metadataProvider MetadataProvider // Enables AI to update resource URLs metadataProvider MetadataProvider // Enables AI to update resource URLs
incidentStore *memory.IncidentStore // Incident timelines for alert memory
// Alert-triggered analysis - token-efficient real-time AI insights // Alert-triggered analysis - token-efficient real-time AI insights
alertTriggeredAnalyzer *AlertTriggeredAnalyzer alertTriggeredAnalyzer *AlertTriggeredAnalyzer
@ -197,6 +200,9 @@ func (s *Service) SetStateProvider(sp StateProvider) {
if s.knowledgeStore != nil { if s.knowledgeStore != nil {
s.patrolService.SetKnowledgeStore(s.knowledgeStore) s.patrolService.SetKnowledgeStore(s.knowledgeStore)
} }
if s.incidentStore != nil {
s.patrolService.SetIncidentStore(s.incidentStore)
}
} }
// Initialize alert-triggered analyzer if not already done // Initialize alert-triggered analyzer if not already done
@ -350,6 +356,17 @@ func (s *Service) SetRemediationLog(remLog *RemediationLog) {
} }
} }
// SetIncidentStore sets the incident store for alert timeline memory.
func (s *Service) SetIncidentStore(store *memory.IncidentStore) {
s.mu.Lock()
defer s.mu.Unlock()
s.incidentStore = store
if s.patrolService != nil {
s.patrolService.SetIncidentStore(store)
}
}
// SetPatternDetector sets the pattern detector for failure prediction // SetPatternDetector sets the pattern detector for failure prediction
func (s *Service) SetPatternDetector(detector *PatternDetector) { func (s *Service) SetPatternDetector(detector *PatternDetector) {
s.mu.RLock() s.mu.RLock()
@ -2183,11 +2200,34 @@ func (s *Service) executeTool(ctx context.Context, req ExecuteRequest, tc provid
// Execute via agent // Execute via agent
result, err := s.executeOnAgent(ctx, execReq, command) result, err := s.executeOnAgent(ctx, execReq, command)
recordIncident := func(success bool, output string) {
alertID := extractAlertID(req.Context)
if alertID == "" {
return
}
s.mu.RLock()
incidentStore := s.incidentStore
s.mu.RUnlock()
if incidentStore == nil {
return
}
details := map[string]interface{}{
"resource_id": req.TargetID,
"resource_type": req.TargetType,
"run_on_host": runOnHost,
}
if targetHost != "" {
details["target_host"] = targetHost
}
incidentStore.RecordCommand(alertID, command, success, output, details)
}
if err != nil { if err != nil {
recordIncident(false, result)
execution.Output = fmt.Sprintf("Error executing command: %s", err) execution.Output = fmt.Sprintf("Error executing command: %s", err)
return execution.Output, execution return execution.Output, execution
} }
recordIncident(true, result)
execution.Output = result execution.Output = result
execution.Success = true execution.Success = true
return result, execution return result, execution
@ -2842,6 +2882,31 @@ This is a 3-command job. Don't over-investigate.`
// Add current alert status - this gives AI awareness of active issues // Add current alert status - this gives AI awareness of active issues
prompt += s.buildAlertContext() prompt += s.buildAlertContext()
// Add incident memory for alert or resource context
alertID := ""
resourceID := req.TargetID
if req.Context != nil {
if val, ok := req.Context["alertId"].(string); ok && val != "" {
alertID = val
} else if val, ok := req.Context["alert_id"].(string); ok && val != "" {
alertID = val
}
if resourceID == "" {
if val, ok := req.Context["resourceId"].(string); ok && val != "" {
resourceID = val
} else if val, ok := req.Context["resource_id"].(string); ok && val != "" {
resourceID = val
} else if val, ok := req.Context["guest_id"].(string); ok && val != "" {
resourceID = val
}
}
}
if incidentContext := s.buildIncidentContext(resourceID, alertID); incidentContext != "" {
prompt += incidentContext
}
// Add all saved knowledge when no specific target is selected // Add all saved knowledge when no specific target is selected
// This gives the AI context about everything learned from previous sessions // This gives the AI context about everything learned from previous sessions
if req.TargetType == "" && s.knowledgeStore != nil { if req.TargetType == "" && s.knowledgeStore != nil {
@ -2877,6 +2942,7 @@ This is a 3-command job. Don't over-investigate.`
// Add past remediation history for this resource // Add past remediation history for this resource
prompt += s.buildRemediationContext(req.TargetID, req.Prompt) prompt += s.buildRemediationContext(req.TargetID, req.Prompt)
} }
// Add any provided context in a structured way // Add any provided context in a structured way
@ -3258,6 +3324,66 @@ func (s *Service) buildRemediationContext(resourceID, currentProblem string) str
return context return context
} }
// buildIncidentContext adds incident timeline context for alerts/resources.
func (s *Service) buildIncidentContext(resourceID, alertID string) string {
s.mu.RLock()
store := s.incidentStore
s.mu.RUnlock()
if store == nil {
return ""
}
if alertID != "" {
return store.FormatForAlert(alertID, 8)
}
if resourceID != "" {
return store.FormatForResource(resourceID, 4)
}
return ""
}
// RecordIncidentAnalysis stores an AI analysis event for an alert.
func (s *Service) RecordIncidentAnalysis(alertID, summary string, details map[string]interface{}) {
if alertID == "" {
return
}
s.mu.RLock()
store := s.incidentStore
s.mu.RUnlock()
if store == nil {
return
}
store.RecordAnalysis(alertID, summary, details)
}
// RecordIncidentRunbook stores a runbook execution event for an alert.
func (s *Service) RecordIncidentRunbook(alertID, runbookID, title string, outcome memory.Outcome, automatic bool, message string) {
if alertID == "" || runbookID == "" {
return
}
s.mu.RLock()
store := s.incidentStore
s.mu.RUnlock()
if store == nil {
return
}
store.RecordRunbook(alertID, runbookID, title, string(outcome), automatic, message)
}
func extractAlertID(ctx map[string]interface{}) string {
if ctx == nil {
return ""
}
if alertID, ok := ctx["alertId"].(string); ok && alertID != "" {
return alertID
}
if alertID, ok := ctx["alert_id"].(string); ok && alertID != "" {
return alertID
}
return ""
}
// truncateString truncates a string to maxLen characters // truncateString truncates a string to maxLen characters
func truncateString(s string, maxLen int) string { func truncateString(s string, maxLen int) string {
if len(s) <= maxLen { if len(s) <= maxLen {
@ -3302,40 +3428,51 @@ func (s *Service) buildEnrichedResourceContext(resourceID, resourceType string,
return 0, false return 0, false
} }
// Check CPU baseline // Helper to format baseline comparison with status
if cpuVal, ok := getRawValue("cpu_usage_raw", "cpu_usage"); ok { // ALWAYS shows baseline info when available - this is Pulse Pro's value-add
if bl, exists := baselineStore.GetBaseline(resourceID, "cpu"); exists && bl.SampleCount >= 10 { formatBaselineComparison := func(metric string, currentVal float64, bl *baseline.MetricBaseline) string {
ratio := cpuVal / bl.Mean if bl.SampleCount < 10 {
if ratio > 1.5 { return fmt.Sprintf("- %s: %.1f%% (baseline learning: %d/10 samples)", metric, currentVal, bl.SampleCount)
baselineInfo = append(baselineInfo, fmt.Sprintf("CPU %.0f%% is **%.1fx higher** than baseline %.0f%% (σ=%.1f)", cpuVal, ratio, bl.Mean, bl.StdDev)) }
ratio := currentVal / bl.Mean
status := "✓ normal"
if ratio > 2.0 {
status = "🔴 **ANOMALY**"
} else if ratio > 1.5 {
status = "🟡 elevated"
} else if ratio < 0.3 {
status = "🔵 unusually low"
} else if ratio < 0.5 { } else if ratio < 0.5 {
baselineInfo = append(baselineInfo, fmt.Sprintf("CPU %.0f%% is **%.1fx lower** than baseline %.0f%%", cpuVal, 1/ratio, bl.Mean)) status = "low"
} }
return fmt.Sprintf("- %s: %.1f%% vs baseline %.1f%% (σ=%.1f) → %s", metric, currentVal, bl.Mean, bl.StdDev, status)
}
// Check CPU baseline - always show if we have data
if cpuVal, ok := getRawValue("cpu_usage_raw", "cpu_usage"); ok {
if bl, exists := baselineStore.GetBaseline(resourceID, "cpu"); exists {
baselineInfo = append(baselineInfo, formatBaselineComparison("CPU", cpuVal, bl))
} }
} }
// Check memory baseline // Check memory baseline - always show if we have data
if memVal, ok := getRawValue("memory_usage_raw", "memory_usage"); ok { if memVal, ok := getRawValue("memory_usage_raw", "memory_usage"); ok {
if bl, exists := baselineStore.GetBaseline(resourceID, "memory"); exists && bl.SampleCount >= 10 { if bl, exists := baselineStore.GetBaseline(resourceID, "memory"); exists {
ratio := memVal / bl.Mean baselineInfo = append(baselineInfo, formatBaselineComparison("Memory", memVal, bl))
if ratio > 1.3 {
baselineInfo = append(baselineInfo, fmt.Sprintf("Memory %.0f%% is **%.1fx higher** than baseline %.0f%%", memVal, ratio, bl.Mean))
}
} }
} }
// Check disk baseline // Check disk baseline - always show if we have data
if diskVal, ok := getRawValue("disk_usage_raw", "disk_usage"); ok { if diskVal, ok := getRawValue("disk_usage_raw", "disk_usage"); ok {
if bl, exists := baselineStore.GetBaseline(resourceID, "disk"); exists && bl.SampleCount >= 10 { if bl, exists := baselineStore.GetBaseline(resourceID, "disk"); exists {
ratio := diskVal / bl.Mean baselineInfo = append(baselineInfo, formatBaselineComparison("Disk", diskVal, bl))
if ratio > 1.2 { // More sensitive for disk
baselineInfo = append(baselineInfo, fmt.Sprintf("Disk %.0f%% is **%.1fx higher** than baseline %.0f%%", diskVal, ratio, bl.Mean))
}
} }
} }
if len(baselineInfo) > 0 { if len(baselineInfo) > 0 {
sections = append(sections, "### Baseline Comparisons\n"+strings.Join(baselineInfo, "\n")) sections = append(sections, "### Learned Baselines (7-day patterns)\n"+strings.Join(baselineInfo, "\n"))
} }
} }

View file

@ -490,6 +490,8 @@ type Manager struct {
historyManager *HistoryManager historyManager *HistoryManager
onAlert func(alert *Alert) onAlert func(alert *Alert)
onResolved func(alertID string) onResolved func(alertID string)
onAcknowledged func(alert *Alert, user string)
onUnacknowledged func(alert *Alert, user string)
onEscalate func(alert *Alert, level int) onEscalate func(alert *Alert, level int)
escalationStop chan struct{} escalationStop chan struct{}
alertRateLimit map[string][]time.Time // Track alert times for rate limiting alertRateLimit map[string][]time.Time // Track alert times for rate limiting
@ -736,6 +738,20 @@ func (m *Manager) SetResolvedCallback(cb func(alertID string)) {
m.onResolved = cb m.onResolved = cb
} }
// SetAcknowledgedCallback sets the callback for acknowledged alerts.
func (m *Manager) SetAcknowledgedCallback(cb func(alert *Alert, user string)) {
m.mu.Lock()
defer m.mu.Unlock()
m.onAcknowledged = cb
}
// SetUnacknowledgedCallback sets the callback for unacknowledged alerts.
func (m *Manager) SetUnacknowledgedCallback(cb func(alert *Alert, user string)) {
m.mu.Lock()
defer m.mu.Unlock()
m.onUnacknowledged = cb
}
// SetEscalateCallback sets the callback for escalated alerts // SetEscalateCallback sets the callback for escalated alerts
func (m *Manager) SetEscalateCallback(cb func(alert *Alert, level int)) { func (m *Manager) SetEscalateCallback(cb func(alert *Alert, level int)) {
m.mu.Lock() m.mu.Lock()
@ -768,6 +784,46 @@ func (m *Manager) safeCallResolvedCallback(alertID string, async bool) {
} }
} }
// safeCallAcknowledgedCallback invokes onAcknowledged with panic recovery and alert cloning.
func (m *Manager) safeCallAcknowledgedCallback(alert *Alert, user string) {
if m.onAcknowledged == nil || alert == nil {
return
}
alertCopy := alert.Clone()
go func(a *Alert, u string) {
defer func() {
if r := recover(); r != nil {
log.Error().
Interface("panic", r).
Str("alertID", a.ID).
Msg("Panic in onAcknowledged callback")
}
}()
m.onAcknowledged(a, u)
}(alertCopy, user)
}
// safeCallUnacknowledgedCallback invokes onUnacknowledged with panic recovery and alert cloning.
func (m *Manager) safeCallUnacknowledgedCallback(alert *Alert, user string) {
if m.onUnacknowledged == nil || alert == nil {
return
}
alertCopy := alert.Clone()
go func(a *Alert, u string) {
defer func() {
if r := recover(); r != nil {
log.Error().
Interface("panic", r).
Str("alertID", a.ID).
Msg("Panic in onUnacknowledged callback")
}
}()
m.onUnacknowledged(a, u)
}(alertCopy, user)
}
// safeCallEscalateCallback invokes onEscalate with panic recovery and alert cloning // safeCallEscalateCallback invokes onEscalate with panic recovery and alert cloning
func (m *Manager) safeCallEscalateCallback(alert *Alert, level int) { func (m *Manager) safeCallEscalateCallback(alert *Alert, level int) {
if m.onEscalate == nil { if m.onEscalate == nil {
@ -2457,7 +2513,6 @@ func (m *Manager) hasHostAgentForNode(nodeName string) bool {
return exists return exists
} }
func hostResourceID(hostID string) string { func hostResourceID(hostID string) string {
trimmed := strings.TrimSpace(hostID) trimmed := strings.TrimSpace(hostID)
if trimmed == "" { if trimmed == "" {
@ -5767,10 +5822,10 @@ func abs(x float64) float64 {
// AcknowledgeAlert acknowledges an alert // AcknowledgeAlert acknowledges an alert
func (m *Manager) AcknowledgeAlert(alertID, user string) error { func (m *Manager) AcknowledgeAlert(alertID, user string) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock()
alert, exists := m.activeAlerts[alertID] alert, exists := m.activeAlerts[alertID]
if !exists { if !exists {
m.mu.Unlock()
return fmt.Errorf("alert not found: %s", alertID) return fmt.Errorf("alert not found: %s", alertID)
} }
@ -5787,22 +5842,26 @@ func (m *Manager) AcknowledgeAlert(alertID, user string) error {
time: now, time: now,
} }
alertCopy := alert.Clone()
m.mu.Unlock()
log.Debug(). log.Debug().
Str("alertID", alertID). Str("alertID", alertID).
Str("user", user). Str("user", user).
Time("ackTime", now). Time("ackTime", now).
Msg("Alert acknowledgment recorded") Msg("Alert acknowledgment recorded")
m.safeCallAcknowledgedCallback(alertCopy, user)
return nil return nil
} }
// UnacknowledgeAlert removes the acknowledged status from an alert // UnacknowledgeAlert removes the acknowledged status from an alert
func (m *Manager) UnacknowledgeAlert(alertID string) error { func (m *Manager) UnacknowledgeAlert(alertID string) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock()
alert, exists := m.activeAlerts[alertID] alert, exists := m.activeAlerts[alertID]
if !exists { if !exists {
m.mu.Unlock()
return fmt.Errorf("alert not found: %s", alertID) return fmt.Errorf("alert not found: %s", alertID)
} }
@ -5814,10 +5873,14 @@ func (m *Manager) UnacknowledgeAlert(alertID string) error {
m.activeAlerts[alertID] = alert m.activeAlerts[alertID] = alert
delete(m.ackState, alertID) delete(m.ackState, alertID)
alertCopy := alert.Clone()
m.mu.Unlock()
log.Info(). log.Info().
Str("alertID", alertID). Str("alertID", alertID).
Msg("Alert unacknowledged") Msg("Alert unacknowledged")
m.safeCallUnacknowledgedCallback(alertCopy, "")
return nil return nil
} }
@ -7593,9 +7656,8 @@ func (m *Manager) ClearAlert(alertID string) bool {
// Cleanup removes old acknowledged alerts and cleans up tracking maps // Cleanup removes old acknowledged alerts and cleans up tracking maps
func (m *Manager) Cleanup(maxAge time.Duration) { func (m *Manager) Cleanup(maxAge time.Duration) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock()
now := time.Now() now := time.Now()
var autoAcked []*Alert
// Auto-acknowledge old alerts if configured // Auto-acknowledge old alerts if configured
if m.config.AutoAcknowledgeAfterHours > 0 { if m.config.AutoAcknowledgeAfterHours > 0 {
@ -7610,6 +7672,7 @@ func (m *Manager) Cleanup(maxAge time.Duration) {
ackTime := now ackTime := now
alert.AckTime = &ackTime alert.AckTime = &ackTime
alert.AckUser = "system-auto" alert.AckUser = "system-auto"
autoAcked = append(autoAcked, alert.Clone())
if recordAlertAcknowledged != nil { if recordAlertAcknowledged != nil {
recordAlertAcknowledged() recordAlertAcknowledged()
@ -7776,6 +7839,12 @@ func (m *Manager) Cleanup(maxAge time.Duration) {
Msg("Cleaned up stale PMG quarantine history") Msg("Cleaned up stale PMG quarantine history")
} }
} }
m.mu.Unlock()
for _, alert := range autoAcked {
m.safeCallAcknowledgedCallback(alert, "system-auto")
}
} }
// convertLegacyThreshold converts a legacy float64 threshold to HysteresisThreshold // convertLegacyThreshold converts a legacy float64 threshold to HysteresisThreshold

View file

@ -19,6 +19,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec" "github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rcourtman/pulse-go-rewrite/internal/ai" "github.com/rcourtman/pulse-go-rewrite/internal/ai"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/cost" "github.com/rcourtman/pulse-go-rewrite/internal/ai/cost"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/memory"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers" "github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
"github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/license" "github.com/rcourtman/pulse-go-rewrite/internal/license"
@ -118,6 +119,11 @@ func (h *AISettingsHandler) SetRemediationLog(remLog *ai.RemediationLog) {
h.aiService.SetRemediationLog(remLog) h.aiService.SetRemediationLog(remLog)
} }
// SetIncidentStore sets the incident store for alert timelines.
func (h *AISettingsHandler) SetIncidentStore(store *memory.IncidentStore) {
h.aiService.SetIncidentStore(store)
}
// SetPatternDetector sets the pattern detector for failure prediction // SetPatternDetector sets the pattern detector for failure prediction
func (h *AISettingsHandler) SetPatternDetector(detector *ai.PatternDetector) { func (h *AISettingsHandler) SetPatternDetector(detector *ai.PatternDetector) {
h.aiService.SetPatternDetector(detector) h.aiService.SetPatternDetector(detector)
@ -1767,11 +1773,29 @@ func (h *AISettingsHandler) HandleInvestigateAlert(w http.ResponseWriter, r *htt
data, _ := json.Marshal(finalEvent) data, _ := json.Marshal(finalEvent)
safeWrite([]byte("data: " + string(data) + "\n\n")) safeWrite([]byte("data: " + string(data) + "\n\n"))
if req.AlertID != "" {
h.aiService.RecordIncidentAnalysis(req.AlertID, "AI alert investigation completed", map[string]interface{}{
"model": resp.Model,
"tool_calls": len(resp.ToolCalls),
"input_tokens": resp.InputTokens,
"output_tokens": resp.OutputTokens,
})
}
log.Info(). log.Info().
Str("alert_id", req.AlertID). Str("alert_id", req.AlertID).
Str("model", resp.Model). Str("model", resp.Model).
Int("tool_calls", len(resp.ToolCalls)). Int("tool_calls", len(resp.ToolCalls)).
Msg("AI alert investigation completed") Msg("AI alert investigation completed")
if req.AlertID != "" {
h.aiService.RecordIncidentAnalysis(req.AlertID, "AI investigation completed", map[string]interface{}{
"model": resp.Model,
"input_tokens": resp.InputTokens,
"output_tokens": resp.OutputTokens,
"tool_calls": len(resp.ToolCalls),
})
}
} }
// SetAlertProvider sets the alert provider for AI context // SetAlertProvider sets the alert provider for AI context

View file

@ -8,6 +8,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/memory"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts" "github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/mock" "github.com/rcourtman/pulse-go-rewrite/internal/mock"
@ -361,6 +362,130 @@ func (h *AlertHandlers) GetAlertHistory(w http.ResponseWriter, r *http.Request)
} }
} }
// GetAlertIncidentTimeline returns the incident timeline for an alert or resource.
func (h *AlertHandlers) GetAlertIncidentTimeline(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
store := h.monitor.GetIncidentStore()
if store == nil {
http.Error(w, "Incident store unavailable", http.StatusServiceUnavailable)
return
}
query := r.URL.Query()
alertID := strings.TrimSpace(query.Get("alert_id"))
resourceID := strings.TrimSpace(query.Get("resource_id"))
startedAtRaw := strings.TrimSpace(query.Get("started_at"))
if startedAtRaw == "" {
startedAtRaw = strings.TrimSpace(query.Get("start_time"))
}
limit := 0
if rawLimit := strings.TrimSpace(query.Get("limit")); rawLimit != "" {
if parsed, err := strconv.Atoi(rawLimit); err == nil && parsed > 0 {
limit = parsed
}
}
if alertID != "" {
if !validateAlertID(alertID) {
http.Error(w, "Invalid alert ID", http.StatusBadRequest)
return
}
var startedAt time.Time
if startedAtRaw != "" {
parsed, err := time.Parse(time.RFC3339, startedAtRaw)
if err != nil {
http.Error(w, "Invalid started_at time", http.StatusBadRequest)
return
}
startedAt = parsed
}
var incident *memory.Incident
if !startedAt.IsZero() {
incident = store.GetTimelineByAlertAt(alertID, startedAt)
} else {
incident = store.GetTimelineByAlertID(alertID)
}
if err := utils.WriteJSONResponse(w, incident); err != nil {
log.Error().Err(err).Msg("Failed to write incident timeline response")
}
return
}
if resourceID != "" {
if len(resourceID) > 500 {
http.Error(w, "Invalid resource ID", http.StatusBadRequest)
return
}
incidents := store.ListIncidentsByResource(resourceID, limit)
if err := utils.WriteJSONResponse(w, incidents); err != nil {
log.Error().Err(err).Msg("Failed to write incident list response")
}
return
}
http.Error(w, "Missing alert_id or resource_id", http.StatusBadRequest)
}
// SaveAlertIncidentNote stores a user note in the incident timeline.
func (h *AlertHandlers) SaveAlertIncidentNote(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
store := h.monitor.GetIncidentStore()
if store == nil {
http.Error(w, "Incident store unavailable", http.StatusServiceUnavailable)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 8*1024)
var req struct {
AlertID string `json:"alert_id"`
IncidentID string `json:"incident_id"`
Note string `json:"note"`
User string `json:"user,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
req.AlertID = strings.TrimSpace(req.AlertID)
req.IncidentID = strings.TrimSpace(req.IncidentID)
req.Note = strings.TrimSpace(req.Note)
req.User = strings.TrimSpace(req.User)
if req.AlertID == "" && req.IncidentID == "" {
http.Error(w, "alert_id or incident_id is required", http.StatusBadRequest)
return
}
if req.AlertID != "" && !validateAlertID(req.AlertID) {
http.Error(w, "Invalid alert ID", http.StatusBadRequest)
return
}
if req.Note == "" {
http.Error(w, "note is required", http.StatusBadRequest)
return
}
if ok := store.RecordNote(req.AlertID, req.IncidentID, req.Note, req.User); !ok {
http.Error(w, "Failed to save note", http.StatusBadRequest)
return
}
if err := utils.WriteJSONResponse(w, map[string]interface{}{
"success": true,
}); err != nil {
log.Error().Err(err).Msg("Failed to write incident note response")
}
}
// ClearAlertHistory clears all alert history // ClearAlertHistory clears all alert history
func (h *AlertHandlers) ClearAlertHistory(w http.ResponseWriter, r *http.Request) { func (h *AlertHandlers) ClearAlertHistory(w http.ResponseWriter, r *http.Request) {
if err := h.monitor.GetAlertManager().ClearAlertHistory(); err != nil { if err := h.monitor.GetAlertManager().ClearAlertHistory(); err != nil {
@ -737,6 +862,16 @@ func (h *AlertHandlers) HandleAlerts(w http.ResponseWriter, r *http.Request) {
return return
} }
h.GetAlertHistory(w, r) h.GetAlertHistory(w, r)
case path == "incidents" && r.Method == http.MethodGet:
if !ensureScope(w, r, config.ScopeMonitoringRead) {
return
}
h.GetAlertIncidentTimeline(w, r)
case path == "incidents/note" && r.Method == http.MethodPost:
if !ensureScope(w, r, config.ScopeMonitoringWrite) {
return
}
h.SaveAlertIncidentNote(w, r)
case path == "history" && r.Method == http.MethodDelete: case path == "history" && r.Method == http.MethodDelete:
if !ensureScope(w, r, config.ScopeMonitoringWrite) { if !ensureScope(w, r, config.ScopeMonitoringWrite) {
return return

View file

@ -1109,6 +1109,9 @@ func (r *Router) setupRoutes() {
if alertManager := r.monitor.GetAlertManager(); alertManager != nil { if alertManager := r.monitor.GetAlertManager(); alertManager != nil {
r.aiSettingsHandler.SetAlertProvider(ai.NewAlertManagerAdapter(alertManager)) r.aiSettingsHandler.SetAlertProvider(ai.NewAlertManagerAdapter(alertManager))
} }
if incidentStore := r.monitor.GetIncidentStore(); incidentStore != nil {
r.aiSettingsHandler.SetIncidentStore(incidentStore)
}
} }
// Inject unified resource provider for Phase 2 AI context (cleaner, deduplicated view) // Inject unified resource provider for Phase 2 AI context (cleaner, deduplicated view)
if r.resourceHandlers != nil { if r.resourceHandlers != nil {

View file

@ -20,6 +20,7 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/memory"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts" "github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/discovery" "github.com/rcourtman/pulse-go-rewrite/internal/discovery"
@ -604,6 +605,7 @@ type Monitor struct {
metricsHistory *MetricsHistory metricsHistory *MetricsHistory
metricsStore *metrics.Store // Persistent SQLite metrics storage metricsStore *metrics.Store // Persistent SQLite metrics storage
alertManager *alerts.Manager alertManager *alerts.Manager
incidentStore *memory.IncidentStore
notificationMgr *notifications.NotificationManager notificationMgr *notifications.NotificationManager
configPersist *config.ConfigPersistence configPersist *config.ConfigPersistence
discoveryService *discovery.Service // Background discovery service discoveryService *discovery.Service // Background discovery service
@ -3050,6 +3052,10 @@ func New(cfg *config.Config) (*Monitor, error) {
Msg("Persistent metrics store initialized with configurable retention") Msg("Persistent metrics store initialized with configurable retention")
} }
incidentStore := memory.NewIncidentStore(memory.IncidentStoreConfig{
DataDir: cfg.DataPath,
})
m := &Monitor{ m := &Monitor{
config: cfg, config: cfg,
state: models.NewState(), state: models.NewState(),
@ -3076,6 +3082,7 @@ func New(cfg *config.Config) (*Monitor, error) {
metricsHistory: NewMetricsHistory(1000, 24*time.Hour), // Keep up to 1000 points or 24 hours metricsHistory: NewMetricsHistory(1000, 24*time.Hour), // Keep up to 1000 points or 24 hours
metricsStore: metricsStore, // Persistent SQLite storage metricsStore: metricsStore, // Persistent SQLite storage
alertManager: alerts.NewManager(), alertManager: alerts.NewManager(),
incidentStore: incidentStore,
notificationMgr: notifications.NewNotificationManager(cfg.PublicURL), notificationMgr: notifications.NewNotificationManager(cfg.PublicURL),
configPersist: config.NewConfigPersistence(cfg.DataPath), configPersist: config.NewConfigPersistence(cfg.DataPath),
discoveryService: nil, // Will be initialized in Start() discoveryService: nil, // Will be initialized in Start()
@ -3756,25 +3763,19 @@ func (m *Monitor) Start(ctx context.Context, wsHub *websocket.Hub) {
// Set up alert callbacks // Set up alert callbacks
m.alertManager.SetAlertCallback(func(alert *alerts.Alert) { m.alertManager.SetAlertCallback(func(alert *alerts.Alert) {
wsHub.BroadcastAlert(alert) m.handleAlertFired(alert)
// Send notifications
log.Debug().
Str("alertID", alert.ID).
Str("level", string(alert.Level)).
Msg("Alert raised, sending to notification manager")
go m.notificationMgr.SendAlert(alert)
}) })
m.alertManager.SetResolvedCallback(func(alertID string) { m.alertManager.SetResolvedCallback(func(alertID string) {
wsHub.BroadcastAlertResolved(alertID) m.handleAlertResolved(alertID)
m.notificationMgr.CancelAlert(alertID)
if m.notificationMgr.GetNotifyOnResolve() {
if resolved := m.alertManager.GetResolvedAlert(alertID); resolved != nil {
go m.notificationMgr.SendResolvedAlert(resolved)
}
}
// Don't broadcast full state here - it causes a cascade with many guests. // Don't broadcast full state here - it causes a cascade with many guests.
// The frontend will get the updated alerts through the regular broadcast ticker. // The frontend will get the updated alerts through the regular broadcast ticker.
}) })
m.alertManager.SetAcknowledgedCallback(func(alert *alerts.Alert, user string) {
m.handleAlertAcknowledged(alert, user)
})
m.alertManager.SetUnacknowledgedCallback(func(alert *alerts.Alert, user string) {
m.handleAlertUnacknowledged(alert, user)
})
m.alertManager.SetEscalateCallback(func(alert *alerts.Alert, level int) { m.alertManager.SetEscalateCallback(func(alert *alerts.Alert, level int) {
log.Info(). log.Info().
Str("alertID", alert.ID). Str("alertID", alert.ID).
@ -7731,6 +7732,11 @@ func (m *Monitor) GetAlertManager() *alerts.Manager {
return m.alertManager return m.alertManager
} }
// GetIncidentStore returns the incident timeline store.
func (m *Monitor) GetIncidentStore() *memory.IncidentStore {
return m.incidentStore
}
// SetAlertTriggeredAICallback sets an additional callback for AI analysis when alerts fire // SetAlertTriggeredAICallback sets an additional callback for AI analysis when alerts fire
// This enables token-efficient, real-time AI insights on specific resources // This enables token-efficient, real-time AI insights on specific resources
func (m *Monitor) SetAlertTriggeredAICallback(callback func(*alerts.Alert)) { func (m *Monitor) SetAlertTriggeredAICallback(callback func(*alerts.Alert)) {
@ -7738,31 +7744,67 @@ func (m *Monitor) SetAlertTriggeredAICallback(callback func(*alerts.Alert)) {
return return
} }
// Get the current callback
originalCallback := m.alertManager
// Wrap the existing callback to also call the AI callback // Wrap the existing callback to also call the AI callback
m.alertManager.SetAlertCallback(func(alert *alerts.Alert) { m.alertManager.SetAlertCallback(func(alert *alerts.Alert) {
// Broadcast to WebSocket (this happens via the callback set in Start()) m.handleAlertFired(alert)
// Trigger AI analysis
go callback(alert)
})
log.Info().Msg("Alert-triggered AI callback registered")
}
func (m *Monitor) handleAlertFired(alert *alerts.Alert) {
if alert == nil {
return
}
if m.wsHub != nil { if m.wsHub != nil {
m.wsHub.BroadcastAlert(alert) m.wsHub.BroadcastAlert(alert)
} }
// Send notifications
log.Debug(). log.Debug().
Str("alertID", alert.ID). Str("alertID", alert.ID).
Str("level", string(alert.Level)). Str("level", string(alert.Level)).
Msg("Alert raised, sending to notification manager") Msg("Alert raised, sending to notification manager")
go m.notificationMgr.SendAlert(alert) go m.notificationMgr.SendAlert(alert)
// Trigger AI analysis if m.incidentStore != nil {
go callback(alert) m.incidentStore.RecordAlertFired(alert)
}) }
}
// Avoid unused variable warning func (m *Monitor) handleAlertResolved(alertID string) {
_ = originalCallback if m.wsHub != nil {
m.wsHub.BroadcastAlertResolved(alertID)
}
m.notificationMgr.CancelAlert(alertID)
if m.notificationMgr.GetNotifyOnResolve() {
if resolved := m.alertManager.GetResolvedAlert(alertID); resolved != nil {
go m.notificationMgr.SendResolvedAlert(resolved)
}
}
log.Info().Msg("Alert-triggered AI callback registered") if m.incidentStore != nil {
if resolved := m.alertManager.GetResolvedAlert(alertID); resolved != nil && resolved.Alert != nil {
m.incidentStore.RecordAlertResolved(resolved.Alert, resolved.ResolvedTime)
}
}
}
func (m *Monitor) handleAlertAcknowledged(alert *alerts.Alert, user string) {
if m.incidentStore == nil || alert == nil {
return
}
m.incidentStore.RecordAlertAcknowledged(alert, user)
}
func (m *Monitor) handleAlertUnacknowledged(alert *alerts.Alert, user string) {
if m.incidentStore == nil || alert == nil {
return
}
m.incidentStore.RecordAlertUnacknowledged(alert, user)
} }
// SetResourceStore sets the resource store for polling optimization. // SetResourceStore sets the resource store for polling optimization.