feat(ai): Enhanced AI patrol system with alert triggers and history persistence

- Add alert-triggered AI analysis for real-time incident response
- Implement patrol history persistence across restarts
- Add patrol schedule configuration UI in AI Settings
- Enhance AIChat with patrol status and manual trigger controls
- Add resource store improvements for AI context building
- Expand Alerts page with AI-powered analysis integration
- Add Vite proxy config for AI API endpoints
- Support both Anthropic and OpenAI providers with streaming
This commit is contained in:
rcourtman 2025-12-10 21:08:22 +00:00
parent 9fb11605a4
commit c88e2db7b4
21 changed files with 3328 additions and 423 deletions

View file

@ -187,6 +187,9 @@ func runServer() {
// Start AI patrol service for background infrastructure monitoring
router.StartPatrol(ctx)
// Wire alert-triggered AI analysis (token-efficient real-time insights when alerts fire)
router.WireAlertTriggeredAI()
// Create HTTP server with unified configuration
// In production, serve everything (frontend + API) on the frontend port
// NOTE: We use ReadHeaderTimeout instead of ReadTimeout to avoid affecting

View file

@ -3,6 +3,8 @@
* Provides access to background AI monitoring findings and status
*/
import { apiFetchJSON } from '@/utils/apiClient';
export type FindingSeverity = 'info' | 'watch' | 'warning' | 'critical';
export type FindingCategory = 'performance' | 'capacity' | 'reliability' | 'backup' | 'security' | 'general';
@ -43,10 +45,40 @@ export interface PatrolStatus {
last_duration_ms: number;
resources_checked: number;
findings_count: number;
error_count: number;
healthy: boolean;
interval_ms: number; // Patrol interval in milliseconds
summary: FindingsSummary;
}
export interface PatrolRunRecord {
id: string;
started_at: string;
completed_at: string;
duration_ms: number;
type: 'quick' | 'deep';
resources_checked: number;
// Breakdown by resource type
nodes_checked: number;
guests_checked: number;
docker_checked: number;
storage_checked: number;
hosts_checked: number;
pbs_checked: number;
// Findings from this run
new_findings: number;
existing_findings: number;
resolved_findings: number;
findings_summary: string;
finding_ids: string[];
error_count: number;
status: 'healthy' | 'issues_found' | 'critical' | 'error';
// AI Analysis details
ai_analysis?: string; // The AI's raw response/analysis
input_tokens?: number; // Tokens sent to AI
output_tokens?: number; // Tokens received from AI
}
/**
* Get the current AI patrol status
*/
@ -83,14 +115,7 @@ export async function getFindings(resourceId?: string): Promise<Finding[]> {
*/
export async function forcePatrol(deep: boolean = false): Promise<{ success: boolean; message: string }> {
const url = deep ? '/api/ai/patrol/run?deep=true' : '/api/ai/patrol/run';
const resp = await fetch(url, {
method: 'POST',
credentials: 'include',
});
if (!resp.ok) {
throw new Error(`Failed to trigger patrol: ${resp.status}`);
}
return resp.json();
return apiFetchJSON(url, { method: 'POST' });
}
/**
@ -111,23 +136,33 @@ export async function getFindingsHistory(startTime?: string): Promise<Finding[]>
return resp.json();
}
/**
* Get the history of patrol check runs
* @param limit Maximum number of records to return (default: 50, max: 100)
*/
export async function getPatrolRunHistory(limit?: number): Promise<PatrolRunRecord[]> {
const url = limit
? `/api/ai/patrol/runs?limit=${limit}`
: '/api/ai/patrol/runs';
const resp = await fetch(url, {
method: 'GET',
credentials: 'include',
});
if (!resp.ok) {
throw new Error(`Failed to get patrol run history: ${resp.status}`);
}
return resp.json();
}
/**
* Acknowledge a finding (marks as seen but keeps visible, like alert acknowledgement)
* Finding will auto-resolve when the underlying condition clears.
*/
export async function acknowledgeFinding(findingId: string): Promise<{ success: boolean; message: string }> {
const resp = await fetch('/api/ai/patrol/acknowledge', {
return apiFetchJSON('/api/ai/patrol/acknowledge', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ finding_id: findingId }),
});
if (!resp.ok) {
throw new Error(`Failed to acknowledge finding: ${resp.status}`);
}
return resp.json();
}
/**
@ -136,18 +171,21 @@ export async function acknowledgeFinding(findingId: string): Promise<{ success:
* @param durationHours Duration in hours (e.g., 1, 24, 168 for 7 days)
*/
export async function snoozeFinding(findingId: string, durationHours: number): Promise<{ success: boolean; message: string }> {
const resp = await fetch('/api/ai/patrol/snooze', {
return apiFetchJSON('/api/ai/patrol/snooze', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ finding_id: findingId, duration_hours: durationHours }),
});
if (!resp.ok) {
throw new Error(`Failed to snooze finding: ${resp.status}`);
}
return resp.json();
}
/**
* Manually resolve a finding (mark as fixed)
* @param findingId The ID of the finding to resolve
*/
export async function resolveFinding(findingId: string): Promise<{ success: boolean; message: string }> {
return apiFetchJSON('/api/ai/patrol/resolve', {
method: 'POST',
body: JSON.stringify({ finding_id: findingId }),
});
}
/**
@ -189,3 +227,45 @@ export function formatTimestamp(ts: string): string {
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
/**
* Event types from patrol stream
*/
export interface PatrolStreamEvent {
type: 'start' | 'content' | 'thinking' | 'phase' | 'complete' | 'error';
content?: string;
phase?: string;
tokens?: number;
}
/**
* Subscribe to live patrol stream via SSE
* Returns an unsubscribe function
*/
export function subscribeToPatrolStream(
onEvent: (event: PatrolStreamEvent) => void,
onError?: (error: Error) => void
): () => void {
const eventSource = new EventSource('/api/ai/patrol/stream', { withCredentials: true });
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as PatrolStreamEvent;
onEvent(data);
} catch (e) {
console.error('Failed to parse patrol stream event:', e);
}
};
eventSource.onerror = () => {
if (onError) {
onError(new Error('Patrol stream connection error'));
}
eventSource.close();
};
// Return unsubscribe function
return () => {
eventSource.close();
};
}

View file

@ -246,6 +246,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
);
const [input, setInput] = createSignal('');
const [isLoading, setIsLoading] = createSignal(false);
const [queuedMessage, setQueuedMessage] = createSignal<string | null>(null);
let messagesEndRef: HTMLDivElement | undefined;
let inputRef: HTMLTextAreaElement | undefined;
let abortControllerRef: AbortController | null = null;
@ -284,6 +285,22 @@ export const AIChat: Component<AIChatProps> = (props) => {
}
});
// Auto-send queued message when AI finishes processing
createEffect(() => {
const loading = isLoading();
const queued = queuedMessage();
// When loading finishes and we have a queued message, send it
if (!loading && queued) {
logger.info('[AIChat] AI finished, auto-sending queued message', { prompt: queued.substring(0, 50) });
setQueuedMessage(null);
// Small delay to let the UI update first
setTimeout(() => {
handleSubmit(undefined, queued);
}, 100);
}
});
const generateId = () => Math.random().toString(36).substring(2, 9);
// Stop/cancel the current AI request
@ -300,13 +317,23 @@ export const AIChat: Component<AIChatProps> = (props) => {
: msg
)
);
// Clear queued message when stopping - user likely doesn't want it sent
setQueuedMessage(null);
setIsLoading(false);
};
const handleSubmit = async (e?: Event) => {
const handleSubmit = async (e?: Event, forcePrompt?: string) => {
e?.preventDefault();
const prompt = input().trim();
if (!prompt || isLoading()) return;
const prompt = forcePrompt || input().trim();
if (!prompt) return;
// If AI is currently working, queue this message for later
if (isLoading() && !forcePrompt) {
setQueuedMessage(prompt);
setInput('');
logger.info('[AIChat] Message queued while AI is working', { prompt: prompt.substring(0, 50) });
return;
}
// IMPORTANT: Capture the current messages BEFORE adding new ones to avoid race conditions
// SolidJS batches updates, so messages() may not be updated synchronously
@ -1256,18 +1283,47 @@ export const AIChat: Component<AIChatProps> = (props) => {
/>
</Show>
</div>
{/* Queued message indicator */}
<Show when={queuedMessage()}>
<div class="flex items-center gap-2 px-3 py-2 mb-2 text-xs rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700 text-amber-700 dark:text-amber-300">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="flex-1 truncate">
<span class="font-medium">Queued:</span> "{queuedMessage()!.substring(0, 50)}{queuedMessage()!.length > 50 ? '...' : ''}"
</span>
<button
type="button"
onClick={() => setQueuedMessage(null)}
class="p-0.5 rounded hover:bg-amber-200 dark:hover:bg-amber-800 transition-colors"
title="Cancel queued message"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</Show>
<form onSubmit={handleSubmit} class="flex gap-2">
<textarea
ref={inputRef}
value={input()}
onInput={(e) => setInput(e.currentTarget.value)}
onKeyDown={handleKeyDown}
placeholder={aiChatStore.contextItems.length > 0
? `Ask about ${aiChatStore.contextItems.length} item${aiChatStore.contextItems.length > 1 ? 's' : ''} in context...`
: "Ask about your infrastructure..."}
placeholder={
isLoading()
? queuedMessage()
? "Type another message to replace queued..."
: "Type to queue your next message..."
: aiChatStore.contextItems.length > 0
? `Ask about ${aiChatStore.contextItems.length} item${aiChatStore.contextItems.length > 1 ? 's' : ''} in context...`
: "Ask about your infrastructure..."
}
rows={2}
class="flex-1 px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-none"
disabled={isLoading()}
class={`flex-1 px-3 py-2 text-sm rounded-lg border bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:border-transparent resize-none transition-colors ${isLoading()
? 'border-amber-300 dark:border-amber-600 focus:ring-amber-500'
: 'border-gray-300 dark:border-gray-600 focus:ring-purple-500'
}`}
/>
<Show
when={isLoading()}
@ -1289,20 +1345,37 @@ export const AIChat: Component<AIChatProps> = (props) => {
</button>
}
>
<button
type="button"
onClick={handleStop}
class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors self-end"
title="Stop generating"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" rx="1" />
</svg>
</button>
<div class="flex flex-col gap-1 self-end">
{/* Queue button when AI is working */}
<button
type="submit"
disabled={!input().trim()}
class="px-4 py-2 bg-amber-500 text-white rounded-lg hover:bg-amber-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title={queuedMessage() ? "Replace queued message" : "Queue message for when AI finishes"}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
{/* Stop button */}
<button
type="button"
onClick={handleStop}
class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
title="Stop generating"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" rx="1" />
</svg>
</button>
</div>
</Show>
</form>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-2">
Press Enter to send, Shift+Enter for new line
{isLoading()
? "Type and press Enter to queue your next message"
: "Press Enter to send, Shift+Enter for new line"
}
</p>
</div>
</Show>

View file

@ -35,6 +35,9 @@ export const AISettings: Component = () => {
clearApiKey: false,
autonomousMode: false,
authMethod: 'api_key' as AuthMethod,
patrolSchedulePreset: '6hr',
alertTriggeredAnalysis: true,
patrolAutoFix: false,
});
const resetForm = (data: AISettingsType | null) => {
@ -48,6 +51,9 @@ export const AISettings: Component = () => {
clearApiKey: false,
autonomousMode: false,
authMethod: 'api_key',
patrolSchedulePreset: '6hr',
alertTriggeredAnalysis: true,
patrolAutoFix: false,
});
return;
}
@ -61,6 +67,9 @@ export const AISettings: Component = () => {
clearApiKey: false,
autonomousMode: data.autonomous_mode || false,
authMethod: data.auth_method || 'api_key',
patrolSchedulePreset: data.patrol_schedule_preset || '6hr',
alertTriggeredAnalysis: data.alert_triggered_analysis !== false, // default to true
patrolAutoFix: data.patrol_auto_fix || false, // default to false (observe only)
});
};
@ -148,6 +157,19 @@ export const AISettings: Component = () => {
payload.autonomous_mode = form.autonomousMode;
}
// Include patrol settings if changed
if (form.patrolSchedulePreset !== settings()?.patrol_schedule_preset) {
payload.patrol_schedule_preset = form.patrolSchedulePreset;
}
if (form.alertTriggeredAnalysis !== settings()?.alert_triggered_analysis) {
payload.alert_triggered_analysis = form.alertTriggeredAnalysis;
}
if (form.patrolAutoFix !== settings()?.patrol_auto_fix) {
payload.patrol_auto_fix = form.patrolAutoFix;
}
const updated = await AIAPI.updateSettings(payload);
setSettings(updated);
resetForm(updated);
@ -630,6 +652,89 @@ export const AISettings: Component = () => {
</div>
</div>
{/* AI Patrol & Efficiency Settings */}
<div class={`${formField} p-4 rounded-lg border bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800`}>
<div class="mb-3">
<label class={`${labelClass()} flex items-center gap-2`}>
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
AI Patrol & Token Efficiency
</label>
<p class="text-xs text-blue-700 dark:text-blue-300 mt-1">
Configure how AI monitors your infrastructure. Balance between coverage and token usage.
</p>
</div>
{/* Patrol Schedule Preset */}
<div class="space-y-3">
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Background Patrol Frequency
</label>
<select
class={controlClass()}
value={form.patrolSchedulePreset}
onChange={(e) => setForm('patrolSchedulePreset', e.currentTarget.value)}
disabled={saving()}
>
<option value="15min">Every 15 minutes (high token usage)</option>
<option value="1hr">Every hour</option>
<option value="6hr">Every 6 hours (recommended)</option>
<option value="12hr">Every 12 hours</option>
<option value="daily">Once daily</option>
<option value="disabled">Disabled (use alert-triggered only)</option>
</select>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
How often to scan all infrastructure for potential issues
</p>
</div>
{/* Alert-Triggered Analysis Toggle */}
<div class="flex items-start justify-between gap-4 pt-2">
<div class="flex-1">
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 flex items-center gap-2">
Alert-Triggered Analysis
<span class="px-1.5 py-0.5 text-[10px] font-semibold bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 rounded">
TOKEN EFFICIENT
</span>
</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
When enabled, AI automatically analyzes specific resources when alerts fire.
Uses minimal tokens since it only analyzes affected resources.
</p>
</div>
<Toggle
checked={form.alertTriggeredAnalysis}
onChange={(event) => setForm('alertTriggeredAnalysis', event.currentTarget.checked)}
disabled={saving()}
/>
</div>
{/* Auto-Fix Mode Toggle */}
<div class="flex items-start justify-between gap-4 pt-3 mt-3 border-t border-blue-200 dark:border-blue-800">
<div class="flex-1">
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 flex items-center gap-2">
Auto-Fix Mode
<span class="px-1.5 py-0.5 text-[10px] font-semibold bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300 rounded">
ADVANCED
</span>
</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
When enabled, patrol can attempt automatic remediation of issues.
When disabled (default), patrol only observes and reports - it won't make changes.
</p>
</div>
<Toggle
checked={form.patrolAutoFix}
onChange={(event) => setForm('patrolAutoFix', event.currentTarget.checked)}
disabled={saving()}
/>
</div>
</div>
</div>
</div>
{/* Status indicator */}

View file

@ -28,7 +28,7 @@ import History from 'lucide-solid/icons/history';
import Gauge from 'lucide-solid/icons/gauge';
import Send from 'lucide-solid/icons/send';
import Calendar from 'lucide-solid/icons/calendar';
import { getPatrolStatus, getFindings, getFindingsHistory, acknowledgeFinding, snoozeFinding, type Finding, type PatrolStatus, severityColors, formatTimestamp } from '@/api/patrol';
import { getPatrolStatus, getFindings, getFindingsHistory, getPatrolRunHistory, forcePatrol, subscribeToPatrolStream, type Finding, type PatrolStatus, type PatrolRunRecord, severityColors, formatTimestamp } from '@/api/patrol';
import { aiChatStore } from '@/stores/aiChat';
type AlertTab = 'overview' | 'thresholds' | 'destinations' | 'schedule' | 'history';
@ -2042,22 +2042,215 @@ function OverviewTab(props: {
// AI Patrol findings state
const [aiFindings, setAiFindings] = createSignal<Finding[]>([]);
const [patrolStatus, setPatrolStatus] = createSignal<PatrolStatus | null>(null);
const [processingFindings, setProcessingFindings] = createSignal<Set<string>>(new Set());
const [patrolRunHistory, setPatrolRunHistory] = createSignal<PatrolRunRecord[]>([]);
// Track findings user marked as "I Fixed It" - hidden until next patrol verifies
const [pendingFixFindings, setPendingFixFindings] = createSignal<Set<string>>(new Set());
const [lastKnownPatrolAt, setLastKnownPatrolAt] = createSignal<string | null>(null);
const [showRunHistory, setShowRunHistory] = createSignal(false);
const [forcePatrolLoading, setForcePatrolLoading] = createSignal(false);
const [expandedRunId, setExpandedRunId] = createSignal<string | null>(null);
const [historyTimeFilter, setHistoryTimeFilter] = createSignal<'24h' | '7d' | 'all'>('all');
// Live streaming state for running patrol
const [expandedLiveStream, setExpandedLiveStream] = createSignal(false);
// Track streaming blocks for sequential display (like AI chat)
interface StreamBlock {
type: 'phase' | 'content' | 'thinking';
text: string;
timestamp: number;
}
const [liveStreamBlocks, setLiveStreamBlocks] = createSignal<StreamBlock[]>([]);
const [currentThinking, setCurrentThinking] = createSignal('');
let liveStreamUnsubscribe: (() => void) | null = null;
// Effect to manage live stream subscription when expanded
createEffect(() => {
const isExpanded = expandedLiveStream();
const isRunning = patrolStatus()?.running;
if (isExpanded && isRunning && !liveStreamUnsubscribe) {
// Subscribe to stream
liveStreamUnsubscribe = subscribeToPatrolStream(
(event) => {
if (event.type === 'start') {
// Clear previous content
setLiveStreamBlocks([]);
setCurrentThinking('');
} else if (event.type === 'thinking' && event.content) {
// Thinking events are separate blocks - just like AI chat
// Finalize any current content first
const current = currentThinking();
if (current.trim()) {
setLiveStreamBlocks(prev => [...prev, {
type: 'thinking',
text: current.trim(),
timestamp: Date.now()
}]);
setCurrentThinking('');
}
// Add the thinking chunk as a new block
setLiveStreamBlocks(prev => [...prev, {
type: 'thinking',
text: event.content!.trim(),
timestamp: Date.now()
}]);
} else if (event.type === 'content' && event.content) {
// Content streams into current block
setCurrentThinking(prev => prev + event.content);
} else if (event.type === 'complete') {
// Finalize current thinking block
const finalThinking = currentThinking();
if (finalThinking.trim()) {
setLiveStreamBlocks(prev => [...prev, {
type: 'thinking',
text: finalThinking.trim(),
timestamp: Date.now()
}]);
setCurrentThinking('');
}
// Mark as complete
setLiveStreamBlocks(prev => [...prev, {
type: 'phase',
text: 'Analysis complete',
timestamp: Date.now()
}]);
// Patrol completed, refresh data
fetchAiData();
}
// Ignore 'phase' events - they're internal
},
() => {
// Error - just log it
console.error('Patrol stream error');
}
);
} else if ((!isExpanded || !isRunning) && liveStreamUnsubscribe) {
// Unsubscribe
liveStreamUnsubscribe();
liveStreamUnsubscribe = null;
if (!isRunning) {
setLiveStreamBlocks([]);
setCurrentThinking('');
setExpandedLiveStream(false);
}
}
});
// Cleanup on unmount
onCleanup(() => {
if (liveStreamUnsubscribe) {
liveStreamUnsubscribe();
liveStreamUnsubscribe = null;
}
});
// Fetch AI data - extracted for reuse
const fetchAiData = async () => {
try {
const [status, findings, runHistory] = await Promise.all([
getPatrolStatus(),
getFindings(),
getPatrolRunHistory(50) // Fetch more for filtering
]);
// Check if a new patrol has completed - if so, clear pending fix findings
const newPatrolAt = status.last_patrol_at;
if (newPatrolAt && newPatrolAt !== lastKnownPatrolAt()) {
setLastKnownPatrolAt(newPatrolAt);
// Clear pending fixes - the patrol has now verified what's actually fixed
if (pendingFixFindings().size > 0) {
setPendingFixFindings(new Set<string>());
}
}
setPatrolStatus(status);
setAiFindings(findings);
setPatrolRunHistory(runHistory);
// Auto-expand history if most recent run found issues
if (runHistory.length > 0 && runHistory[0].status !== 'healthy') {
setShowRunHistory(true);
}
} catch (e) {
// AI patrol may not be enabled - silently fail
}
};
// Handle force patrol button click
const handleForcePatrol = async (deep: boolean = false) => {
setForcePatrolLoading(true);
try {
const result = await forcePatrol(deep);
if (!result.success) {
showError(result.message || 'Failed to start patrol');
setForcePatrolLoading(false);
return;
}
showSuccess('Patrol started - results will appear shortly');
// Wait a bit for the patrol to start and potentially complete
setTimeout(() => {
fetchAiData();
setForcePatrolLoading(false);
}, 2000);
} catch (e) {
console.error('Force patrol error:', e);
showError('Failed to start patrol: ' + (e instanceof Error ? e.message : 'Unknown error'));
setForcePatrolLoading(false);
}
};
// Filter patrol history by time
const filteredPatrolHistory = createMemo(() => {
const history = patrolRunHistory();
const filter = historyTimeFilter();
if (filter === 'all') return history;
const now = Date.now();
const cutoffs = {
'24h': now - 24 * 60 * 60 * 1000,
'7d': now - 7 * 24 * 60 * 60 * 1000,
};
const cutoff = cutoffs[filter];
return history.filter(r => new Date(r.completed_at).getTime() > cutoff);
});
// Calculate next patrol time
const nextPatrolIn = createMemo(() => {
const status = patrolStatus();
if (!status) return null;
let nextPatrolTime: number;
// Use next_patrol_at from backend if available
if (status.next_patrol_at) {
nextPatrolTime = new Date(status.next_patrol_at).getTime();
} else if (status.last_patrol_at && status.interval_ms) {
// Calculate from last patrol + interval
const lastPatrol = new Date(status.last_patrol_at).getTime();
nextPatrolTime = lastPatrol + status.interval_ms;
} else {
return null;
}
const now = Date.now();
const remainingMs = nextPatrolTime - now;
if (remainingMs <= 0) return 'Soon';
const hours = Math.floor(remainingMs / 3600000);
const minutes = Math.floor((remainingMs % 3600000) / 60000);
const seconds = Math.floor((remainingMs % 60000) / 1000);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
if (minutes > 0) {
return `${minutes}m ${seconds}s`;
}
return `${seconds}s`;
});
// Fetch AI findings on mount and every 30 seconds
onMount(() => {
const fetchAiData = async () => {
try {
const [status, findings] = await Promise.all([
getPatrolStatus(),
getFindings()
]);
setPatrolStatus(status);
setAiFindings(findings);
} catch (e) {
// AI patrol may not be enabled - silently fail
}
};
fetchAiData();
const interval = setInterval(fetchAiData, 30000);
onCleanup(() => clearInterval(interval));
@ -2229,46 +2422,131 @@ function OverviewTab(props: {
{/* AI Insights Section - show when AI tab selected and there are findings */}
<Show when={overviewSubTab() === 'ai-insights' && patrolStatus()?.enabled}>
<div>
<SectionHeader
title="AI Insights"
size="md"
class="mb-3"
/>
<div class="text-xs text-gray-500 dark:text-gray-400 mb-3 flex items-center gap-2">
<svg class="w-4 h-4 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Proactively detected by AI patrol Last check: {patrolStatus()?.last_patrol_at ? formatTimestamp(patrolStatus()!.last_patrol_at!) : 'never'}</span>
<div class="flex items-center justify-between mb-3">
<SectionHeader
title="AI Insights"
size="md"
class="mb-0"
/>
<button
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-all bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300 border border-purple-300 dark:border-purple-700 hover:bg-purple-200 dark:hover:bg-purple-900/60 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => handleForcePatrol(true)}
disabled={forcePatrolLoading() || patrolStatus()?.running}
title={patrolStatus()?.running ? 'Patrol in progress - see table below' : 'Run a patrol check now'}
>
<span class="flex items-center gap-1.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Run Patrol
</span>
</button>
</div>
{/* Summary Stats Bar */}
<div class="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-3 mb-4 flex flex-wrap items-center gap-4 text-xs">
<div class="flex items-center gap-1.5">
<span class="text-gray-500 dark:text-gray-400">Runs:</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{patrolRunHistory().length}</span>
</div>
<div class="flex items-center gap-1.5">
<span class="text-gray-500 dark:text-gray-400">Healthy:</span>
<span class="font-medium text-green-600 dark:text-green-400">
{patrolRunHistory().filter(r => r.status === 'healthy').length}
</span>
</div>
<Show when={patrolRunHistory().filter(r => r.status !== 'healthy').length > 0}>
<div class="flex items-center gap-1.5">
<span class="text-gray-500 dark:text-gray-400">Issues:</span>
<span class="font-medium text-yellow-600 dark:text-yellow-400">
{patrolRunHistory().filter(r => r.status !== 'healthy').length}
</span>
</div>
</Show>
<div class="flex items-center gap-1.5">
<span class="text-gray-500 dark:text-gray-400">Last:</span>
<span class="font-medium text-gray-700 dark:text-gray-300">
{patrolStatus()?.last_patrol_at ? formatTimestamp(patrolStatus()!.last_patrol_at!) : 'never'}
</span>
</div>
<Show when={patrolStatus()?.resources_checked}>
<div class="flex items-center gap-1.5">
<span class="text-gray-500 dark:text-gray-400">Resources:</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{patrolStatus()?.resources_checked}</span>
</div>
</Show>
</div>
<div class="space-y-2">
<For each={aiFindings()}>
{(finding) => {
const colors = severityColors[finding.severity];
return (
<div
class={`border rounded-lg p-4 transition-all ${finding.acknowledged_at ? 'opacity-60' : ''}`}
style={{
'background-color': colors.bg,
'border-color': colors.border,
}}
>
<div class="flex flex-col sm:flex-row sm:items-start">
<div class="flex items-start flex-1">
<Show
when={aiFindings().length > 0}
fallback={
<div class="text-center py-6 border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div class="flex justify-center mb-2">
<svg class="w-10 h-10 text-green-500 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4" />
</svg>
</div>
<p class="text-sm font-medium text-green-700 dark:text-green-400">All Systems Healthy</p>
<p class="text-xs text-green-600 dark:text-green-500 mt-1">AI patrol found no issues to report</p>
</div>
}
>
<For each={aiFindings().filter(f => !pendingFixFindings().has(f.id))}>
{(finding) => {
const colors = severityColors[finding.severity];
const [isExpanded, setIsExpanded] = createSignal(false);
return (
<div
class="border rounded-lg transition-all"
style={{
'background-color': colors.bg,
'border-color': colors.border,
}}
>
{/* Compact header - always visible, clickable */}
<div
class="flex items-center gap-3 p-3 cursor-pointer hover:opacity-80"
onClick={() => setIsExpanded(!isExpanded())}
>
{/* Expand chevron */}
<svg
class={`w-4 h-4 text-gray-500 transition-transform flex-shrink-0 ${isExpanded() ? 'rotate-90' : ''}`}
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
{/* Severity badge */}
<span
class="px-2 py-0.5 text-xs rounded capitalize font-medium flex-shrink-0"
style={{ 'background-color': colors.border, color: colors.text }}
>
{finding.severity}
</span>
{/* Title (main info) */}
<span class="text-sm font-medium text-gray-800 dark:text-gray-200 truncate flex-1">
{finding.title}
</span>
{/* Resource name pill */}
<span class="text-xs px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 flex-shrink-0 hidden sm:inline">
{finding.resource_name}
</span>
{/* AI badge */}
<div class="mr-3 mt-0.5">
<span
class="inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold"
style={{ 'background-color': colors.border, color: colors.text }}
>
AI
</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span
class="text-sm font-medium truncate"
style={{ color: colors.text }}
>
<span
class="inline-flex items-center justify-center w-5 h-5 rounded text-[9px] font-bold flex-shrink-0"
style={{ 'background-color': colors.border, color: colors.text }}
>
AI
</span>
</div>
{/* Expanded details */}
<Show when={isExpanded()}>
<div class="px-4 pb-4 pt-1 border-t" style={{ 'border-color': colors.border }}>
{/* Resource and category info */}
<div class="flex flex-wrap items-center gap-2 mb-2">
<span class="text-sm font-medium" style={{ color: colors.text }}>
{finding.resource_name}
</span>
<span class="text-xs text-gray-600 dark:text-gray-400">
@ -2279,146 +2557,457 @@ function OverviewTab(props: {
on {finding.node}
</span>
</Show>
<span
class="px-2 py-0.5 text-xs rounded capitalize"
style={{ 'background-color': colors.border, color: colors.text }}
>
{finding.severity}
</span>
</div>
<p class="text-sm text-gray-700 dark:text-gray-300 mt-1 font-medium">
{finding.title}
</p>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-0.5">
{/* Description */}
<p class="text-sm text-gray-600 dark:text-gray-400">
{finding.description}
</p>
{/* Recommendation */}
<Show when={finding.recommendation}>
<p class="text-xs text-gray-500 dark:text-gray-500 mt-1 italic">
{finding.recommendation}
<p class="text-xs text-gray-500 dark:text-gray-500 mt-2 italic">
Suggested: {finding.recommendation}
</p>
</Show>
<p class="text-xs text-gray-500 dark:text-gray-500 mt-1">
Detected: {formatTimestamp(finding.detected_at)}
</p>
</div>
</div>
<div class="flex gap-2 mt-3 sm:mt-0 sm:ml-4 self-end sm:self-start">
<button
class={`px-3 py-1.5 text-xs font-medium border rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed ${finding.acknowledged_at
? 'bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 border-green-300 dark:border-green-700'
: '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={processingFindings().has(finding.id) || !!finding.acknowledged_at}
onClick={async () => {
if (processingFindings().has(finding.id)) return;
setProcessingFindings((prev) => new Set(prev).add(finding.id));
try {
await acknowledgeFinding(finding.id);
// Update local state to mark as acknowledged (keep visible but dimmed)
setAiFindings((prev) => prev.map(f =>
f.id === finding.id
? { ...f, acknowledged_at: new Date().toISOString() }
: f
));
showSuccess('Finding acknowledged');
} catch (err) {
logger.error('Failed to acknowledge finding:', err);
showError('Failed to acknowledge finding');
} finally {
setProcessingFindings((prev) => {
const next = new Set(prev);
next.delete(finding.id);
return next;
});
}
}}
>
{processingFindings().has(finding.id) ? 'Acknowledging...' : finding.acknowledged_at ? '✓ Acknowledged' : 'Acknowledge'}
</button>
{/* Snooze dropdown */}
<div class="relative group">
<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={processingFindings().has(finding.id)}
>
Snooze
</button>
<div class="absolute right-0 mt-1 w-28 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-50">
<button
class="w-full px-3 py-2 text-left text-xs hover:bg-gray-100 dark:hover:bg-gray-700 rounded-t-lg"
onClick={async () => {
setProcessingFindings((prev) => new Set(prev).add(finding.id));
try {
await snoozeFinding(finding.id, 1);
setAiFindings((prev) => prev.filter(f => f.id !== finding.id));
showSuccess('Snoozed for 1 hour');
} catch (err) {
showError('Failed to snooze');
} finally {
setProcessingFindings((prev) => { const n = new Set(prev); n.delete(finding.id); return n; });
}
}}
>
1 hour
</button>
<button
class="w-full px-3 py-2 text-left text-xs hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={async () => {
setProcessingFindings((prev) => new Set(prev).add(finding.id));
try {
await snoozeFinding(finding.id, 24);
setAiFindings((prev) => prev.filter(f => f.id !== finding.id));
showSuccess('Snoozed for 24 hours');
} catch (err) {
showError('Failed to snooze');
} finally {
setProcessingFindings((prev) => { const n = new Set(prev); n.delete(finding.id); return n; });
}
}}
>
24 hours
</button>
<button
class="w-full px-3 py-2 text-left text-xs hover:bg-gray-100 dark:hover:bg-gray-700 rounded-b-lg"
onClick={async () => {
setProcessingFindings((prev) => new Set(prev).add(finding.id));
try {
await snoozeFinding(finding.id, 168);
setAiFindings((prev) => prev.filter(f => f.id !== finding.id));
showSuccess('Snoozed for 7 days');
} catch (err) {
showError('Failed to snooze');
} finally {
setProcessingFindings((prev) => { const n = new Set(prev); n.delete(finding.id); return n; });
}
}}
>
7 days
</button>
{/* Footer with time and actions */}
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mt-3 pt-2 border-t border-gray-200 dark:border-gray-600">
<p class="text-xs text-gray-500 dark:text-gray-500">
Detected: {formatTimestamp(finding.detected_at)}
</p>
<div class="flex items-center gap-2">
{/* Get Help with AI button */}
<button
class="px-3 py-1.5 text-xs font-medium border rounded-lg transition-all bg-purple-50 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 border-purple-300 dark:border-purple-700 hover:bg-purple-100 dark:hover:bg-purple-900/50 flex items-center gap-1.5"
onClick={(e) => {
e.stopPropagation();
aiChatStore.openWithPrompt(
`Help me fix this issue on ${finding.resource_name}: ${finding.title}\n\nDescription: ${finding.description}\n\nSuggested fix: ${finding.recommendation || 'None provided'}\n\nPlease guide me through applying this fix.`
);
}}
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
Get Help
</button>
{/* I Fixed It button - hides until next patrol verifies */}
<button
class="px-3 py-1.5 text-xs font-medium border rounded-lg transition-all bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 border-green-300 dark:border-green-700 hover:bg-green-100 dark:hover:bg-green-900/50 flex items-center gap-1.5"
onClick={(e) => {
e.stopPropagation();
setPendingFixFindings(prev => {
const next = new Set(prev);
next.add(finding.id);
return next;
});
}}
title="Hide until next patrol verifies the fix"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
I Fixed It
</button>
</div>
</div>
</div>
<button
class="px-3 py-1.5 text-xs font-medium border rounded-lg transition-all bg-purple-50 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 border-purple-300 dark:border-purple-700 hover:bg-purple-100 dark:hover:bg-purple-900/50"
onClick={() => {
aiChatStore.openWithPrompt(
`Tell me more about this issue: ${finding.title} on ${finding.resource_name}. ${finding.description}`
</Show>
</div>
);
}}
</For>
</Show>
</div>
{/* Patrol Check History */}
<div class="mt-6">
<div class="flex items-center justify-between">
<button
class="flex items-center gap-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors"
onClick={() => setShowRunHistory(!showRunHistory())}
>
<svg
class={`w-4 h-4 transition-transform ${showRunHistory() ? 'rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
Patrol Check History ({filteredPatrolHistory().length} runs)
<Show when={patrolRunHistory().length > 0 && patrolRunHistory()[0].status !== 'healthy'}>
<span class="ml-1 px-1.5 py-0.5 text-[10px] bg-yellow-100 dark:bg-yellow-900/50 text-yellow-700 dark:text-yellow-300 rounded">Issues Found</span>
</Show>
</button>
{/* Next Patrol Timer */}
<Show when={nextPatrolIn()}>
<span class="text-xs text-gray-500 dark:text-gray-400">
Next patrol in <span class="font-mono font-medium text-purple-600 dark:text-purple-400">{nextPatrolIn()}</span>
</span>
</Show>
</div>
<Show when={showRunHistory()}>
<div class="mt-3">
{/* Time Filter + Mini Health Chart */}
<div class="flex flex-wrap items-center gap-3 mb-3">
<div class="flex gap-1">
<button
class={`px-2 py-1 text-xs rounded transition-colors ${historyTimeFilter() === '24h' ? 'bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'}`}
onClick={() => setHistoryTimeFilter('24h')}
>
24h
</button>
<button
class={`px-2 py-1 text-xs rounded transition-colors ${historyTimeFilter() === '7d' ? 'bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'}`}
onClick={() => setHistoryTimeFilter('7d')}
>
7d
</button>
<button
class={`px-2 py-1 text-xs rounded transition-colors ${historyTimeFilter() === 'all' ? 'bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'}`}
onClick={() => setHistoryTimeFilter('all')}
>
All
</button>
</div>
{/* Mini Health Chart */}
<Show when={filteredPatrolHistory().length > 0}>
<div class="flex items-center gap-0.5 h-4">
<For each={filteredPatrolHistory().slice(0, 20).reverse()}>
{(run) => (
<div
class={`w-1.5 h-full rounded-sm transition-all hover:opacity-75 ${run.status === 'healthy' ? 'bg-green-400 dark:bg-green-500' :
run.status === 'critical' || run.status === 'error' ? 'bg-red-400 dark:bg-red-500' :
'bg-yellow-400 dark:bg-yellow-500'
}`}
title={`${formatTimestamp(run.completed_at)}: ${run.findings_summary}`}
/>
)}
</For>
<span class="ml-1.5 text-[10px] text-gray-400"> newest</span>
</div>
</Show>
</div>
<Show
when={filteredPatrolHistory().length > 0}
fallback={
<p class="text-sm text-gray-500 dark:text-gray-400 italic py-4">No patrol runs in selected time range.</p>
}
>
<div class="border border-gray-200 dark:border-gray-700 rounded overflow-hidden">
<table class="w-full text-xs sm:text-sm">
<thead>
<tr class="bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 border-b border-gray-300 dark:border-gray-600">
<th class="p-1.5 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider w-4"></th>
<th class="p-1.5 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider">Time</th>
<th class="p-1.5 px-2 text-center text-[10px] sm:text-xs font-medium uppercase tracking-wider">Type</th>
<th class="p-1.5 px-2 text-center text-[10px] sm:text-xs font-medium uppercase tracking-wider">Status</th>
<th class="p-1.5 px-2 text-center text-[10px] sm:text-xs font-medium uppercase tracking-wider">Resources</th>
<th class="p-1.5 px-2 text-center text-[10px] sm:text-xs font-medium uppercase tracking-wider">New</th>
<th class="p-1.5 px-2 text-center text-[10px] sm:text-xs font-medium uppercase tracking-wider">Resolved</th>
<th class="p-1.5 px-2 text-center text-[10px] sm:text-xs font-medium uppercase tracking-wider">Duration</th>
</tr>
</thead>
<tbody>
{/* Show "Currently Running" row when patrol is in progress */}
<Show when={patrolStatus()?.running}>
<tr
class="border-b border-gray-200 dark:border-gray-600 bg-purple-50 dark:bg-purple-900/20 cursor-pointer hover:bg-purple-100 dark:hover:bg-purple-900/30"
onClick={() => setExpandedLiveStream(!expandedLiveStream())}
>
<td class="p-1.5 px-2 text-purple-500">
<svg class={`w-3 h-3 transition-transform ${expandedLiveStream() ? 'rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</td>
<td class="p-1.5 px-2 text-purple-600 dark:text-purple-400 font-mono whitespace-nowrap">
<div class="flex items-center gap-1.5">
<svg class="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Now
</div>
</td>
<td class="p-1.5 px-2 text-center">
<span class="text-[10px] px-1.5 py-0.5 rounded font-medium bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300">
Running
</span>
</td>
<td class="p-1.5 px-2 text-center" colspan="5">
<span class="text-xs text-purple-600 dark:text-purple-400">
{expandedLiveStream() ? 'Click to collapse' : 'Click to view live AI analysis'}
</span>
</td>
</tr>
{/* Expanded Live Stream Row */}
<Show when={expandedLiveStream()}>
<tr class="bg-purple-50 dark:bg-purple-900/10 border-b border-gray-200 dark:border-gray-600">
<td colspan="8" class="p-3">
<div class="flex items-center justify-between mb-3">
<span class="text-[10px] text-purple-500 dark:text-purple-400 uppercase tracking-wider flex items-center gap-1.5">
<svg class="w-3 h-3 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
Live AI Analysis
</span>
<span class="text-[9px] text-purple-400 dark:text-purple-500">
Streaming in real-time...
</span>
</div>
{/* Sequential blocks display - like AI chat */}
<div class="space-y-2 max-h-80 overflow-y-auto">
{/* Rendered blocks */}
<For each={liveStreamBlocks()}>
{(block) => (
<Show
when={block.type === 'phase'}
fallback={
/* Thinking block */
<div class="px-3 py-2 text-xs bg-blue-50 dark:bg-blue-900/20 text-gray-700 dark:text-gray-300 rounded-lg border-l-2 border-blue-400 whitespace-pre-wrap font-mono leading-relaxed">
{block.text.length > 800 ? block.text.substring(0, 800) + '...' : block.text}
</div>
}
>
{/* Phase marker */}
<div class="flex items-center gap-2 px-2 py-1">
<Show
when={block.text === 'Analysis complete'}
fallback={
<svg class="w-3 h-3 animate-spin text-purple-500" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
}
>
<svg class="w-3 h-3 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</Show>
<span class="text-[10px] font-medium text-purple-600 dark:text-purple-400">
{block.text}
</span>
</div>
</Show>
)}
</For>
{/* Currently streaming content */}
<Show when={currentThinking()}>
<div class="px-3 py-2 text-xs bg-blue-50 dark:bg-blue-900/20 text-gray-700 dark:text-gray-300 rounded-lg border-l-2 border-blue-400 whitespace-pre-wrap font-mono leading-relaxed">
{currentThinking().length > 500 ? currentThinking().substring(0, 500) + '...' : currentThinking()}
<span class="inline-block w-1.5 h-3 bg-blue-500 ml-0.5 animate-pulse" />
</div>
</Show>
{/* Empty state */}
<Show when={liveStreamBlocks().length === 0 && !currentThinking()}>
<div class="flex items-center gap-2 px-2 py-3 text-xs text-gray-500 dark:text-gray-400">
<svg class="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<span class="italic">Waiting for AI response...</span>
</div>
</Show>
</div>
</td>
</tr>
</Show>
</Show>
<For each={filteredPatrolHistory()}>
{(run) => {
const statusStyles = {
healthy: 'bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300',
issues_found: 'bg-yellow-100 dark:bg-yellow-900/50 text-yellow-700 dark:text-yellow-300',
critical: 'bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300',
error: 'bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300',
};
const statusStyle = statusStyles[run.status] || statusStyles.healthy;
const hasDetails = run.nodes_checked > 0 || run.guests_checked > 0 || run.docker_checked > 0 || run.storage_checked > 0 || run.ai_analysis;
return (
<>
<tr
class={`border-b border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50 ${hasDetails ? 'cursor-pointer' : ''}`}
onClick={() => hasDetails && setExpandedRunId(expandedRunId() === run.id ? null : run.id)}
>
<td class="p-1.5 px-2 text-gray-400">
<Show when={hasDetails}>
<svg class={`w-3 h-3 transition-transform ${expandedRunId() === run.id ? 'rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</Show>
</td>
<td class="p-1.5 px-2 text-gray-600 dark:text-gray-400 font-mono whitespace-nowrap">
{formatTimestamp(run.completed_at)}
</td>
<td class="p-1.5 px-2 text-center">
<span class={`text-[10px] px-1.5 py-0.5 rounded font-medium ${run.type === 'deep'
? 'bg-violet-100 dark:bg-violet-900/50 text-violet-700 dark:text-violet-300'
: 'bg-sky-100 dark:bg-sky-900/50 text-sky-700 dark:text-sky-300'
}`}>
{run.type === 'deep' ? 'Deep' : 'Quick'}
</span>
</td>
<td class="p-1.5 px-2 text-center">
<span class={`text-[10px] px-1.5 py-0.5 rounded font-medium ${statusStyle}`}>
{run.findings_summary}
</span>
</td>
<td class="p-1.5 px-2 text-center text-gray-700 dark:text-gray-300">
{run.resources_checked}
</td>
<td class="p-1.5 px-2 text-center">
<Show when={run.new_findings > 0} fallback={<span class="text-gray-400">-</span>}>
<span class="text-yellow-600 dark:text-yellow-400 font-medium">{run.new_findings}</span>
</Show>
</td>
<td class="p-1.5 px-2 text-center">
<Show when={run.resolved_findings > 0} fallback={<span class="text-gray-400">-</span>}>
<span class="text-green-600 dark:text-green-400 font-medium">{run.resolved_findings}</span>
</Show>
</td>
<td class="p-1.5 px-2 text-center text-gray-500 dark:text-gray-400 font-mono">
{(() => {
const totalSeconds = Math.round(run.duration_ms / 1000000000);
if (totalSeconds < 60) {
return `${totalSeconds}s`;
}
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
})()}
</td>
</tr>
{/* Expanded Details Row */}
<Show when={expandedRunId() === run.id}>
<tr class="bg-gray-50 dark:bg-gray-800/50">
<td colspan="8" class="p-3">
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
<Show when={run.nodes_checked > 0}>
<div class="flex items-center gap-2">
<span class="px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded text-[10px]">Nodes</span>
<span class="font-medium">{run.nodes_checked}</span>
</div>
</Show>
<Show when={run.guests_checked > 0}>
<div class="flex items-center gap-2">
<span class="px-1.5 py-0.5 bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300 rounded text-[10px]">VMs/CTs</span>
<span class="font-medium">{run.guests_checked}</span>
</div>
</Show>
<Show when={run.docker_checked > 0}>
<div class="flex items-center gap-2">
<span class="px-1.5 py-0.5 bg-cyan-100 dark:bg-cyan-900/50 text-cyan-700 dark:text-cyan-300 rounded text-[10px]">Docker</span>
<span class="font-medium">{run.docker_checked}</span>
</div>
</Show>
<Show when={run.storage_checked > 0}>
<div class="flex items-center gap-2">
<span class="px-1.5 py-0.5 bg-orange-100 dark:bg-orange-900/50 text-orange-700 dark:text-orange-300 rounded text-[10px]">Storage</span>
<span class="font-medium">{run.storage_checked}</span>
</div>
</Show>
<Show when={run.hosts_checked > 0}>
<div class="flex items-center gap-2">
<span class="px-1.5 py-0.5 bg-teal-100 dark:bg-teal-900/50 text-teal-700 dark:text-teal-300 rounded text-[10px]">Hosts</span>
<span class="font-medium">{run.hosts_checked}</span>
</div>
</Show>
<Show when={run.pbs_checked > 0}>
<div class="flex items-center gap-2">
<span class="px-1.5 py-0.5 bg-amber-100 dark:bg-amber-900/50 text-amber-700 dark:text-amber-300 rounded text-[10px]">PBS</span>
<span class="font-medium">{run.pbs_checked}</span>
</div>
</Show>
</div>
{/* Only show findings section if we have active findings to display */}
{(() => {
const activeFindings = (run.finding_ids || [])
.map(id => aiFindings().find(f => f.id === id))
.filter(f => f !== undefined);
const resolvedCount = (run.finding_ids?.length || 0) - activeFindings.length;
if (activeFindings.length === 0 && resolvedCount === 0) return null;
return (
<div class="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
<span class="text-[10px] text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Findings from this run:
</span>
<div class="flex flex-col gap-1 mt-1">
<For each={activeFindings}>
{(finding) => (
<div class="flex items-center gap-2 text-xs">
<span class={`px-1.5 py-0.5 rounded text-[10px] ${finding!.severity === 'critical' ? 'bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300' :
finding!.severity === 'warning' ? 'bg-yellow-100 dark:bg-yellow-900/50 text-yellow-700 dark:text-yellow-300' :
'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300'
}`}>
{finding!.severity}
</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{finding!.title}</span>
<span class="text-gray-500 dark:text-gray-400">on {finding!.resource_name}</span>
</div>
)}
</For>
<Show when={resolvedCount > 0}>
<div class="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
{resolvedCount} finding{resolvedCount > 1 ? 's' : ''} since resolved
</div>
</Show>
</div>
</div>
);
})()}
{/* AI Analysis Section */}
<Show when={run.ai_analysis}>
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between mb-2">
<span class="text-[10px] text-gray-500 dark:text-gray-400 uppercase tracking-wider flex items-center gap-1.5">
<svg class="w-3 h-3 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
AI Analysis
</span>
<Show when={run.input_tokens || run.output_tokens}>
<span class="text-[9px] text-gray-400 dark:text-gray-500">
{run.input_tokens?.toLocaleString()} in / {run.output_tokens?.toLocaleString()} out tokens
</span>
</Show>
</div>
<div class="bg-gray-100 dark:bg-gray-900 rounded-lg p-3 max-h-64 overflow-y-auto">
<pre class="text-xs text-gray-700 dark:text-gray-300 whitespace-pre-wrap font-mono leading-relaxed">{run.ai_analysis}</pre>
</div>
</div>
</Show>
</td>
</tr>
</Show>
</>
);
}}
>
Investigate
</button>
</div>
</div>
</For>
</tbody>
</table>
</div>
);
}}
</For>
</Show>
</div>
</Show>
</div>
</div>
</Show>
</Show >
{/* Active Alerts - show when alerts tab selected OR when patrol is disabled (no sub-tabs) */}
<Show when={overviewSubTab() === 'active-alerts' || !patrolStatus()?.enabled}>
< Show when={overviewSubTab() === 'active-alerts' || !patrolStatus()?.enabled
}>
<div>
<SectionHeader title="Active Alerts" size="md" class="mb-3" />
<Show
@ -2668,8 +3257,8 @@ function OverviewTab(props: {
</div>
</Show>
</div>
</Show>
</div>
</Show >
</div >
);
}

View file

@ -15,6 +15,10 @@ export interface AISettings {
// OAuth fields for Claude Pro/Max subscription authentication
auth_method: AuthMethod; // "api_key" or "oauth"
oauth_connected: boolean; // true if OAuth tokens are configured
// Patrol settings for token efficiency
patrol_schedule_preset?: string; // "15min" | "1hr" | "6hr" | "12hr" | "daily" | "disabled"
alert_triggered_analysis?: boolean; // true if AI should analyze when alerts fire
patrol_auto_fix?: boolean; // true if patrol can attempt automatic remediation
}
export interface AISettingsUpdateRequest {
@ -26,6 +30,10 @@ export interface AISettingsUpdateRequest {
autonomous_mode?: boolean;
custom_context?: string; // user-provided infrastructure context
auth_method?: AuthMethod; // "api_key" or "oauth"
// Patrol settings for token efficiency
patrol_schedule_preset?: string; // "15min" | "1hr" | "6hr" | "12hr" | "daily" | "disabled"
alert_triggered_analysis?: boolean; // true if AI should analyze when alerts fire
patrol_auto_fix?: boolean; // true if patrol can attempt automatic remediation
}

View file

@ -133,6 +133,33 @@ export default defineConfig({
});
},
},
// SSE endpoint for AI patrol streaming
'/api/ai/patrol/stream': {
target: backendUrl,
changeOrigin: true,
timeout: 0,
proxyTimeout: 0,
configure: (proxy, _options) => {
proxy.options.timeout = 0;
proxy.options.proxyTimeout = 0;
proxy.on('proxyReq', (proxyReq, req, res) => {
req.socket.setTimeout(0);
req.socket.setNoDelay(true);
req.socket.setKeepAlive(true, 30000);
proxyReq.socket?.setTimeout(0);
});
proxy.on('proxyRes', (proxyRes, req, res) => {
res.socket?.setTimeout(0);
res.socket?.setNoDelay(true);
res.socket?.setKeepAlive(true, 30000);
proxyRes.socket?.setTimeout(0);
});
proxy.on('error', (err, req, res) => {
console.error('[SSE Proxy Error - Patrol Stream]', err.message);
});
},
},
'/api/agent/ws': {
target: backendWsUrl,
ws: true,
@ -167,6 +194,7 @@ export default defineConfig({
'/api': {
target: backendUrl,
changeOrigin: true,
cookieDomainRewrite: '',
},
'/install-docker-agent.sh': {
target: backendUrl,

View file

@ -0,0 +1,382 @@
package ai
import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rs/zerolog/log"
)
// AlertTriggeredAnalyzer handles AI analysis triggered by firing alerts
// This provides token-efficient, real-time AI insights on specific resources
type AlertTriggeredAnalyzer struct {
mu sync.RWMutex
patrolService *PatrolService
stateProvider StateProvider
enabled bool
// Cooldown to prevent analyzing the same resource repeatedly
lastAnalyzed map[string]time.Time
cooldown time.Duration
// Track pending analyses to deduplicate concurrent alerts
pending map[string]bool
}
// NewAlertTriggeredAnalyzer creates a new alert-triggered analyzer
func NewAlertTriggeredAnalyzer(patrolService *PatrolService, stateProvider StateProvider) *AlertTriggeredAnalyzer {
return &AlertTriggeredAnalyzer{
patrolService: patrolService,
stateProvider: stateProvider,
enabled: false,
lastAnalyzed: make(map[string]time.Time),
cooldown: 5 * time.Minute, // Don't re-analyze the same resource within 5 minutes
pending: make(map[string]bool),
}
}
// SetEnabled enables or disables alert-triggered analysis
func (a *AlertTriggeredAnalyzer) SetEnabled(enabled bool) {
a.mu.Lock()
defer a.mu.Unlock()
a.enabled = enabled
log.Info().Bool("enabled", enabled).Msg("Alert-triggered AI analysis setting updated")
}
// IsEnabled returns whether alert-triggered analysis is enabled
func (a *AlertTriggeredAnalyzer) IsEnabled() bool {
a.mu.RLock()
defer a.mu.RUnlock()
return a.enabled
}
// OnAlertFired is called when an alert fires - triggers AI analysis of the affected resource
func (a *AlertTriggeredAnalyzer) OnAlertFired(alert *alerts.Alert) {
if alert == nil {
return
}
a.mu.Lock()
if !a.enabled {
a.mu.Unlock()
return
}
// Create a resource key for deduplication
resourceKey := a.resourceKeyFromAlert(alert)
if resourceKey == "" {
a.mu.Unlock()
log.Debug().
Str("alertID", alert.ID).
Str("type", alert.Type).
Msg("Cannot determine resource key for alert, skipping AI analysis")
return
}
// Check cooldown
if lastTime, exists := a.lastAnalyzed[resourceKey]; exists {
if time.Since(lastTime) < a.cooldown {
a.mu.Unlock()
log.Debug().
Str("resourceKey", resourceKey).
Str("alertID", alert.ID).
Dur("cooldownRemaining", a.cooldown-time.Since(lastTime)).
Msg("Resource recently analyzed, skipping due to cooldown")
return
}
}
// Check for pending analysis
if a.pending[resourceKey] {
a.mu.Unlock()
log.Debug().
Str("resourceKey", resourceKey).
Str("alertID", alert.ID).
Msg("Analysis already pending for resource, skipping duplicate")
return
}
// Mark as pending
a.pending[resourceKey] = true
a.mu.Unlock()
// Run analysis in background
go a.analyzeResource(alert, resourceKey)
}
// analyzeResource performs AI analysis on the resource associated with an alert
func (a *AlertTriggeredAnalyzer) analyzeResource(alert *alerts.Alert, resourceKey string) {
defer func() {
a.mu.Lock()
delete(a.pending, resourceKey)
a.lastAnalyzed[resourceKey] = time.Now()
a.mu.Unlock()
}()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
log.Info().
Str("alertID", alert.ID).
Str("type", alert.Type).
Str("resource", alert.ResourceName).
Str("resourceKey", resourceKey).
Float64("value", alert.Value).
Float64("threshold", alert.Threshold).
Msg("Starting AI analysis triggered by alert")
startTime := time.Now()
// Determine what type of resource this is and analyze it
findings := a.analyzeResourceByAlert(ctx, alert)
duration := time.Since(startTime)
if len(findings) > 0 {
log.Info().
Str("alertID", alert.ID).
Str("resourceKey", resourceKey).
Int("findingsCount", len(findings)).
Dur("duration", duration).
Msg("Alert-triggered AI analysis completed with findings")
// Add findings to the patrol service's findings store
if a.patrolService != nil && a.patrolService.findings != nil {
for _, finding := range findings {
// Link finding to the triggering alert
finding.AlertID = alert.ID
a.patrolService.findings.Add(finding)
}
}
} else {
log.Debug().
Str("alertID", alert.ID).
Str("resourceKey", resourceKey).
Dur("duration", duration).
Msg("Alert-triggered AI analysis completed with no additional findings")
}
}
// analyzeResourceByAlert determines the resource type from the alert and analyzes it
func (a *AlertTriggeredAnalyzer) analyzeResourceByAlert(ctx context.Context, alert *alerts.Alert) []*Finding {
if a.patrolService == nil {
return nil
}
// Parse alert type to determine what kind of resource this is
alertType := strings.ToLower(alert.Type)
switch {
// Node alerts
case strings.HasPrefix(alertType, "node") ||
alertType == "cpu" && strings.Contains(alert.ResourceID, "/node/") ||
alertType == "memory" && strings.Contains(alert.ResourceID, "/node/"):
return a.analyzeNodeFromAlert(ctx, alert)
// Guest (VM/Container) alerts
case strings.Contains(alertType, "container") ||
strings.Contains(alertType, "vm") ||
strings.HasPrefix(alertType, "qemu") ||
strings.HasPrefix(alertType, "lxc") ||
strings.Contains(alert.ResourceID, "/qemu/") ||
strings.Contains(alert.ResourceID, "/lxc/"):
return a.analyzeGuestFromAlert(ctx, alert)
// Docker alerts
case strings.Contains(alertType, "docker"):
return a.analyzeDockerFromAlert(ctx, alert)
// Storage alerts
case strings.Contains(alertType, "storage") ||
strings.HasSuffix(alertType, "-usage"):
return a.analyzeStorageFromAlert(ctx, alert)
// Generic CPU/Memory/Disk alerts - try to determine from resource ID
case alertType == "cpu" || alertType == "memory" || alertType == "disk":
return a.analyzeGenericResourceFromAlert(ctx, alert)
default:
log.Debug().
Str("alertType", alertType).
Str("resourceID", alert.ResourceID).
Msg("Unknown alert type for targeted AI analysis, skipping")
return nil
}
}
// analyzeNodeFromAlert analyzes a Proxmox node triggered by an alert
func (a *AlertTriggeredAnalyzer) analyzeNodeFromAlert(_ context.Context, alert *alerts.Alert) []*Finding {
if a.stateProvider == nil {
return nil
}
state := a.stateProvider.GetState()
// Find the node - first try ResourceID, then ResourceName
var targetNode *models.Node
for i := range state.Nodes {
node := &state.Nodes[i]
if node.ID == alert.ResourceID || node.Name == alert.ResourceName || node.Name == alert.Node {
targetNode = node
break
}
}
if targetNode == nil {
log.Warn().
Str("alertID", alert.ID).
Str("resourceID", alert.ResourceID).
Str("resourceName", alert.ResourceName).
Msg("Could not find node for alert-triggered analysis")
return nil
}
// Use patrol service's node analysis
return a.patrolService.analyzeNode(*targetNode, true) // deep=true for triggered analysis
}
// analyzeGuestFromAlert analyzes a VM/Container triggered by an alert
func (a *AlertTriggeredAnalyzer) analyzeGuestFromAlert(_ context.Context, alert *alerts.Alert) []*Finding {
if a.stateProvider == nil {
return nil
}
state := a.stateProvider.GetState()
// Check VMs
for _, vm := range state.VMs {
if vm.ID == alert.ResourceID || vm.Name == alert.ResourceName {
return a.patrolService.analyzeGuest(
vm.ID, vm.Name, "vm", vm.Node, vm.Status,
vm.CPU, vm.Memory.Usage, vm.Disk.Usage,
nil, vm.Template, true, // deep=true for triggered analysis
)
}
}
// Check containers
for _, ct := range state.Containers {
if ct.ID == alert.ResourceID || ct.Name == alert.ResourceName {
return a.patrolService.analyzeGuest(
ct.ID, ct.Name, "container", ct.Node, ct.Status,
ct.CPU, ct.Memory.Usage, ct.Disk.Usage,
nil, ct.Template, true, // deep=true for triggered analysis
)
}
}
log.Warn().
Str("alertID", alert.ID).
Str("resourceID", alert.ResourceID).
Msg("Could not find guest for alert-triggered analysis")
return nil
}
// analyzeDockerFromAlert analyzes a Docker container/host triggered by an alert
func (a *AlertTriggeredAnalyzer) analyzeDockerFromAlert(_ context.Context, alert *alerts.Alert) []*Finding {
if a.stateProvider == nil {
return nil
}
state := a.stateProvider.GetState()
// Try to find the Docker host or container
// Containers are nested inside DockerHosts
for _, dh := range state.DockerHosts {
// Check if this is a host alert
if dh.ID == alert.ResourceID || dh.Hostname == alert.ResourceName {
return a.patrolService.analyzeDockerHost(dh, true) // deep=true
}
// Check containers within this host
for _, container := range dh.Containers {
if container.ID == alert.ResourceID || container.Name == alert.ResourceName {
return a.patrolService.analyzeDockerHost(dh, true) // deep=true
}
}
}
log.Warn().
Str("alertID", alert.ID).
Str("resourceID", alert.ResourceID).
Msg("Could not find Docker resource for alert-triggered analysis")
return nil
}
// analyzeStorageFromAlert analyzes a storage resource triggered by an alert
func (a *AlertTriggeredAnalyzer) analyzeStorageFromAlert(_ context.Context, alert *alerts.Alert) []*Finding {
if a.stateProvider == nil {
return nil
}
state := a.stateProvider.GetState()
// Storage is at the top level of StateSnapshot
for _, storage := range state.Storage {
if storage.ID == alert.ResourceID || storage.Name == alert.ResourceName {
return a.patrolService.analyzeStorage(storage, true)
}
}
log.Warn().
Str("alertID", alert.ID).
Str("resourceID", alert.ResourceID).
Msg("Could not find storage for alert-triggered analysis")
return nil
}
// analyzeGenericResourceFromAlert tries to determine resource type and analyze
func (a *AlertTriggeredAnalyzer) analyzeGenericResourceFromAlert(ctx context.Context, alert *alerts.Alert) []*Finding {
// Try each resource type in order of likelihood
resourceID := alert.ResourceID
switch {
case strings.Contains(resourceID, "/node/"):
return a.analyzeNodeFromAlert(ctx, alert)
case strings.Contains(resourceID, "/qemu/") || strings.Contains(resourceID, "/lxc/"):
return a.analyzeGuestFromAlert(ctx, alert)
case strings.Contains(resourceID, "docker"):
return a.analyzeDockerFromAlert(ctx, alert)
default:
// Try guest first (most common), then node
findings := a.analyzeGuestFromAlert(ctx, alert)
if len(findings) > 0 {
return findings
}
return a.analyzeNodeFromAlert(ctx, alert)
}
}
// resourceKeyFromAlert creates a unique key for the resource in an alert
func (a *AlertTriggeredAnalyzer) resourceKeyFromAlert(alert *alerts.Alert) string {
if alert.ResourceID != "" {
return alert.ResourceID
}
if alert.ResourceName != "" && alert.Instance != "" {
return fmt.Sprintf("%s/%s", alert.Instance, alert.ResourceName)
}
if alert.ResourceName != "" {
return alert.ResourceName
}
return ""
}
// CleanupOldCooldowns removes expired cooldown entries to prevent memory growth
func (a *AlertTriggeredAnalyzer) CleanupOldCooldowns() {
a.mu.Lock()
defer a.mu.Unlock()
now := time.Now()
for key, lastTime := range a.lastAnalyzed {
// Remove entries older than 1 hour
if now.Sub(lastTime) > time.Hour {
delete(a.lastAnalyzed, key)
}
}
}

View file

@ -45,6 +45,7 @@ type Finding struct {
Description string `json:"description"`
Recommendation string `json:"recommendation,omitempty"`
Evidence string `json:"evidence,omitempty"` // data/commands that led to this finding
Source string `json:"source,omitempty"` // "ai-analysis" for LLM findings, empty for rule-based
DetectedAt time.Time `json:"detected_at"`
LastSeenAt time.Time `json:"last_seen_at"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,247 @@
// Package ai provides AI-powered infrastructure monitoring and investigation.
package ai
import (
"sync"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rs/zerolog/log"
)
// PatrolHistoryPersistence interface for saving/loading patrol run history
type PatrolHistoryPersistence interface {
SavePatrolRunHistory(runs []PatrolRunRecord) error
LoadPatrolRunHistory() ([]PatrolRunRecord, error)
}
// PatrolHistoryPersistenceAdapter bridges ConfigPersistence to PatrolHistoryPersistence interface
type PatrolHistoryPersistenceAdapter struct {
config *config.ConfigPersistence
}
// NewPatrolHistoryPersistenceAdapter creates a new adapter
func NewPatrolHistoryPersistenceAdapter(cfg *config.ConfigPersistence) *PatrolHistoryPersistenceAdapter {
return &PatrolHistoryPersistenceAdapter{config: cfg}
}
// SavePatrolRunHistory saves patrol run history to disk via ConfigPersistence
func (a *PatrolHistoryPersistenceAdapter) SavePatrolRunHistory(runs []PatrolRunRecord) error {
// Convert from ai.PatrolRunRecord to config.PatrolRunRecord
records := make([]config.PatrolRunRecord, len(runs))
for i, r := range runs {
records[i] = config.PatrolRunRecord{
ID: r.ID,
StartedAt: r.StartedAt,
CompletedAt: r.CompletedAt,
DurationMs: int64(r.Duration),
Type: r.Type,
ResourcesChecked: r.ResourcesChecked,
NodesChecked: r.NodesChecked,
GuestsChecked: r.GuestsChecked,
DockerChecked: r.DockerChecked,
StorageChecked: r.StorageChecked,
HostsChecked: r.HostsChecked,
PBSChecked: r.PBSChecked,
NewFindings: r.NewFindings,
ExistingFindings: r.ExistingFindings,
ResolvedFindings: r.ResolvedFindings,
FindingsSummary: r.FindingsSummary,
FindingIDs: r.FindingIDs,
ErrorCount: r.ErrorCount,
Status: r.Status,
AIAnalysis: r.AIAnalysis,
InputTokens: r.InputTokens,
OutputTokens: r.OutputTokens,
}
}
return a.config.SavePatrolRunHistory(records)
}
// LoadPatrolRunHistory loads patrol run history from disk via ConfigPersistence
func (a *PatrolHistoryPersistenceAdapter) LoadPatrolRunHistory() ([]PatrolRunRecord, error) {
data, err := a.config.LoadPatrolRunHistory()
if err != nil {
return nil, err
}
// Convert from config.PatrolRunRecord to ai.PatrolRunRecord
runs := make([]PatrolRunRecord, len(data.Runs))
for i, r := range data.Runs {
runs[i] = PatrolRunRecord{
ID: r.ID,
StartedAt: r.StartedAt,
CompletedAt: r.CompletedAt,
Duration: time.Duration(r.DurationMs),
Type: r.Type,
ResourcesChecked: r.ResourcesChecked,
NodesChecked: r.NodesChecked,
GuestsChecked: r.GuestsChecked,
DockerChecked: r.DockerChecked,
StorageChecked: r.StorageChecked,
HostsChecked: r.HostsChecked,
PBSChecked: r.PBSChecked,
NewFindings: r.NewFindings,
ExistingFindings: r.ExistingFindings,
ResolvedFindings: r.ResolvedFindings,
FindingsSummary: r.FindingsSummary,
FindingIDs: r.FindingIDs,
ErrorCount: r.ErrorCount,
Status: r.Status,
AIAnalysis: r.AIAnalysis,
InputTokens: r.InputTokens,
OutputTokens: r.OutputTokens,
}
}
return runs, nil
}
// PatrolRunHistoryStore provides thread-safe storage for patrol run history with optional persistence
type PatrolRunHistoryStore struct {
mu sync.RWMutex
runs []PatrolRunRecord
maxRuns int
persistence PatrolHistoryPersistence
// Debounce save operations
saveTimer *time.Timer
savePending bool
saveDebounce time.Duration
}
// NewPatrolRunHistoryStore creates a new patrol run history store
func NewPatrolRunHistoryStore(maxRuns int) *PatrolRunHistoryStore {
if maxRuns <= 0 {
maxRuns = MaxPatrolRunHistory
}
return &PatrolRunHistoryStore{
runs: make([]PatrolRunRecord, 0, maxRuns),
maxRuns: maxRuns,
saveDebounce: 5 * time.Second,
}
}
// SetPersistence sets the persistence layer and loads existing history
func (s *PatrolRunHistoryStore) SetPersistence(p PatrolHistoryPersistence) error {
s.mu.Lock()
s.persistence = p
s.mu.Unlock()
// Load existing history from disk
if p != nil {
runs, err := p.LoadPatrolRunHistory()
if err != nil {
return err
}
if len(runs) > 0 {
s.mu.Lock()
s.runs = runs
// Trim to max if loaded more than maxRuns
if len(s.runs) > s.maxRuns {
s.runs = s.runs[:s.maxRuns]
}
s.mu.Unlock()
log.Info().Int("count", len(runs)).Msg("Loaded patrol run history from disk")
}
}
return nil
}
// Add adds a new patrol run to the history
func (s *PatrolRunHistoryStore) Add(run PatrolRunRecord) {
s.mu.Lock()
defer s.mu.Unlock()
// Prepend (newest first)
s.runs = append([]PatrolRunRecord{run}, s.runs...)
// Trim to max
if len(s.runs) > s.maxRuns {
s.runs = s.runs[:s.maxRuns]
}
// Schedule save
s.scheduleSave()
}
// GetAll returns all runs (newest first)
func (s *PatrolRunHistoryStore) GetAll() []PatrolRunRecord {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]PatrolRunRecord, len(s.runs))
copy(result, s.runs)
return result
}
// GetRecent returns at most n recent runs
func (s *PatrolRunHistoryStore) GetRecent(n int) []PatrolRunRecord {
s.mu.RLock()
defer s.mu.RUnlock()
if n <= 0 || n > len(s.runs) {
n = len(s.runs)
}
result := make([]PatrolRunRecord, n)
copy(result, s.runs[:n])
return result
}
// Count returns the number of runs in history
func (s *PatrolRunHistoryStore) Count() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.runs)
}
// scheduleSave schedules a debounced save operation
func (s *PatrolRunHistoryStore) scheduleSave() {
if s.persistence == nil {
return
}
// Cancel existing timer if pending
if s.saveTimer != nil {
s.saveTimer.Stop()
}
s.savePending = true
s.saveTimer = time.AfterFunc(s.saveDebounce, func() {
s.mu.Lock()
if !s.savePending {
s.mu.Unlock()
return
}
s.savePending = false
// Copy runs while locked
runs := make([]PatrolRunRecord, len(s.runs))
copy(runs, s.runs)
persistence := s.persistence
s.mu.Unlock()
// Save outside lock
if persistence != nil {
if err := persistence.SavePatrolRunHistory(runs); err != nil {
log.Error().Err(err).Msg("Failed to save patrol run history")
}
}
})
}
// FlushPersistence immediately saves any pending changes
func (s *PatrolRunHistoryStore) FlushPersistence() error {
s.mu.Lock()
if s.saveTimer != nil {
s.saveTimer.Stop()
}
s.savePending = false
runs := make([]PatrolRunRecord, len(s.runs))
copy(runs, s.runs)
persistence := s.persistence
s.mu.Unlock()
if persistence != nil {
return persistence.SavePatrolRunHistory(runs)
}
return nil
}

View file

@ -317,6 +317,14 @@ func (c *AnthropicClient) Chat(ctx context.Context, req ChatRequest) (*ChatRespo
}
}
// Log content summary for debugging
log.Debug().
Int("content_blocks", len(anthropicResp.Content)).
Int("text_length", len(textContent)).
Int("tool_calls", len(toolCalls)).
Str("stop_reason", anthropicResp.StopReason).
Msg("Anthropic response parsed")
return &ChatResponse{
Content: textContent,
Model: anthropicResp.Model,

View file

@ -339,8 +339,17 @@ func (c *OpenAIClient) Chat(ctx context.Context, req ChatRequest) (*ChatResponse
}
choice := openaiResp.Choices[0]
// For DeepSeek reasoner, the actual content may be in reasoning_content
// when content is empty (it shows the "thinking" but that's the full response)
contentToUse := choice.Message.Content
if contentToUse == "" && choice.Message.ReasoningContent != "" {
// DeepSeek reasoner puts output in reasoning_content
contentToUse = choice.Message.ReasoningContent
}
result := &ChatResponse{
Content: choice.Message.Content,
Content: contentToUse,
ReasoningContent: choice.Message.ReasoningContent, // DeepSeek thinking mode
Model: openaiResp.Model,
StopReason: choice.FinishReason,

View file

@ -41,6 +41,9 @@ type Service struct {
resourceProvider ResourceProvider // Unified resource model provider (Phase 2)
patrolService *PatrolService // Background AI monitoring service
metadataProvider MetadataProvider // Enables AI to update resource URLs
// Alert-triggered analysis - token-efficient real-time AI insights
alertTriggeredAnalyzer *AlertTriggeredAnalyzer
}
// NewService creates a new AI service
@ -73,6 +76,11 @@ func (s *Service) SetStateProvider(sp StateProvider) {
if s.patrolService == nil && sp != nil {
s.patrolService = NewPatrolService(s, sp)
}
// Initialize alert-triggered analyzer if not already done
if s.alertTriggeredAnalyzer == nil && sp != nil && s.patrolService != nil {
s.alertTriggeredAnalyzer = NewAlertTriggeredAnalyzer(s.patrolService, sp)
}
}
// GetPatrolService returns the patrol service for background monitoring
@ -82,7 +90,19 @@ func (s *Service) GetPatrolService() *PatrolService {
return s.patrolService
}
// GetAlertTriggeredAnalyzer returns the alert-triggered analyzer for token-efficient real-time analysis
func (s *Service) GetAlertTriggeredAnalyzer() *AlertTriggeredAnalyzer {
s.mu.RLock()
defer s.mu.RUnlock()
return s.alertTriggeredAnalyzer
}
// GetAIConfig returns the current AI configuration
func (s *Service) GetAIConfig() *config.AIConfig {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cfg
}
// SetPatrolThresholdProvider sets the threshold provider for patrol
// This should be called with an AlertThresholdAdapter to connect patrol to user-configured thresholds
@ -100,6 +120,7 @@ func (s *Service) SetPatrolThresholdProvider(provider ThresholdProvider) {
func (s *Service) StartPatrol(ctx context.Context) {
s.mu.RLock()
patrol := s.patrolService
alertAnalyzer := s.alertTriggeredAnalyzer
cfg := s.cfg
s.mu.RUnlock()
@ -125,6 +146,14 @@ func (s *Service) StartPatrol(ctx context.Context) {
}
patrol.SetConfig(patrolCfg)
patrol.Start(ctx)
// Configure alert-triggered analyzer
if alertAnalyzer != nil {
alertAnalyzer.SetEnabled(cfg.IsAlertTriggeredAnalysisEnabled())
log.Info().
Bool("enabled", cfg.IsAlertTriggeredAnalysisEnabled()).
Msg("Alert-triggered AI analysis configured")
}
}
// StopPatrol stops the background patrol service
@ -138,6 +167,42 @@ func (s *Service) StopPatrol() {
}
}
// ReconfigurePatrol updates the patrol configuration without restarting
// Call this after changing patrol settings to apply them immediately
func (s *Service) ReconfigurePatrol() {
s.mu.RLock()
patrol := s.patrolService
alertAnalyzer := s.alertTriggeredAnalyzer
cfg := s.cfg
s.mu.RUnlock()
if patrol == nil || cfg == nil {
return
}
// Update patrol configuration
patrolCfg := PatrolConfig{
Enabled: cfg.IsPatrolEnabled(),
QuickCheckInterval: cfg.GetPatrolInterval(),
DeepAnalysisInterval: 6 * time.Hour,
AnalyzeNodes: cfg.PatrolAnalyzeNodes,
AnalyzeGuests: cfg.PatrolAnalyzeGuests,
AnalyzeDocker: cfg.PatrolAnalyzeDocker,
AnalyzeStorage: cfg.PatrolAnalyzeStorage,
}
patrol.SetConfig(patrolCfg)
log.Info().
Bool("enabled", patrolCfg.Enabled).
Dur("interval", patrolCfg.QuickCheckInterval).
Msg("Patrol configuration updated")
// Update alert-triggered analyzer
if alertAnalyzer != nil {
alertAnalyzer.SetEnabled(cfg.IsAlertTriggeredAnalysisEnabled())
}
}
// GuestInfo contains information about a guest (VM or container) found by VMID lookup
type GuestInfo struct {
Node string
@ -1864,6 +1929,32 @@ GOOD: Do it, then report the result.
BAD: Tables, headers, bullet-heavy summaries
GOOD: Plain prose, 2-4 sentences.
## ACTION BIAS - AVOID INVESTIGATION LOOPS
When the user asks you to DO something (install, fix, update, configure), ACT IMMEDIATELY:
- Don't extensively investigate before acting. Run 1-2 diagnostic commands max, then DO the thing.
- Don't explain what you're about to do - just do it.
- Don't ask for confirmation. The user asked you to do it, so do it.
- If the first approach fails, try the next most obvious approach. Don't stop to report.
- Complete the task END TO END. If asked to "install and run X", you're not done until X is running.
INVESTIGATION ANTI-PATTERNS TO AVOID:
- Running 10+ diagnostic commands before taking action
- Explaining each step before doing it
- Stopping to report partial progress
- Asking "would you like me to proceed?" after the user already asked you to do it
- Checking version, checking config, checking service, checking ports, checking this, checking that... JUST ACT.
GOOD PATTERN for "install X and make sure it's running":
1. Download/install X (1 command)
2. Start X (1 command)
3. Verify X is running (1 command)
4. Report: "Installed X vN.N. Service is running on port NNNN."
BAD PATTERN:
1. Check current version... 2. Check if installed... 3. Check service status... 4. Check config file...
5. Check another config... 6. Try to enable something... 7. Check if it worked... 8. Read a script...
9. Check yet another file... [user: "do it"] 10. Still investigating... [user: "DO IT"]
## Using Context Data
Pulse provides real metrics in "Current Metrics and State". Use this data directly - don't ask users to check things you already know.
@ -1909,7 +2000,21 @@ Common discovery commands:
- Check running processes: ps aux | grep -E 'node|python|java|nginx|apache|httpd'
- Get IP: hostname -I | awk '{print $1}'
When you find a web service and are confident, use set_resource_url to save it. The resource_id should match the ID from the current context.`
When you find a web service and are confident, use set_resource_url to save it. The resource_id should match the ID from the current context.
## Installing/Updating Pulse Itself
If asked to install or update Pulse itself, use the official install script. DO NOT investigate configs/services first.
Quick install/update command (x86_64 Linux):
` + "`" + `curl -sSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash` + "`" + `
To install a specific version:
` + "`" + `curl -sSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash -s -- --version vX.Y.Z` + "`" + `
After install, enable and start the service:
` + "`" + `systemctl enable pulse && systemctl start pulse` + "`" + `
The latest version can be found at: https://api.github.com/repos/rcourtman/Pulse/releases/latest
This is a 3-command job. Don't over-investigate.`
// Add custom context from AI settings (user's infrastructure description)

View file

@ -83,11 +83,24 @@ func (h *AISettingsHandler) SetPatrolFindingsPersistence(persistence ai.Findings
return nil
}
// SetPatrolRunHistoryPersistence enables patrol run history persistence for the patrol service
func (h *AISettingsHandler) SetPatrolRunHistoryPersistence(persistence ai.PatrolHistoryPersistence) error {
if patrol := h.aiService.GetPatrolService(); patrol != nil {
return patrol.SetRunHistoryPersistence(persistence)
}
return nil
}
// StopPatrol stops the background AI patrol service
func (h *AISettingsHandler) StopPatrol() {
h.aiService.StopPatrol()
}
// GetAlertTriggeredAnalyzer returns the alert-triggered analyzer for wiring into alert callbacks
func (h *AISettingsHandler) GetAlertTriggeredAnalyzer() *ai.AlertTriggeredAnalyzer {
return h.aiService.GetAlertTriggeredAnalyzer()
}
// AISettingsResponse is returned by GET /api/settings/ai
// API key is masked for security
type AISettingsResponse struct {
@ -100,8 +113,11 @@ type AISettingsResponse struct {
AutonomousMode bool `json:"autonomous_mode"` // true if AI can execute without approval
CustomContext string `json:"custom_context"` // user-provided infrastructure context
// OAuth fields for Claude Pro/Max subscription authentication
AuthMethod string `json:"auth_method"` // "api_key" or "oauth"
OAuthConnected bool `json:"oauth_connected"` // true if OAuth tokens are configured
AuthMethod string `json:"auth_method"` // "api_key" or "oauth"
OAuthConnected bool `json:"oauth_connected"` // true if OAuth tokens are configured
// Patrol settings for token efficiency
PatrolSchedulePreset string `json:"patrol_schedule_preset"` // "15min", "1hr", "6hr", "12hr", "daily", "disabled"
AlertTriggeredAnalysis bool `json:"alert_triggered_analysis"` // true if AI analyzes when alerts fire
}
// AISettingsUpdateRequest is the request body for PUT /api/settings/ai
@ -114,6 +130,9 @@ type AISettingsUpdateRequest struct {
AutonomousMode *bool `json:"autonomous_mode,omitempty"`
CustomContext *string `json:"custom_context,omitempty"` // user-provided infrastructure context
AuthMethod *string `json:"auth_method,omitempty"` // "api_key" or "oauth"
// Patrol settings for token efficiency
PatrolSchedulePreset *string `json:"patrol_schedule_preset,omitempty"` // "15min", "1hr", "6hr", "12hr", "daily", "disabled"
AlertTriggeredAnalysis *bool `json:"alert_triggered_analysis,omitempty"` // true if AI analyzes when alerts fire
}
// HandleGetAISettings returns the current AI settings (GET /api/settings/ai)
@ -151,6 +170,9 @@ func (h *AISettingsHandler) HandleGetAISettings(w http.ResponseWriter, r *http.R
CustomContext: settings.CustomContext,
AuthMethod: authMethod,
OAuthConnected: settings.OAuthAccessToken != "",
// Patrol settings
PatrolSchedulePreset: settings.PatrolSchedulePreset,
AlertTriggeredAnalysis: settings.AlertTriggeredAnalysis,
}
if err := utils.WriteJSONResponse(w, response); err != nil {
@ -256,6 +278,24 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
settings.Enabled = *req.Enabled
}
// Handle patrol schedule preset
if req.PatrolSchedulePreset != nil {
preset := strings.ToLower(strings.TrimSpace(*req.PatrolSchedulePreset))
switch preset {
case "15min", "1hr", "6hr", "12hr", "daily", "disabled":
settings.PatrolSchedulePreset = preset
settings.PatrolIntervalMinutes = config.PresetToMinutes(preset)
default:
http.Error(w, "Invalid patrol_schedule_preset. Must be '15min', '1hr', '6hr', '12hr', 'daily', or 'disabled'", http.StatusBadRequest)
return
}
}
// Handle alert-triggered analysis toggle
if req.AlertTriggeredAnalysis != nil {
settings.AlertTriggeredAnalysis = *req.AlertTriggeredAnalysis
}
// Save settings
if err := h.persistence.SaveAIConfig(*settings); err != nil {
log.Error().Err(err).Msg("Failed to save AI settings")
@ -268,22 +308,42 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
log.Warn().Err(err).Msg("Failed to reload AI service after settings update")
}
// Reconfigure patrol service with new settings (applies interval changes immediately)
h.aiService.ReconfigurePatrol()
// Update alert-triggered analyzer if available
if analyzer := h.aiService.GetAlertTriggeredAnalyzer(); analyzer != nil {
analyzer.SetEnabled(settings.AlertTriggeredAnalysis)
}
log.Info().
Bool("enabled", settings.Enabled).
Str("provider", settings.Provider).
Str("model", settings.GetModel()).
Str("patrolPreset", settings.PatrolSchedulePreset).
Bool("alertTriggeredAnalysis", settings.AlertTriggeredAnalysis).
Msg("AI settings updated")
// Determine auth method for response
authMethod := string(settings.AuthMethod)
if authMethod == "" {
authMethod = string(config.AuthMethodAPIKey)
}
// Return updated settings
response := AISettingsResponse{
Enabled: settings.Enabled,
Provider: settings.Provider,
APIKeySet: settings.APIKey != "",
Model: settings.GetModel(),
BaseURL: settings.BaseURL,
Configured: settings.IsConfigured(),
AutonomousMode: settings.AutonomousMode,
CustomContext: settings.CustomContext,
Enabled: settings.Enabled,
Provider: settings.Provider,
APIKeySet: settings.APIKey != "",
Model: settings.GetModel(),
BaseURL: settings.BaseURL,
Configured: settings.IsConfigured(),
AutonomousMode: settings.AutonomousMode,
CustomContext: settings.CustomContext,
AuthMethod: authMethod,
OAuthConnected: settings.OAuthAccessToken != "",
PatrolSchedulePreset: settings.PatrolSchedulePreset,
AlertTriggeredAnalysis: settings.AlertTriggeredAnalysis,
}
if err := utils.WriteJSONResponse(w, response); err != nil {
@ -1618,6 +1678,56 @@ func (h *AISettingsHandler) HandleGetPatrolStatus(w http.ResponseWriter, r *http
}
}
// HandlePatrolStream streams real-time patrol analysis via SSE (GET /api/ai/patrol/stream)
func (h *AISettingsHandler) HandlePatrolStream(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
patrol := h.aiService.GetPatrolService()
if patrol == nil {
http.Error(w, "Patrol service not available", http.StatusServiceUnavailable)
return
}
// Set SSE headers
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming not supported", http.StatusInternalServerError)
return
}
// Subscribe to patrol stream
// Note: SubscribeToStream already sends the current buffered output to the channel
ch := patrol.SubscribeToStream()
defer patrol.UnsubscribeFromStream(ch)
// Stream events until client disconnects
ctx := r.Context()
for {
select {
case <-ctx.Done():
return
case event, ok := <-ch:
if !ok {
return
}
data, err := json.Marshal(event)
if err != nil {
continue
}
fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
}
}
}
// HandleGetPatrolFindings returns all active findings (GET /api/ai/patrol/findings)
func (h *AISettingsHandler) HandleGetPatrolFindings(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
@ -1809,6 +1919,60 @@ func (h *AISettingsHandler) HandleSnoozeFinding(w http.ResponseWriter, r *http.R
}
}
// HandleResolveFinding manually marks a finding as resolved (POST /api/ai/patrol/resolve)
// Use this when the user has fixed the issue and wants to mark it as resolved
func (h *AISettingsHandler) HandleResolveFinding(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Require authentication
if !CheckAuth(h.config, w, r) {
return
}
patrol := h.aiService.GetPatrolService()
if patrol == nil {
http.Error(w, "Patrol service not available", http.StatusServiceUnavailable)
return
}
var req struct {
FindingID string `json:"finding_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.FindingID == "" {
http.Error(w, "finding_id is required", http.StatusBadRequest)
return
}
findings := patrol.GetFindings()
// Mark as manually resolved (auto=false since user did it)
if !findings.Resolve(req.FindingID, false) {
http.Error(w, "Finding not found or already resolved", http.StatusNotFound)
return
}
log.Info().
Str("finding_id", req.FindingID).
Msg("AI Patrol: Finding manually resolved by user")
response := map[string]interface{}{
"success": true,
"message": "Finding marked as resolved",
}
if err := utils.WriteJSONResponse(w, response); err != nil {
log.Error().Err(err).Msg("Failed to write resolve response")
}
}
// HandleGetFindingsHistory returns all findings including resolved for history (GET /api/ai/patrol/history)
func (h *AISettingsHandler) HandleGetFindingsHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
@ -1844,3 +2008,36 @@ func (h *AISettingsHandler) HandleGetFindingsHistory(w http.ResponseWriter, r *h
log.Error().Err(err).Msg("Failed to write findings history response")
}
}
// HandleGetPatrolRunHistory returns the history of patrol check runs (GET /api/ai/patrol/runs)
func (h *AISettingsHandler) HandleGetPatrolRunHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
patrol := h.aiService.GetPatrolService()
if patrol == nil {
// Return empty history
if err := utils.WriteJSONResponse(w, []interface{}{}); err != nil {
log.Error().Err(err).Msg("Failed to write patrol run history response")
}
return
}
// Parse optional limit query parameter (default: 50)
limit := 50
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
if l, err := fmt.Sscanf(limitStr, "%d", &limit); err == nil && l > 0 {
if limit > 100 {
limit = 100 // Cap at MaxPatrolRunHistory
}
}
}
runs := patrol.GetRunHistory(limit)
if err := utils.WriteJSONResponse(w, runs); err != nil {
log.Error().Err(err).Msg("Failed to write patrol run history response")
}
}

View file

@ -1096,12 +1096,15 @@ func (r *Router) setupRoutes() {
// AI Patrol routes for background monitoring
r.mux.HandleFunc("/api/ai/patrol/status", RequireAuth(r.config, r.aiSettingsHandler.HandleGetPatrolStatus))
r.mux.HandleFunc("/api/ai/patrol/stream", RequireAuth(r.config, r.aiSettingsHandler.HandlePatrolStream))
r.mux.HandleFunc("/api/ai/patrol/findings", RequireAuth(r.config, r.aiSettingsHandler.HandleGetPatrolFindings))
r.mux.HandleFunc("/api/ai/patrol/history", RequireAuth(r.config, r.aiSettingsHandler.HandleGetFindingsHistory))
r.mux.HandleFunc("/api/ai/patrol/run", RequireAdmin(r.config, r.aiSettingsHandler.HandleForcePatrol))
r.mux.HandleFunc("/api/ai/patrol/acknowledge", RequireAuth(r.config, r.aiSettingsHandler.HandleAcknowledgeFinding))
r.mux.HandleFunc("/api/ai/patrol/dismiss", RequireAuth(r.config, r.aiSettingsHandler.HandleAcknowledgeFinding)) // Backward compat
r.mux.HandleFunc("/api/ai/patrol/snooze", RequireAuth(r.config, r.aiSettingsHandler.HandleSnoozeFinding))
r.mux.HandleFunc("/api/ai/patrol/resolve", RequireAuth(r.config, r.aiSettingsHandler.HandleResolveFinding))
r.mux.HandleFunc("/api/ai/patrol/runs", RequireAuth(r.config, r.aiSettingsHandler.HandleGetPatrolRunHistory))
// Agent WebSocket for AI command execution
r.mux.HandleFunc("/api/agent/ws", r.handleAgentWebSocket)
@ -1378,6 +1381,12 @@ func (r *Router) StartPatrol(ctx context.Context) {
if err := r.aiSettingsHandler.SetPatrolFindingsPersistence(findingsPersistence); err != nil {
log.Error().Err(err).Msg("Failed to initialize AI findings persistence")
}
// Enable patrol run history persistence
historyPersistence := ai.NewPatrolHistoryPersistenceAdapter(r.persistence)
if err := r.aiSettingsHandler.SetPatrolRunHistoryPersistence(historyPersistence); err != nil {
log.Error().Err(err).Msg("Failed to initialize AI patrol run history persistence")
}
}
r.aiSettingsHandler.StartPatrol(ctx)
@ -1391,6 +1400,34 @@ func (r *Router) StopPatrol() {
}
}
// GetAlertTriggeredAnalyzer returns the alert-triggered analyzer for wiring into the monitor's alert callback
// This enables AI to analyze specific resources when alerts fire, providing token-efficient real-time insights
func (r *Router) GetAlertTriggeredAnalyzer() *ai.AlertTriggeredAnalyzer {
if r.aiSettingsHandler != nil {
return r.aiSettingsHandler.GetAlertTriggeredAnalyzer()
}
return nil
}
// WireAlertTriggeredAI connects the alert-triggered AI analyzer to the monitor's alert callback
// This should be called after StartPatrol() to ensure the analyzer is initialized
func (r *Router) WireAlertTriggeredAI() {
analyzer := r.GetAlertTriggeredAnalyzer()
if analyzer == nil {
log.Debug().Msg("Alert-triggered AI analyzer not available")
return
}
if r.monitor == nil {
log.Debug().Msg("Monitor not available for AI alert callback")
return
}
// Wire the analyzer's OnAlertFired method to the monitor's alert callback
r.monitor.SetAlertTriggeredAICallback(analyzer.OnAlertFired)
log.Info().Msg("Alert-triggered AI analysis wired to monitor")
}
// reloadSystemSettings loads system settings from disk and caches them
func (r *Router) reloadSystemSettings() {
r.settingsMu.Lock()

View file

@ -30,12 +30,17 @@ type AIConfig struct {
OAuthExpiresAt time.Time `json:"oauth_expires_at,omitempty"` // Token expiration time
// Patrol settings for background AI monitoring
PatrolEnabled bool `json:"patrol_enabled"` // Enable background AI health patrol
PatrolIntervalMinutes int `json:"patrol_interval_minutes,omitempty"` // How often to run quick patrols (default: 15)
PatrolAnalyzeNodes bool `json:"patrol_analyze_nodes,omitempty"` // Include Proxmox nodes in patrol
PatrolAnalyzeGuests bool `json:"patrol_analyze_guests,omitempty"` // Include VMs/containers in patrol
PatrolAnalyzeDocker bool `json:"patrol_analyze_docker,omitempty"` // Include Docker hosts in patrol
PatrolAnalyzeStorage bool `json:"patrol_analyze_storage,omitempty"` // Include storage in patrol
PatrolEnabled bool `json:"patrol_enabled"` // Enable background AI health patrol
PatrolIntervalMinutes int `json:"patrol_interval_minutes,omitempty"` // How often to run quick patrols (default: 360 = 6 hours)
PatrolSchedulePreset string `json:"patrol_schedule_preset,omitempty"` // User-friendly preset: "15min", "1hr", "6hr", "12hr", "daily", "disabled"
PatrolAnalyzeNodes bool `json:"patrol_analyze_nodes,omitempty"` // Include Proxmox nodes in patrol
PatrolAnalyzeGuests bool `json:"patrol_analyze_guests,omitempty"` // Include VMs/containers in patrol
PatrolAnalyzeDocker bool `json:"patrol_analyze_docker,omitempty"` // Include Docker hosts in patrol
PatrolAnalyzeStorage bool `json:"patrol_analyze_storage,omitempty"` // Include storage in patrol
PatrolAutoFix bool `json:"patrol_auto_fix,omitempty"` // When true, patrol can attempt automatic remediation (default: false, observe only)
// Alert-triggered AI analysis - analyze specific resources when alerts fire
AlertTriggeredAnalysis bool `json:"alert_triggered_analysis,omitempty"` // Enable AI analysis when alerts fire (token-efficient)
}
// AIProvider constants
@ -63,13 +68,17 @@ func NewDefaultAIConfig() *AIConfig {
Provider: AIProviderAnthropic,
Model: DefaultAIModelAnthropic,
AuthMethod: AuthMethodAPIKey,
// Patrol defaults - enabled when AI is enabled, check every 15 minutes
PatrolEnabled: true,
PatrolIntervalMinutes: 15,
PatrolAnalyzeNodes: true,
PatrolAnalyzeGuests: true,
PatrolAnalyzeDocker: true,
PatrolAnalyzeStorage: true,
// Patrol defaults - enabled when AI is enabled
// Default to 6 hour intervals (much more token-efficient than 15 min)
PatrolEnabled: true,
PatrolIntervalMinutes: 360, // 6 hours - balance between coverage and token efficiency
PatrolSchedulePreset: "6hr",
PatrolAnalyzeNodes: true,
PatrolAnalyzeGuests: true,
PatrolAnalyzeDocker: true,
PatrolAnalyzeStorage: true,
// Alert-triggered analysis is highly token-efficient - enabled by default
AlertTriggeredAnalysis: true,
}
}
@ -147,15 +156,72 @@ func (c *AIConfig) ClearAPIKey() {
}
// GetPatrolInterval returns the patrol interval as a duration
// Uses the preset if set, otherwise falls back to custom minutes
func (c *AIConfig) GetPatrolInterval() time.Duration {
if c.PatrolIntervalMinutes <= 0 {
return 15 * time.Minute // default
// If preset is set, use it
if c.PatrolSchedulePreset != "" {
switch c.PatrolSchedulePreset {
case "15min":
return 15 * time.Minute
case "1hr":
return 1 * time.Hour
case "6hr":
return 6 * time.Hour
case "12hr":
return 12 * time.Hour
case "daily":
return 24 * time.Hour
case "disabled":
return 0 // Signal that scheduled patrol is disabled
}
}
// Fall back to custom minutes if set
// BUT: If PatrolIntervalMinutes is the old default (15), migrate to new default (360 = 6hr)
// This provides better token efficiency for existing installations
if c.PatrolIntervalMinutes > 0 {
// Migrate old 15-minute default to new 6-hour default
if c.PatrolIntervalMinutes == 15 && c.PatrolSchedulePreset == "" {
return 6 * time.Hour
}
return time.Duration(c.PatrolIntervalMinutes) * time.Minute
}
return 6 * time.Hour // default to 6 hours
}
// PresetToMinutes converts a patrol schedule preset to minutes
func PresetToMinutes(preset string) int {
switch preset {
case "15min":
return 15
case "1hr":
return 60
case "6hr":
return 360
case "12hr":
return 720
case "daily":
return 1440
case "disabled":
return 0
default:
return 360 // default 6hr
}
return time.Duration(c.PatrolIntervalMinutes) * time.Minute
}
// IsPatrolEnabled returns true if patrol should run
// Note: Patrol uses local heuristics and doesn't require an AI API key
func (c *AIConfig) IsPatrolEnabled() bool {
// If preset is "disabled", patrol is disabled
if c.PatrolSchedulePreset == "disabled" {
return false
}
return c.PatrolEnabled
}
// IsAlertTriggeredAnalysisEnabled returns true if AI should analyze resources when alerts fire
func (c *AIConfig) IsAlertTriggeredAnalysisEnabled() bool {
return c.AlertTriggeredAnalysis
}

View file

@ -20,20 +20,21 @@ import (
// ConfigPersistence handles saving and loading configuration
type ConfigPersistence struct {
mu sync.RWMutex
tx *importTransaction
configDir string
alertFile string
emailFile string
webhookFile string
appriseFile string
nodesFile string
systemFile string
oidcFile string
apiTokensFile string
aiFile string
aiFindingsFile string
crypto *crypto.CryptoManager
mu sync.RWMutex
tx *importTransaction
configDir string
alertFile string
emailFile string
webhookFile string
appriseFile string
nodesFile string
systemFile string
oidcFile string
apiTokensFile string
aiFile string
aiFindingsFile string
aiPatrolRunsFile string
crypto *crypto.CryptoManager
}
// NewConfigPersistence creates a new config persistence manager.
@ -66,18 +67,19 @@ func newConfigPersistence(configDir string) (*ConfigPersistence, error) {
}
cp := &ConfigPersistence{
configDir: configDir,
alertFile: filepath.Join(configDir, "alerts.json"),
emailFile: filepath.Join(configDir, "email.enc"),
webhookFile: filepath.Join(configDir, "webhooks.enc"),
appriseFile: filepath.Join(configDir, "apprise.enc"),
nodesFile: filepath.Join(configDir, "nodes.enc"),
systemFile: filepath.Join(configDir, "system.json"),
oidcFile: filepath.Join(configDir, "oidc.enc"),
apiTokensFile: filepath.Join(configDir, "api_tokens.json"),
aiFile: filepath.Join(configDir, "ai.enc"),
aiFindingsFile: filepath.Join(configDir, "ai_findings.json"),
crypto: cryptoMgr,
configDir: configDir,
alertFile: filepath.Join(configDir, "alerts.json"),
emailFile: filepath.Join(configDir, "email.enc"),
webhookFile: filepath.Join(configDir, "webhooks.enc"),
appriseFile: filepath.Join(configDir, "apprise.enc"),
nodesFile: filepath.Join(configDir, "nodes.enc"),
systemFile: filepath.Join(configDir, "system.json"),
oidcFile: filepath.Join(configDir, "oidc.enc"),
apiTokensFile: filepath.Join(configDir, "api_tokens.json"),
aiFile: filepath.Join(configDir, "ai.enc"),
aiFindingsFile: filepath.Join(configDir, "ai_findings.json"),
aiPatrolRunsFile: filepath.Join(configDir, "ai_patrol_runs.json"),
crypto: cryptoMgr,
}
log.Debug().
@ -1470,6 +1472,112 @@ func (c *ConfigPersistence) LoadAIFindings() (*AIFindingsData, error) {
return &findingsData, nil
}
// PatrolRunHistoryData represents persisted patrol run history with metadata
type PatrolRunHistoryData struct {
Version int `json:"version"`
LastSaved time.Time `json:"last_saved"`
Runs []PatrolRunRecord `json:"runs"`
}
// PatrolRunRecord represents a single patrol check run
type PatrolRunRecord struct {
ID string `json:"id"`
StartedAt time.Time `json:"started_at"`
CompletedAt time.Time `json:"completed_at"`
DurationMs int64 `json:"duration_ms"`
Type string `json:"type"` // "quick" or "deep"
ResourcesChecked int `json:"resources_checked"`
// Breakdown by resource type
NodesChecked int `json:"nodes_checked"`
GuestsChecked int `json:"guests_checked"`
DockerChecked int `json:"docker_checked"`
StorageChecked int `json:"storage_checked"`
HostsChecked int `json:"hosts_checked"`
PBSChecked int `json:"pbs_checked"`
// Findings from this run
NewFindings int `json:"new_findings"`
ExistingFindings int `json:"existing_findings"`
ResolvedFindings int `json:"resolved_findings"`
FindingsSummary string `json:"findings_summary"`
FindingIDs []string `json:"finding_ids,omitempty"`
ErrorCount int `json:"error_count"`
Status string `json:"status"` // "healthy", "issues_found", "critical", "error"
// AI Analysis details
AIAnalysis string `json:"ai_analysis,omitempty"` // The AI's raw response/analysis
InputTokens int `json:"input_tokens,omitempty"` // Tokens sent to AI
OutputTokens int `json:"output_tokens,omitempty"` // Tokens received from AI
}
// SavePatrolRunHistory persists patrol run history to disk
func (c *ConfigPersistence) SavePatrolRunHistory(runs []PatrolRunRecord) error {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.EnsureConfigDir(); err != nil {
return err
}
data := PatrolRunHistoryData{
Version: 1,
LastSaved: time.Now(),
Runs: runs,
}
jsonData, err := json.Marshal(data)
if err != nil {
return err
}
if err := c.writeConfigFileLocked(c.aiPatrolRunsFile, jsonData, 0600); err != nil {
return err
}
log.Debug().
Str("file", c.aiPatrolRunsFile).
Int("count", len(runs)).
Msg("Patrol run history saved")
return nil
}
// LoadPatrolRunHistory loads patrol run history from disk
func (c *ConfigPersistence) LoadPatrolRunHistory() (*PatrolRunHistoryData, error) {
c.mu.RLock()
defer c.mu.RUnlock()
data, err := os.ReadFile(c.aiPatrolRunsFile)
if err != nil {
if os.IsNotExist(err) {
// Return empty data if file doesn't exist
return &PatrolRunHistoryData{
Version: 1,
Runs: make([]PatrolRunRecord, 0),
}, nil
}
return nil, err
}
var historyData PatrolRunHistoryData
if err := json.Unmarshal(data, &historyData); err != nil {
log.Error().Err(err).Str("file", c.aiPatrolRunsFile).Msg("Failed to parse patrol run history file")
// Return empty data on parse error rather than failing
return &PatrolRunHistoryData{
Version: 1,
Runs: make([]PatrolRunRecord, 0),
}, nil
}
if historyData.Runs == nil {
historyData.Runs = make([]PatrolRunRecord, 0)
}
log.Info().
Str("file", c.aiPatrolRunsFile).
Int("count", len(historyData.Runs)).
Time("last_saved", historyData.LastSaved).
Msg("Patrol run history loaded")
return &historyData, nil
}
// LoadSystemSettings loads system settings from file
func (c *ConfigPersistence) LoadSystemSettings() (*SystemSettings, error) {
c.mu.RLock()

View file

@ -7147,6 +7147,40 @@ func (m *Monitor) GetAlertManager() *alerts.Manager {
return m.alertManager
}
// SetAlertTriggeredAICallback sets an additional callback for AI analysis when alerts fire
// This enables token-efficient, real-time AI insights on specific resources
func (m *Monitor) SetAlertTriggeredAICallback(callback func(*alerts.Alert)) {
if m.alertManager == nil || callback == nil {
return
}
// Get the current callback
originalCallback := m.alertManager
// Wrap the existing callback to also call the AI callback
m.alertManager.SetAlertCallback(func(alert *alerts.Alert) {
// Broadcast to WebSocket (this happens via the callback set in Start())
if m.wsHub != nil {
m.wsHub.BroadcastAlert(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)
// Trigger AI analysis
go callback(alert)
})
// Avoid unused variable warning
_ = originalCallback
log.Info().Msg("Alert-triggered AI callback registered")
}
// SetResourceStore sets the resource store for polling optimization.
// When set, the monitor will check if it should reduce polling frequency
// for nodes that have host agents providing data.

View file

@ -109,3 +109,80 @@ func TestStorePopulateFromSnapshot(t *testing.T) {
t.Logf("SUCCESS: PopulateFromSnapshot works correctly!")
t.Logf("Total resources: %d (1 node + 1 VM + 1 container + 1 host)", len(all))
}
// TestPopulateFromSnapshotRemovesStaleResources verifies that resources not present
// in subsequent snapshots are removed from the store (fixing the "ghost LXCs" bug).
func TestPopulateFromSnapshotRemovesStaleResources(t *testing.T) {
store := NewStore()
// First snapshot with 2 containers
snapshot1 := models.StateSnapshot{
Containers: []models.Container{
{
ID: "ct-100",
VMID: 100,
Name: "container-to-keep",
Node: "pve-node-1",
Instance: "pve1",
Status: "running",
},
{
ID: "ct-200",
VMID: 200,
Name: "container-to-remove",
Node: "pve-node-1",
Instance: "pve1",
Status: "running",
},
},
}
// Populate with first snapshot
store.PopulateFromSnapshot(snapshot1)
// Verify both containers exist
containers := store.Query().OfType(ResourceTypeContainer).Execute()
if len(containers) != 2 {
t.Fatalf("Expected 2 containers after first snapshot, got %d", len(containers))
}
t.Logf("After first snapshot: %d containers", len(containers))
// Second snapshot with only 1 container (the other was "deleted" from Proxmox)
snapshot2 := models.StateSnapshot{
Containers: []models.Container{
{
ID: "ct-100",
VMID: 100,
Name: "container-to-keep",
Node: "pve-node-1",
Instance: "pve1",
Status: "running",
},
// container-to-remove is NOT included - simulating it was deleted
},
}
// Populate with second snapshot
store.PopulateFromSnapshot(snapshot2)
// Verify only 1 container remains
containers = store.Query().OfType(ResourceTypeContainer).Execute()
if len(containers) != 1 {
t.Fatalf("Expected 1 container after second snapshot (removed container should be gone), got %d", len(containers))
}
// Verify the correct container remains
if containers[0].ID != "ct-100" {
t.Errorf("Expected container-to-keep (ct-100) to remain, got %s", containers[0].ID)
}
// Verify the removed container is gone
_, found := store.Get("ct-200")
if found {
t.Error("container-to-remove (ct-200) should have been removed from the store")
}
t.Logf("SUCCESS: Removed resources are correctly cleaned up!")
t.Logf("After second snapshot: %d container(s) - 'container-to-remove' was properly removed", len(containers))
}

View file

@ -1105,52 +1105,84 @@ type PlatformSummary struct {
// PopulateFromSnapshot converts all resources from a StateSnapshot to the unified store.
// This should be called whenever the state is updated (e.g., before WebSocket broadcasts).
// It also removes resources that are no longer present in the snapshot (except agent-sourced
// resources which persist independently of the Proxmox API polling).
func (s *Store) PopulateFromSnapshot(snapshot models.StateSnapshot) {
// Track which resource IDs we see in this snapshot
seenIDs := make(map[string]bool)
// Convert nodes
for _, node := range snapshot.Nodes {
r := FromNode(node)
s.Upsert(r)
id := s.Upsert(r)
seenIDs[id] = true
}
// Convert VMs
for _, vm := range snapshot.VMs {
r := FromVM(vm)
s.Upsert(r)
id := s.Upsert(r)
seenIDs[id] = true
}
// Convert containers
for _, ct := range snapshot.Containers {
r := FromContainer(ct)
s.Upsert(r)
id := s.Upsert(r)
seenIDs[id] = true
}
// Convert hosts
for _, host := range snapshot.Hosts {
r := FromHost(host)
s.Upsert(r)
id := s.Upsert(r)
seenIDs[id] = true
}
// Convert docker hosts and their containers
for _, dh := range snapshot.DockerHosts {
r := FromDockerHost(dh)
s.Upsert(r)
id := s.Upsert(r)
seenIDs[id] = true
// Convert containers within the docker host
for _, dc := range dh.Containers {
r := FromDockerContainer(dc, dh.ID, dh.Hostname)
s.Upsert(r)
id := s.Upsert(r)
seenIDs[id] = true
}
}
// Convert PBS instances
for _, pbs := range snapshot.PBSInstances {
r := FromPBSInstance(pbs)
s.Upsert(r)
id := s.Upsert(r)
seenIDs[id] = true
}
// Convert storage
for _, storage := range snapshot.Storage {
r := FromStorage(storage)
s.Upsert(r)
id := s.Upsert(r)
seenIDs[id] = true
}
// Remove resources that were NOT in this snapshot BUT were sourced from API
// (Agent-sourced resources like hosts from host-agent should persist independently)
s.mu.Lock()
defer s.mu.Unlock()
var toRemove []string
for id, r := range s.resources {
if !seenIDs[id] && r.SourceType == SourceAPI {
toRemove = append(toRemove, id)
}
}
for _, id := range toRemove {
if r, ok := s.resources[id]; ok {
s.removeFromIndexes(r)
delete(s.resources, id)
}
}
}