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:
parent
9fb11605a4
commit
c88e2db7b4
21 changed files with 3328 additions and 423 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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 >
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
382
internal/ai/alert_triggered.go
Normal file
382
internal/ai/alert_triggered.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
247
internal/ai/patrol_history_persistence.go
Normal file
247
internal/ai/patrol_history_persistence.go
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue