diff --git a/frontend-modern/src/api/__tests__/ai.test.ts b/frontend-modern/src/api/__tests__/ai.test.ts index 103e930..6dd3f28 100644 --- a/frontend-modern/src/api/__tests__/ai.test.ts +++ b/frontend-modern/src/api/__tests__/ai.test.ts @@ -59,6 +59,10 @@ describe('AIAPI', () => { apiFetchJSONMock.mockResolvedValueOnce({} as any); await AIAPI.getRecentChanges(12); expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/intelligence/changes?hours=12'); + + apiFetchJSONMock.mockResolvedValueOnce({} as any); + await AIAPI.getRemediations({ resourceId: 'vm:101', hours: 72, limit: 5 }); + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/intelligence/remediations?resource_id=vm%3A101&hours=72&limit=5'); }); it('sanitizes runCommand payload consistently', async () => { @@ -127,4 +131,3 @@ describe('AIAPI', () => { ).rejects.toThrow('No response body'); }); }); - diff --git a/frontend-modern/src/api/ai.ts b/frontend-modern/src/api/ai.ts index 9424161..a590da8 100644 --- a/frontend-modern/src/api/ai.ts +++ b/frontend-modern/src/api/ai.ts @@ -15,6 +15,7 @@ import type { CorrelationsResponse, ChangesResponse, BaselinesResponse, + RemediationsResponse, } from '@/types/aiIntelligence'; export class AIAPI { @@ -102,6 +103,22 @@ export class AIAPI { return apiFetchJSON(`${this.baseUrl}/ai/intelligence/baselines${params}`) as Promise; } + // Get remediation history + static async getRemediations(options?: { + resourceId?: string; + findingId?: string; + hours?: number; + limit?: number; + }): Promise { + const params = new URLSearchParams(); + if (options?.resourceId) params.set('resource_id', options.resourceId); + if (options?.findingId) params.set('finding_id', options.findingId); + if (options?.hours) params.set('hours', String(options.hours)); + if (options?.limit) params.set('limit', String(options.limit)); + const query = params.toString(); + return apiFetchJSON(`${this.baseUrl}/ai/intelligence/remediations${query ? `?${query}` : ''}`) as Promise; + } + // Start OAuth flow for Claude Pro/Max subscription // Returns the authorization URL to redirect the user to @@ -135,6 +152,14 @@ export class AIAPI { }) as Promise; } + // Analyze a Kubernetes cluster with AI + static async analyzeKubernetesCluster(clusterId: string): Promise { + return apiFetchJSON(`${this.baseUrl}/ai/kubernetes/analyze`, { + method: 'POST', + body: JSON.stringify({ cluster_id: clusterId }), + }) as Promise; + } + // Run a single command (for approved commands) static async runCommand(request: { command: string; diff --git a/frontend-modern/src/api/patrol.ts b/frontend-modern/src/api/patrol.ts index 9a64dc9..4549307 100644 --- a/frontend-modern/src/api/patrol.ts +++ b/frontend-modern/src/api/patrol.ts @@ -10,6 +10,7 @@ export type FindingCategory = 'performance' | 'capacity' | 'reliability' | 'back export interface Finding { id: string; + key?: string; severity: FindingSeverity; category: FindingCategory; resource_id: string; @@ -34,6 +35,32 @@ export interface Finding { suppressed: boolean; } +export interface RunbookInfo { + id: string; + title: string; + description: string; + risk: 'low' | 'medium' | 'high'; +} + +export interface RunbookStepResult { + name: string; + command: string; + output: string; + success: boolean; +} + +export interface RunbookExecutionResult { + runbook_id: string; + outcome: 'resolved' | 'partial' | 'failed' | 'unknown'; + message: string; + steps: RunbookStepResult[]; + verification?: RunbookStepResult; + resolved: boolean; + executed_at: string; + finding_id: string; + finding_key?: string; +} + export interface FindingsSummary { critical: number; warning: number; @@ -79,6 +106,7 @@ export interface PatrolRunRecord { new_findings: number; existing_findings: number; resolved_findings: number; + auto_fix_count?: number; findings_summary: string; finding_ids: string[]; error_count: number; @@ -198,6 +226,24 @@ export async function resolveFinding(findingId: string): Promise<{ success: bool }); } +export async function getRunbooksForFinding(findingId: string): Promise { + const url = `/api/ai/runbooks?finding_id=${encodeURIComponent(findingId)}`; + const resp = await fetch(url, { + credentials: 'include', + }); + if (!resp.ok) { + throw new Error(`Failed to get runbooks: ${resp.status}`); + } + return resp.json(); +} + +export async function executeRunbook(findingId: string, runbookId: string): Promise { + return apiFetchJSON('/api/ai/runbooks/execute', { + method: 'POST', + body: JSON.stringify({ finding_id: findingId, runbook_id: runbookId }), + }); +} + /** * Dismiss a finding with a reason (LLM memory feature) * The LLM will be told not to re-raise this issue in future patrols. diff --git a/frontend-modern/src/components/AI/AICostDashboard.tsx b/frontend-modern/src/components/AI/AICostDashboard.tsx index 26738eb..d0bfbf7 100644 --- a/frontend-modern/src/components/AI/AICostDashboard.tsx +++ b/frontend-modern/src/components/AI/AICostDashboard.tsx @@ -224,10 +224,10 @@ export const AICostDashboard: Component = () => { return ( -
+
-
- +
+
diff --git a/frontend-modern/src/components/Kubernetes/KubernetesClusters.tsx b/frontend-modern/src/components/Kubernetes/KubernetesClusters.tsx index e0cd244..2b8400a 100644 --- a/frontend-modern/src/components/Kubernetes/KubernetesClusters.tsx +++ b/frontend-modern/src/components/Kubernetes/KubernetesClusters.tsx @@ -1,5 +1,7 @@ import type { Component } from 'solid-js'; -import { For, Show, createMemo, createSignal, createEffect } from 'solid-js'; +import { For, Show, createMemo, createSignal, createEffect, onMount } from 'solid-js'; +import { AIAPI } from '@/api/ai'; +import { LicenseAPI, type LicenseFeatureStatus } from '@/api/license'; import { usePersistentSignal } from '@/hooks/usePersistentSignal'; import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { useColumnVisibility, type ColumnDef } from '@/hooks/useColumnVisibility'; @@ -9,6 +11,7 @@ import type { KubernetesNode, KubernetesPod, } from '@/types/api'; +import type { AISettings } from '@/types/ai'; import { Card } from '@/components/shared/Card'; import { EmptyState } from '@/components/shared/EmptyState'; import { ScrollableTable } from '@/components/shared/ScrollableTable'; @@ -148,6 +151,15 @@ export const KubernetesClusters: Component = (props) => const [statusFilter, setStatusFilter] = createSignal('all'); const [showHidden, setShowHidden] = createSignal(false); const [namespaceFilter, setNamespaceFilter] = createSignal('all'); + const [licenseFeatures, setLicenseFeatures] = createSignal(null); + const [licenseLoading, setLicenseLoading] = createSignal(true); + const [aiSettings, setAiSettings] = createSignal(null); + const [aiLoading, setAiLoading] = createSignal(true); + const [analysisClusterId, setAnalysisClusterId] = createSignal(''); + const [analysisLoading, setAnalysisLoading] = createSignal(false); + const [analysisResult, setAnalysisResult] = createSignal(''); + const [analysisError, setAnalysisError] = createSignal(''); + const [analysisMeta, setAnalysisMeta] = createSignal<{ model: string; inputTokens: number; outputTokens: number } | null>(null); // Column visibility for pods table const podColumns = useColumnVisibility('k8s-pod-columns', POD_COLUMNS); @@ -199,6 +211,100 @@ export const KubernetesClusters: Component = (props) => return () => document.removeEventListener('keydown', handleKeyDown); }); + const kubernetesAiEnabled = createMemo(() => licenseFeatures()?.features?.kubernetes_ai === true); + const aiConfigured = createMemo(() => aiSettings()?.configured === true); + const upgradeUrl = createMemo(() => licenseFeatures()?.upgrade_url || 'https://pulsemonitor.app/pro'); + + const clustersForAnalysis = createMemo(() => props.clusters ?? []); + + const getClusterOptionLabel = (cluster: KubernetesCluster): string => { + const base = getClusterDisplayName(cluster); + if (cluster.pendingUninstall) return `${base} (pending uninstall)`; + if (cluster.hidden) return `${base} (hidden)`; + return base; + }; + + const loadLicenseStatus = async () => { + setLicenseLoading(true); + try { + const status = await LicenseAPI.getFeatures(); + setLicenseFeatures(status); + } catch (_err) { + setLicenseFeatures(null); + } finally { + setLicenseLoading(false); + } + }; + + const loadAiSettings = async () => { + setAiLoading(true); + try { + const settings = await AIAPI.getSettings(); + setAiSettings(settings); + } catch (_err) { + setAiSettings(null); + } finally { + setAiLoading(false); + } + }; + + onMount(() => { + void loadLicenseStatus(); + void loadAiSettings(); + }); + + createEffect(() => { + const clusters = clustersForAnalysis(); + if (clusters.length === 0) { + setAnalysisClusterId(''); + return; + } + if (!clusters.some((cluster) => cluster.id === analysisClusterId())) { + setAnalysisClusterId(clusters[0].id); + } + }); + + createEffect(() => { + analysisClusterId(); + setAnalysisError(''); + setAnalysisResult(''); + setAnalysisMeta(null); + }); + + const handleAnalyzeCluster = async () => { + if (!analysisClusterId()) { + setAnalysisError('Select a cluster to analyze.'); + return; + } + if (!aiConfigured()) { + setAnalysisError('AI is not configured. Configure it in Settings -> AI.'); + return; + } + if (!kubernetesAiEnabled()) { + setAnalysisError('Pulse Pro is required for Kubernetes AI analysis.'); + return; + } + + setAnalysisLoading(true); + setAnalysisError(''); + setAnalysisResult(''); + setAnalysisMeta(null); + try { + const response = await AIAPI.analyzeKubernetesCluster(analysisClusterId()); + setAnalysisResult(response.content || ''); + setAnalysisMeta({ + model: response.model, + inputTokens: response.input_tokens, + outputTokens: response.output_tokens, + }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to analyze cluster'; + setAnalysisError(message); + } finally { + setAnalysisLoading(false); + } + }; + // Get all unique namespaces for the filter dropdown const allNamespaces = createMemo(() => { const namespaces = new Set(); @@ -445,6 +551,103 @@ export const KubernetesClusters: Component = (props) => return (
+ 0}> + +
+
+
+
+ Kubernetes AI Analysis +
+
+ Generate a health summary and next actions for a cluster. +
+
+ + + Upgrade to Pro + + +
+ + +
+ + Loading AI and license status... +
+
+ + +
+ Kubernetes AI analysis requires Pulse Pro. +
+
+ + +
+
+ + + + Running analysis... + +
+ + +
+ AI is not configured. Configure it in Settings -> AI. +
+
+ + +
+ {analysisError()} +
+
+ + +
+ +
+ Model: {analysisMeta()!.model} · Tokens: {analysisMeta()!.inputTokens + analysisMeta()!.outputTokens} +
+
+
+ {analysisResult()} +
+
+
+
+
+
+
+
{/* Filter Bar */}
diff --git a/frontend-modern/src/components/Settings/AISettings.tsx b/frontend-modern/src/components/Settings/AISettings.tsx index 3dd42a0..2f79fe7 100644 --- a/frontend-modern/src/components/Settings/AISettings.tsx +++ b/frontend-modern/src/components/Settings/AISettings.tsx @@ -532,11 +532,11 @@ export const AISettings: Component = () => { class="overflow-hidden border border-gray-200 dark:border-gray-700" border={false} > -
+
-
+
{
-
+
@@ -607,7 +607,7 @@ export const AISettings: Component = () => { -
+
{/* Default Model Selection - Always visible */}
@@ -619,7 +619,7 @@ export const AISettings: Component = () => { type="button" onClick={loadModels} disabled={modelsLoading()} - class="text-xs text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-300 disabled:opacity-50 flex items-center gap-1" + class="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 disabled:opacity-50 flex items-center gap-1" title="Refresh model list from all configured providers" > @@ -702,7 +702,7 @@ export const AISettings: Component = () => { Advanced Model Selection - Customized + Customized
@@ -791,10 +791,10 @@ export const AISettings: Component = () => {
{/* AI Provider Configuration - Configure API keys for all providers */} -
+

- + AI Provider Configuration @@ -841,7 +841,7 @@ export const AISettings: Component = () => { />

- Get API key → + Get API key →

@@ -917,7 +917,7 @@ export const AISettings: Component = () => { />

- Get API key → + Get API key →

@@ -985,7 +985,7 @@ export const AISettings: Component = () => { />

- Get API key → + Get API key →

@@ -1053,7 +1053,7 @@ export const AISettings: Component = () => { />

- Get API key → + Get API key →

@@ -1121,7 +1121,7 @@ export const AISettings: Component = () => { />

- Learn about Ollama → + Learn about Ollama → · Free & local

@@ -1426,11 +1426,11 @@ export const AISettings: Component = () => { {/* Actions - sticky at bottom for easy access */} -
+
@@ -107,8 +145,35 @@ export const AIInsightsPanel: Component<{ resourceId?: string }> = (props) => {
+ +
+ {error()} +
+
+ + +
+
+
+

Pulse Pro required

+

+ Predictive intelligence is available with Pulse Pro. {lockedCount() || 0} insights are ready to review. +

+
+ + Upgrade + +
+
+
+ {/* Predictions */} - 0}> + 0}>

@@ -146,7 +211,7 @@ export const AIInsightsPanel: Component<{ resourceId?: string }> = (props) => { {/* Correlations */} - 0}> + 0}>

@@ -184,7 +249,7 @@ export const AIInsightsPanel: Component<{ resourceId?: string }> = (props) => { {/* Empty state */} - +

No predictions or correlations detected yet. The AI will learn patterns over time.

diff --git a/frontend-modern/src/components/shared/AIOverviewTable.tsx b/frontend-modern/src/components/shared/AIOverviewTable.tsx new file mode 100644 index 0000000..4daa6d7 --- /dev/null +++ b/frontend-modern/src/components/shared/AIOverviewTable.tsx @@ -0,0 +1,677 @@ +import { Component, createEffect, createSignal, For, Show } from 'solid-js'; +import { AIAPI } from '@/api/ai'; +import type { FailurePrediction, InfrastructureChange, RemediationRecord, RemediationStats, ResourceCorrelation } from '@/types/aiIntelligence'; + +const DEFAULT_UPGRADE_URL = 'https://pulsemonitor.app/pro'; + +interface InsightRow { + id: string; + type: 'prediction' | 'impact' | 'memory'; + typeBadge: string; + typeBadgeClass: string; + title: string; + subtitle: string; + timestamp: string; + confidence?: number; + locked: boolean; + badgeClass: string; +} + +/** + * AIOverviewTable displays AI Insights, Pulse AI Impact, and Operational Memory + * in a unified, scannable table format + */ +export const AIOverviewTable: Component<{ showWhenEmpty?: boolean }> = (props) => { + const [predictions, setPredictions] = createSignal([]); + const [correlations, setCorrelations] = createSignal([]); + const [remediations, setRemediations] = createSignal([]); + const [remediationStats, setRemediationStats] = createSignal(null); + const [changes, setChanges] = createSignal([]); + const [loading, setLoading] = createSignal(false); + const [error, setError] = createSignal(''); + + // Locked states + const [insightsLocked, setInsightsLocked] = createSignal(false); + const [impactLocked, setImpactLocked] = createSignal(false); + const [memoryLocked, setMemoryLocked] = createSignal(false); + const [upgradeUrl, setUpgradeUrl] = createSignal(DEFAULT_UPGRADE_URL); + + // Locked counts (for locked state display) + const [insightsLockedCount, setInsightsLockedCount] = createSignal(0); + const [memoryLockedCount, setMemoryLockedCount] = createSignal(0); + + const showWhenEmpty = () => Boolean(props.showWhenEmpty); + + const loadData = async () => { + setLoading(true); + setError(''); + try { + const [predResp, corrResp, remResp, changesResp] = await Promise.all([ + AIAPI.getPredictions(), + AIAPI.getCorrelations(), + AIAPI.getRemediations({ hours: 168, limit: 6 }), + AIAPI.getRecentChanges(24), + ]); + + // Handle insights lock + const insightsLockedState = Boolean(predResp.license_required || corrResp.license_required); + setInsightsLocked(insightsLockedState); + if (insightsLockedState) { + const predCount = predResp.count || 0; + const corrCount = corrResp.count || 0; + setInsightsLockedCount(predCount + corrCount); + setPredictions([]); + setCorrelations([]); + } else { + setInsightsLockedCount(0); + setPredictions(predResp.predictions || []); + setCorrelations(corrResp.correlations || []); + } + + // Handle impact lock + setImpactLocked(Boolean(remResp.license_required)); + setRemediationStats(remResp.stats || null); + setRemediations(remResp.remediations || []); + + // Handle memory lock + const memoryLockedState = Boolean(changesResp.license_required); + setMemoryLocked(memoryLockedState); + if (memoryLockedState) { + setMemoryLockedCount(changesResp.count || 0); + setChanges([]); + } else { + setMemoryLockedCount(0); + setChanges(changesResp.changes || []); + } + + // Set upgrade URL from any response + setUpgradeUrl( + predResp.upgrade_url || corrResp.upgrade_url || remResp.upgrade_url || changesResp.upgrade_url || DEFAULT_UPGRADE_URL + ); + } catch (e) { + console.error('Failed to load AI overview data:', e); + setError('Failed to load AI overview data.'); + } finally { + setLoading(false); + } + }; + + createEffect(() => { + void loadData(); + }); + + // Format relative time + const formatRelativeTime = (ts: string) => { + const date = new Date(ts); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString(); + }; + + // Format days until + const formatDaysUntil = (days: number) => { + if (days < 0) return 'Overdue'; + if (days < 1) return 'Today'; + if (days < 2) return 'Tomorrow'; + return `In ${Math.round(days)} days`; + }; + + // Build unified rows + const unifiedRows = (): InsightRow[] => { + const rows: InsightRow[] = []; + + // Predictions + for (const pred of predictions()) { + const colorClass = pred.is_overdue || pred.days_until < 0 + ? 'text-red-600 dark:text-red-400' + : pred.days_until < 3 + ? 'text-amber-600 dark:text-amber-400' + : pred.days_until < 7 + ? 'text-yellow-600 dark:text-yellow-400' + : 'text-blue-600 dark:text-blue-400'; + + rows.push({ + id: `pred-${pred.resource_id}-${pred.event_type}`, + type: 'prediction', + typeBadge: 'Prediction', + typeBadgeClass: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300', + title: formatEventName(pred.event_type), + subtitle: pred.basis, + timestamp: formatDaysUntil(pred.days_until), + confidence: pred.confidence, + locked: false, + badgeClass: colorClass, + }); + } + + // Correlations + for (const corr of correlations()) { + rows.push({ + id: `corr-${corr.source_id}-${corr.target_id}`, + type: 'prediction', + typeBadge: 'Dependency', + typeBadgeClass: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300', + title: `${corr.source_name || corr.source_id} → ${corr.target_name || corr.target_id}`, + subtitle: corr.description || `${corr.event_pattern} (${corr.occurrences} observations)`, + timestamp: `Delay: ${corr.avg_delay}`, + confidence: corr.confidence, + locked: false, + badgeClass: 'text-indigo-600 dark:text-indigo-400', + }); + } + + // Remediations - ONLY show high-value entries: + // 1. Achievements logged via log_achievement tool (have summary field or [achievement:] action) + // 2. Records with explicit summary field (new backend generates these) + // Skip: Low-value diagnostic commands without summaries + const achievements = remediations().filter(rem => { + // Has an AI-generated summary - always show + if (rem.summary && rem.summary.trim() !== '') return true; + // Is an explicit achievement logged via the tool + if (rem.action.startsWith('[achievement:')) return true; + // Skip everything else - no more generic "Checked disk usage" entries + return false; + }); + + for (const rem of achievements) { + const categoryBadgeClass: Record = { + diagnosis: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', + fix: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', + verification: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300', + discovery: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-300', + optimization: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', + }; + + // Extract category from [achievement:category] action + const categoryMatch = rem.action.match(/\[achievement:(\w+)\]/); + const category = categoryMatch ? categoryMatch[1] : 'general'; + + // Format category for display + const categoryLabels: Record = { + diagnosis: 'Diagnosed', + fix: 'Fixed', + verification: 'Verified', + discovery: 'Discovered', + optimization: 'Optimized', + general: 'Helped', + }; + + rows.push({ + id: `rem-${rem.finding_id}-${rem.timestamp}`, + type: 'impact', + typeBadge: categoryLabels[category] || 'Helped', + typeBadgeClass: categoryBadgeClass[category] || 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300', + title: rem.summary || rem.problem, // Summary is the key field + subtitle: rem.resource_name || '', // Show resource name if available + timestamp: formatRelativeTime(rem.timestamp), + locked: false, + badgeClass: 'text-emerald-600 dark:text-emerald-400', + }); + } + + // Changes - only show meaningful changes, not just "created" (infrastructure detection) + // Valuable changes: config, status, migrated, restarted, deleted, backed_up + // Skip: created (just means AI patrol discovered the resource) + const meaningfulChanges = changes().filter(c => c.change_type !== 'created'); + for (const change of meaningfulChanges.slice(0, 6)) { + const changeTypeBadgeClass: Record = { + deleted: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', + config: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', + status: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', + migrated: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300', + restarted: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300', + backed_up: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300', + }; + + rows.push({ + id: `change-${change.resource_id}-${change.detected_at}`, + type: 'memory', + typeBadge: formatChangeType(change.change_type), + typeBadgeClass: changeTypeBadgeClass[change.change_type] || 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300', + title: change.resource_name || change.resource_id, + subtitle: change.description || 'Change detected by AI Patrol.', + timestamp: formatRelativeTime(change.detected_at), + locked: false, + badgeClass: 'text-teal-600 dark:text-teal-400', + }); + } + + return rows; + }; + + const formatEventName = (eventType: string) => { + const names: Record = { + high_memory: 'High Memory', + high_cpu: 'High CPU', + disk_full: 'Disk Full', + oom: 'Out of Memory', + restart: 'Restart', + unresponsive: 'Unresponsive', + backup_failed: 'Backup Failure', + }; + if (names[eventType]) return names[eventType]; + return eventType + .split(/[_-]/) + .map(part => { + if (part === 'cpu') return 'CPU'; + if (part === 'vm') return 'VM'; + if (part === 'pbs') return 'PBS'; + if (part === 'raid') return 'RAID'; + return part.charAt(0).toUpperCase() + part.slice(1); + }) + .join(' '); + }; + + const formatChangeType = (changeType: string) => { + const labels: Record = { + created: 'Created', + deleted: 'Deleted', + config: 'Config', + status: 'Status', + migrated: 'Migrated', + restarted: 'Restarted', + backed_up: 'Backup', + }; + if (labels[changeType]) return labels[changeType]; + return changeType.split(/[_-]/).map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(' '); + }; + + // Summarize a shell command action into a human-readable description + const summarizeAction = (action: string): { title: string; subtitle: string } => { + const cmd = action.trim(); + + // Common command patterns and their summaries + if (cmd.includes('df -h') || cmd.includes('du -s')) { + const pathMatch = cmd.match(/\/([\w/.-]+)/); + const path = pathMatch ? pathMatch[0] : 'storage'; + return { + title: 'Checked disk usage', + subtitle: `Analyzed space on ${path}` + }; + } + if (cmd.includes('docker ps') || cmd.includes('docker status')) { + const containerMatch = cmd.match(/name[=:]?\s*(\w+)/i); + const container = containerMatch ? containerMatch[1] : 'containers'; + return { + title: 'Checked container status', + subtitle: `Verified ${container} is running` + }; + } + if (cmd.includes('docker restart') || cmd.includes('docker start')) { + const containerMatch = cmd.match(/(?:restart|start)\s+(\w+)/i); + const container = containerMatch ? containerMatch[1] : 'container'; + return { + title: 'Restarted container', + subtitle: `Restored ${container} service` + }; + } + if (cmd.includes('systemctl restart') || (cmd.includes('service') && cmd.includes('restart'))) { + const serviceMatch = cmd.match(/(?:restart|start)\s+(\S+)/i); + const service = serviceMatch ? serviceMatch[1] : 'service'; + return { + title: 'Restarted service', + subtitle: `Restored ${service}` + }; + } + if (cmd.includes('grep') && cmd.includes('config')) { + return { + title: 'Checked configuration', + subtitle: 'Verified config settings' + }; + } + if (cmd.includes('grep')) { + return { + title: 'Searched logs/config', + subtitle: 'Retrieved diagnostic information' + }; + } + if (cmd.includes('tail') || cmd.includes('cat') || cmd.includes('head')) { + return { + title: 'Inspected logs/files', + subtitle: 'Retrieved diagnostic information' + }; + } + if (cmd.includes('ps aux') || cmd.includes('top') || cmd.includes('htop')) { + return { + title: 'Checked processes', + subtitle: 'Analyzed running processes' + }; + } + if (cmd.includes('free') || cmd.includes('memory')) { + return { + title: 'Checked memory usage', + subtitle: 'Analyzed memory allocation' + }; + } + if (cmd.includes('ping') || cmd.includes('curl') || cmd.includes('wget')) { + return { + title: 'Tested connectivity', + subtitle: 'Verified network connection' + }; + } + if (cmd.startsWith('[host]') || cmd.startsWith('[')) { + // Agent command - extract the actual command + const innerCmd = cmd.replace(/^\[[\w\s]+\]\s*/, ''); + const inner = summarizeAction(innerCmd); + return { + title: inner.title, + subtitle: `${inner.subtitle} (via agent)` + }; + } + + // Fallback: truncate and show the command + const truncated = cmd.length > 50 ? cmd.substring(0, 47) + '...' : cmd; + return { + title: 'Executed command', + subtitle: truncated + }; + }; + + // Create a meaningful summary from the ACTION (command) only + // The problem field is just chat context and not useful here + const summarizeValue = (_problem: string, action: string): string => { + const cmd = action.trim(); + + // Extract target/context from the command (container names, paths, services) + const extractTarget = (): string | null => { + // Container name from docker commands + const containerMatch = cmd.match(/name[=:]?\s*(\w+)/i) || cmd.match(/filter.*?(\w+)/); + if (containerMatch) return containerMatch[1]; + + // Service name from systemctl + const serviceMatch = cmd.match(/systemctl\s+\w+\s+(\S+)/); + if (serviceMatch) return serviceMatch[1]; + + // Path for disk commands - get the meaningful part + const pathMatch = cmd.match(/\/([\w]+)(?:\/[\w_-]+)*(?:\s|$)/); + if (pathMatch) { + const fullPath = cmd.match(/\/[\w\/_-]+/)?.[0] || ''; + // Extract meaningful name from path + if (fullPath.includes('frigate')) return 'Frigate'; + if (fullPath.includes('plex')) return 'Plex'; + if (fullPath.includes('docker')) return 'Docker'; + if (fullPath.includes('recordings')) return 'recordings'; + if (fullPath.includes('media')) return 'media'; + if (fullPath.includes('config')) return 'config'; + return fullPath.split('/').filter(Boolean).pop() || null; + } + + // Config file reference + if (cmd.includes('config.yml') || cmd.includes('config.yaml')) return 'config'; + + return null; + }; + + const target = extractTarget(); + const targetStr = target ? ` (${target})` : ''; + + // Generate meaningful descriptions based on command type + if (cmd.includes('docker restart') || cmd.includes('docker start')) { + return `Restarted container${targetStr}`; + } + if (cmd.includes('docker stop')) { + return `Stopped container${targetStr}`; + } + if (cmd.includes('docker ps') || cmd.includes('docker status')) { + return `Verified container status${targetStr}`; + } + if (cmd.includes('docker logs')) { + return `Retrieved container logs${targetStr}`; + } + if (cmd.includes('systemctl restart') || cmd.includes('service') && cmd.includes('restart')) { + return `Restarted service${targetStr}`; + } + if (cmd.includes('systemctl status')) { + return `Checked service status${targetStr}`; + } + if ((cmd.includes('df') || cmd.includes('du')) && target) { + return `Analyzed storage usage${targetStr}`; + } + if (cmd.includes('df') || cmd.includes('du')) { + return 'Analyzed disk space'; + } + if (cmd.includes('grep') && (cmd.includes('config') || cmd.includes('.yml') || cmd.includes('.yaml'))) { + return `Inspected configuration${targetStr}`; + } + if (cmd.includes('grep') || cmd.includes('tail -f') || cmd.includes('journalctl')) { + return `Reviewed logs${targetStr}`; + } + if (cmd.includes('tail') || cmd.includes('cat') || cmd.includes('head')) { + return `Retrieved file contents${targetStr}`; + } + if (cmd.includes('ping') || cmd.includes('curl') || cmd.includes('wget')) { + return 'Tested network connectivity'; + } + if (cmd.includes('free') || cmd.includes('/proc/meminfo')) { + return 'Checked memory usage'; + } + if (cmd.includes('ps aux') || cmd.includes('top') || cmd.includes('htop')) { + return 'Analyzed running processes'; + } + if (cmd.includes('chmod') || cmd.includes('chown')) { + return `Fixed permissions${targetStr}`; + } + if (cmd.includes('rm ') || cmd.includes('rm -')) { + return `Cleaned up files${targetStr}`; + } + if (cmd.includes('mkdir')) { + return `Created directory${targetStr}`; + } + if (cmd.startsWith('[host]') || cmd.startsWith('[')) { + // Agent command - recurse with inner command + const innerCmd = cmd.replace(/^\[[\w\s]+\]\s*/, ''); + return summarizeValue('', innerCmd); + } + + return 'Ran diagnostic'; + }; + + const totalItems = () => unifiedRows().length; + const anyLocked = () => insightsLocked() || impactLocked() || memoryLocked(); + + const shouldShow = () => showWhenEmpty() || loading() || totalItems() > 0 || anyLocked(); + + // Stats for quick summary + const statsDisplay = () => { + const stats = remediationStats(); + if (!stats) return null; + return stats; + }; + + return ( + +
+ {/* Header */} +
+
+
+
+ + + + AI Intelligence Summary +
+ 0}> + + {totalItems()} items + + + + + Some Locked + + +
+ + {/* Quick Stats */} + +
+
+ + {statsDisplay()!.resolved} resolved +
+
+ + {statsDisplay()!.automatic} auto-fix +
+
+
+
+
+ + {/* Loading */} + +
+
+ + Loading AI intelligence... +
+
+
+ + {/* Error */} + +
+
{error()}
+
+
+ + {/* Locked notice (if all are locked) */} + +
+
+
+
+

Pulse Pro required

+

+ Full AI intelligence features including predictions, impact tracking, and operational memory require Pulse Pro. +

+
+ + Upgrade + +
+
+
+
+ + {/* Table */} + 0 || !anyLocked())}> + 0} + fallback={ +
+
+ + + +

+ No AI insights yet. The AI will learn patterns and surface intelligence over time. +

+
+
+ } + > +
+ + + + + + + + + + + {(row) => ( + + + + + + )} + + +
TypeDetailsWhen
+ + {row.typeBadge} + + +
+ + {row.title} + + + {row.subtitle} + +
+ + + {Math.round(row.confidence! * 100)}% confidence + + +
+ + {row.timestamp} + +
+
+
+
+ + {/* Section locked notices (partial lock) */} + +
+
+ +
+ + + + {insightsLockedCount()} AI Insights locked +
+
+ +
+ + + + Impact details locked +
+
+ +
+ + + + {memoryLockedCount()} changes locked +
+
+ + Upgrade to Pulse Pro + +
+
+
+
+
+ ); +}; diff --git a/frontend-modern/src/components/shared/AIRecentChangesPanel.tsx b/frontend-modern/src/components/shared/AIRecentChangesPanel.tsx new file mode 100644 index 0000000..7c29863 --- /dev/null +++ b/frontend-modern/src/components/shared/AIRecentChangesPanel.tsx @@ -0,0 +1,197 @@ +import { Component, createEffect, createSignal, For, Show } from 'solid-js'; +import { AIAPI } from '@/api/ai'; +import type { InfrastructureChange } from '@/types/aiIntelligence'; + +const DEFAULT_UPGRADE_URL = 'https://pulsemonitor.app/pro'; + +export const AIRecentChangesPanel: Component<{ hours?: number; showWhenEmpty?: boolean }> = (props) => { + const [changes, setChanges] = createSignal([]); + const [loading, setLoading] = createSignal(false); + const [locked, setLocked] = createSignal(false); + const [lockedCount, setLockedCount] = createSignal(0); + const [upgradeUrl, setUpgradeUrl] = createSignal(DEFAULT_UPGRADE_URL); + const [error, setError] = createSignal(''); + const showWhenEmpty = () => Boolean(props.showWhenEmpty); + const hours = () => props.hours ?? 24; + + const loadData = async () => { + setLoading(true); + setError(''); + try { + const response = await AIAPI.getRecentChanges(hours()); + const licenseLocked = Boolean(response.license_required); + setLocked(licenseLocked); + setUpgradeUrl(response.upgrade_url || DEFAULT_UPGRADE_URL); + if (licenseLocked) { + setLockedCount(response.count || 0); + setChanges([]); + } else { + setLockedCount(0); + setChanges(response.changes || []); + } + } catch (e) { + console.error('Failed to load AI change history:', e); + setError('Failed to load recent changes.'); + } finally { + setLoading(false); + } + }; + + createEffect(() => { + void loadData(); + }); + + const displayedChanges = () => changes().slice(0, 6); + const shouldShow = () => showWhenEmpty() || loading() || displayedChanges().length > 0 || lockedCount() > 0; + + const formatRelativeTime = (ts: string) => { + const date = new Date(ts); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString(); + }; + + const changeTypeLabel = (changeType: string) => { + const labels: Record = { + created: 'Created', + deleted: 'Deleted', + config: 'Config change', + status: 'Status change', + migrated: 'Migrated', + restarted: 'Restarted', + backed_up: 'Backup completed', + }; + if (labels[changeType]) { + return labels[changeType]; + } + return changeType + .split(/[_-]/) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); + }; + + const changeTypeBadge = (changeType: string) => { + const styles: Record = { + created: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', + deleted: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', + config: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', + status: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', + migrated: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300', + restarted: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300', + backed_up: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300', + }; + return styles[changeType] || 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'; + }; + + return ( + +
+
+
+ + + + Operational Memory + 0 && locked()}> + + {lockedCount()} + + + 0}> + + {displayedChanges().length} + + + + + Locked + + +
+ {hours()}h window +
+
+ +
+ + Loading changes... +
+
+ + +
+ {error()} +
+
+ + +
+
+
+

Pulse Pro required

+

+ Operational memory is available with Pulse Pro. {lockedCount() || 0} changes are ready to review. +

+
+ + Upgrade + +
+
+
+ + + 0} + fallback={ +

+ No recent changes detected. The AI will surface configuration and state changes here. +

+ } + > +
+ + {(change) => ( +
+
+
+
+ + {changeTypeLabel(change.change_type)} + + + {change.resource_name || change.resource_id} + +
+

+ {change.description || 'Change detected by AI Patrol.'} +

+
+ + {formatRelativeTime(change.detected_at)} + +
+
+ )} +
+
+
+
+
+
+
+ ); +}; diff --git a/frontend-modern/src/pages/Alerts.tsx b/frontend-modern/src/pages/Alerts.tsx index 2516176..ab2d0be 100644 --- a/frontend-modern/src/pages/Alerts.tsx +++ b/frontend-modern/src/pages/Alerts.tsx @@ -7,6 +7,7 @@ import { ThresholdsTable } from '@/components/Alerts/ThresholdsTable'; import { InvestigateAlertButton } from '@/components/Alerts/InvestigateAlertButton'; import type { RawOverrideConfig, PMGThresholdDefaults, SnapshotAlertConfig, BackupAlertConfig } from '@/types/alerts'; import { Card } from '@/components/shared/Card'; +import { AIOverviewTable } from '@/components/shared/AIOverviewTable'; import { SectionHeader } from '@/components/shared/SectionHeader'; import { SettingsPanel } from '@/components/shared/SettingsPanel'; import { Toggle } from '@/components/shared/Toggle'; @@ -17,10 +18,12 @@ import { showSuccess, showError } from '@/utils/toast'; import { showTooltip, hideTooltip } from '@/components/shared/Tooltip'; import { AlertsAPI } from '@/api/alerts'; import { NotificationsAPI, Webhook } from '@/api/notifications'; +import { AIAPI } from '@/api/ai'; import { LicenseAPI, type LicenseFeatureStatus } from '@/api/license'; import type { EmailConfig, AppriseConfig } from '@/api/notifications'; import type { HysteresisThreshold } from '@/types/alerts'; import type { Alert, State, VM, Container, DockerHost, DockerContainer, Host } from '@/types/api'; +import type { RemediationRecord } from '@/types/aiIntelligence'; import { useNavigate, useLocation } from '@solidjs/router'; import { useAlertsActivation } from '@/stores/alertsActivation'; import { logger } from '@/utils/logger'; @@ -29,7 +32,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, getPatrolRunHistory, forcePatrol, subscribeToPatrolStream, dismissFinding, suppressFinding, getSuppressionRules, addSuppressionRule, deleteSuppressionRule, type Finding, type PatrolStatus, type PatrolRunRecord, type SuppressionRule, severityColors, formatTimestamp, categoryLabels } from '@/api/patrol'; +import { getPatrolStatus, getFindings, getFindingsHistory, getPatrolRunHistory, forcePatrol, subscribeToPatrolStream, dismissFinding, suppressFinding, getSuppressionRules, addSuppressionRule, deleteSuppressionRule, getRunbooksForFinding, executeRunbook, type Finding, type PatrolStatus, type PatrolRunRecord, type SuppressionRule, type RunbookInfo, type RunbookExecutionResult, severityColors, formatTimestamp, categoryLabels } from '@/api/patrol'; import { aiChatStore } from '@/stores/aiChat'; type AlertTab = 'overview' | 'thresholds' | 'destinations' | 'schedule' | 'history'; @@ -2080,17 +2083,34 @@ function OverviewTab(props: { const [newRuleDescription, setNewRuleDescription] = createSignal(''); const [licenseFeatures, setLicenseFeatures] = createSignal(null); const [licenseLoading, setLicenseLoading] = createSignal(false); + const [runbooksByFinding, setRunbooksByFinding] = createSignal>({}); + const [runbookLoadingByFinding, setRunbookLoadingByFinding] = createSignal>({}); + const [runbookSelection, setRunbookSelection] = createSignal>({}); + const [runbookResults, setRunbookResults] = createSignal>({}); + const [runbookErrors, setRunbookErrors] = createSignal>({}); + const [expandedRunbookFinding, setExpandedRunbookFinding] = createSignal(null); + const [remediationsByFinding, setRemediationsByFinding] = createSignal>({}); + const [remediationLoadingByFinding, setRemediationLoadingByFinding] = createSignal>({}); + const [remediationErrorsByFinding, setRemediationErrorsByFinding] = createSignal>({}); + const [remediationLockedByFinding, setRemediationLockedByFinding] = createSignal>({}); + const [remediationUpgradeURLByFinding, setRemediationUpgradeURLByFinding] = createSignal>({}); const hasAIAlertsFeature = createMemo(() => { const status = licenseFeatures(); if (!status) return true; return Boolean(status.features?.['ai_alerts']); }); + const hasAutoFixFeature = createMemo(() => { + const status = licenseFeatures(); + if (!status) return false; + return Boolean(status.features?.['ai_autofix']); + }); const showAIAlertsUpgrade = createMemo(() => { const status = licenseFeatures(); if (!status) return false; return !status.features?.['ai_alerts']; }); const aiAlertsUpgradeURL = createMemo(() => licenseFeatures()?.upgrade_url || 'https://pulsemonitor.app/pro'); + const autoFixUpgradeURL = createMemo(() => licenseFeatures()?.upgrade_url || 'https://pulsemonitor.app/pro'); // Live streaming state for running patrol const [expandedLiveStream, setExpandedLiveStream] = createSignal(false); // Track streaming blocks for sequential display (like AI chat) @@ -2254,6 +2274,73 @@ function OverviewTab(props: { } }; + const loadRunbooks = async (findingId: string) => { + setRunbookLoadingByFinding((prev) => ({ ...prev, [findingId]: true })); + setRunbookErrors((prev) => ({ ...prev, [findingId]: '' })); + try { + const runbooks = await getRunbooksForFinding(findingId); + setRunbooksByFinding((prev) => ({ ...prev, [findingId]: runbooks })); + if (runbooks.length > 0 && !runbookSelection()[findingId]) { + setRunbookSelection((prev) => ({ ...prev, [findingId]: runbooks[0].id })); + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to load runbooks'; + setRunbookErrors((prev) => ({ ...prev, [findingId]: message })); + } finally { + setRunbookLoadingByFinding((prev) => ({ ...prev, [findingId]: false })); + } + }; + + const toggleRunbookPanel = (findingId: string) => { + setExpandedRunbookFinding((prev) => (prev === findingId ? null : findingId)); + if (!runbooksByFinding()[findingId] && !runbookLoadingByFinding()[findingId]) { + void loadRunbooks(findingId); + } + }; + + const loadRemediationsForFinding = async (findingId: string) => { + setRemediationLoadingByFinding((prev) => ({ ...prev, [findingId]: true })); + setRemediationErrorsByFinding((prev) => ({ ...prev, [findingId]: '' })); + try { + const response = await AIAPI.getRemediations({ findingId, limit: 3 }); + const locked = Boolean(response.license_required); + setRemediationLockedByFinding((prev) => ({ ...prev, [findingId]: locked })); + if (response.upgrade_url) { + setRemediationUpgradeURLByFinding((prev) => ({ ...prev, [findingId]: response.upgrade_url || '' })); + } + setRemediationsByFinding((prev) => ({ ...prev, [findingId]: locked ? [] : (response.remediations || []) })); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to load remediation history'; + setRemediationErrorsByFinding((prev) => ({ ...prev, [findingId]: message })); + } finally { + setRemediationLoadingByFinding((prev) => ({ ...prev, [findingId]: false })); + } + }; + + const runSelectedRunbook = async (findingId: string) => { + const runbookId = runbookSelection()[findingId]; + if (!runbookId) { + setRunbookErrors((prev) => ({ ...prev, [findingId]: 'Select a runbook to execute.' })); + return; + } + setRunbookErrors((prev) => ({ ...prev, [findingId]: '' })); + setRunbookResults((prev) => ({ ...prev, [findingId]: null })); + setRunbookLoadingByFinding((prev) => ({ ...prev, [findingId]: true })); + try { + const result = await executeRunbook(findingId, runbookId); + setRunbookResults((prev) => ({ ...prev, [findingId]: result })); + if (result.resolved) { + fetchAiData(); + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Runbook execution failed'; + setRunbookErrors((prev) => ({ ...prev, [findingId]: message })); + } finally { + setRunbookLoadingByFinding((prev) => ({ ...prev, [findingId]: false })); + void loadRemediationsForFinding(findingId); + } + }; + // Handle force patrol button click const handleForcePatrol = async (deep: boolean = false) => { setForcePatrolLoading(true); @@ -2582,6 +2669,63 @@ function OverviewTab(props: {

+ +
+ +
+ + +
+
+ Preview findings (details locked) + {aiFindings().length} detected +
+ 0} + fallback={ +
+ No preview findings yet. Enable Patrol to generate a preview. +
+ } + > + + {(finding) => ( +
+
+ + {finding.severity} + + + {finding.title || 'Potential issue detected'} + + Locked +
+
+
+ {finding.description || 'Upgrade to view full analysis.'} +
+ +
+
+ )} +
+ 3}> +
+ +{aiFindings().length - 3} more findings hidden +
+
+
+
+
- {/* Compact header - always visible, clickable */} -
setIsExpanded(!isExpanded())} - > - {/* Expand chevron */} - { + const nextExpanded = !isExpanded(); + setIsExpanded(nextExpanded); + if (nextExpanded && remediationsByFinding()[finding.id] === undefined && !remediationLoadingByFinding()[finding.id]) { + void loadRemediationsForFinding(finding.id); + } + }} > - - - {/* Severity badge */} - - {finding.severity} - - {/* Title (main info) */} - - {finding.title} - - {/* Recurrence badge - show if raised multiple times */} - 1}> - - ×{finding.times_raised} + + + {/* Severity badge */} + + {finding.severity} - - {/* Dismissed status badge */} - - - {finding.dismissed_reason === 'not_an_issue' && '✓ Dismissed'} - {finding.dismissed_reason === 'expected_behavior' && '✓ Expected'} - {finding.dismissed_reason === 'will_fix_later' && '⏱ Noted'} + {/* Title (main info) */} + + {finding.title} - - {/* Suppressed badge */} - - - 🔇 Suppressed + {/* Recurrence badge - show if raised multiple times */} + 1}> + + ×{finding.times_raised} + + + {/* Dismissed status badge */} + + + {finding.dismissed_reason === 'not_an_issue' && '✓ Dismissed'} + {finding.dismissed_reason === 'expected_behavior' && '✓ Expected'} + {finding.dismissed_reason === 'will_fix_later' && '⏱ Noted'} + + + {/* Suppressed badge */} + + + 🔇 Suppressed + + + {/* Resource name pill */} + - - {/* Resource name pill */} - - {/* AI badge */} - - AI - -
+ {/* AI badge */} + + AI + +
- {/* Expanded details */} - -
- {/* Resource and category info */} -
- - {finding.resource_name} - - - ({finding.category}) - - - - on {finding.node} + {/* Expanded details */} + +
+ {/* Resource and category info */} +
+ + {finding.resource_name} - -
- - {/* Description */} -

- {finding.description} -

- - {/* Recommendation */} - -

- Suggested: {finding.recommendation} -

-
- - {/* User note if present */} - -
-

- Your note: {finding.user_note} -

+ + ({finding.category}) + + + + on {finding.node} + +
-
- {/* Footer with time and actions */} -
-

- Detected: {formatTimestamp(finding.detected_at)} + {/* Description */} +

+ {finding.description}

-
- {/* Get Help with AI button */} - - {/* I Fixed It button - hides until next patrol verifies */} - - {/* Not an Issue dropdown - LLM memory system */} -
+ + {/* Recommendation */} + +

+ Suggested: {finding.recommendation} +

+
+ + {/* User note if present */} + +
+

+ Your note: {finding.user_note} +

+
+
+ + {/* Footer with time and actions */} +
+

+ Detected: {formatTimestamp(finding.detected_at)} +

+
+ {/* Get Help with AI button */} - {/* Dropdown menu - pt-2 creates visual gap while maintaining hover area */} - -
- -
+ +
); }} @@ -2877,129 +3278,129 @@ function OverviewTab(props: {
- {/* Add rule form */} - -
-

- Add a suppression rule to prevent alerts -

-
- setNewRuleResource(e.currentTarget.value)} - /> - - setNewRuleDescription(e.currentTarget.value)} - /> -
-
- - -
-
-
- - {/* Rules list */} - -
- -

- No suppression rules. Dismiss findings or add rules to prevent unwanted alerts. + {/* Add rule form */} + +

+

+ Add a suppression rule to prevent alerts

- - - {(rule) => ( -
-
-
- - {rule.resource_name || rule.resource_id || 'Any resource'} - - - - {categoryLabels[rule.category!] || rule.category} +
+ setNewRuleResource(e.currentTarget.value)} + /> + + setNewRuleDescription(e.currentTarget.value)} + /> +
+
+ + +
+
+ + + {/* Rules list */} + +
+ +

+ No suppression rules. Dismiss findings or add rules to prevent unwanted alerts. +

+
+ + {(rule) => ( +
+
+
+ + {rule.resource_name || rule.resource_id || 'Any resource'} - - - - Any category + + + {categoryLabels[rule.category!] || rule.category} + + + + + Any category + + + + {rule.created_from === 'finding' ? 'From Finding' : 'Manual'} - - - {rule.created_from === 'finding' ? 'From Finding' : 'Manual'} - +
+

+ {rule.description || 'No description'} +

-

- {rule.description || 'No description'} -

+
- -
- )} - -
+ )} + +
@@ -3026,364 +3427,376 @@ function OverviewTab(props: { - {/* Next Patrol Timer */} - - - Next patrol in {nextPatrolIn()} - - -
+ {/* Next Patrol Timer */} + + + Next patrol in {nextPatrolIn()} + + +
- -
- {/* Time Filter + Mini Health Chart */} -
-
- - - + +
+ {/* Time Filter + Mini Health Chart */} +
+
+ + + +
+ + {/* Mini Health Chart */} + 0}> +
+ + {(run) => ( +
+ )} + + ← newest +
+
- {/* Mini Health Chart */} - 0}> -
- - {(run) => ( -
- )} - - ← newest + 0} + fallback={ +

No patrol runs in selected time range.

+ } + > +
+ + + + + + + + + + + + + + + + {/* Show "Currently Running" row when patrol is in progress */} + + setExpandedLiveStream(!expandedLiveStream())} + > + + + + + + {/* Expanded Live Stream Row */} + + + + + + + + {(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 ( + <> + hasDetails && setExpandedRunId(expandedRunId() === run.id ? null : run.id)} + > + + + + + + + + + + + {/* Expanded Details Row */} + + + + + + + ); + }} + + +
TimeTypeStatusResourcesNewResolvedAuto-FixDuration
+ + + + +
+ + + + + Now +
+
+ + Running + + + + {expandedLiveStream() ? 'Click to collapse' : 'Click to view live AI analysis'} + +
+
+ + + + + Live AI Analysis + + + Streaming in real-time... + +
+ {/* Sequential blocks display - like AI chat */} +
+ {/* Rendered blocks */} + + {(block) => ( + + {block.text.length > 800 ? block.text.substring(0, 800) + '...' : block.text} +
+ } + > + {/* Phase marker */} +
+ + + + + } + > + + + + + + {block.text} + +
+ + )} + + {/* Currently streaming content */} + +
+ {currentThinking().length > 500 ? currentThinking().substring(0, 500) + '...' : currentThinking()} + +
+
+ {/* Empty state */} + +
+ + + + + Waiting for AI response... +
+
+ +
+ + + + + + + {formatTimestamp(run.completed_at)} + + + {run.type === 'deep' ? 'Deep' : 'Quick'} + + + + {run.findings_summary} + + + {run.resources_checked} + + 0} fallback={-}> + {run.new_findings} + + + 0} fallback={-}> + {run.resolved_findings} + + + 0} fallback={-}> + {run.auto_fix_count} + + + {(() => { + 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`; + })()} +
+
+ 0}> +
+ Nodes + {run.nodes_checked} +
+
+ 0}> +
+ VMs/CTs + {run.guests_checked} +
+
+ 0}> +
+ Docker + {run.docker_checked} +
+
+ 0}> +
+ Storage + {run.storage_checked} +
+
+ 0}> +
+ Hosts + {run.hosts_checked} +
+
+ 0}> +
+ PBS + {run.pbs_checked} +
+
+ 0}> +
+ Auto-Fix + {run.auto_fix_count} +
+
+
+ {/* 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 ( +
+ + Findings from this run: + +
+ + {(finding) => ( +
+ + {finding!.severity} + + {finding!.title} + on {finding!.resource_name} +
+ )} +
+ 0}> +
+ + + + {resolvedCount} finding{resolvedCount > 1 ? 's' : ''} since resolved +
+
+
+
+ ); + })()} + {/* AI Analysis Section */} + +
+
+ + + + + AI Analysis + + + + {run.input_tokens?.toLocaleString()} in / {run.output_tokens?.toLocaleString()} out tokens + + +
+
+
{run.ai_analysis}
+
+
+
+
- - 0} - fallback={ -

No patrol runs in selected time range.

- } - > -
- - - - - - - - - - - - - - - {/* Show "Currently Running" row when patrol is in progress */} - - setExpandedLiveStream(!expandedLiveStream())} - > - - - - - - {/* Expanded Live Stream Row */} - - - - - - - - {(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 ( - <> - hasDetails && setExpandedRunId(expandedRunId() === run.id ? null : run.id)} - > - - - - - - - - - - {/* Expanded Details Row */} - - - - - - - ); - }} - - -
TimeTypeStatusResourcesNewResolvedDuration
- - - - -
- - - - - Now -
-
- - Running - - - - {expandedLiveStream() ? 'Click to collapse' : 'Click to view live AI analysis'} - -
-
- - - - - Live AI Analysis - - - Streaming in real-time... - -
- {/* Sequential blocks display - like AI chat */} -
- {/* Rendered blocks */} - - {(block) => ( - - {block.text.length > 800 ? block.text.substring(0, 800) + '...' : block.text} -
- } - > - {/* Phase marker */} -
- - - - - } - > - - - - - - {block.text} - -
- - )} - - {/* Currently streaming content */} - -
- {currentThinking().length > 500 ? currentThinking().substring(0, 500) + '...' : currentThinking()} - -
-
- {/* Empty state */} - -
- - - - - Waiting for AI response... -
-
- -
- - - - - - - {formatTimestamp(run.completed_at)} - - - {run.type === 'deep' ? 'Deep' : 'Quick'} - - - - {run.findings_summary} - - - {run.resources_checked} - - 0} fallback={-}> - {run.new_findings} - - - 0} fallback={-}> - {run.resolved_findings} - - - {(() => { - 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`; - })()} -
-
- 0}> -
- Nodes - {run.nodes_checked} -
-
- 0}> -
- VMs/CTs - {run.guests_checked} -
-
- 0}> -
- Docker - {run.docker_checked} -
-
- 0}> -
- Storage - {run.storage_checked} -
-
- 0}> -
- Hosts - {run.hosts_checked} -
-
- 0}> -
- PBS - {run.pbs_checked} -
-
-
- {/* 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 ( -
- - Findings from this run: - -
- - {(finding) => ( -
- - {finding!.severity} - - {finding!.title} - on {finding!.resource_name} -
- )} -
- 0}> -
- - - - {resolvedCount} finding{resolvedCount > 1 ? 's' : ''} since resolved -
-
-
-
- ); - })()} - {/* AI Analysis Section */} - -
-
- - - - - AI Analysis - - - - {run.input_tokens?.toLocaleString()} in / {run.output_tokens?.toLocaleString()} out tokens - - -
-
-
{run.ai_analysis}
-
-
-
-
-
-
-
diff --git a/frontend-modern/src/types/aiIntelligence.ts b/frontend-modern/src/types/aiIntelligence.ts index 0b385f8..7c8e3e2 100644 --- a/frontend-modern/src/types/aiIntelligence.ts +++ b/frontend-modern/src/types/aiIntelligence.ts @@ -60,29 +60,68 @@ export interface ResourceBaseline { last_update: string; } +export interface RemediationRecord { + id: string; + timestamp: string; + resource_id: string; + resource_type: string; + resource_name: string; + finding_id?: string; + problem: string; + summary?: string; // AI-generated summary of what was achieved + action: string; + output?: string; + outcome: string; + duration_ms?: number; + note?: string; + automatic: boolean; +} + +export interface RemediationStats { + total: number; + resolved: number; + partial: number; + failed: number; + unknown: number; + automatic: number; + manual: number; +} + // API response types -export interface PatternsResponse { +interface LicenseGatedResponse { + license_required?: boolean; + upgrade_url?: string; + message?: string; +} + +export interface PatternsResponse extends LicenseGatedResponse { patterns: FailurePattern[]; count: number; } -export interface PredictionsResponse { +export interface PredictionsResponse extends LicenseGatedResponse { predictions: FailurePrediction[]; count: number; } -export interface CorrelationsResponse { +export interface CorrelationsResponse extends LicenseGatedResponse { correlations: ResourceCorrelation[]; count: number; } -export interface ChangesResponse { +export interface ChangesResponse extends LicenseGatedResponse { changes: InfrastructureChange[]; count: number; hours: number; } -export interface BaselinesResponse { +export interface BaselinesResponse extends LicenseGatedResponse { baselines: ResourceBaseline[]; count: number; } + +export interface RemediationsResponse extends LicenseGatedResponse { + remediations: RemediationRecord[]; + count: number; + stats?: RemediationStats; +} diff --git a/internal/ai/alert_adapter_test.go b/internal/ai/alert_adapter_test.go index 01afc11..ffb0bab 100644 --- a/internal/ai/alert_adapter_test.go +++ b/internal/ai/alert_adapter_test.go @@ -107,3 +107,74 @@ func TestAlertManagerAdapter_ConvertsAndFilters(t *testing.T) { } } +func TestInferResourceType(t *testing.T) { + tests := []struct { + name string + alertType string + metadata map[string]interface{} + expected string + }{ + {"node_offline", "node_offline", nil, "node"}, + {"node_cpu", "node_cpu", nil, "node"}, + {"node_memory", "node_memory", nil, "node"}, + {"node_temperature", "node_temperature", nil, "node"}, + {"storage_usage", "storage_usage", nil, "storage"}, + {"storage", "storage", nil, "storage"}, + {"docker_cpu", "docker_cpu", nil, "docker"}, + {"docker_memory", "docker_memory", nil, "docker"}, + {"docker_restart", "docker_restart", nil, "docker"}, + {"docker_offline", "docker_offline", nil, "docker"}, + {"host_cpu", "host_cpu", nil, "host"}, + {"host_memory", "host_memory", nil, "host"}, + {"host_offline", "host_offline", nil, "host"}, + {"host_disk", "host_disk", nil, "host"}, + {"pmg", "pmg", nil, "pmg"}, + {"pmg_queue", "pmg_queue", nil, "pmg"}, + {"pmg_quarantine", "pmg_quarantine", nil, "pmg"}, + {"backup", "backup", nil, "backup"}, + {"backup_missing", "backup_missing", nil, "backup"}, + {"snapshot", "snapshot", nil, "snapshot"}, + {"snapshot_age", "snapshot_age", nil, "snapshot"}, + {"unknown_type", "unknown_type", nil, "guest"}, + {"with_metadata", "unknown", map[string]interface{}{"resourceType": "custom"}, "custom"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := inferResourceType(tt.alertType, tt.metadata) + if result != tt.expected { + t.Errorf("inferResourceType(%q, %v) = %q, want %q", tt.alertType, tt.metadata, result, tt.expected) + } + }) + } +} + +func TestFormatDuration(t *testing.T) { + tests := []struct { + name string + duration time.Duration + expected string + }{ + {"less_than_minute", 30 * time.Second, "< 1 min"}, + {"one_minute", 1 * time.Minute, "1 min"}, + {"5_minutes", 5 * time.Minute, "5 mins"}, + {"59_minutes", 59 * time.Minute, "59 mins"}, + {"one_hour", 1 * time.Hour, "1 hour"}, + {"2_hours", 2 * time.Hour, "2 hours"}, + {"1h_30m", 90 * time.Minute, "1h 30m"}, + {"one_day", 24 * time.Hour, "1 day"}, + {"2_days", 48 * time.Hour, "2 days"}, + {"1d_12h", 36 * time.Hour, "1d 12h"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatDuration(tt.duration) + if result != tt.expected { + t.Errorf("formatDuration(%v) = %q, want %q", tt.duration, result, tt.expected) + } + }) + } +} + + diff --git a/internal/ai/context/builder_test.go b/internal/ai/context/builder_test.go index fbdf7c3..1356e12 100644 --- a/internal/ai/context/builder_test.go +++ b/internal/ai/context/builder_test.go @@ -378,3 +378,107 @@ func containsSubstring(s, substr string) bool { } return false } + +func TestFilterRecentPoints_Empty(t *testing.T) { + points := []MetricPoint{} + result := filterRecentPoints(points, time.Hour) + + if len(result) != 0 { + t.Errorf("Expected empty result, got %d points", len(result)) + } +} + +func TestFilterRecentPoints_AllRecent(t *testing.T) { + now := time.Now() + points := []MetricPoint{ + {Timestamp: now.Add(-30 * time.Minute), Value: 1.0}, + {Timestamp: now.Add(-15 * time.Minute), Value: 2.0}, + {Timestamp: now.Add(-5 * time.Minute), Value: 3.0}, + } + + result := filterRecentPoints(points, time.Hour) + + if len(result) != 3 { + t.Errorf("Expected 3 points, got %d", len(result)) + } +} + +func TestFilterRecentPoints_FilterOld(t *testing.T) { + now := time.Now() + points := []MetricPoint{ + {Timestamp: now.Add(-3 * time.Hour), Value: 1.0}, // Old + {Timestamp: now.Add(-2 * time.Hour), Value: 2.0}, // Old + {Timestamp: now.Add(-30 * time.Minute), Value: 3.0}, // Recent + } + + result := filterRecentPoints(points, time.Hour) + + if len(result) != 1 { + t.Errorf("Expected 1 recent point, got %d", len(result)) + } + if result[0].Value != 3.0 { + t.Errorf("Expected value 3.0, got %f", result[0].Value) + } +} + +func TestFormatAnomalyDescription(t *testing.T) { + tests := []struct { + name string + metric string + current float64 + mean float64 + stddev float64 + severity string + direction string + wantContains []string + }{ + { + name: "cpu high", + metric: "cpu", + current: 95.0, + mean: 50.0, + stddev: 10.0, + severity: "significantly", + direction: "above", + wantContains: []string{"Cpu", "significantly", "above", "95%", "50%"}, + }, + { + name: "memory low", + metric: "memory", + current: 20.0, + mean: 60.0, + stddev: 15.0, + severity: "slightly", + direction: "below", + wantContains: []string{"Memory", "slightly", "below", "20%", "60%"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatAnomalyDescription(tt.metric, tt.current, tt.mean, tt.stddev, tt.severity, tt.direction) + for _, want := range tt.wantContains { + if !containsSubstring(result, want) { + t.Errorf("formatAnomalyDescription() = %q, want to contain %q", result, want) + } + } + }) + } +} + +func TestFormatPredictionBasis(t *testing.T) { + trend := Trend{ + RatePerDay: 2.5, + Period: 7 * 24 * time.Hour, // 7 days + } + + result := formatPredictionBasis(trend) + + if !containsSubstring(result, "Growing") { + t.Errorf("Expected 'Growing' in result, got %q", result) + } + if !containsSubstring(result, "based on") { + t.Errorf("Expected 'based on' in result, got %q", result) + } +} + diff --git a/internal/ai/context/trends_test.go b/internal/ai/context/trends_test.go index f19dc5b..2504200 100644 --- a/internal/ai/context/trends_test.go +++ b/internal/ai/context/trends_test.go @@ -355,3 +355,89 @@ func TestComputeTrend_LongTimeSpanNoChange(t *testing.T) { } } +// ======================================== +// intToString tests +// ======================================== + +func TestIntToString(t *testing.T) { + tests := []struct { + input int + expected string + }{ + {0, "0"}, + {1, "1"}, + {9, "9"}, + {10, "10"}, + {123, "123"}, + {1000, "1000"}, + {-1, "-1"}, + {-99, "-99"}, + {-123, "-123"}, + } + + for _, tt := range tests { + result := intToString(tt.input) + if result != tt.expected { + t.Errorf("intToString(%d) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +// ======================================== +// floatToString tests +// ======================================== + +func TestFloatToString(t *testing.T) { + tests := []struct { + name string + value float64 + precision int + expected string + }{ + {"zero precision positive", 5.7, 0, "6"}, + {"zero precision negative", -5.7, 0, "-6"}, + {"one precision", 5.43, 1, "5.4"}, // 5.43 rounds down to 5.4 + {"one precision round up", 5.48, 1, "5.5"}, + {"two precision", 3.14159, 2, "3.14"}, + {"three precision", 3.14159, 3, "3.142"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := floatToString(tt.value, tt.precision) + if result != tt.expected { + t.Errorf("floatToString(%.4f, %d) = %q, want %q", tt.value, tt.precision, result, tt.expected) + } + }) + } +} + +// ======================================== +// trimTrailingZeros tests +// ======================================== + +func TestTrimTrailingZeros(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"", ""}, + {"123", "123"}, + {"12.00", "12"}, + {"12.30", "12.3"}, + {"12.34", "12.34"}, + {"100.0", "100"}, + {"100.100", "100.1"}, + {"0.00", "0"}, + {"0.50", "0.5"}, + } + + for _, tt := range tests { + result := trimTrailingZeros(tt.input) + if result != tt.expected { + t.Errorf("trimTrailingZeros(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} + + diff --git a/internal/ai/correlation/detector_test.go b/internal/ai/correlation/detector_test.go index f99f2ca..65e9b36 100644 --- a/internal/ai/correlation/detector_test.go +++ b/internal/ai/correlation/detector_test.go @@ -244,3 +244,93 @@ func contains(s, substr string) bool { } return false } + +func TestFormatDuration(t *testing.T) { + tests := []struct { + name string + duration time.Duration + expected string + }{ + { + name: "seconds", + duration: 30 * time.Second, + expected: "seconds", + }, + { + name: "one minute", + duration: 1 * time.Minute, + expected: "1 minute", + }, + { + name: "multiple minutes", + duration: 30 * time.Minute, + expected: "30 minutes", + }, + { + name: "just under an hour", + duration: 59 * time.Minute, + expected: "59 minutes", + }, + { + name: "one hour", + duration: 1 * time.Hour, + expected: "1 hour", + }, + { + name: "multiple hours", + duration: 5 * time.Hour, + expected: "5 hours", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatDuration(tt.duration) + if result != tt.expected { + t.Errorf("formatDuration(%v) = %q, want %q", tt.duration, result, tt.expected) + } + }) + } +} + +func TestFormatConfidence(t *testing.T) { + tests := []struct { + confidence float64 + expected string + }{ + {0.0, "0%"}, + {0.5, "50%"}, + {0.75, "75%"}, + {1.0, "100%"}, + {0.333, "33%"}, + } + + for _, tt := range tests { + result := formatConfidence(tt.confidence) + if result != tt.expected { + t.Errorf("formatConfidence(%.2f) = %q, want %q", tt.confidence, result, tt.expected) + } + } +} + +func TestIntToStr(t *testing.T) { + tests := []struct { + input int + expected string + }{ + {0, "0"}, + {1, "1"}, + {10, "10"}, + {100, "100"}, + {999, "999"}, + {12345, "12345"}, + } + + for _, tt := range tests { + result := intToStr(tt.input) + if result != tt.expected { + t.Errorf("intToStr(%d) = %q, want %q", tt.input, result, tt.expected) + } + } +} + diff --git a/internal/ai/cost/resolve_test.go b/internal/ai/cost/resolve_test.go new file mode 100644 index 0000000..2248193 --- /dev/null +++ b/internal/ai/cost/resolve_test.go @@ -0,0 +1,164 @@ +package cost + +import "testing" + +func TestResolveProviderAndModel(t *testing.T) { + tests := []struct { + name string + eventProvider string + requestModel string + responseModel string + expectedProvider string + expectedModel string + }{ + { + name: "simple openai", + eventProvider: "openai", + requestModel: "gpt-4o", + responseModel: "", + expectedProvider: "openai", + expectedModel: "gpt-4o", + }, + { + name: "anthropic", + eventProvider: "anthropic", + requestModel: "claude-3-opus", + responseModel: "", + expectedProvider: "anthropic", + expectedModel: "claude-3-opus", + }, + { + name: "deepseek via openai format", + eventProvider: "openai", + requestModel: "deepseek:deepseek-chat", + responseModel: "", + expectedProvider: "deepseek", + expectedModel: "deepseek-chat", + }, + { + name: "deepseek with prefix", + eventProvider: "openai", + requestModel: "deepseek-reasoner", + responseModel: "", + expectedProvider: "deepseek", + expectedModel: "deepseek-reasoner", + }, + { + name: "empty provider inferred from model", + eventProvider: "", + requestModel: "openai:gpt-4", + responseModel: "", + expectedProvider: "openai", + expectedModel: "openai:gpt-4", // Model is not parsed, just provider is inferred + }, + { + name: "whitespace trimming", + eventProvider: " OpenAI ", + requestModel: " gpt-4o ", + responseModel: "", + expectedProvider: "openai", + expectedModel: "gpt-4o", + }, + { + name: "ollama provider", + eventProvider: "ollama", + requestModel: "llama3:8b", + responseModel: "", + expectedProvider: "ollama", + expectedModel: "llama3:8b", + }, + { + name: "gemini provider", + eventProvider: "gemini", + requestModel: "gemini-pro", + responseModel: "", + expectedProvider: "gemini", + expectedModel: "gemini-pro", + }, + { + name: "responseModel fallback", + eventProvider: "openai", + requestModel: "", + responseModel: "gpt-4o-2024-08-06", + expectedProvider: "openai", + expectedModel: "gpt-4o-2024-08-06", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider, model := ResolveProviderAndModel(tt.eventProvider, tt.requestModel, tt.responseModel) + if provider != tt.expectedProvider { + t.Errorf("provider = %q, want %q", provider, tt.expectedProvider) + } + if model != tt.expectedModel { + t.Errorf("model = %q, want %q", model, tt.expectedModel) + } + }) + } +} + +func TestInferProviderAndModel(t *testing.T) { + tests := []struct { + name string + provider string + model string + expectedProvider string + expectedModel string + }{ + { + name: "openai with deepseek prefix", + provider: "openai", + model: "deepseek:deepseek-chat", + expectedProvider: "deepseek", + expectedModel: "deepseek-chat", + }, + { + name: "openai with deepseek model", + provider: "openai", + model: "deepseek-reasoner", + expectedProvider: "deepseek", + expectedModel: "deepseek-reasoner", + }, + { + name: "regular openai", + provider: "openai", + model: "gpt-4", + expectedProvider: "openai", + expectedModel: "gpt-4", + }, + { + name: "anthropic unchanged", + provider: "anthropic", + model: "claude-3-opus", + expectedProvider: "anthropic", + expectedModel: "claude-3-opus", + }, + { + name: "gemini unchanged", + provider: "gemini", + model: "gemini-pro", + expectedProvider: "gemini", + expectedModel: "gemini-pro", + }, + { + name: "whitespace trimmed", + provider: "openai", + model: " gpt-4 ", + expectedProvider: "openai", + expectedModel: "gpt-4", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider, model := inferProviderAndModel(tt.provider, tt.model) + if provider != tt.expectedProvider { + t.Errorf("provider = %q, want %q", provider, tt.expectedProvider) + } + if model != tt.expectedModel { + t.Errorf("model = %q, want %q", model, tt.expectedModel) + } + }) + } +} diff --git a/internal/ai/cost/store_test.go b/internal/ai/cost/store_test.go index 867a4bf..b79c53d 100644 --- a/internal/ai/cost/store_test.go +++ b/internal/ai/cost/store_test.go @@ -239,3 +239,92 @@ func TestSummaryTargetsRollup(t *testing.T) { t.Fatalf("expected pricing known with positive USD: %+v", got) } } + +// mockPersistence implements Persistence for testing +type mockPersistence struct { + events []UsageEvent + saved []UsageEvent +} + +func (m *mockPersistence) LoadUsageHistory() ([]UsageEvent, error) { + return m.events, nil +} + +func (m *mockPersistence) SaveUsageHistory(events []UsageEvent) error { + m.saved = events + return nil +} + +func TestSetPersistence_NilPersistence(t *testing.T) { + store := NewStore(30) + + // Setting nil persistence should be a no-op + err := store.SetPersistence(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSetPersistence_LoadsExistingHistory(t *testing.T) { + store := NewStore(30) + now := time.Now() + + // Create mock persistence with pre-existing events + mock := &mockPersistence{ + events: []UsageEvent{ + { + Timestamp: now.Add(-1 * time.Hour), + Provider: "openai", + RequestModel: "openai:gpt-4o", + InputTokens: 500, + OutputTokens: 250, + UseCase: "chat", + }, + }, + } + + // Set persistence - should load existing events + err := store.SetPersistence(mock) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify events were loaded + summary := store.GetSummary(7) + if len(summary.ProviderModels) != 1 { + t.Fatalf("expected 1 provider model from loaded events, got %d", len(summary.ProviderModels)) + } + if summary.ProviderModels[0].InputTokens != 500 { + t.Fatalf("expected 500 input tokens from loaded events, got %d", summary.ProviderModels[0].InputTokens) + } +} + +func TestSetPersistence_TrimsOldEventsOnLoad(t *testing.T) { + store := NewStore(1) // 1 day retention + now := time.Now() + + // Create mock with old events (beyond retention) + mock := &mockPersistence{ + events: []UsageEvent{ + { + Timestamp: now.Add(-72 * time.Hour), // 3 days old + Provider: "openai", + RequestModel: "openai:gpt-4o", + InputTokens: 1000, + OutputTokens: 500, + }, + }, + } + + err := store.SetPersistence(mock) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Old events should be trimmed + summary := store.GetSummary(7) + if len(summary.ProviderModels) != 0 { + t.Fatalf("expected old events to be trimmed, got %d provider models", len(summary.ProviderModels)) + } +} + diff --git a/internal/ai/cost_persistence_test.go b/internal/ai/cost_persistence_test.go new file mode 100644 index 0000000..fad175f --- /dev/null +++ b/internal/ai/cost_persistence_test.go @@ -0,0 +1,109 @@ +package ai + +import ( + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/ai/cost" + "github.com/rcourtman/pulse-go-rewrite/internal/config" +) + +func TestNewCostPersistenceAdapter(t *testing.T) { + tmp := t.TempDir() + persistence := config.NewConfigPersistence(tmp) + + adapter := NewCostPersistenceAdapter(persistence) + if adapter == nil { + t.Fatal("expected non-nil adapter") + } + if adapter.config != persistence { + t.Fatal("expected config to match persistence") + } +} + +func TestCostPersistenceAdapter_SaveAndLoad(t *testing.T) { + tmp := t.TempDir() + persistence := config.NewConfigPersistence(tmp) + adapter := NewCostPersistenceAdapter(persistence) + + events := []cost.UsageEvent{ + { + Timestamp: time.Now(), + Provider: "openai", + RequestModel: "gpt-4", + ResponseModel: "gpt-4", + UseCase: "patrol", + InputTokens: 100, + OutputTokens: 50, + TargetType: "vm", + TargetID: "node1-100", + FindingID: "finding-123", + }, + { + Timestamp: time.Now().Add(-time.Hour), + Provider: "anthropic", + RequestModel: "claude-3-sonnet", + ResponseModel: "claude-3-sonnet", + UseCase: "chat", + InputTokens: 200, + OutputTokens: 100, + TargetType: "container", + TargetID: "node1-101", + FindingID: "", + }, + } + + // Save events + err := adapter.SaveUsageHistory(events) + if err != nil { + t.Fatalf("failed to save usage history: %v", err) + } + + // Load events back + loaded, err := adapter.LoadUsageHistory() + if err != nil { + t.Fatalf("failed to load usage history: %v", err) + } + + if len(loaded) != len(events) { + t.Fatalf("expected %d events, got %d", len(events), len(loaded)) + } + + // Verify first event + if loaded[0].Provider != events[0].Provider { + t.Errorf("expected provider %q, got %q", events[0].Provider, loaded[0].Provider) + } + if loaded[0].InputTokens != events[0].InputTokens { + t.Errorf("expected input tokens %d, got %d", events[0].InputTokens, loaded[0].InputTokens) + } + if loaded[0].UseCase != events[0].UseCase { + t.Errorf("expected use case %q, got %q", events[0].UseCase, loaded[0].UseCase) + } +} + +func TestCostPersistenceAdapter_LoadEmpty(t *testing.T) { + tmp := t.TempDir() + persistence := config.NewConfigPersistence(tmp) + adapter := NewCostPersistenceAdapter(persistence) + + // Load from empty persistence should return empty slice, not error + loaded, err := adapter.LoadUsageHistory() + if err != nil { + t.Fatalf("expected no error for empty load, got: %v", err) + } + if len(loaded) != 0 { + t.Fatalf("expected empty slice, got %d events", len(loaded)) + } +} + +func TestCostPersistenceAdapter_SaveEmpty(t *testing.T) { + tmp := t.TempDir() + persistence := config.NewConfigPersistence(tmp) + adapter := NewCostPersistenceAdapter(persistence) + + // Save empty slice should work + err := adapter.SaveUsageHistory([]cost.UsageEvent{}) + if err != nil { + t.Fatalf("failed to save empty usage history: %v", err) + } +} diff --git a/internal/ai/findings.go b/internal/ai/findings.go index 958159d..ea08867 100644 --- a/internal/ai/findings.go +++ b/internal/ai/findings.go @@ -37,6 +37,7 @@ const ( // Finding represents an AI-discovered insight about infrastructure type Finding struct { ID string `json:"id"` + Key string `json:"key,omitempty"` // Stable issue key for runbook matching Severity FindingSeverity `json:"severity"` Category FindingCategory `json:"category"` ResourceID string `json:"resource_id"` @@ -200,7 +201,7 @@ func (s *FindingsStore) ForceSave() error { s.saveTimer.Stop() } s.savePending = false - + findingsCopy := make(map[string]*Finding, len(s.findings)) for id, f := range s.findings { copy := *f @@ -229,7 +230,7 @@ func (s *FindingsStore) Add(f *Finding) bool { s.mu.Unlock() return false } - + // Check if dismissed - only update if severity has escalated if existing.DismissedReason != "" { severityOrder := map[FindingSeverity]int{ @@ -251,7 +252,7 @@ func (s *FindingsStore) Add(f *Finding) bool { existing.UserNote = "" // Clear note since situation changed existing.AcknowledgedAt = nil } - + // Update existing finding existing.LastSeenAt = time.Now() existing.Description = f.Description @@ -448,7 +449,7 @@ func (s *FindingsStore) isSuppressedInternal(resourceID string, category Finding } } } - + // Also check manual suppression rules for _, rule := range s.suppressionRules { resourceMatches := rule.ResourceID == "" || rule.ResourceID == resourceID @@ -457,7 +458,7 @@ func (s *FindingsStore) isSuppressedInternal(resourceID string, category Finding return true } } - + return false } @@ -586,7 +587,7 @@ func (s *FindingsStore) Cleanup(maxAge time.Duration) int { return removed } -// GetDismissedForContext returns findings that the user has dismissed/acknowledged, +// GetDismissedForContext returns findings that the user has dismissed/acknowledged, // formatted for injection into LLM prompts. This is the core of the "memory" system - // it tells the LLM what not to re-raise. func (s *FindingsStore) GetDismissedForContext() string { @@ -594,51 +595,51 @@ func (s *FindingsStore) GetDismissedForContext() string { defer s.mu.RUnlock() var suppressed, dismissed, snoozed []string - + for _, f := range s.findings { // Skip very old findings (more than 30 days) if time.Since(f.LastSeenAt) > 30*24*time.Hour { continue } - + // Collect suppressed findings if f.Suppressed { note := "" if f.UserNote != "" { note = " - User note: " + f.UserNote } - suppressed = append(suppressed, + suppressed = append(suppressed, fmt.Sprintf("- %s on %s: %s%s", f.Title, f.ResourceName, f.DismissedReason, note)) continue } - + // Collect dismissed/acknowledged findings if f.DismissedReason != "" { note := "" if f.UserNote != "" { note = " - User note: " + f.UserNote } - dismissed = append(dismissed, + dismissed = append(dismissed, fmt.Sprintf("- %s on %s (%s)%s", f.Title, f.ResourceName, f.DismissedReason, note)) continue } - - // Collect snoozed findings + + // Collect snoozed findings if f.IsSnoozed() { - snoozed = append(snoozed, - fmt.Sprintf("- %s on %s (snoozed until %s)", + snoozed = append(snoozed, + fmt.Sprintf("- %s on %s (snoozed until %s)", f.Title, f.ResourceName, f.SnoozedUntil.Format("Jan 2"))) } } - + if len(suppressed) == 0 && len(dismissed) == 0 && len(snoozed) == 0 { return "" } - + var result strings.Builder result.WriteString("\n## Previous Findings - User Feedback\n") result.WriteString("The following findings have been addressed by the user. Do NOT re-raise these unless the situation has significantly worsened:\n\n") - + if len(suppressed) > 0 { result.WriteString("### Permanently Suppressed (never re-raise):\n") for _, s := range suppressed { @@ -646,7 +647,7 @@ func (s *FindingsStore) GetDismissedForContext() string { } result.WriteString("\n") } - + if len(dismissed) > 0 { result.WriteString("### Dismissed by User:\n") for _, d := range dismissed { @@ -654,14 +655,14 @@ func (s *FindingsStore) GetDismissedForContext() string { } result.WriteString("\n") } - + if len(snoozed) > 0 { result.WriteString("### Temporarily Snoozed:\n") for _, s := range snoozed { result.WriteString(s + "\n") } } - + return result.String() } diff --git a/internal/ai/findings_persistence.go b/internal/ai/findings_persistence.go index 92f9712..134abe3 100644 --- a/internal/ai/findings_persistence.go +++ b/internal/ai/findings_persistence.go @@ -22,6 +22,7 @@ func (a *FindingsPersistenceAdapter) SaveFindings(findings map[string]*Finding) for id, f := range findings { records[id] = &config.AIFindingRecord{ ID: f.ID, + Key: f.Key, Severity: string(f.Severity), Category: string(f.Category), ResourceID: f.ResourceID, @@ -56,6 +57,7 @@ func (a *FindingsPersistenceAdapter) LoadFindings() (map[string]*Finding, error) for id, r := range data.Findings { findings[id] = &Finding{ ID: r.ID, + Key: r.Key, Severity: FindingSeverity(r.Severity), Category: FindingCategory(r.Category), ResourceID: r.ResourceID, diff --git a/internal/ai/findings_persistence_test.go b/internal/ai/findings_persistence_test.go new file mode 100644 index 0000000..75ac9a3 --- /dev/null +++ b/internal/ai/findings_persistence_test.go @@ -0,0 +1,216 @@ +package ai + +import ( + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" +) + +func TestNewFindingsPersistenceAdapter(t *testing.T) { + tmp := t.TempDir() + persistence := config.NewConfigPersistence(tmp) + + adapter := NewFindingsPersistenceAdapter(persistence) + if adapter == nil { + t.Fatal("expected non-nil adapter") + } + if adapter.config != persistence { + t.Fatal("expected config to match persistence") + } +} + +func TestFindingsPersistenceAdapter_SaveAndLoad(t *testing.T) { + tmp := t.TempDir() + persistence := config.NewConfigPersistence(tmp) + adapter := NewFindingsPersistenceAdapter(persistence) + + now := time.Now() + findings := map[string]*Finding{ + "finding-1": { + ID: "finding-1", + Key: "high-cpu", + Severity: FindingSeverityWarning, + Category: FindingCategoryPerformance, + ResourceID: "node1-100", + ResourceName: "test-vm", + ResourceType: "vm", + Node: "node1", + Title: "High CPU Usage", + Description: "CPU is at 95%", + Recommendation: "Check running processes", + Evidence: "CPU: 95%", + DetectedAt: now, + LastSeenAt: now, + }, + "finding-2": { + ID: "finding-2", + Key: "high-memory", + Severity: FindingSeverityCritical, + Category: FindingCategoryCapacity, + ResourceID: "node1-101", + ResourceName: "test-container", + ResourceType: "container", + Node: "node1", + Title: "High Memory Usage", + Description: "Memory is at 90%", + Recommendation: "Increase memory allocation", + Evidence: "Memory: 90%", + DetectedAt: now.Add(-time.Hour), + LastSeenAt: now, + AlertID: "alert-123", + }, + } + + // Save findings + err := adapter.SaveFindings(findings) + if err != nil { + t.Fatalf("failed to save findings: %v", err) + } + + // Load findings back + loaded, err := adapter.LoadFindings() + if err != nil { + t.Fatalf("failed to load findings: %v", err) + } + + if len(loaded) != len(findings) { + t.Fatalf("expected %d findings, got %d", len(findings), len(loaded)) + } + + // Verify first finding + f1 := loaded["finding-1"] + if f1 == nil { + t.Fatal("finding-1 not found") + } + if f1.Key != "high-cpu" { + t.Errorf("expected key 'high-cpu', got %q", f1.Key) + } + if f1.Severity != FindingSeverityWarning { + t.Errorf("expected severity 'warning', got %q", f1.Severity) + } + if f1.Category != FindingCategoryPerformance { + t.Errorf("expected category 'performance', got %q", f1.Category) + } + if f1.ResourceID != "node1-100" { + t.Errorf("expected resource ID 'node1-100', got %q", f1.ResourceID) + } + + // Verify second finding + f2 := loaded["finding-2"] + if f2 == nil { + t.Fatal("finding-2 not found") + } + if f2.AlertID != "alert-123" { + t.Errorf("expected alert ID 'alert-123', got %q", f2.AlertID) + } +} + +func TestFindingsPersistenceAdapter_LoadEmpty(t *testing.T) { + tmp := t.TempDir() + persistence := config.NewConfigPersistence(tmp) + adapter := NewFindingsPersistenceAdapter(persistence) + + // Load from empty persistence should return empty map, not error + loaded, err := adapter.LoadFindings() + if err != nil { + t.Fatalf("expected no error for empty load, got: %v", err) + } + if len(loaded) != 0 { + t.Fatalf("expected empty map, got %d findings", len(loaded)) + } +} + +func TestFindingsPersistenceAdapter_SaveEmpty(t *testing.T) { + tmp := t.TempDir() + persistence := config.NewConfigPersistence(tmp) + adapter := NewFindingsPersistenceAdapter(persistence) + + // Save empty map should work + err := adapter.SaveFindings(map[string]*Finding{}) + if err != nil { + t.Fatalf("failed to save empty findings: %v", err) + } +} + +func TestFindingsPersistenceAdapter_PreservesAllFields(t *testing.T) { + tmp := t.TempDir() + persistence := config.NewConfigPersistence(tmp) + adapter := NewFindingsPersistenceAdapter(persistence) + + now := time.Now() + resolved := now.Add(time.Hour) + acked := now.Add(30 * time.Minute) + snoozed := now.Add(24 * time.Hour) + + originalFinding := &Finding{ + ID: "test-finding", + Key: "test-key", + Severity: FindingSeverityCritical, + Category: FindingCategorySecurity, + ResourceID: "resource-123", + ResourceName: "Test Resource", + ResourceType: "vm", + Node: "node1", + Title: "Test Title", + Description: "Test Description", + Recommendation: "Test Recommendation", + Evidence: "Test Evidence", + DetectedAt: now, + LastSeenAt: now, + ResolvedAt: &resolved, + AutoResolved: true, + AcknowledgedAt: &acked, + SnoozedUntil: &snoozed, + AlertID: "alert-456", + } + + findings := map[string]*Finding{"test-finding": originalFinding} + + // Save and load + if err := adapter.SaveFindings(findings); err != nil { + t.Fatalf("save failed: %v", err) + } + + loaded, err := adapter.LoadFindings() + if err != nil { + t.Fatalf("load failed: %v", err) + } + + f := loaded["test-finding"] + if f == nil { + t.Fatal("finding not found after load") + } + + // Verify all fields preserved + if f.ID != originalFinding.ID { + t.Errorf("ID mismatch: got %q", f.ID) + } + if f.Key != originalFinding.Key { + t.Errorf("Key mismatch: got %q", f.Key) + } + if f.Severity != originalFinding.Severity { + t.Errorf("Severity mismatch: got %q", f.Severity) + } + if f.Category != originalFinding.Category { + t.Errorf("Category mismatch: got %q", f.Category) + } + if f.ResourceID != originalFinding.ResourceID { + t.Errorf("ResourceID mismatch: got %q", f.ResourceID) + } + if f.AutoResolved != originalFinding.AutoResolved { + t.Errorf("AutoResolved mismatch: got %v", f.AutoResolved) + } + if f.AlertID != originalFinding.AlertID { + t.Errorf("AlertID mismatch: got %q", f.AlertID) + } + if f.ResolvedAt == nil { + t.Error("ResolvedAt should not be nil") + } + if f.AcknowledgedAt == nil { + t.Error("AcknowledgedAt should not be nil") + } + if f.SnoozedUntil == nil { + t.Error("SnoozedUntil should not be nil") + } +} diff --git a/internal/ai/knowledge/store_test.go b/internal/ai/knowledge/store_test.go index 86ba101..d23c0ae 100644 --- a/internal/ai/knowledge/store_test.go +++ b/internal/ai/knowledge/store_test.go @@ -153,3 +153,178 @@ func countOccurrences(s, substr string) int { } return count } + +func TestNewStore(t *testing.T) { + tmpDir := t.TempDir() + + store, err := NewStore(tmpDir) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + if store == nil { + t.Fatal("Expected non-nil store") + } +} + +func TestGetKnowledge_NotExists(t *testing.T) { + tmpDir := t.TempDir() + + store, err := NewStore(tmpDir) + if err != nil { + t.Fatal(err) + } + + // Get knowledge for non-existent guest + knowledge, err := store.GetKnowledge("non-existent-guest") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Should return empty knowledge (not nil) + if knowledge == nil { + t.Fatal("Expected empty knowledge, got nil") + } + if len(knowledge.Notes) != 0 { + t.Errorf("Expected 0 notes, got %d", len(knowledge.Notes)) + } +} + +func TestGetKnowledge_AfterSave(t *testing.T) { + tmpDir := t.TempDir() + + store, err := NewStore(tmpDir) + if err != nil { + t.Fatal(err) + } + + // Save a note + err = store.SaveNote("test-guest", "TestGuest", "vm", "service", "WebServer", "http://localhost") + if err != nil { + t.Fatal(err) + } + + // Get knowledge + knowledge, err := store.GetKnowledge("test-guest") + if err != nil { + t.Fatal(err) + } + + if len(knowledge.Notes) != 1 { + t.Errorf("Expected 1 note, got %d", len(knowledge.Notes)) + } + if knowledge.GuestName != "TestGuest" { + t.Errorf("Expected guest name 'TestGuest', got '%s'", knowledge.GuestName) + } +} + +func TestFormatForContext_SingleGuest(t *testing.T) { + tmpDir := t.TempDir() + + store, err := NewStore(tmpDir) + if err != nil { + t.Fatal(err) + } + + // Save notes + err = store.SaveNote("test-guest", "TestGuest", "vm", "service", "WebServer", "nginx on port 80") + if err != nil { + t.Fatal(err) + } + + context := store.FormatForContext("test-guest") + + if context == "" { + t.Error("Expected non-empty context") + } + if !contains(context, "nginx") { + t.Errorf("Expected context to contain 'nginx', got: %s", context) + } +} + +func TestDeleteNote(t *testing.T) { + tmpDir := t.TempDir() + + store, err := NewStore(tmpDir) + if err != nil { + t.Fatal(err) + } + + // Save a note + err = store.SaveNote("test-guest", "TestGuest", "vm", "config", "Setting", "value") + if err != nil { + t.Fatal(err) + } + + // Get knowledge to find note ID + knowledge, _ := store.GetKnowledge("test-guest") + if len(knowledge.Notes) == 0 { + t.Fatal("Expected notes to be saved") + } + noteID := knowledge.Notes[0].ID + + // Delete the note + err = store.DeleteNote("test-guest", noteID) + if err != nil { + t.Fatalf("Failed to delete note: %v", err) + } + + // Verify note is deleted + knowledge, _ = store.GetKnowledge("test-guest") + if len(knowledge.Notes) != 0 { + t.Errorf("Expected 0 notes after delete, got %d", len(knowledge.Notes)) + } +} + +func TestGetNotesByCategory(t *testing.T) { + tmpDir := t.TempDir() + + store, err := NewStore(tmpDir) + if err != nil { + t.Fatal(err) + } + + // Save multiple notes with different categories + store.SaveNote("test-guest", "TestGuest", "vm", "service", "WebServer", "nginx") + store.SaveNote("test-guest", "TestGuest", "vm", "config", "Setting", "value") + store.SaveNote("test-guest", "TestGuest", "vm", "service", "Database", "postgres") + + // Get service notes + serviceNotes, err := store.GetNotesByCategory("test-guest", "service") + if err != nil { + t.Fatal(err) + } + if len(serviceNotes) != 2 { + t.Errorf("Expected 2 service notes, got %d", len(serviceNotes)) + } + + // Get config notes + configNotes, err := store.GetNotesByCategory("test-guest", "config") + if err != nil { + t.Fatal(err) + } + if len(configNotes) != 1 { + t.Errorf("Expected 1 config note, got %d", len(configNotes)) + } +} + +func TestListGuests(t *testing.T) { + tmpDir := t.TempDir() + + store, err := NewStore(tmpDir) + if err != nil { + t.Fatal(err) + } + + // Save notes for different guests + store.SaveNote("guest-1", "Guest1", "vm", "service", "Note", "content") + store.SaveNote("guest-2", "Guest2", "vm", "service", "Note", "content") + + guests, err := store.ListGuests() + if err != nil { + t.Fatal(err) + } + if len(guests) != 2 { + t.Errorf("Expected 2 guests, got %d", len(guests)) + } +} + diff --git a/internal/ai/kubernetes_analysis.go b/internal/ai/kubernetes_analysis.go new file mode 100644 index 0000000..00d2202 --- /dev/null +++ b/internal/ai/kubernetes_analysis.go @@ -0,0 +1,447 @@ +package ai + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/models" +) + +const ( + maxKubernetesNodeIssues = 15 + maxKubernetesPodIssues = 25 + maxKubernetesPodRestartLeads = 10 + maxKubernetesDeploymentIssues = 15 + maxKubernetesMessageLength = 160 +) + +var ( + ErrKubernetesStateUnavailable = errors.New("kubernetes state unavailable") + ErrKubernetesClusterNotFound = errors.New("kubernetes cluster not found") +) + +// AnalyzeKubernetesCluster runs AI analysis for a specific Kubernetes cluster. +func (s *Service) AnalyzeKubernetesCluster(ctx context.Context, clusterID string) (*ExecuteResponse, error) { + clusterID = strings.TrimSpace(clusterID) + if clusterID == "" { + return nil, fmt.Errorf("cluster_id is required") + } + + s.mu.RLock() + stateProvider := s.stateProvider + s.mu.RUnlock() + if stateProvider == nil { + return nil, ErrKubernetesStateUnavailable + } + + state := stateProvider.GetState() + cluster, ok := findKubernetesCluster(state.KubernetesClusters, clusterID) + if !ok { + return nil, ErrKubernetesClusterNotFound + } + + clusterName := kubernetesClusterDisplayName(cluster) + prompt := fmt.Sprintf( + "Analyze the Kubernetes cluster %q. Summarize health, highlight critical issues, and suggest the next actions. Be concise and specific to the telemetry.", + clusterName, + ) + + systemPrompt := s.buildSystemPrompt(ExecuteRequest{ + Prompt: prompt, + TargetType: "kubernetes_cluster", + TargetID: cluster.ID, + }) + systemPrompt += "\n\n## Kubernetes Cluster Telemetry\n" + systemPrompt += buildKubernetesClusterContext(cluster) + systemPrompt += "\n\nUse the telemetry above only. Do not request kubectl output." + + return s.Execute(ctx, ExecuteRequest{ + Prompt: prompt, + TargetType: "kubernetes_cluster", + TargetID: cluster.ID, + SystemPrompt: systemPrompt, + UseCase: "chat", + }) +} + +func findKubernetesCluster(clusters []models.KubernetesCluster, clusterID string) (models.KubernetesCluster, bool) { + for _, cluster := range clusters { + if cluster.ID == clusterID { + return cluster, true + } + } + return models.KubernetesCluster{}, false +} + +func kubernetesClusterDisplayName(cluster models.KubernetesCluster) string { + if cluster.CustomDisplayName != "" { + return cluster.CustomDisplayName + } + if cluster.DisplayName != "" { + return cluster.DisplayName + } + if cluster.Name != "" { + return cluster.Name + } + return cluster.ID +} + +func buildKubernetesClusterContext(cluster models.KubernetesCluster) string { + var b strings.Builder + + clusterName := kubernetesClusterDisplayName(cluster) + b.WriteString("### Cluster Summary\n") + b.WriteString(fmt.Sprintf("- Name: %s\n", clusterName)) + b.WriteString(fmt.Sprintf("- ID: %s\n", cluster.ID)) + if cluster.Status != "" { + b.WriteString(fmt.Sprintf("- Status: %s\n", cluster.Status)) + } + if cluster.Version != "" { + b.WriteString(fmt.Sprintf("- Version: %s\n", cluster.Version)) + } + if cluster.Server != "" { + b.WriteString(fmt.Sprintf("- API server: %s\n", cluster.Server)) + } + if cluster.Context != "" { + b.WriteString(fmt.Sprintf("- Context: %s\n", cluster.Context)) + } + if cluster.AgentVersion != "" { + b.WriteString(fmt.Sprintf("- Agent version: %s\n", cluster.AgentVersion)) + } + if cluster.IntervalSeconds > 0 { + b.WriteString(fmt.Sprintf("- Telemetry interval: %ds\n", cluster.IntervalSeconds)) + } + if !cluster.LastSeen.IsZero() { + age := formatKubernetesAge(time.Since(cluster.LastSeen)) + b.WriteString(fmt.Sprintf("- Last seen: %s (%s ago)\n", cluster.LastSeen.Format(time.RFC3339), age)) + } + if cluster.PendingUninstall { + b.WriteString("- Pending uninstall: true\n") + } + + nodeSummary, nodeIssues := summarizeKubernetesNodes(cluster.Nodes) + podSummary, podIssues, restartLeaders := summarizeKubernetesPods(cluster.Pods) + deploymentSummary, deploymentIssues := summarizeKubernetesDeployments(cluster.Deployments) + + b.WriteString("\n### Workload Summary\n") + b.WriteString(nodeSummary) + b.WriteString(podSummary) + b.WriteString(deploymentSummary) + + if len(nodeIssues) > 0 { + b.WriteString("\n### Unhealthy Nodes\n") + for _, issue := range nodeIssues { + b.WriteString("- ") + b.WriteString(issue) + b.WriteString("\n") + } + } + + if len(podIssues) > 0 { + b.WriteString("\n### Unhealthy Pods\n") + for _, issue := range podIssues { + b.WriteString("- ") + b.WriteString(issue) + b.WriteString("\n") + } + } + + if len(restartLeaders) > 0 { + b.WriteString("\n### Pods With Restarts\n") + for _, entry := range restartLeaders { + b.WriteString("- ") + b.WriteString(entry) + b.WriteString("\n") + } + } + + if len(deploymentIssues) > 0 { + b.WriteString("\n### Deployments Not Fully Available\n") + for _, issue := range deploymentIssues { + b.WriteString("- ") + b.WriteString(issue) + b.WriteString("\n") + } + } + + return strings.TrimSpace(b.String()) +} + +func summarizeKubernetesNodes(nodes []models.KubernetesNode) (string, []string) { + total := len(nodes) + ready := 0 + notReady := 0 + unschedulable := 0 + var issues []string + + for _, node := range nodes { + if node.Ready { + ready++ + } else { + notReady++ + } + if node.Unschedulable { + unschedulable++ + } + + if !node.Ready || node.Unschedulable { + issue := fmt.Sprintf("%s (ready=%t, unschedulable=%t)", node.Name, node.Ready, node.Unschedulable) + issues = append(issues, issue) + } + } + + if len(issues) > maxKubernetesNodeIssues { + issues = append(issues[:maxKubernetesNodeIssues], fmt.Sprintf("... and %d more", len(issues)-maxKubernetesNodeIssues)) + } + + summary := fmt.Sprintf("- Nodes: %d total, %d ready, %d not ready, %d unschedulable\n", total, ready, notReady, unschedulable) + return summary, issues +} + +func summarizeKubernetesPods(pods []models.KubernetesPod) (string, []string, []string) { + total := len(pods) + phaseCounts := make(map[string]int) + var unhealthy []podIssue + var restarts []podIssue + + for _, pod := range pods { + phase := strings.ToLower(strings.TrimSpace(pod.Phase)) + if phase == "" { + phase = "unknown" + } + phaseCounts[phase]++ + + if phase == "succeeded" { + continue + } + + isHealthy := isKubernetesPodHealthy(pod) + if !isHealthy { + unhealthy = append(unhealthy, podIssue{ + name: pod.Name, + namespace: pod.Namespace, + reason: kubernetesPodReason(pod), + restarts: pod.Restarts, + }) + } + + if pod.Restarts > 0 { + restarts = append(restarts, podIssue{ + name: pod.Name, + namespace: pod.Namespace, + reason: kubernetesPodReason(pod), + restarts: pod.Restarts, + }) + } + } + + issueLines := formatPodIssues(unhealthy, maxKubernetesPodIssues) + restartLines := formatPodRestarts(restarts, maxKubernetesPodRestartLeads) + summary := fmt.Sprintf( + "- Pods: %d total, %d running, %d pending, %d failed, %d succeeded, %d unknown\n", + total, + phaseCounts["running"], + phaseCounts["pending"], + phaseCounts["failed"], + phaseCounts["succeeded"], + phaseCounts["unknown"], + ) + return summary, issueLines, restartLines +} + +func summarizeKubernetesDeployments(deployments []models.KubernetesDeployment) (string, []string) { + total := len(deployments) + healthy := 0 + var issues []string + + for _, deployment := range deployments { + if isKubernetesDeploymentHealthy(deployment) { + healthy++ + continue + } + issues = append(issues, fmt.Sprintf( + "%s/%s desired=%d ready=%d updated=%d available=%d", + deployment.Namespace, + deployment.Name, + deployment.DesiredReplicas, + deployment.ReadyReplicas, + deployment.UpdatedReplicas, + deployment.AvailableReplicas, + )) + } + + if len(issues) > maxKubernetesDeploymentIssues { + issues = append(issues[:maxKubernetesDeploymentIssues], fmt.Sprintf("... and %d more", len(issues)-maxKubernetesDeploymentIssues)) + } + + summary := fmt.Sprintf("- Deployments: %d total, %d healthy, %d unhealthy\n", total, healthy, total-healthy) + return summary, issues +} + +type podIssue struct { + name string + namespace string + reason string + restarts int +} + +func formatPodIssues(issues []podIssue, limit int) []string { + lines := make([]string, 0, min(limit, len(issues))) + for _, issue := range issues { + if len(lines) >= limit { + break + } + lines = append(lines, formatPodIssueLine(issue)) + } + if len(issues) > limit { + lines = append(lines, fmt.Sprintf("... and %d more", len(issues)-limit)) + } + return lines +} + +func formatPodRestarts(issues []podIssue, limit int) []string { + if len(issues) == 0 { + return nil + } + sort.Slice(issues, func(i, j int) bool { + if issues[i].restarts == issues[j].restarts { + return issues[i].name < issues[j].name + } + return issues[i].restarts > issues[j].restarts + }) + if len(issues) > limit { + issues = issues[:limit] + } + + lines := make([]string, 0, len(issues)) + for _, issue := range issues { + line := fmt.Sprintf("%s/%s restarts=%d", issue.namespace, issue.name, issue.restarts) + if issue.reason != "" { + line += " " + issue.reason + } + lines = append(lines, line) + } + return lines +} + +func formatPodIssueLine(issue podIssue) string { + base := fmt.Sprintf("%s/%s", issue.namespace, issue.name) + if issue.reason == "" { + return base + } + return fmt.Sprintf("%s %s", base, issue.reason) +} + +func isKubernetesPodHealthy(pod models.KubernetesPod) bool { + phase := strings.ToLower(strings.TrimSpace(pod.Phase)) + if phase == "" { + return false + } + if phase != "running" { + return false + } + + containers := pod.Containers + if len(containers) == 0 { + return true + } + + for _, container := range containers { + if !container.Ready { + return false + } + state := strings.ToLower(strings.TrimSpace(container.State)) + if state != "" && state != "running" { + return false + } + } + return true +} + +func isKubernetesDeploymentHealthy(deployment models.KubernetesDeployment) bool { + desired := deployment.DesiredReplicas + if desired <= 0 { + return true + } + if deployment.AvailableReplicas < desired { + return false + } + if deployment.ReadyReplicas < desired { + return false + } + if deployment.UpdatedReplicas < desired { + return false + } + return true +} + +func kubernetesPodReason(pod models.KubernetesPod) string { + var parts []string + phase := strings.TrimSpace(pod.Phase) + if phase != "" { + parts = append(parts, fmt.Sprintf("phase=%s", phase)) + } + if pod.Reason != "" { + parts = append(parts, fmt.Sprintf("reason=%s", pod.Reason)) + } + if message := strings.TrimSpace(pod.Message); message != "" { + parts = append(parts, fmt.Sprintf("message=%s", truncateKubernetesMessage(message))) + } + + containerIssues := []string{} + for _, container := range pod.Containers { + if container.Ready && strings.ToLower(strings.TrimSpace(container.State)) == "running" { + continue + } + detail := container.Name + if container.State != "" { + detail += fmt.Sprintf(" state=%s", container.State) + } + if container.Reason != "" { + detail += fmt.Sprintf(" reason=%s", container.Reason) + } + containerIssues = append(containerIssues, detail) + if len(containerIssues) >= 3 { + break + } + } + if len(containerIssues) > 0 { + parts = append(parts, fmt.Sprintf("containers=%s", strings.Join(containerIssues, "; "))) + } + if pod.Restarts > 0 { + parts = append(parts, fmt.Sprintf("restarts=%d", pod.Restarts)) + } + + return strings.TrimSpace(strings.Join(parts, ", ")) +} + +func truncateKubernetesMessage(message string) string { + if len(message) <= maxKubernetesMessageLength { + return message + } + return message[:maxKubernetesMessageLength] + "..." +} + +func formatKubernetesAge(duration time.Duration) string { + if duration < time.Minute { + seconds := int(duration.Seconds()) + if seconds < 0 { + seconds = 0 + } + return fmt.Sprintf("%ds", seconds) + } + if duration < time.Hour { + minutes := int(duration.Minutes()) + return fmt.Sprintf("%dm", minutes) + } + if duration < 24*time.Hour { + hours := int(duration.Hours()) + return fmt.Sprintf("%dh", hours) + } + days := int(duration.Hours() / 24) + return fmt.Sprintf("%dd", days) +} diff --git a/internal/ai/kubernetes_analysis_test.go b/internal/ai/kubernetes_analysis_test.go new file mode 100644 index 0000000..be43d20 --- /dev/null +++ b/internal/ai/kubernetes_analysis_test.go @@ -0,0 +1,470 @@ +package ai + +import ( + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/models" +) + +// ======================================== +// truncateKubernetesMessage tests +// ======================================== + +func TestTruncateKubernetesMessage(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "short message", + input: "This is a short message", + expected: "This is a short message", + }, + { + name: "empty message", + input: "", + expected: "", + }, + { + name: "max length message", + input: string(make([]byte, maxKubernetesMessageLength)), + expected: string(make([]byte, maxKubernetesMessageLength)), + }, + { + name: "over max length", + input: string(make([]byte, maxKubernetesMessageLength+50)), + expected: string(make([]byte, maxKubernetesMessageLength)) + "...", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := truncateKubernetesMessage(tt.input) + if result != tt.expected { + if len(result) < 100 { + t.Errorf("truncateKubernetesMessage() = %q, want %q", result, tt.expected) + } else { + t.Errorf("truncateKubernetesMessage() length = %d, want %d", len(result), len(tt.expected)) + } + } + }) + } +} + +// ======================================== +// formatKubernetesAge tests +// ======================================== + +func TestFormatKubernetesAge(t *testing.T) { + tests := []struct { + name string + duration time.Duration + expected string + }{ + { + name: "zero duration", + duration: 0, + expected: "0s", + }, + { + name: "30 seconds", + duration: 30 * time.Second, + expected: "30s", + }, + { + name: "5 minutes", + duration: 5 * time.Minute, + expected: "5m", + }, + { + name: "59 minutes", + duration: 59 * time.Minute, + expected: "59m", + }, + { + name: "1 hour", + duration: time.Hour, + expected: "1h", + }, + { + name: "23 hours", + duration: 23 * time.Hour, + expected: "23h", + }, + { + name: "1 day", + duration: 24 * time.Hour, + expected: "1d", + }, + { + name: "7 days", + duration: 7 * 24 * time.Hour, + expected: "7d", + }, + { + name: "negative duration", + duration: -5 * time.Second, + expected: "0s", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatKubernetesAge(tt.duration) + if result != tt.expected { + t.Errorf("formatKubernetesAge(%v) = %q, want %q", tt.duration, result, tt.expected) + } + }) + } +} + +// ======================================== +// isKubernetesPodHealthy tests +// ======================================== + +func TestIsKubernetesPodHealthy(t *testing.T) { + tests := []struct { + name string + pod models.KubernetesPod + expected bool + }{ + { + name: "empty phase", + pod: models.KubernetesPod{Phase: ""}, + expected: false, + }, + { + name: "pending phase", + pod: models.KubernetesPod{Phase: "Pending"}, + expected: false, + }, + { + name: "running phase, no containers", + pod: models.KubernetesPod{Phase: "Running", Containers: nil}, + expected: true, + }, + { + name: "running phase, healthy container", + pod: models.KubernetesPod{ + Phase: "Running", + Containers: []models.KubernetesPodContainer{ + {Name: "app", Ready: true, State: "running"}, + }, + }, + expected: true, + }, + { + name: "running phase, not ready container", + pod: models.KubernetesPod{ + Phase: "Running", + Containers: []models.KubernetesPodContainer{ + {Name: "app", Ready: false, State: "running"}, + }, + }, + expected: false, + }, + { + name: "running phase, container not running", + pod: models.KubernetesPod{ + Phase: "Running", + Containers: []models.KubernetesPodContainer{ + {Name: "app", Ready: true, State: "waiting"}, + }, + }, + expected: false, + }, + { + name: "failed phase", + pod: models.KubernetesPod{Phase: "Failed"}, + expected: false, + }, + { + name: "succeeded phase", + pod: models.KubernetesPod{Phase: "Succeeded"}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isKubernetesPodHealthy(tt.pod) + if result != tt.expected { + t.Errorf("isKubernetesPodHealthy() = %v, want %v", result, tt.expected) + } + }) + } +} + +// ======================================== +// kubernetesPodReason tests +// ======================================== + +func TestKubernetesPodReason(t *testing.T) { + tests := []struct { + name string + pod models.KubernetesPod + contains []string + }{ + { + name: "empty pod", + pod: models.KubernetesPod{}, + contains: []string{}, + }, + { + name: "pod with phase", + pod: models.KubernetesPod{Phase: "Pending"}, + contains: []string{"phase=Pending"}, + }, + { + name: "pod with reason", + pod: models.KubernetesPod{Phase: "Failed", Reason: "OOMKilled"}, + contains: []string{"phase=Failed", "reason=OOMKilled"}, + }, + { + name: "pod with restarts", + pod: models.KubernetesPod{Phase: "Running", Restarts: 5}, + contains: []string{"restarts=5"}, + }, + { + name: "pod with container issue", + pod: models.KubernetesPod{ + Phase: "Running", + Containers: []models.KubernetesPodContainer{ + {Name: "app", Ready: false, State: "waiting", Reason: "CrashLoopBackOff"}, + }, + }, + contains: []string{"containers=app"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := kubernetesPodReason(tt.pod) + for _, expected := range tt.contains { + if len(expected) > 0 && !kubeTestContains(result, expected) { + t.Errorf("kubernetesPodReason() = %q, want to contain %q", result, expected) + } + } + }) + } +} + +func kubeTestContains(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +// ======================================== +// isKubernetesDeploymentHealthy tests +// ======================================== + +func TestIsKubernetesDeploymentHealthy(t *testing.T) { + tests := []struct { + name string + deployment models.KubernetesDeployment + expected bool + }{ + { + name: "empty deployment", + deployment: models.KubernetesDeployment{}, + expected: true, // 0/0 replicas is considered healthy + }, + { + name: "all replicas ready", + deployment: models.KubernetesDeployment{ + DesiredReplicas: 3, + ReadyReplicas: 3, + AvailableReplicas: 3, + UpdatedReplicas: 3, + }, + expected: true, + }, + { + name: "some replicas not ready", + deployment: models.KubernetesDeployment{ + DesiredReplicas: 3, + ReadyReplicas: 2, + AvailableReplicas: 3, + UpdatedReplicas: 3, + }, + expected: false, + }, + { + name: "no replicas ready", + deployment: models.KubernetesDeployment{ + DesiredReplicas: 3, + ReadyReplicas: 0, + AvailableReplicas: 0, + UpdatedReplicas: 0, + }, + expected: false, + }, + { + name: "not all available", + deployment: models.KubernetesDeployment{ + DesiredReplicas: 3, + ReadyReplicas: 3, + AvailableReplicas: 2, + UpdatedReplicas: 3, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isKubernetesDeploymentHealthy(tt.deployment) + if result != tt.expected { + t.Errorf("isKubernetesDeploymentHealthy() = %v, want %v", result, tt.expected) + } + }) + } +} + +// ======================================== +// formatPodIssueLine tests +// ======================================== + +func TestFormatPodIssueLine(t *testing.T) { + tests := []struct { + name string + issue podIssue + expected string + }{ + { + name: "with namespace and name only", + issue: podIssue{ + namespace: "default", + name: "nginx-pod", + }, + expected: "default/nginx-pod", + }, + { + name: "with reason", + issue: podIssue{ + namespace: "kube-system", + name: "coredns-pod", + reason: "CrashLoopBackOff", + }, + expected: "kube-system/coredns-pod CrashLoopBackOff", + }, + { + name: "empty reason", + issue: podIssue{ + namespace: "production", + name: "api-server", + reason: "", + }, + expected: "production/api-server", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatPodIssueLine(tt.issue) + if result != tt.expected { + t.Errorf("formatPodIssueLine() = %q, want %q", result, tt.expected) + } + }) + } +} + +// ======================================== +// formatPodIssues tests +// ======================================== + +func TestFormatPodIssues(t *testing.T) { + issues := []podIssue{ + {namespace: "ns1", name: "pod1", reason: "reason1"}, + {namespace: "ns2", name: "pod2", reason: "reason2"}, + {namespace: "ns3", name: "pod3", reason: "reason3"}, + {namespace: "ns4", name: "pod4", reason: "reason4"}, + } + + t.Run("within limit", func(t *testing.T) { + result := formatPodIssues(issues, 10) + if len(result) != 4 { + t.Errorf("Expected 4 lines, got %d", len(result)) + } + }) + + t.Run("exceeds limit", func(t *testing.T) { + result := formatPodIssues(issues, 2) + if len(result) != 3 { // 2 issues + "... and X more" + t.Errorf("Expected 3 lines (2 issues + truncation notice), got %d", len(result)) + } + if !kubeTestContains(result[2], "and 2 more") { + t.Errorf("Expected truncation notice, got %q", result[2]) + } + }) + + t.Run("empty issues", func(t *testing.T) { + result := formatPodIssues([]podIssue{}, 10) + if len(result) != 0 { + t.Errorf("Expected 0 lines for empty issues, got %d", len(result)) + } + }) +} + +// ======================================== +// formatPodRestarts tests +// ======================================== + +func TestFormatPodRestarts(t *testing.T) { + t.Run("empty issues", func(t *testing.T) { + result := formatPodRestarts(nil, 10) + if result != nil { + t.Errorf("Expected nil, got %v", result) + } + }) + + t.Run("sorts by restarts descending", func(t *testing.T) { + issues := []podIssue{ + {namespace: "ns1", name: "pod1", restarts: 5}, + {namespace: "ns2", name: "pod2", restarts: 10}, + {namespace: "ns3", name: "pod3", restarts: 2}, + } + result := formatPodRestarts(issues, 10) + if len(result) != 3 { + t.Errorf("Expected 3 lines, got %d", len(result)) + } + // First should be pod2 (10 restarts) + if !kubeTestContains(result[0], "restarts=10") { + t.Errorf("Expected first to have 10 restarts, got %q", result[0]) + } + }) + + t.Run("with reason", func(t *testing.T) { + issues := []podIssue{ + {namespace: "ns1", name: "pod1", restarts: 5, reason: "OOMKilled"}, + } + result := formatPodRestarts(issues, 10) + if len(result) != 1 { + t.Errorf("Expected 1 line, got %d", len(result)) + } + if !kubeTestContains(result[0], "OOMKilled") { + t.Errorf("Expected reason in output, got %q", result[0]) + } + }) + + t.Run("respects limit", func(t *testing.T) { + issues := []podIssue{ + {namespace: "ns1", name: "pod1", restarts: 10}, + {namespace: "ns2", name: "pod2", restarts: 8}, + {namespace: "ns3", name: "pod3", restarts: 6}, + {namespace: "ns4", name: "pod4", restarts: 4}, + } + result := formatPodRestarts(issues, 2) + if len(result) != 2 { + t.Errorf("Expected 2 lines (limit), got %d", len(result)) + } + }) +} + + diff --git a/internal/ai/memory/memory_test.go b/internal/ai/memory/memory_test.go index 368b583..4de9817 100644 --- a/internal/ai/memory/memory_test.go +++ b/internal/ai/memory/memory_test.go @@ -230,3 +230,107 @@ func TestChangeDetector_GetRecentChanges(t *testing.T) { t.Error("Expected at least 1 recent change") } } + +func TestRemediationLog_GetRecentRemediationStats(t *testing.T) { + r := NewRemediationLog(RemediationLogConfig{MaxRecords: 100}) + + // Log some remediations with different outcomes + now := time.Now() + + r.Log(RemediationRecord{ + Timestamp: now.Add(-1 * time.Hour), + Problem: "p1", + Action: "a1", + Outcome: OutcomeResolved, + Automatic: true, + }) + r.Log(RemediationRecord{ + Timestamp: now.Add(-2 * time.Hour), + Problem: "p2", + Action: "a2", + Outcome: OutcomePartial, + Automatic: false, + }) + r.Log(RemediationRecord{ + Timestamp: now.Add(-30 * time.Minute), + Problem: "p3", + Action: "a3", + Outcome: OutcomeFailed, + Automatic: true, + }) + r.Log(RemediationRecord{ + Timestamp: now.Add(-48 * time.Hour), + Problem: "old", + Action: "old", + Outcome: OutcomeResolved, + Automatic: false, + }) + + // Get stats for last 24 hours + since := now.Add(-24 * time.Hour) + stats := r.GetRecentRemediationStats(since) + + if stats["total"] != 3 { + t.Errorf("Expected 3 total (last 24h), got %d", stats["total"]) + } + if stats["resolved"] != 1 { + t.Errorf("Expected 1 resolved, got %d", stats["resolved"]) + } + if stats["partial"] != 1 { + t.Errorf("Expected 1 partial, got %d", stats["partial"]) + } + if stats["failed"] != 1 { + t.Errorf("Expected 1 failed, got %d", stats["failed"]) + } + if stats["automatic"] != 2 { + t.Errorf("Expected 2 automatic, got %d", stats["automatic"]) + } + if stats["manual"] != 1 { + t.Errorf("Expected 1 manual, got %d", stats["manual"]) + } +} + +func TestRemediationLog_AutomaticVsManual(t *testing.T) { + r := NewRemediationLog(RemediationLogConfig{MaxRecords: 100}) + + r.Log(RemediationRecord{ + Problem: "auto problem", + Action: "auto action", + Outcome: OutcomeResolved, + Automatic: true, + }) + r.Log(RemediationRecord{ + Problem: "manual problem", + Action: "manual action", + Outcome: OutcomeResolved, + Automatic: false, + }) + + stats := r.GetRemediationStats() + // Verify both are counted + if stats["total"] != 2 { + t.Errorf("Expected 2 total, got %d", stats["total"]) + } +} + +func TestChangeDetector_Limit(t *testing.T) { + d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 5}) + + // Create many changes to exceed limit + for i := 0; i < 10; i++ { + d.DetectChanges([]ResourceSnapshot{ + {ID: "vm-100", Name: "web", Type: "vm", Status: "running", Node: "node1"}, + }) + // Alternate status to create changes + d.DetectChanges([]ResourceSnapshot{ + {ID: "vm-100", Name: "web", Type: "vm", Status: "stopped", Node: "node1"}, + }) + } + + // Should have limited records + allChanges := d.GetRecentChanges(100, time.Time{}) + if len(allChanges) > 5 { + t.Errorf("Expected max 5 changes due to limit, got %d", len(allChanges)) + } +} + diff --git a/internal/ai/memory/remediation.go b/internal/ai/memory/remediation.go index ebf9ecc..903b823 100644 --- a/internal/ai/memory/remediation.go +++ b/internal/ai/memory/remediation.go @@ -30,7 +30,8 @@ type RemediationRecord struct { ResourceType string `json:"resource_type,omitempty"` ResourceName string `json:"resource_name,omitempty"` FindingID string `json:"finding_id,omitempty"` // Linked AI finding if any - Problem string `json:"problem"` // What was wrong + Problem string `json:"problem"` // What was wrong (user's original message) + Summary string `json:"summary,omitempty"` // AI-generated summary of what was achieved Action string `json:"action"` // What was done (command or action) Output string `json:"output,omitempty"` // Command output if any Outcome Outcome `json:"outcome"` // Did it work? @@ -221,6 +222,46 @@ func (r *RemediationLog) GetRecentRemediations(limit int, since time.Time) []Rem return result } +// GetRecentRemediationStats returns remediation stats for actions since the given time. +func (r *RemediationLog) GetRecentRemediationStats(since time.Time) map[string]int { + r.mu.RLock() + defer r.mu.RUnlock() + + stats := map[string]int{ + "total": 0, + "resolved": 0, + "partial": 0, + "failed": 0, + "unknown": 0, + "automatic": 0, + "manual": 0, + } + + for _, rec := range r.records { + if rec.Timestamp.Before(since) { + continue + } + stats["total"]++ + switch rec.Outcome { + case OutcomeResolved: + stats["resolved"]++ + case OutcomePartial: + stats["partial"]++ + case OutcomeFailed: + stats["failed"]++ + default: + stats["unknown"]++ + } + if rec.Automatic { + stats["automatic"]++ + } else { + stats["manual"]++ + } + } + + return stats +} + // FormatForContext creates AI-consumable summary of remediation history func (r *RemediationLog) FormatForContext(resourceID string, limit int) string { records := r.GetForResource(resourceID, limit) diff --git a/internal/ai/patrol.go b/internal/ai/patrol.go index a7f204d..c31c8a8 100644 --- a/internal/ai/patrol.go +++ b/internal/ai/patrol.go @@ -34,10 +34,10 @@ type ThresholdProvider interface { // PatrolThresholds holds calculated thresholds for patrol (derived from alert thresholds) type PatrolThresholds struct { // Node thresholds - NodeCPUWatch float64 // CPU % to flag as "watch" (typically alertThreshold - 15) - NodeCPUWarning float64 // CPU % to flag as "warning" (typically alertThreshold - 5) - NodeMemWatch float64 - NodeMemWarning float64 + NodeCPUWatch float64 // CPU % to flag as "watch" (typically alertThreshold - 15) + NodeCPUWarning float64 // CPU % to flag as "warning" (typically alertThreshold - 5) + NodeMemWatch float64 + NodeMemWarning float64 // Guest thresholds (VMs/containers) GuestMemWatch float64 GuestMemWarning float64 @@ -53,11 +53,11 @@ type PatrolThresholds struct { // DefaultPatrolThresholds returns fallback thresholds when no provider is set func DefaultPatrolThresholds() PatrolThresholds { return PatrolThresholds{ - NodeCPUWatch: 75, NodeCPUWarning: 85, - NodeMemWatch: 75, NodeMemWarning: 85, - GuestMemWatch: 80, GuestMemWarning: 88, - GuestDiskWatch: 75, GuestDiskWarn: 85, GuestDiskCrit: 92, - StorageWatch: 70, StorageWarning: 80, StorageCritical: 90, + NodeCPUWatch: 75, NodeCPUWarning: 85, + NodeMemWatch: 75, NodeMemWarning: 85, + GuestMemWatch: 80, GuestMemWarning: 88, + GuestDiskWatch: 75, GuestDiskWarn: 85, GuestDiskCrit: 92, + StorageWatch: 70, StorageWarning: 80, StorageCritical: 90, } } @@ -143,14 +143,14 @@ func (c PatrolConfig) GetInterval() time.Duration { // DefaultPatrolConfig returns sensible defaults func DefaultPatrolConfig() PatrolConfig { return PatrolConfig{ - Enabled: true, - Interval: 15 * time.Minute, - AnalyzeNodes: true, - AnalyzeGuests: true, - AnalyzeDocker: true, + Enabled: true, + Interval: 15 * time.Minute, + AnalyzeNodes: true, + AnalyzeGuests: true, + AnalyzeDocker: true, AnalyzeStorage: true, - AnalyzePBS: true, - AnalyzeHosts: true, + AnalyzePBS: true, + AnalyzeHosts: true, } } @@ -177,18 +177,19 @@ type PatrolRunRecord struct { Type string `json:"type"` // Always "patrol" now (kept for backwards compat) 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"` + 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"` // e.g., "All healthy" or "2 warnings, 1 critical" - FindingIDs []string `json:"finding_ids"` // IDs of findings from this run + AutoFixCount int `json:"auto_fix_count,omitempty"` + FindingsSummary string `json:"findings_summary"` // e.g., "All healthy" or "2 warnings, 1 critical" + FindingIDs []string `json:"finding_ids"` // IDs of findings from this run ErrorCount int `json:"error_count"` Status string `json:"status"` // "healthy", "issues_found", "error" // AI Analysis details @@ -209,13 +210,13 @@ type PatrolService struct { thresholdProvider ThresholdProvider config PatrolConfig findings *FindingsStore - knowledgeStore *knowledge.Store // For per-resource notes in patrol context + knowledgeStore *knowledge.Store // For per-resource notes in patrol context metricsHistory MetricsHistoryProvider // For trend analysis and predictions - baselineStore *baseline.Store // For anomaly detection via learned baselines - changeDetector *ChangeDetector // For tracking infrastructure changes - remediationLog *RemediationLog // For tracking remediation actions - patternDetector *PatternDetector // For failure prediction from historical patterns - correlationDetector *CorrelationDetector // For multi-resource correlation + baselineStore *baseline.Store // For anomaly detection via learned baselines + changeDetector *ChangeDetector // For tracking infrastructure changes + remediationLog *RemediationLog // For tracking remediation actions + patternDetector *PatternDetector // For failure prediction from historical patterns + correlationDetector *CorrelationDetector // For multi-resource correlation // Cached thresholds (recalculated when thresholdProvider changes) thresholds PatrolThresholds @@ -241,10 +242,10 @@ type PatrolService struct { // PatrolStreamEvent represents a streaming update from the patrol type PatrolStreamEvent struct { - Type string `json:"type"` // "start", "content", "phase", "complete", "error" + Type string `json:"type"` // "start", "content", "phase", "complete", "error" Content string `json:"content,omitempty"` - Phase string `json:"phase,omitempty"` // Current phase description - Tokens int `json:"tokens,omitempty"` // Token count so far + Phase string `json:"phase,omitempty"` // Current phase description + Tokens int `json:"tokens,omitempty"` // Token count so far } // NewPatrolService creates a new patrol service @@ -666,6 +667,7 @@ func (p *PatrolService) runPatrol(ctx context.Context) { errors int aiAnalysis *AIAnalysisResult // Stores the AI's analysis for the run record } + var newFindings []*Finding // Get current state if p.stateProvider == nil { @@ -680,6 +682,7 @@ func (p *PatrolService) runPatrol(ctx context.Context) { isNew := p.findings.Add(f) if isNew { runStats.newFindings++ + newFindings = append(newFindings, f) log.Info(). Str("finding_id", f.ID). Str("severity", string(f.Severity)). @@ -700,15 +703,18 @@ func (p *PatrolService) runPatrol(ctx context.Context) { runStats.storageChecked = len(state.Storage) runStats.pbsChecked = len(state.PBSInstances) runStats.hostsChecked = len(state.Hosts) - runStats.resourceCount = runStats.nodesChecked + runStats.guestsChecked + + runStats.resourceCount = runStats.nodesChecked + runStats.guestsChecked + runStats.dockerChecked + runStats.storageChecked + runStats.pbsChecked + runStats.hostsChecked + hasPatrolFeature := p.aiService == nil || p.aiService.HasLicenseFeature(FeatureAIPatrol) // Check license before running LLM analysis (Pro feature) - if p.aiService != nil && !p.aiService.HasLicenseFeature(FeatureAIPatrol) { - log.Debug().Msg("AI Patrol: Skipping LLM analysis - requires Pulse Pro license") - // No LLM analysis for free users - patrol just tracks resource counts + if !hasPatrolFeature { + log.Debug().Msg("AI Patrol: Running heuristic analysis only - requires Pulse Pro license for LLM analysis") + for _, f := range p.runHeuristicAnalysis(state) { + trackFinding(f) + } } else { - // Run AI analysis using the LLM - this is the ONLY analysis method + // Run AI analysis using the LLM - this is the ONLY analysis method for Pro users // The LLM analyzes the infrastructure and identifies issues aiResult, aiErr := p.runAIAnalysis(ctx, state) if aiErr != nil { @@ -722,18 +728,35 @@ func (p *PatrolService) runPatrol(ctx context.Context) { } } - // Auto-resolve findings that weren't seen in this patrol run - // Only do this if we have a license - otherwise preserve existing findings - var resolvedCount int - if p.aiService != nil && p.aiService.HasLicenseFeature(FeatureAIPatrol) { - resolvedCount = p.autoResolveStaleFindings(start) + // Auto-fix with runbooks when enabled (Pro only) + var runbookResolved int + autoFixEnabled := false + if p.aiService != nil { + if aiCfg := p.aiService.GetAIConfig(); aiCfg != nil { + autoFixEnabled = aiCfg.PatrolAutoFix + } + } + if hasPatrolFeature && autoFixEnabled && p.aiService.HasLicenseFeature(FeatureAIAutoFix) { + runbookResolved = p.AutoFixWithRunbooks(ctx, newFindings) + if runbookResolved > 0 { + log.Info().Int("resolved", runbookResolved).Msg("AI Patrol: Auto-fix runbooks resolved findings") + } + } - // Cleanup old resolved findings (only when licensed to modify findings) + // Auto-resolve findings that weren't seen in this patrol run + var resolvedCount int + if hasPatrolFeature { + resolvedCount = p.autoResolveStaleFindings(start, nil) + + // Cleanup old resolved findings (only when licensed to modify AI findings) cleaned := p.findings.Cleanup(24 * time.Hour) if cleaned > 0 { log.Debug().Int("cleaned", cleaned).Msg("AI Patrol: Cleaned up old findings") } + } else { + resolvedCount = p.autoResolveStaleFindings(start, map[string]bool{"heuristic": true}) } + resolvedCount += runbookResolved duration := time.Since(start) completedAt := time.Now() @@ -785,6 +808,7 @@ func (p *PatrolService) runPatrol(ctx context.Context) { NewFindings: runStats.newFindings, ExistingFindings: runStats.existingFindings, ResolvedFindings: resolvedCount, + AutoFixCount: runbookResolved, FindingsSummary: findingsSummaryStr, FindingIDs: runStats.findingIDs, ErrorCount: runStats.errors, @@ -838,7 +862,7 @@ func joinParts(parts []string) string { if len(parts) == 2 { return parts[0] + " and " + parts[1] } - return fmt.Sprintf("%s, and %s", + return fmt.Sprintf("%s, and %s", fmt.Sprintf("%s", parts[0:len(parts)-1]), parts[len(parts)-1]) } @@ -849,6 +873,77 @@ func generateFindingID(resourceID, category, issue string) string { return fmt.Sprintf("%x", hash[:8]) } +func (p *PatrolService) runHeuristicAnalysis(state models.StateSnapshot) []*Finding { + p.mu.RLock() + cfg := p.config + p.mu.RUnlock() + + var findings []*Finding + + if cfg.AnalyzeNodes { + for _, node := range state.Nodes { + findings = append(findings, p.analyzeNode(node)...) + } + } + + if cfg.AnalyzeGuests { + for _, vm := range state.VMs { + var lastBackup *time.Time + if !vm.LastBackup.IsZero() { + lastBackup = &vm.LastBackup + } + findings = append(findings, p.analyzeGuest( + vm.ID, vm.Name, "vm", vm.Node, vm.Status, + vm.CPU, vm.Memory.Usage, vm.Disk.Usage, + lastBackup, vm.Template, + )...) + } + for _, ct := range state.Containers { + var lastBackup *time.Time + if !ct.LastBackup.IsZero() { + lastBackup = &ct.LastBackup + } + findings = append(findings, p.analyzeGuest( + ct.ID, ct.Name, "container", ct.Node, ct.Status, + ct.CPU, ct.Memory.Usage, ct.Disk.Usage, + lastBackup, ct.Template, + )...) + } + } + + if cfg.AnalyzeDocker { + for _, host := range state.DockerHosts { + findings = append(findings, p.analyzeDockerHost(host)...) + } + } + + if cfg.AnalyzeStorage { + for _, storage := range state.Storage { + findings = append(findings, p.analyzeStorage(storage)...) + } + } + + if cfg.AnalyzePBS { + for _, pbs := range state.PBSInstances { + findings = append(findings, p.analyzePBSInstance(pbs, state.PBSBackups)...) + } + } + + if cfg.AnalyzeHosts { + for _, host := range state.Hosts { + findings = append(findings, p.analyzeHost(host)...) + } + } + + for _, finding := range findings { + if finding != nil && finding.Source == "" { + finding.Source = "heuristic" + } + } + + return findings +} + // analyzeNode checks a Proxmox node for issues func (p *PatrolService) analyzeNode(node models.Node) []*Finding { var findings []*Finding @@ -866,6 +961,7 @@ func (p *PatrolService) analyzeNode(node models.Node) []*Finding { if node.Status == "offline" || node.Status == "unknown" { findings = append(findings, &Finding{ ID: generateFindingID(node.ID, "reliability", "offline"), + Key: "node-offline", Severity: FindingSeverityCritical, Category: FindingCategoryReliability, ResourceID: node.ID, @@ -885,6 +981,7 @@ func (p *PatrolService) analyzeNode(node models.Node) []*Finding { } findings = append(findings, &Finding{ ID: generateFindingID(node.ID, "performance", "high-cpu"), + Key: "high-cpu", Severity: severity, Category: FindingCategoryPerformance, ResourceID: node.ID, @@ -905,6 +1002,7 @@ func (p *PatrolService) analyzeNode(node models.Node) []*Finding { } findings = append(findings, &Finding{ ID: generateFindingID(node.ID, "performance", "high-memory"), + Key: "high-memory", Severity: severity, Category: FindingCategoryPerformance, ResourceID: node.ID, @@ -943,6 +1041,7 @@ func (p *PatrolService) analyzeGuest(id, name, guestType, node, status string, } findings = append(findings, &Finding{ ID: generateFindingID(id, "performance", "high-memory"), + Key: "high-memory", Severity: severity, Category: FindingCategoryPerformance, ResourceID: id, @@ -967,6 +1066,7 @@ func (p *PatrolService) analyzeGuest(id, name, guestType, node, status string, } findings = append(findings, &Finding{ ID: generateFindingID(id, "capacity", "high-disk"), + Key: "high-disk", Severity: severity, Category: FindingCategoryCapacity, ResourceID: id, @@ -990,6 +1090,7 @@ func (p *PatrolService) analyzeGuest(id, name, guestType, node, status string, } findings = append(findings, &Finding{ ID: generateFindingID(id, "backup", "stale"), + Key: "backup-stale", Severity: severity, Category: FindingCategoryBackup, ResourceID: id, @@ -1005,6 +1106,7 @@ func (p *PatrolService) analyzeGuest(id, name, guestType, node, status string, } else if status == "running" && lastBackup == nil { findings = append(findings, &Finding{ ID: generateFindingID(id, "backup", "never"), + Key: "backup-never", Severity: FindingSeverityWarning, Category: FindingCategoryBackup, ResourceID: id, @@ -1033,6 +1135,7 @@ func (p *PatrolService) analyzeDockerHost(host models.DockerHost) []*Finding { if host.Status != "online" && host.Status != "connected" { findings = append(findings, &Finding{ ID: generateFindingID(host.ID, "reliability", "offline"), + Key: "docker-host-offline", Severity: FindingSeverityCritical, Category: FindingCategoryReliability, ResourceID: host.ID, @@ -1050,6 +1153,7 @@ func (p *PatrolService) analyzeDockerHost(host models.DockerHost) []*Finding { if c.State == "restarting" || c.RestartCount > 3 { findings = append(findings, &Finding{ ID: generateFindingID(c.ID, "reliability", "restart-loop"), + Key: "restart-loop", Severity: FindingSeverityWarning, Category: FindingCategoryReliability, ResourceID: c.ID, @@ -1067,6 +1171,7 @@ func (p *PatrolService) analyzeDockerHost(host models.DockerHost) []*Finding { if c.MemoryPercent > 90 { findings = append(findings, &Finding{ ID: generateFindingID(c.ID, "performance", "high-memory"), + Key: "high-memory", Severity: FindingSeverityWatch, Category: FindingCategoryPerformance, ResourceID: c.ID, @@ -1107,6 +1212,7 @@ func (p *PatrolService) analyzeStorage(storage models.Storage) []*Finding { findings = append(findings, &Finding{ ID: generateFindingID(storage.ID, "capacity", "high-usage"), + Key: "storage-high-usage", Severity: severity, Category: FindingCategoryCapacity, ResourceID: storage.ID, @@ -1125,12 +1231,17 @@ func (p *PatrolService) analyzeStorage(storage models.Storage) []*Finding { // autoResolveHealthyResources marks findings as resolved when they weren't seen in the current patrol // patrolStartTime is used to determine which findings are stale (LastSeenAt < patrolStartTime) // Returns the count of findings that were resolved -func (p *PatrolService) autoResolveStaleFindings(patrolStartTime time.Time) int { +func (p *PatrolService) autoResolveStaleFindings(patrolStartTime time.Time, sourceAllowlist map[string]bool) int { // Get all active findings and check if they're stale activeFindings := p.findings.GetActive(FindingSeverityInfo) resolvedCount := 0 - + for _, f := range activeFindings { + if sourceAllowlist != nil { + if !sourceAllowlist[f.Source] { + continue + } + } // If the finding wasn't updated during this patrol (LastSeenAt is before patrol started), // it means the condition that caused it has been resolved if f.LastSeenAt.Before(patrolStartTime) { @@ -1198,7 +1309,7 @@ func (p *PatrolService) GetRunHistory(limit int) []PatrolRunRecord { // GetAllFindings returns all active findings sorted by severity func (p *PatrolService) GetAllFindings() []*Finding { findings := p.findings.GetActive(FindingSeverityInfo) - + // Sort by severity (critical first) then by time severityOrder := map[FindingSeverity]int{ FindingSeverityCritical: 0, @@ -1206,14 +1317,14 @@ func (p *PatrolService) GetAllFindings() []*Finding { FindingSeverityWatch: 2, FindingSeverityInfo: 3, } - + sort.Slice(findings, func(i, j int) bool { if severityOrder[findings[i].Severity] != severityOrder[findings[j].Severity] { return severityOrder[findings[i].Severity] < severityOrder[findings[j].Severity] } return findings[i].DetectedAt.After(findings[j].DetectedAt) }) - + return findings } @@ -1221,12 +1332,12 @@ func (p *PatrolService) GetAllFindings() []*Finding { // Optionally filter by startTime func (p *PatrolService) GetFindingsHistory(startTime *time.Time) []*Finding { findings := p.findings.GetAll(startTime) - + // Sort by detected time (newest first) sort.Slice(findings, func(i, j int) bool { return findings[i].DetectedAt.After(findings[j].DetectedAt) }) - + return findings } @@ -1250,6 +1361,7 @@ func (p *PatrolService) analyzePBSInstance(pbs models.PBSInstance, allBackups [] if pbs.Status != "online" && pbs.Status != "connected" && pbs.Status != "" { findings = append(findings, &Finding{ ID: generateFindingID(pbs.ID, "reliability", "offline"), + Key: "pbs-offline", Severity: FindingSeverityCritical, Category: FindingCategoryReliability, ResourceID: pbs.ID, @@ -1281,6 +1393,7 @@ func (p *PatrolService) analyzePBSInstance(pbs models.PBSInstance, allBackups [] findings = append(findings, &Finding{ ID: generateFindingID(pbs.ID+":"+ds.Name, "capacity", "high-usage"), + Key: "pbs-datastore-high-usage", Severity: severity, Category: FindingCategoryCapacity, ResourceID: pbs.ID + ":" + ds.Name, @@ -1297,6 +1410,7 @@ func (p *PatrolService) analyzePBSInstance(pbs models.PBSInstance, allBackups [] if ds.Error != "" { findings = append(findings, &Finding{ ID: generateFindingID(pbs.ID+":"+ds.Name, "reliability", "error"), + Key: "pbs-datastore-error", Severity: FindingSeverityCritical, Category: FindingCategoryReliability, ResourceID: pbs.ID + ":" + ds.Name, @@ -1325,13 +1439,14 @@ func (p *PatrolService) analyzePBSInstance(pbs models.PBSInstance, allBackups [] for _, ds := range pbs.Datastores { lastBackup, hasBackups := datastoreLastBackup[ds.Name] - + if !hasBackups { // No backups found for this datastore - might be intentional (empty datastore) // Only warn if datastore has actual content if ds.Used > 0 { findings = append(findings, &Finding{ ID: generateFindingID(pbs.ID+":"+ds.Name, "backup", "no-recent"), + Key: "pbs-backup-no-recent", Severity: FindingSeverityWatch, Category: FindingCategoryBackup, ResourceID: pbs.ID + ":" + ds.Name, @@ -1357,6 +1472,7 @@ func (p *PatrolService) analyzePBSInstance(pbs models.PBSInstance, allBackups [] findings = append(findings, &Finding{ ID: generateFindingID(pbs.ID+":"+ds.Name, "backup", "stale"), + Key: "pbs-backup-stale", Severity: severity, Category: FindingCategoryBackup, ResourceID: pbs.ID + ":" + ds.Name, @@ -1375,6 +1491,7 @@ func (p *PatrolService) analyzePBSInstance(pbs models.PBSInstance, allBackups [] if job.Status == "error" || job.Error != "" { findings = append(findings, &Finding{ ID: generateFindingID(pbs.ID+":job:"+job.ID, "backup", "job-failed"), + Key: "pbs-job-failed", Severity: FindingSeverityWarning, Category: FindingCategoryBackup, ResourceID: pbs.ID + ":job:" + job.ID, @@ -1392,6 +1509,7 @@ func (p *PatrolService) analyzePBSInstance(pbs models.PBSInstance, allBackups [] if job.Status == "error" || job.Error != "" { findings = append(findings, &Finding{ ID: generateFindingID(pbs.ID+":verify:"+job.ID, "backup", "verify-failed"), + Key: "pbs-verify-failed", Severity: FindingSeverityWarning, Category: FindingCategoryBackup, ResourceID: pbs.ID + ":verify:" + job.ID, @@ -1421,6 +1539,7 @@ func (p *PatrolService) analyzeHost(host models.Host) []*Finding { if host.Status != "online" && host.Status != "connected" && host.Status != "" { findings = append(findings, &Finding{ ID: generateFindingID(host.ID, "reliability", "offline"), + Key: "host-offline", Severity: FindingSeverityCritical, Category: FindingCategoryReliability, ResourceID: host.ID, @@ -1444,6 +1563,7 @@ func (p *PatrolService) analyzeHost(host models.Host) []*Finding { case "degraded", "DEGRADED": findings = append(findings, &Finding{ ID: generateFindingID(host.ID+":"+raid.Device, "reliability", "raid-degraded"), + Key: "raid-degraded", Severity: FindingSeverityCritical, Category: FindingCategoryReliability, ResourceID: host.ID + ":" + raid.Device, @@ -1462,6 +1582,7 @@ func (p *PatrolService) analyzeHost(host models.Host) []*Finding { } findings = append(findings, &Finding{ ID: generateFindingID(host.ID+":"+raid.Device, "reliability", "raid-rebuilding"), + Key: "raid-rebuilding", Severity: severity, Category: FindingCategoryReliability, ResourceID: host.ID + ":" + raid.Device, @@ -1476,6 +1597,7 @@ func (p *PatrolService) analyzeHost(host models.Host) []*Finding { case "inactive", "INACTIVE": findings = append(findings, &Finding{ ID: generateFindingID(host.ID+":"+raid.Device, "reliability", "raid-inactive"), + Key: "raid-inactive", Severity: FindingSeverityCritical, Category: FindingCategoryReliability, ResourceID: host.ID + ":" + raid.Device, @@ -1492,6 +1614,7 @@ func (p *PatrolService) analyzeHost(host models.Host) []*Finding { if raid.FailedDevices > 0 && raid.State != "degraded" { findings = append(findings, &Finding{ ID: generateFindingID(host.ID+":"+raid.Device, "reliability", "raid-failed-devices"), + Key: "raid-failed-devices", Severity: FindingSeverityWarning, Category: FindingCategoryReliability, ResourceID: host.ID + ":" + raid.Device, @@ -1515,6 +1638,7 @@ func (p *PatrolService) analyzeHost(host models.Host) []*Finding { } findings = append(findings, &Finding{ ID: generateFindingID(host.ID+":temp:"+sensorName, "reliability", "high-temp"), + Key: "high-temp", Severity: severity, Category: FindingCategoryReliability, ResourceID: host.ID + ":temp:" + sensorName, @@ -1588,7 +1712,7 @@ func (p *PatrolService) runAIAnalysis(ctx context.Context, state models.StateSna } } }) - + if err != nil { p.setStreamPhase("idle") p.broadcast(PatrolStreamEvent{Type: "error", Content: err.Error()}) @@ -1642,6 +1766,7 @@ IMPORTANT: You must respond in a specific structured format so findings can be p For each issue you identify, output a finding block like this: [FINDING] +KEY: SEVERITY: critical|warning|watch|info CATEGORY: performance|reliability|security|capacity|configuration RESOURCE: @@ -1653,6 +1778,7 @@ EVIDENCE: [/FINDING] Guidelines: +- Use KEY as a stable identifier for the issue type (examples: high-cpu, high-memory, high-disk, backup-stale, backup-never, restart-loop, storage-high-usage, pbs-datastore-high-usage, pbs-job-failed, node-offline). Use "general" if nothing fits. - CRITICAL: Immediate action required (data loss risk, service down) - WARNING: Should be addressed soon (degraded performance, nearing limits) - WATCH: Worth monitoring (trends, minor inefficiencies) @@ -1778,7 +1904,7 @@ func (p *PatrolService) buildInfrastructureSummary(state models.StateSnapshot) s dh.Hostname, dh.Status, len(dh.Containers))) for _, c := range dh.Containers { sb.WriteString(fmt.Sprintf(" - %s: State=%s, CPU=%.1f%%, Memory=%.1f%%, Restarts=%d\n", - c.Name, c.State, c.CPUPercent, c.MemoryPercent, c.RestartCount)) + c.Name, c.State, c.CPUPercent, c.MemoryPercent, c.RestartCount)) } } sb.WriteString("\n") @@ -1811,7 +1937,7 @@ func (p *PatrolService) buildEnrichedContext(state models.StateSnapshot) string if knowledgeStore != nil { builder = builder.WithKnowledge(&knowledgeShim{store: knowledgeStore}) } - + // Add baseline provider for anomaly detection if available if baselineStore != nil { adapter := NewBaselineStoreAdapter(baselineStore) @@ -1829,39 +1955,39 @@ func (p *PatrolService) buildEnrichedContext(state models.StateSnapshot) string // Format for AI consumption formatted := aicontext.FormatInfrastructureContext(infraCtx) - + // Append recent changes if change detector is available if changeDetector != nil { // Detect any new changes from current state snapshots := stateToSnapshots(state) newChanges := changeDetector.DetectChanges(snapshots) - + // Get summary of recent changes (last 24 hours) since := time.Now().Add(-24 * time.Hour) changesSummary := changeDetector.GetChangesSummary(since, 20) - + if changesSummary != "" { formatted += "\n## Recent Infrastructure Changes (24h)\n\n" + changesSummary } - + if len(newChanges) > 0 { log.Debug().Int("new_changes", len(newChanges)).Msg("AI Patrol: Detected infrastructure changes") } } - + // Append failure predictions if pattern detector is available p.mu.RLock() patternDetector := p.patternDetector correlationDetector := p.correlationDetector p.mu.RUnlock() - + if patternDetector != nil { predictionsContext := patternDetector.FormatForContext("") if predictionsContext != "" { formatted += predictionsContext } } - + // Append resource correlations if correlation detector is available if correlationDetector != nil { correlationsContext := correlationDetector.FormatForContext("") @@ -1882,7 +2008,7 @@ func (p *PatrolService) buildEnrichedContext(state models.StateSnapshot) string // stateToSnapshots converts state to resource snapshots for change detection func stateToSnapshots(state models.StateSnapshot) []ResourceSnapshot { var snapshots []ResourceSnapshot - + for _, node := range state.Nodes { snapshots = append(snapshots, ResourceSnapshot{ ID: node.ID, @@ -1893,7 +2019,7 @@ func stateToSnapshots(state models.StateSnapshot) []ResourceSnapshot { MemoryBytes: node.Memory.Total, }) } - + for _, vm := range state.VMs { if vm.Template { continue @@ -1910,7 +2036,7 @@ func stateToSnapshots(state models.StateSnapshot) []ResourceSnapshot { LastBackup: vm.LastBackup, }) } - + for _, ct := range state.Containers { if ct.Template { continue @@ -1927,7 +2053,7 @@ func stateToSnapshots(state models.StateSnapshot) []ResourceSnapshot { LastBackup: ct.LastBackup, }) } - + return snapshots } @@ -2049,7 +2175,7 @@ func convertToContextMetricsMap(metricsMap map[string][]MetricPoint) map[string] func (p *PatrolService) buildPatrolPrompt(summary string) string { // Get user feedback context (dismissed/snoozed findings) feedbackContext := p.findings.GetDismissedForContext() - + // Get resource notes from knowledge store (per-resource user notes) var knowledgeContext string p.mu.RLock() @@ -2058,7 +2184,7 @@ func (p *PatrolService) buildPatrolPrompt(summary string) string { if knowledgeStore != nil { knowledgeContext = knowledgeStore.FormatAllForContext() } - + basePrompt := fmt.Sprintf(`Please perform a comprehensive analysis of the following infrastructure and identify any issues, potential problems, or optimization opportunities. %s @@ -2080,14 +2206,14 @@ If predictions show a resource will be full within 7 days, flag it as high prior If everything looks healthy with stable trends, say so briefly.`, summary) var contextAdditions strings.Builder - + // Append knowledge context (user notes about resources) if knowledgeContext != "" { contextAdditions.WriteString("\n\n") contextAdditions.WriteString(knowledgeContext) contextAdditions.WriteString("\nIMPORTANT: Consider the user's saved notes above when analyzing. If a user has noted that a resource behaves a certain way (e.g., 'runs hot for transcoding'), do not flag it as an issue.\n") } - + // Append user feedback context (dismissed/snoozed findings) if feedbackContext != "" { contextAdditions.WriteString("\n\n") @@ -2101,11 +2227,11 @@ IMPORTANT: Respect the user's feedback above. Do NOT re-raise findings that are: Only report NEW issues or issues where the severity has clearly escalated.`) } - + if contextAdditions.Len() > 0 { return basePrompt + contextAdditions.String() } - + return basePrompt } @@ -2145,6 +2271,10 @@ func (p *PatrolService) parseFindingBlock(block string) *Finding { severity := extract("SEVERITY") category := extract("CATEGORY") + key := extract("KEY") + if key == "" { + key = extract("FINDING_KEY") + } resource := extract("RESOURCE") resourceType := extract("RESOURCE_TYPE") title := extract("TITLE") @@ -2194,6 +2324,7 @@ func (p *PatrolService) parseFindingBlock(block string) *Finding { return &Finding{ ID: id, + Key: normalizeFindingKey(key), Severity: sev, Category: cat, ResourceID: resource, @@ -2207,6 +2338,25 @@ func (p *PatrolService) parseFindingBlock(block string) *Finding { } } +func normalizeFindingKey(key string) string { + if key == "" { + return "" + } + key = strings.TrimSpace(strings.ToLower(key)) + if key == "" { + return "" + } + key = strings.ReplaceAll(key, "_", "-") + key = strings.ReplaceAll(key, " ", "-") + var b strings.Builder + for _, r := range key { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + b.WriteRune(r) + } + } + return strings.Trim(b.String(), "-") +} + // formatDurationPatrol formats a duration as a human-readable string for patrol func formatDurationPatrol(d time.Duration) string { if d < time.Hour { diff --git a/internal/ai/patrol_history_persistence.go b/internal/ai/patrol_history_persistence.go index 82d438f..0d50f53 100644 --- a/internal/ai/patrol_history_persistence.go +++ b/internal/ai/patrol_history_persistence.go @@ -46,6 +46,7 @@ func (a *PatrolHistoryPersistenceAdapter) SavePatrolRunHistory(runs []PatrolRunR NewFindings: r.NewFindings, ExistingFindings: r.ExistingFindings, ResolvedFindings: r.ResolvedFindings, + AutoFixCount: r.AutoFixCount, FindingsSummary: r.FindingsSummary, FindingIDs: r.FindingIDs, ErrorCount: r.ErrorCount, @@ -84,6 +85,7 @@ func (a *PatrolHistoryPersistenceAdapter) LoadPatrolRunHistory() ([]PatrolRunRec NewFindings: r.NewFindings, ExistingFindings: r.ExistingFindings, ResolvedFindings: r.ResolvedFindings, + AutoFixCount: r.AutoFixCount, FindingsSummary: r.FindingsSummary, FindingIDs: r.FindingIDs, ErrorCount: r.ErrorCount, @@ -153,7 +155,7 @@ func (s *PatrolRunHistoryStore) Add(run PatrolRunRecord) { // 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] @@ -167,7 +169,7 @@ func (s *PatrolRunHistoryStore) Add(run PatrolRunRecord) { func (s *PatrolRunHistoryStore) GetAll() []PatrolRunRecord { s.mu.RLock() defer s.mu.RUnlock() - + result := make([]PatrolRunRecord, len(s.runs)) copy(result, s.runs) return result @@ -177,11 +179,11 @@ func (s *PatrolRunHistoryStore) GetAll() []PatrolRunRecord { 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 diff --git a/internal/ai/patrol_test.go b/internal/ai/patrol_test.go index 30f28e9..d2aabbb 100644 --- a/internal/ai/patrol_test.go +++ b/internal/ai/patrol_test.go @@ -951,3 +951,109 @@ func TestPatrolService_ResolveFinding_Errors(t *testing.T) { t.Error("Expected error for non-existent finding") } } + +func TestPatrolService_SetFindingsPersistence_Nil(t *testing.T) { + ps := NewPatrolService(nil, nil) + + // Setting nil persistence should not error + err := ps.SetFindingsPersistence(nil) + if err != nil { + t.Errorf("Expected no error with nil persistence, got: %v", err) + } +} + +func TestPatrolService_SetRunHistoryPersistence_Nil(t *testing.T) { + ps := NewPatrolService(nil, nil) + + // Setting nil persistence should not error + err := ps.SetRunHistoryPersistence(nil) + if err != nil { + t.Errorf("Expected no error with nil persistence, got: %v", err) + } +} + +func TestPatrolService_SetKnowledgeStore(t *testing.T) { + ps := NewPatrolService(nil, nil) + + // Setting nil knowledge store should not panic + ps.SetKnowledgeStore(nil) + + // Verify it was set (field is internal, just checking no panic) +} + +// ======================================== +// normalizeFindingKey tests +// ======================================== + +func TestNormalizeFindingKey(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "empty string", + input: "", + expected: "", + }, + { + name: "whitespace only", + input: " ", + expected: "", + }, + { + name: "simple lowercase", + input: "high-cpu-usage", + expected: "high-cpu-usage", + }, + { + name: "uppercase to lowercase", + input: "High-CPU-Usage", + expected: "high-cpu-usage", + }, + { + name: "underscores to dashes", + input: "high_cpu_usage", + expected: "high-cpu-usage", + }, + { + name: "spaces to dashes", + input: "high cpu usage", + expected: "high-cpu-usage", + }, + { + name: "mixed separators", + input: "high_cpu usage-warning", + expected: "high-cpu-usage-warning", + }, + { + name: "special characters removed", + input: "cpu@100%!warning", + expected: "cpu100warning", + }, + { + name: "leading/trailing whitespace", + input: " high-cpu ", + expected: "high-cpu", + }, + { + name: "with numbers", + input: "vm-123-cpu-high", + expected: "vm-123-cpu-high", + }, + { + name: "leading/trailing dashes trimmed", + input: "-high-cpu-", + expected: "high-cpu", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := normalizeFindingKey(tt.input) + if result != tt.expected { + t.Errorf("normalizeFindingKey(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} diff --git a/internal/ai/patterns/detector_test.go b/internal/ai/patterns/detector_test.go index 89bbcd2..f152a94 100644 --- a/internal/ai/patterns/detector_test.go +++ b/internal/ai/patterns/detector_test.go @@ -204,3 +204,115 @@ func containsHelper(s, substr string) bool { } return false } + +func TestFormatDays(t *testing.T) { + tests := []struct { + name string + days float64 + expected string + }{ + { + name: "less than an hour", + days: 0.01, // about 14 minutes + expected: "less than an hour", + }, + { + name: "a few hours", + days: 0.25, // 6 hours + expected: "6 hours", + }, + { + name: "half a day", + days: 0.5, // 12 hours + expected: "12 hours", + }, + { + name: "one day", + days: 1.0, + expected: "1 day", + }, + { + name: "just under two days", + days: 1.9, + expected: "1 day", + }, + { + name: "two days", + days: 2.0, + expected: "2 days", + }, + { + name: "seven days", + days: 7.0, + expected: "7 days", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatDays(tt.days) + if result != tt.expected { + t.Errorf("formatDays(%.2f) = %q, want %q", tt.days, result, tt.expected) + } + }) + } +} + +func TestAverageDuration(t *testing.T) { + tests := []struct { + name string + durations []time.Duration + expected time.Duration + }{ + { + name: "empty slice", + durations: []time.Duration{}, + expected: 0, + }, + { + name: "single duration", + durations: []time.Duration{10 * time.Hour}, + expected: 10 * time.Hour, + }, + { + name: "multiple durations", + durations: []time.Duration{6 * time.Hour, 12 * time.Hour, 18 * time.Hour}, + expected: 12 * time.Hour, // average of 6, 12, 18 is 12 + }, + { + name: "mixed durations", + durations: []time.Duration{24 * time.Hour, 48 * time.Hour}, + expected: 36 * time.Hour, // average of 1 and 2 days is 1.5 days + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := averageDuration(tt.durations) + if result != tt.expected { + t.Errorf("averageDuration() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestIntToStr(t *testing.T) { + tests := []struct { + input int + expected string + }{ + {0, "0"}, + {1, "1"}, + {10, "10"}, + {100, "100"}, + {999, "999"}, + } + + for _, tt := range tests { + result := intToStr(tt.input) + if result != tt.expected { + t.Errorf("intToStr(%d) = %q, want %q", tt.input, result, tt.expected) + } + } +} + diff --git a/internal/ai/runbooks.go b/internal/ai/runbooks.go new file mode 100644 index 0000000..4bd24f0 --- /dev/null +++ b/internal/ai/runbooks.go @@ -0,0 +1,702 @@ +package ai + +import ( + "context" + "errors" + "fmt" + "regexp" + "strconv" + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/ai/memory" + "github.com/rs/zerolog/log" +) + +type RunbookRisk string + +const ( + RunbookRiskLow RunbookRisk = "low" + RunbookRiskMedium RunbookRisk = "medium" + RunbookRiskHigh RunbookRisk = "high" +) + +const runbookVerifierDiskUsage = "disk-usage" + +var ( + ErrRunbookNotFound = errors.New("runbook not found") + ErrRunbookNotApplicable = errors.New("runbook does not apply to finding") +) + +type RunbookInfo struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Risk RunbookRisk `json:"risk"` +} + +type RunbookStep struct { + Name string + Command string + RunOnHost bool + AllowFailure bool +} + +type RunbookVerification struct { + Name string + Command string + RunOnHost bool + SuccessRegex string + FailureRegex string + Verifier string +} + +type Runbook struct { + ID string + Title string + Description string + Risk RunbookRisk + FindingKeys []string + ResourceTypes []string + Steps []RunbookStep + Verification *RunbookVerification + ResolutionNote string +} + +type RunbookStepResult struct { + Name string `json:"name"` + Command string `json:"command"` + Output string `json:"output"` + Success bool `json:"success"` +} + +type RunbookExecutionResult struct { + RunbookID string `json:"runbook_id"` + Outcome memory.Outcome `json:"outcome"` + Message string `json:"message"` + Steps []RunbookStepResult `json:"steps"` + VerifyStep *RunbookStepResult `json:"verification,omitempty"` + Resolved bool `json:"resolved"` + ExecutedAt time.Time `json:"executed_at"` + FindingID string `json:"finding_id"` + FindingKey string `json:"finding_key,omitempty"` +} + +func (p *PatrolService) GetRunbooksForFinding(findingID string) ([]RunbookInfo, error) { + finding := p.findings.Get(findingID) + if finding == nil { + return nil, fmt.Errorf("finding not found") + } + + runbooks := matchRunbooksForFinding(finding) + infos := make([]RunbookInfo, 0, len(runbooks)) + for _, rb := range runbooks { + infos = append(infos, RunbookInfo{ + ID: rb.ID, + Title: rb.Title, + Description: rb.Description, + Risk: rb.Risk, + }) + } + return infos, nil +} + +func (p *PatrolService) ExecuteRunbook(ctx context.Context, findingID, runbookID string) (*RunbookExecutionResult, error) { + return p.executeRunbook(ctx, findingID, runbookID, false) +} + +func (p *PatrolService) executeRunbook(ctx context.Context, findingID, runbookID string, automatic bool) (*RunbookExecutionResult, error) { + finding := p.findings.Get(findingID) + if finding == nil { + return nil, fmt.Errorf("finding not found") + } + + runbook, ok := getRunbookByID(runbookID) + if !ok { + return nil, ErrRunbookNotFound + } + if !runbookApplies(runbook, finding) { + return nil, ErrRunbookNotApplicable + } + if p.aiService == nil { + return nil, fmt.Errorf("AI service not available") + } + + context := buildRunbookContext(finding) + results := make([]RunbookStepResult, 0, len(runbook.Steps)) + executionTime := time.Now() + + for _, step := range runbook.Steps { + if step.RunOnHost && context.Node == "" { + return nil, fmt.Errorf("target host is required for runbook step %q", step.Name) + } + command, err := renderRunbookCommand(step.Command, context) + if err != nil { + return nil, err + } + + resp, err := p.aiService.RunCommand(ctx, RunCommandRequest{ + Command: command, + TargetType: runbookTargetType(finding, step.RunOnHost), + TargetID: finding.ResourceID, + RunOnHost: step.RunOnHost, + VMID: context.VMID, + TargetHost: context.Node, + }) + if err != nil { + return nil, err + } + + stepResult := RunbookStepResult{ + Name: step.Name, + Command: command, + Output: strings.TrimSpace(resp.Output), + Success: resp.Success, + } + results = append(results, stepResult) + + if !resp.Success && !step.AllowFailure { + outcome := memory.OutcomeFailed + message := fmt.Sprintf("Runbook step failed: %s", step.Name) + p.logRunbookExecution(finding, runbook, results, nil, outcome, message, executionTime, automatic) + return &RunbookExecutionResult{ + RunbookID: runbook.ID, + Outcome: outcome, + Message: message, + Steps: results, + Resolved: false, + ExecutedAt: executionTime, + FindingID: finding.ID, + FindingKey: finding.Key, + }, nil + } + } + + var verifyResult *RunbookStepResult + outcome := memory.OutcomeUnknown + message := "Runbook completed" + + if runbook.Verification != nil { + if runbook.Verification.RunOnHost && context.Node == "" { + return nil, fmt.Errorf("target host is required for runbook verification") + } + command, err := renderRunbookCommand(runbook.Verification.Command, context) + if err != nil { + return nil, err + } + + resp, err := p.aiService.RunCommand(ctx, RunCommandRequest{ + Command: command, + TargetType: runbookTargetType(finding, runbook.Verification.RunOnHost), + TargetID: finding.ResourceID, + RunOnHost: runbook.Verification.RunOnHost, + VMID: context.VMID, + TargetHost: context.Node, + }) + if err != nil { + return nil, err + } + + verifyResult = &RunbookStepResult{ + Name: runbook.Verification.Name, + Command: command, + Output: strings.TrimSpace(resp.Output), + Success: resp.Success, + } + + outcome, message = evaluateRunbookVerification(runbook.Verification, verifyResult.Output, finding, p.thresholds) + if !resp.Success && outcome == memory.OutcomeResolved { + outcome = memory.OutcomePartial + message = "Verification command failed" + } + } else { + outcome = memory.OutcomeUnknown + message = "Runbook completed without verification" + } + + resolved := false + if outcome == memory.OutcomeResolved { + note := runbook.ResolutionNote + if note == "" { + note = fmt.Sprintf("Applied runbook: %s", runbook.Title) + } + if err := p.ResolveFinding(finding.ID, note); err == nil { + resolved = true + } + } + + p.logRunbookExecution(finding, runbook, results, verifyResult, outcome, message, executionTime, automatic) + + return &RunbookExecutionResult{ + RunbookID: runbook.ID, + Outcome: outcome, + Message: message, + Steps: results, + VerifyStep: verifyResult, + Resolved: resolved, + ExecutedAt: executionTime, + FindingID: finding.ID, + FindingKey: finding.Key, + }, nil +} + +func (p *PatrolService) AutoFixWithRunbooks(ctx context.Context, findings []*Finding) int { + if ctx == nil { + ctx = context.Background() + } + if p.aiService == nil || len(findings) == 0 { + return 0 + } + + resolved := 0 + for _, finding := range findings { + if finding == nil || finding.Key == "" { + continue + } + select { + case <-ctx.Done(): + return resolved + default: + } + + runbook, ok := selectRunbookForAutoFix(finding) + if !ok { + continue + } + if !p.shouldAutoFixFinding(finding) { + continue + } + + result, err := p.executeRunbook(ctx, finding.ID, runbook.ID, true) + if err != nil { + log.Warn().Err(err).Str("runbook_id", runbook.ID).Str("finding_id", finding.ID).Msg("Runbook auto-fix failed") + continue + } + if result != nil && result.Resolved { + resolved++ + } + } + + return resolved +} + +func (p *PatrolService) logRunbookExecution(finding *Finding, runbook Runbook, steps []RunbookStepResult, verify *RunbookStepResult, outcome memory.Outcome, message string, executedAt time.Time, automatic bool) { + if p.remediationLog == nil { + return + } + + noteParts := []string{} + for _, step := range steps { + status := "ok" + if !step.Success { + status = "failed" + } + noteParts = append(noteParts, fmt.Sprintf("%s (%s)", step.Name, status)) + } + if verify != nil { + noteParts = append(noteParts, fmt.Sprintf("verification: %s", verify.Name)) + } + note := strings.Join(noteParts, "; ") + if message != "" { + note = message + ". " + note + } + + output := "" + if verify != nil { + output = verify.Output + } + + record := memory.RemediationRecord{ + Timestamp: executedAt, + ResourceID: finding.ResourceID, + ResourceType: finding.ResourceType, + ResourceName: finding.ResourceName, + FindingID: finding.ID, + Problem: finding.Title, + Action: runbook.Title, + Output: truncateRunbookOutput(output, 1000), + Outcome: outcome, + Note: truncateRunbookOutput(note, 500), + Automatic: automatic, + } + + if err := p.remediationLog.Log(record); err != nil { + log.Warn().Err(err).Msg("Failed to log runbook execution") + } +} + +type runbookContext struct { + ResourceID string + ResourceName string + Node string + VMID string + PBSID string + Datastore string + JobID string +} + +func buildRunbookContext(finding *Finding) runbookContext { + vmid := parseVMID(finding.ResourceID) + pbsID, datastore, jobID := parsePBSResourceParts(finding.ResourceID) + + return runbookContext{ + ResourceID: finding.ResourceID, + ResourceName: finding.ResourceName, + Node: finding.Node, + VMID: vmid, + PBSID: pbsID, + Datastore: datastore, + JobID: jobID, + } +} + +func runbookTargetType(finding *Finding, runOnHost bool) string { + if runOnHost { + return "host" + } + switch finding.ResourceType { + case "vm": + return "vm" + case "container": + return "container" + default: + return "host" + } +} + +func renderRunbookCommand(command string, ctx runbookContext) (string, error) { + placeholders := map[string]string{ + "resource_id": ctx.ResourceID, + "resource_name": ctx.ResourceName, + "node": ctx.Node, + "vmid": ctx.VMID, + "pbs_id": ctx.PBSID, + "datastore": ctx.Datastore, + "job_id": ctx.JobID, + } + + result := command + for key, value := range placeholders { + placeholder := "{{" + key + "}}" + if strings.Contains(result, placeholder) { + if value == "" { + return "", fmt.Errorf("missing value for %s", key) + } + result = strings.ReplaceAll(result, placeholder, escapeShellArg(value)) + } + } + + return result, nil +} + +func escapeShellArg(value string) string { + if value == "" { + return "''" + } + if !strings.ContainsAny(value, " \t\n'\"") { + return value + } + escaped := strings.ReplaceAll(value, `'`, `'\''`) + return "'" + escaped + "'" +} + +func parseVMID(resourceID string) string { + if resourceID == "" { + return "" + } + parts := strings.Split(resourceID, "-") + last := parts[len(parts)-1] + if _, err := strconv.Atoi(last); err == nil { + return last + } + return "" +} + +func parsePBSResourceParts(resourceID string) (string, string, string) { + if resourceID == "" { + return "", "", "" + } + parts := strings.Split(resourceID, ":") + if len(parts) < 2 { + return resourceID, "", "" + } + pbsID := parts[0] + if len(parts) >= 3 && (parts[1] == "job" || parts[1] == "verify") { + return pbsID, "", parts[2] + } + return pbsID, parts[1], "" +} + +func evaluateRunbookVerification(verification *RunbookVerification, output string, finding *Finding, thresholds PatrolThresholds) (memory.Outcome, string) { + if verification == nil { + return memory.OutcomeUnknown, "No verification configured" + } + + if verification.Verifier == runbookVerifierDiskUsage { + return verifyDiskUsage(output, finding, thresholds) + } + + if verification.SuccessRegex != "" { + matched, _ := regexp.MatchString(verification.SuccessRegex, output) + if matched { + return memory.OutcomeResolved, "Verification passed" + } + } + + if verification.FailureRegex != "" { + matched, _ := regexp.MatchString(verification.FailureRegex, output) + if matched { + return memory.OutcomeFailed, "Verification failed" + } + } + + return memory.OutcomeUnknown, "Verification inconclusive" +} + +func verifyDiskUsage(output string, finding *Finding, thresholds PatrolThresholds) (memory.Outcome, string) { + usage, ok := parseDFUsagePercent(output) + if !ok { + return memory.OutcomeUnknown, "Unable to parse disk usage" + } + + threshold := thresholds.GuestDiskWatch + if finding.ResourceType == "storage" { + threshold = thresholds.StorageWatch + } + + note := fmt.Sprintf("Disk usage now %d%% (threshold %.0f%%)", usage, threshold) + + if float64(usage) < threshold { + return memory.OutcomeResolved, note + } + + baseline := parsePercentFromFinding(finding.Evidence) + if baseline > 0 { + if float64(usage) < baseline { + return memory.OutcomePartial, note + fmt.Sprintf(", was %.0f%%", baseline) + } + return memory.OutcomeFailed, note + fmt.Sprintf(", was %.0f%%", baseline) + } + + return memory.OutcomeUnknown, note +} + +func parseDFUsagePercent(output string) (int, bool) { + lines := strings.Split(output, "\n") + for _, line := range lines { + fields := strings.Fields(line) + if len(fields) < 6 { + continue + } + mount := fields[len(fields)-1] + if mount != "/" { + continue + } + usageField := fields[4] + usageField = strings.TrimSuffix(usageField, "%") + usage, err := strconv.Atoi(usageField) + if err != nil { + return 0, false + } + return usage, true + } + return 0, false +} + +func parsePercentFromFinding(value string) float64 { + if value == "" { + return 0 + } + re := regexp.MustCompile(`([0-9]+(?:\.[0-9]+)?)%`) + match := re.FindStringSubmatch(value) + if len(match) < 2 { + return 0 + } + parsed, err := strconv.ParseFloat(match[1], 64) + if err != nil { + return 0 + } + return parsed +} + +func truncateRunbookOutput(output string, limit int) string { + output = strings.TrimSpace(output) + if len(output) <= limit { + return output + } + return output[:limit] + "..." +} + +func runbookApplies(runbook Runbook, finding *Finding) bool { + if len(runbook.FindingKeys) > 0 { + matched := false + for _, key := range runbook.FindingKeys { + if key == finding.Key { + matched = true + break + } + } + if !matched { + return false + } + } + + if len(runbook.ResourceTypes) > 0 { + matched := false + for _, rt := range runbook.ResourceTypes { + if rt == finding.ResourceType { + matched = true + break + } + } + if !matched { + return false + } + } + + return true +} + +func selectRunbookForAutoFix(finding *Finding) (Runbook, bool) { + for _, runbook := range runbooksCatalog { + if runbook.Risk != RunbookRiskLow { + continue + } + if runbookApplies(runbook, finding) { + return runbook, true + } + } + return Runbook{}, false +} + +func (p *PatrolService) shouldAutoFixFinding(finding *Finding) bool { + if p.remediationLog == nil { + return false + } + + records := p.remediationLog.GetForFinding(finding.ID, 1) + if len(records) == 0 { + return true + } + + last := records[0] + if time.Since(last.Timestamp) < 6*time.Hour { + return false + } + + return true +} + +func matchRunbooksForFinding(finding *Finding) []Runbook { + var matches []Runbook + for _, runbook := range runbooksCatalog { + if runbookApplies(runbook, finding) { + matches = append(matches, runbook) + } + } + return matches +} + +func getRunbookByID(id string) (Runbook, bool) { + for _, runbook := range runbooksCatalog { + if runbook.ID == id { + return runbook, true + } + } + return Runbook{}, false +} + +var runbooksCatalog = []Runbook{ + { + ID: "docker-restart-loop", + Title: "Restart Docker container and verify", + Description: "Collect recent logs, restart the container, then verify it is running.", + Risk: RunbookRiskLow, + FindingKeys: []string{"restart-loop"}, + ResourceTypes: []string{ + "docker_container", + }, + Steps: []RunbookStep{ + { + Name: "Fetch recent logs", + Command: "docker logs --tail 200 {{resource_name}}", + RunOnHost: true, + AllowFailure: true, + }, + { + Name: "Restart container", + Command: "docker restart {{resource_name}}", + RunOnHost: true, + }, + }, + Verification: &RunbookVerification{ + Name: "Verify container running", + Command: "docker inspect -f '{{.State.Status}}' {{resource_name}}", + RunOnHost: true, + SuccessRegex: "(?i)running", + }, + ResolutionNote: "Restarted docker container and verified it is running.", + }, + { + ID: "guest-disk-cleanup", + Title: "Clean package cache and old logs", + Description: "Vacuum systemd journal and clean package cache to free disk space.", + Risk: RunbookRiskMedium, + FindingKeys: []string{"high-disk"}, + ResourceTypes: []string{ + "vm", + "container", + }, + Steps: []RunbookStep{ + { + Name: "Vacuum systemd journal", + Command: "journalctl --vacuum-time=7d", + RunOnHost: false, + AllowFailure: true, + }, + { + Name: "Clean package cache", + Command: "apt-get clean", + RunOnHost: false, + AllowFailure: true, + }, + }, + Verification: &RunbookVerification{ + Name: "Check root filesystem usage", + Command: "df -P /", + RunOnHost: false, + Verifier: runbookVerifierDiskUsage, + }, + ResolutionNote: "Cleaned logs and package cache, then verified disk usage.", + }, + { + ID: "docker-high-memory-restart", + Title: "Restart Docker container to clear memory", + Description: "Capture current memory usage, restart the container, then verify it is running.", + Risk: RunbookRiskMedium, + FindingKeys: []string{"high-memory"}, + ResourceTypes: []string{ + "docker_container", + }, + Steps: []RunbookStep{ + { + Name: "Capture memory usage", + Command: "docker stats --no-stream {{resource_name}}", + RunOnHost: true, + AllowFailure: true, + }, + { + Name: "Restart container", + Command: "docker restart {{resource_name}}", + RunOnHost: true, + }, + }, + Verification: &RunbookVerification{ + Name: "Verify container running", + Command: "docker inspect -f '{{.State.Status}}' {{resource_name}}", + RunOnHost: true, + SuccessRegex: "(?i)running", + }, + ResolutionNote: "Restarted docker container to clear memory usage.", + }, +} diff --git a/internal/ai/runbooks_test.go b/internal/ai/runbooks_test.go new file mode 100644 index 0000000..8aa3005 --- /dev/null +++ b/internal/ai/runbooks_test.go @@ -0,0 +1,561 @@ +package ai + +import ( + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/ai/memory" +) + +func TestMatchRunbooksForFinding_DockerRestartLoop(t *testing.T) { + finding := &Finding{ + Key: "restart-loop", + ResourceType: "docker_container", + } + + runbooks := matchRunbooksForFinding(finding) + if len(runbooks) == 0 { + t.Fatal("expected at least one runbook") + } + + found := false + for _, rb := range runbooks { + if rb.ID == "docker-restart-loop" { + found = true + break + } + } + + if !found { + t.Fatal("expected docker-restart-loop runbook to match") + } +} + +func TestMatchRunbooksForFinding_DockerHighMemory(t *testing.T) { + finding := &Finding{ + Key: "high-memory", + ResourceType: "docker_container", + } + + runbooks := matchRunbooksForFinding(finding) + found := false + for _, rb := range runbooks { + if rb.ID == "docker-high-memory-restart" { + found = true + break + } + } + if !found { + t.Fatal("expected docker-high-memory-restart runbook to match") + } +} + +func TestVerifyDiskUsage(t *testing.T) { + finding := &Finding{ + ResourceType: "container", + Evidence: "Disk: 95.0%", + } + thresholds := PatrolThresholds{ + GuestDiskWatch: 85, + } + + output := `Filesystem 1024-blocks Used Available Capacity Mounted on +/dev/sda1 20511356 1000000 19511356 80% /` + + outcome, note := verifyDiskUsage(output, finding, thresholds) + if outcome != memory.OutcomeResolved { + t.Fatalf("expected resolved, got %s (%s)", outcome, note) + } + + output = `Filesystem 1024-blocks Used Available Capacity Mounted on +/dev/sda1 20511356 1000000 19511356 93% /` + + outcome, _ = verifyDiskUsage(output, finding, thresholds) + if outcome != memory.OutcomePartial { + t.Fatalf("expected partial, got %s", outcome) + } +} + +// ======================================== +// escapeShellArg tests +// ======================================== + +func TestEscapeShellArg(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "empty string", + input: "", + expected: "''", + }, + { + name: "simple string", + input: "hello", + expected: "hello", + }, + { + name: "string with space", + input: "hello world", + expected: "'hello world'", + }, + { + name: "string with single quote", + input: "it's", + expected: "'it'\\''s'", + }, + { + name: "string with tab", + input: "hello\tworld", + expected: "'hello\tworld'", + }, + { + name: "alphanumeric only", + input: "test123", + expected: "test123", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := escapeShellArg(tt.input) + if result != tt.expected { + t.Errorf("escapeShellArg(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +// ======================================== +// parseVMID tests +// ======================================== + +func TestParseVMID(t *testing.T) { + tests := []struct { + name string + resourceID string + expected string + }{ + { + name: "empty string", + resourceID: "", + expected: "", + }, + { + name: "simple vmid", + resourceID: "node1-100", + expected: "100", + }, + { + name: "complex id", + resourceID: "pve-cluster-node-200", + expected: "200", + }, + { + name: "non-numeric ending", + resourceID: "node1-abc", + expected: "", + }, + { + name: "single number", + resourceID: "100", + expected: "100", + }, + { + name: "no dash prefix", + resourceID: "node", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseVMID(tt.resourceID) + if result != tt.expected { + t.Errorf("parseVMID(%q) = %q, want %q", tt.resourceID, result, tt.expected) + } + }) + } +} + +// ======================================== +// truncateRunbookOutput tests +// ======================================== + +func TestTruncateRunbookOutput(t *testing.T) { + tests := []struct { + name string + output string + limit int + expected string + }{ + { + name: "empty string", + output: "", + limit: 10, + expected: "", + }, + { + name: "under limit", + output: "hello", + limit: 10, + expected: "hello", + }, + { + name: "at limit", + output: "helloworld", + limit: 10, + expected: "helloworld", + }, + { + name: "over limit", + output: "hello world example", + limit: 10, + expected: "hello worl...", + }, + { + name: "with whitespace", + output: " hello ", + limit: 10, + expected: "hello", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := truncateRunbookOutput(tt.output, tt.limit) + if result != tt.expected { + t.Errorf("truncateRunbookOutput(%q, %d) = %q, want %q", tt.output, tt.limit, result, tt.expected) + } + }) + } +} + +// ======================================== +// getRunbookByID tests +// ======================================== + +func TestGetRunbookByID(t *testing.T) { + // Test finding a real runbook from the catalog + rb, found := getRunbookByID("docker-restart-loop") + if !found { + t.Fatal("expected to find docker-restart-loop runbook") + } + if rb.ID != "docker-restart-loop" { + t.Errorf("expected ID 'docker-restart-loop', got %q", rb.ID) + } + + // Test not finding a non-existent runbook + _, found = getRunbookByID("non-existent-runbook") + if found { + t.Error("expected not to find non-existent-runbook") + } +} + +// ======================================== +// parseDFUsagePercent tests +// ======================================== + +func TestParseDFUsagePercent(t *testing.T) { + tests := []struct { + name string + output string + expected int + ok bool + }{ + { + name: "standard df output", + output: "Filesystem 1024-blocks Used Available Capacity Mounted on\n/dev/sda1 20511356 1000000 19511356 80% /", + expected: 80, + ok: true, + }, + { + name: "no percentage", + output: "No disk info here", + expected: 0, + ok: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, ok := parseDFUsagePercent(tt.output) + if ok != tt.ok { + t.Errorf("parseDFUsagePercent(%q) ok = %v, want %v", tt.output, ok, tt.ok) + } + if ok && result != tt.expected { + t.Errorf("parseDFUsagePercent(%q) = %v, want %v", tt.output, result, tt.expected) + } + }) + } +} + +// ======================================== +// parsePercentFromFinding tests +// ======================================== + +func TestParsePercentFromFinding(t *testing.T) { + tests := []struct { + name string + evidence string + expected float64 + }{ + { + name: "disk percentage", + evidence: "Disk: 95.0%", + expected: 95.0, + }, + { + name: "cpu percentage", + evidence: "CPU usage is at 87.5% utilization", + expected: 87.5, + }, + { + name: "no percentage", + evidence: "No percentage here", + expected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parsePercentFromFinding(tt.evidence) + if result != tt.expected { + t.Errorf("parsePercentFromFinding(%q) = %v, want %v", tt.evidence, result, tt.expected) + } + }) + } +} + +// ======================================== +// runbookApplies tests +// ======================================== + +func TestRunbookApplies(t *testing.T) { + // Test with matching resource type and exact key + rb := Runbook{ + ResourceTypes: []string{"docker_container"}, + FindingKeys: []string{"restart-loop"}, + } + finding := &Finding{ + Key: "restart-loop", // exact match required + ResourceType: "docker_container", + } + if !runbookApplies(rb, finding) { + t.Error("expected runbook to apply") + } + + // Test with non-matching resource type + finding2 := &Finding{ + Key: "restart-loop", + ResourceType: "vm", + } + if runbookApplies(rb, finding2) { + t.Error("expected runbook NOT to apply (wrong resource type)") + } + + // Test with non-matching key + finding3 := &Finding{ + Key: "high-memory", + ResourceType: "docker_container", + } + if runbookApplies(rb, finding3) { + t.Error("expected runbook NOT to apply (wrong key)") + } + + // Test with empty FindingKeys (should match any key) + rb2 := Runbook{ + ResourceTypes: []string{"vm"}, + FindingKeys: []string{}, // empty means match any key + } + finding4 := &Finding{ + Key: "any-key", + ResourceType: "vm", + } + if !runbookApplies(rb2, finding4) { + t.Error("expected runbook with empty FindingKeys to apply to any key") + } +} + +// ======================================== +// parsePBSResourceParts tests +// ======================================== + +func TestParsePBSResourceParts(t *testing.T) { + tests := []struct { + name string + resourceID string + expectedPBS string + expectedDS string + expectedJobID string + }{ + { + name: "empty string", + resourceID: "", + expectedPBS: "", + expectedDS: "", + expectedJobID: "", + }, + { + name: "simple pbs id only", + resourceID: "pbs1", + expectedPBS: "pbs1", + expectedDS: "", + expectedJobID: "", + }, + { + name: "pbs with datastore", + resourceID: "pbs1:datastore1", + expectedPBS: "pbs1", + expectedDS: "datastore1", + expectedJobID: "", + }, + { + name: "pbs with job", + resourceID: "pbs1:job:backup-job-1", + expectedPBS: "pbs1", + expectedDS: "", + expectedJobID: "backup-job-1", + }, + { + name: "pbs with verify job", + resourceID: "pbs1:verify:verify-job-1", + expectedPBS: "pbs1", + expectedDS: "", + expectedJobID: "verify-job-1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pbsID, ds, jobID := parsePBSResourceParts(tt.resourceID) + if pbsID != tt.expectedPBS { + t.Errorf("pbsID = %q, want %q", pbsID, tt.expectedPBS) + } + if ds != tt.expectedDS { + t.Errorf("ds = %q, want %q", ds, tt.expectedDS) + } + if jobID != tt.expectedJobID { + t.Errorf("jobID = %q, want %q", jobID, tt.expectedJobID) + } + }) + } +} + +// ======================================== +// runbookTargetType tests +// ======================================== + +func TestRunbookTargetType(t *testing.T) { + tests := []struct { + name string + finding *Finding + runOnHost bool + expected string + }{ + { + name: "run on host override", + finding: &Finding{ResourceType: "vm"}, + runOnHost: true, + expected: "host", + }, + { + name: "vm resource", + finding: &Finding{ResourceType: "vm"}, + runOnHost: false, + expected: "vm", + }, + { + name: "container resource", + finding: &Finding{ResourceType: "container"}, + runOnHost: false, + expected: "container", + }, + { + name: "other resource type", + finding: &Finding{ResourceType: "storage"}, + runOnHost: false, + expected: "host", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := runbookTargetType(tt.finding, tt.runOnHost) + if result != tt.expected { + t.Errorf("runbookTargetType() = %q, want %q", result, tt.expected) + } + }) + } +} + +// ======================================== +// renderRunbookCommand tests +// ======================================== + +func TestRenderRunbookCommand(t *testing.T) { + tests := []struct { + name string + command string + ctx runbookContext + expected string + expectError bool + }{ + { + name: "simple command no placeholders", + command: "echo hello", + ctx: runbookContext{}, + expected: "echo hello", + }, + { + name: "command with resource_id", + command: "restart {{resource_id}}", + ctx: runbookContext{ResourceID: "node1-100"}, + expected: "restart node1-100", + }, + { + name: "command with vmid", + command: "qm reboot {{vmid}}", + ctx: runbookContext{VMID: "100"}, + expected: "qm reboot 100", + }, + { + name: "command with node", + command: "ssh {{node}} hostname", + ctx: runbookContext{Node: "node1"}, + expected: "ssh node1 hostname", + }, + { + name: "missing required value", + command: "restart {{vmid}}", + ctx: runbookContext{VMID: ""}, + expectError: true, + }, + { + name: "command with value needing escaping", + command: "echo {{resource_name}}", + ctx: runbookContext{ResourceName: "my vm"}, + expected: "echo 'my vm'", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := renderRunbookCommand(tt.command, tt.ctx) + if tt.expectError { + if err == nil { + t.Error("expected error but got none") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != tt.expected { + t.Errorf("renderRunbookCommand() = %q, want %q", result, tt.expected) + } + }) + } +} + diff --git a/internal/ai/service.go b/internal/ai/service.go index e66efde..7e3afe6 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -442,11 +442,11 @@ func (s *Service) StartPatrol(ctx context.Context) { // Configure patrol from AI config patrolCfg := PatrolConfig{ - Enabled: true, - Interval: cfg.GetPatrolInterval(), - AnalyzeNodes: cfg.PatrolAnalyzeNodes, - AnalyzeGuests: cfg.PatrolAnalyzeGuests, - AnalyzeDocker: cfg.PatrolAnalyzeDocker, + Enabled: true, + Interval: cfg.GetPatrolInterval(), + AnalyzeNodes: cfg.PatrolAnalyzeNodes, + AnalyzeGuests: cfg.PatrolAnalyzeGuests, + AnalyzeDocker: cfg.PatrolAnalyzeDocker, AnalyzeStorage: cfg.PatrolAnalyzeStorage, } patrol.SetConfig(patrolCfg) @@ -494,11 +494,11 @@ func (s *Service) ReconfigurePatrol() { // Update patrol configuration patrolCfg := PatrolConfig{ - Enabled: cfg.IsPatrolEnabled(), - Interval: cfg.GetPatrolInterval(), - AnalyzeNodes: cfg.PatrolAnalyzeNodes, - AnalyzeGuests: cfg.PatrolAnalyzeGuests, - AnalyzeDocker: cfg.PatrolAnalyzeDocker, + Enabled: cfg.IsPatrolEnabled(), + Interval: cfg.GetPatrolInterval(), + AnalyzeNodes: cfg.PatrolAnalyzeNodes, + AnalyzeGuests: cfg.PatrolAnalyzeGuests, + AnalyzeDocker: cfg.PatrolAnalyzeDocker, AnalyzeStorage: cfg.PatrolAnalyzeStorage, } patrol.SetConfig(patrolCfg) @@ -1668,6 +1668,10 @@ func (s *Service) logRemediation(req ExecuteRequest, command, output string, suc problem = problem[:200] + "..." } + // Generate a meaningful summary from the command that describes what was achieved + // This is what gets displayed in the Pulse AI Impact section + summary := generateRemediationSummary(command, req.TargetType, req.Context) + // Get resource name from context if available resourceName := "" if req.Context != nil { @@ -1689,6 +1693,7 @@ func (s *Service) logRemediation(req ExecuteRequest, command, output string, suc ResourceName: resourceName, FindingID: req.FindingID, Problem: problem, + Summary: summary, Action: command, Output: truncatedOutput, Outcome: outcome, @@ -1698,10 +1703,170 @@ func (s *Service) logRemediation(req ExecuteRequest, command, output string, suc log.Debug(). Str("resource_id", req.TargetID). Str("command", command). + Str("summary", summary). Bool("success", success). Msg("Logged remediation action to operational memory") } +// generateRemediationSummary creates a human-readable summary of what a command achieved +// This is used to display meaningful descriptions in the Pulse AI Impact section +func generateRemediationSummary(command string, targetType string, context map[string]interface{}) string { + cmd := strings.TrimSpace(command) + + // Extract target name from context + targetName := "" + if context != nil { + if name, ok := context["name"].(string); ok && name != "" { + targetName = name + } else if name, ok := context["containerName"].(string); ok && name != "" { + targetName = name + } else if name, ok := context["guestName"].(string); ok && name != "" { + targetName = name + } + } + + // Extract meaningful path or service from command + extractPath := func() string { + // Look for common path patterns + pathPatterns := []string{ + `/[\w/._-]+`, // Unix paths + } + for _, pattern := range pathPatterns { + re := regexp.MustCompile(pattern) + if match := re.FindString(cmd); match != "" { + // Extract the meaningful part (last 2 segments) + parts := strings.Split(strings.Trim(match, "/"), "/") + if len(parts) > 2 { + parts = parts[len(parts)-2:] + } + return "/" + strings.Join(parts, "/") + } + } + return "" + } + + // Extract container/service name from docker commands + extractDockerTarget := func() string { + // docker ps --filter name=XXX + if match := regexp.MustCompile(`name[=:]\s*(\w+)`).FindStringSubmatch(cmd); len(match) > 1 { + return match[1] + } + // docker restart XXX, docker start XXX, etc. + if match := regexp.MustCompile(`docker\s+(?:restart|start|stop|logs)\s+(\w+)`).FindStringSubmatch(cmd); len(match) > 1 { + return match[1] + } + return "" + } + + path := extractPath() + dockerTarget := extractDockerTarget() + + // Generate summary based on command type + switch { + case strings.Contains(cmd, "docker restart") || strings.Contains(cmd, "docker start"): + if dockerTarget != "" { + return fmt.Sprintf("Restarted %s container", dockerTarget) + } + return "Restarted container" + + case strings.Contains(cmd, "docker stop"): + if dockerTarget != "" { + return fmt.Sprintf("Stopped %s container", dockerTarget) + } + return "Stopped container" + + case strings.Contains(cmd, "docker ps"): + if dockerTarget != "" { + return fmt.Sprintf("Verified %s container is running", dockerTarget) + } + return "Checked container status" + + case strings.Contains(cmd, "docker logs"): + if dockerTarget != "" { + return fmt.Sprintf("Retrieved %s logs", dockerTarget) + } + return "Retrieved container logs" + + case strings.Contains(cmd, "systemctl restart"): + if match := regexp.MustCompile(`systemctl\s+restart\s+(\S+)`).FindStringSubmatch(cmd); len(match) > 1 { + return fmt.Sprintf("Restarted %s service", match[1]) + } + return "Restarted system service" + + case strings.Contains(cmd, "systemctl status"): + if match := regexp.MustCompile(`systemctl\s+status\s+(\S+)`).FindStringSubmatch(cmd); len(match) > 1 { + return fmt.Sprintf("Checked %s service status", match[1]) + } + return "Checked service status" + + case strings.Contains(cmd, "df ") || strings.Contains(cmd, "du "): + if path != "" { + // Check for known services in path + pathLower := strings.ToLower(path) + if strings.Contains(pathLower, "frigate") { + return "Analyzed Frigate storage usage" + } + if strings.Contains(pathLower, "plex") { + return "Analyzed Plex storage usage" + } + if strings.Contains(pathLower, "recordings") { + return "Analyzed recordings storage" + } + return fmt.Sprintf("Analyzed %s storage", path) + } + return "Analyzed disk usage" + + case strings.Contains(cmd, "grep") && (strings.Contains(cmd, "config") || strings.Contains(cmd, ".yml") || strings.Contains(cmd, ".yaml")): + if path != "" && strings.Contains(strings.ToLower(path), "frigate") { + return "Inspected Frigate configuration" + } + if path != "" { + return fmt.Sprintf("Inspected %s configuration", path) + } + return "Inspected configuration" + + case strings.Contains(cmd, "tail") || strings.Contains(cmd, "journalctl"): + if targetName != "" { + return fmt.Sprintf("Reviewed %s logs", targetName) + } + return "Reviewed system logs" + + case strings.Contains(cmd, "pct resize"): + if match := regexp.MustCompile(`pct\s+resize\s+(\d+)`).FindStringSubmatch(cmd); len(match) > 1 { + return fmt.Sprintf("Resized container %s disk", match[1]) + } + return "Resized container disk" + + case strings.Contains(cmd, "qm resize"): + if match := regexp.MustCompile(`qm\s+resize\s+(\d+)`).FindStringSubmatch(cmd); len(match) > 1 { + return fmt.Sprintf("Resized VM %s disk", match[1]) + } + return "Resized VM disk" + + case strings.Contains(cmd, "ping") || strings.Contains(cmd, "curl"): + return "Tested network connectivity" + + case strings.Contains(cmd, "free") || strings.Contains(cmd, "meminfo"): + return "Checked memory usage" + + case strings.Contains(cmd, "ps aux") || strings.Contains(cmd, "top"): + return "Analyzed running processes" + + case strings.Contains(cmd, "rm "): + return "Cleaned up files" + + case strings.Contains(cmd, "chmod") || strings.Contains(cmd, "chown"): + return "Fixed file permissions" + + default: + // Generic fallback - try to use target name if available + if targetName != "" { + return fmt.Sprintf("Ran diagnostics on %s", targetName) + } + return "Ran system diagnostics" + } +} + // hasAgentForTarget checks if we have an agent connection for the given target. // This uses the same routing logic as command execution to determine if the target // can be reached, including cluster peer routing for Proxmox clusters. @@ -1867,6 +2032,25 @@ func (s *Service) getTools() []providers.Tool { "required": []string{"finding_id", "resolution_note"}, }, }, + { + Name: "log_achievement", + Description: "Log a meaningful achievement to remind the user what Pulse Pro accomplished for them. Call this when you've successfully helped the user with something valuable - like diagnosing an issue, fixing a problem, or answering their question with actionable information. Write a clear, specific summary that will be useful to the user in 2 weeks when they review what Pulse Pro has done for them. Examples: 'Verified Frigate is retaining 7 days of recordings with 45GB storage used', 'Restarted nginx service after detecting crash - service now healthy', 'Confirmed disk has 120GB free after cleanup'.", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "achievement": map[string]interface{}{ + "type": "string", + "description": "A clear, specific summary of what was accomplished. Be specific with numbers, names, and outcomes. This will be shown to the user as a reminder of value delivered.", + }, + "category": map[string]interface{}{ + "type": "string", + "description": "Category of the achievement", + "enum": []string{"diagnosis", "fix", "verification", "discovery", "optimization"}, + }, + }, + "required": []string{"achievement"}, + }, + }, } // Add web search tool for Anthropic provider @@ -2070,6 +2254,68 @@ func (s *Service) executeTool(ctx context.Context, req ExecuteRequest, tc provid execution.Success = true return execution.Output, execution + case "log_achievement": + achievement, _ := tc.Input["achievement"].(string) + category, _ := tc.Input["category"].(string) + execution.Input = fmt.Sprintf("achievement: %s, category: %s", achievement, category) + + if achievement == "" { + execution.Output = "Error: achievement is required. Please describe what was accomplished." + return execution.Output, execution + } + + if category == "" { + category = "general" // Default category + } + + // Log the achievement to the remediation log + s.mu.RLock() + patrol := s.patrolService + s.mu.RUnlock() + + if patrol == nil { + execution.Output = "Error: Patrol service not available" + return execution.Output, execution + } + + remLog := patrol.GetRemediationLog() + if remLog == nil { + execution.Output = "Error: Remediation log not available" + return execution.Output, execution + } + + // Get resource name from context if available + resourceName := "" + if req.Context != nil { + if name, ok := req.Context["name"].(string); ok { + resourceName = name + } + } + + // Log as an achievement - use Summary field prominently, Action as "achievement" + remLog.Log(RemediationRecord{ + ResourceID: req.TargetID, + ResourceType: req.TargetType, + ResourceName: resourceName, + FindingID: req.FindingID, + Problem: req.Prompt, // Keep original prompt for context + Summary: achievement, // THIS is the key field - user-facing summary + Action: "[achievement:" + category + "]", // Mark as achievement, not a command + Outcome: OutcomeResolved, + Automatic: req.UseCase == "patrol", + }) + + log.Info(). + Str("resource_id", req.TargetID). + Str("resource_name", resourceName). + Str("category", category). + Str("achievement", achievement). + Msg("Logged AI achievement to operational memory") + + execution.Output = fmt.Sprintf("Achievement logged! This will be shown to the user as a reminder of what Pulse Pro accomplished.\nAchievement: %s\nCategory: %s", achievement, category) + execution.Success = true + return execution.Output, execution + default: execution.Output = fmt.Sprintf("Unknown tool: %s", tc.Name) return execution.Output, execution @@ -2457,7 +2703,7 @@ func (s *Service) RunCommand(ctx context.Context, req RunCommandRequest) (*RunCo // buildSystemPrompt creates the system prompt based on the request context func (s *Service) buildSystemPrompt(req ExecuteRequest) string { - prompt := `You are Pulse's diagnostic assistant - a built-in tool for investigating Proxmox and Docker homelab issues. + prompt := `You are Pulse's diagnostic assistant - a built-in tool for investigating Proxmox, Docker, and Kubernetes homelab issues. ## Response Style - Be DIRECT and CONCISE. No greetings, no "I'll help you", no "Let me check" diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index 4f3af28..c091560 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -4,6 +4,7 @@ import ( "context" "encoding/csv" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -724,6 +725,15 @@ type AIExecuteResponse struct { PendingApprovals []ai.ApprovalNeededData `json:"pending_approvals,omitempty"` // Commands that require approval (non-streaming) } +type AIKubernetesAnalyzeRequest struct { + ClusterID string `json:"cluster_id"` +} + +type AIRunbookExecuteRequest struct { + FindingID string `json:"finding_id"` + RunbookID string `json:"runbook_id"` +} + // HandleExecute executes an AI prompt (POST /api/ai/execute) func (h *AISettingsHandler) HandleExecute(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -803,6 +813,153 @@ func (h *AISettingsHandler) HandleExecute(w http.ResponseWriter, r *http.Request } } +// HandleAnalyzeKubernetesCluster analyzes a Kubernetes cluster with AI (POST /api/ai/kubernetes/analyze) +func (h *AISettingsHandler) HandleAnalyzeKubernetesCluster(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 + } + + if !h.aiService.IsEnabled() { + http.Error(w, "AI is not enabled or configured", http.StatusBadRequest) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 16*1024) + var req AIKubernetesAnalyzeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if strings.TrimSpace(req.ClusterID) == "" { + http.Error(w, "cluster_id is required", http.StatusBadRequest) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 180*time.Second) + defer cancel() + + resp, err := h.aiService.AnalyzeKubernetesCluster(ctx, req.ClusterID) + if err != nil { + switch { + case errors.Is(err, ai.ErrKubernetesClusterNotFound): + http.Error(w, "Kubernetes cluster not found", http.StatusNotFound) + return + case errors.Is(err, ai.ErrKubernetesStateUnavailable): + http.Error(w, "Kubernetes state not available", http.StatusServiceUnavailable) + return + default: + log.Error().Err(err).Str("cluster_id", req.ClusterID).Msg("Kubernetes AI analysis failed") + http.Error(w, "AI request failed: "+err.Error(), http.StatusInternalServerError) + return + } + } + + response := AIExecuteResponse{ + Content: resp.Content, + Model: resp.Model, + InputTokens: resp.InputTokens, + OutputTokens: resp.OutputTokens, + ToolCalls: resp.ToolCalls, + PendingApprovals: resp.PendingApprovals, + } + + if err := utils.WriteJSONResponse(w, response); err != nil { + log.Error().Err(err).Msg("Failed to write Kubernetes AI response") + } +} + +// HandleGetRunbooksForFinding returns available runbooks for a finding (GET /api/ai/runbooks) +func (h *AISettingsHandler) HandleGetRunbooksForFinding(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + if !CheckAuth(h.config, w, r) { + return + } + + findingID := strings.TrimSpace(r.URL.Query().Get("finding_id")) + if findingID == "" { + http.Error(w, "finding_id is required", http.StatusBadRequest) + return + } + + patrol := h.aiService.GetPatrolService() + if patrol == nil { + http.Error(w, "AI patrol not available", http.StatusServiceUnavailable) + return + } + + runbooks, err := patrol.GetRunbooksForFinding(findingID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + if err := utils.WriteJSONResponse(w, runbooks); err != nil { + log.Error().Err(err).Msg("Failed to write runbooks response") + } +} + +// HandleExecuteRunbook executes a runbook for a finding (POST /api/ai/runbooks/execute) +func (h *AISettingsHandler) HandleExecuteRunbook(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + if !CheckAuth(h.config, w, r) { + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 16*1024) + var req AIRunbookExecuteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if strings.TrimSpace(req.FindingID) == "" || strings.TrimSpace(req.RunbookID) == "" { + http.Error(w, "finding_id and runbook_id are required", http.StatusBadRequest) + return + } + + patrol := h.aiService.GetPatrolService() + if patrol == nil { + http.Error(w, "AI patrol not available", http.StatusServiceUnavailable) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 300*time.Second) + defer cancel() + + result, err := patrol.ExecuteRunbook(ctx, req.FindingID, req.RunbookID) + if err != nil { + switch { + case errors.Is(err, ai.ErrRunbookNotFound): + http.Error(w, "Runbook not found", http.StatusNotFound) + case errors.Is(err, ai.ErrRunbookNotApplicable): + http.Error(w, "Runbook does not apply to finding", http.StatusBadRequest) + default: + log.Error().Err(err).Str("runbook_id", req.RunbookID).Msg("Runbook execution failed") + http.Error(w, "Runbook execution failed: "+err.Error(), http.StatusInternalServerError) + } + return + } + + if err := utils.WriteJSONResponse(w, result); err != nil { + log.Error().Err(err).Msg("Failed to write runbook execution response") + } +} + // HandleExecuteStream executes an AI prompt with SSE streaming (POST /api/ai/execute/stream) func (h *AISettingsHandler) HandleExecuteStream(w http.ResponseWriter, r *http.Request) { // Handle CORS for dev mode (frontend on different port) @@ -2081,6 +2238,97 @@ func (h *AISettingsHandler) HandlePatrolStream(w http.ResponseWriter, r *http.Re } } +func previewTitle(category ai.FindingCategory) string { + switch category { + case ai.FindingCategoryPerformance: + return "Performance issue detected" + case ai.FindingCategoryCapacity: + return "Capacity issue detected" + case ai.FindingCategoryReliability: + return "Reliability issue detected" + case ai.FindingCategoryBackup: + return "Backup issue detected" + case ai.FindingCategorySecurity: + return "Security issue detected" + default: + return "Potential issue detected" + } +} + +func previewResourceName(resourceType string) string { + switch resourceType { + case "node": + return "Node" + case "vm": + return "VM" + case "container", "oci_container": + return "Container" + case "docker_host": + return "Docker host" + case "docker_container": + return "Docker container" + case "storage": + return "Storage" + case "pbs": + return "PBS server" + case "pbs_datastore": + return "PBS datastore" + case "pbs_job": + return "PBS job" + case "host": + return "Host" + case "host_raid": + return "RAID array" + case "host_sensor": + return "Host sensor" + default: + return "Resource" + } +} + +func redactFindingsForPreview(findings []*ai.Finding) []*ai.Finding { + redacted := make([]*ai.Finding, 0, len(findings)) + for _, finding := range findings { + if finding == nil { + continue + } + copy := *finding + copy.Key = "" + copy.ResourceID = "" + copy.ResourceName = previewResourceName(finding.ResourceType) + copy.Node = "" + copy.Title = previewTitle(finding.Category) + copy.Description = "Upgrade to view full analysis." + copy.Recommendation = "" + copy.Evidence = "" + copy.AlertID = "" + copy.AcknowledgedAt = nil + copy.SnoozedUntil = nil + copy.ResolvedAt = nil + copy.AutoResolved = false + copy.DismissedReason = "" + copy.UserNote = "" + copy.TimesRaised = 0 + copy.Suppressed = false + copy.Source = "preview" + redacted = append(redacted, ©) + } + return redacted +} + +func redactPatrolRunHistory(runs []ai.PatrolRunRecord) []ai.PatrolRunRecord { + redacted := make([]ai.PatrolRunRecord, len(runs)) + for i, run := range runs { + copy := run + copy.AIAnalysis = "" + copy.InputTokens = 0 + copy.OutputTokens = 0 + copy.FindingIDs = nil + redacted[i] = copy + } + return redacted +} + // 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 { @@ -2106,6 +2354,12 @@ func (h *AISettingsHandler) HandleGetPatrolFindings(w http.ResponseWriter, r *ht findings = patrol.GetAllFindings() } + if !h.aiService.HasLicenseFeature(license.FeatureAIPatrol) { + w.Header().Set("X-License-Required", "true") + w.Header().Set("X-License-Feature", license.FeatureAIPatrol) + findings = redactFindingsForPreview(findings) + } + if err := utils.WriteJSONResponse(w, findings); err != nil { log.Error().Err(err).Msg("Failed to write patrol findings response") } @@ -2479,6 +2733,12 @@ func (h *AISettingsHandler) HandleGetFindingsHistory(w http.ResponseWriter, r *h findings := patrol.GetFindingsHistory(startTime) + if !h.aiService.HasLicenseFeature(license.FeatureAIPatrol) { + w.Header().Set("X-License-Required", "true") + w.Header().Set("X-License-Feature", license.FeatureAIPatrol) + findings = redactFindingsForPreview(findings) + } + if err := utils.WriteJSONResponse(w, findings); err != nil { log.Error().Err(err).Msg("Failed to write findings history response") } @@ -2512,6 +2772,12 @@ func (h *AISettingsHandler) HandleGetPatrolRunHistory(w http.ResponseWriter, r * runs := patrol.GetRunHistory(limit) + if !h.aiService.HasLicenseFeature(license.FeatureAIPatrol) { + w.Header().Set("X-License-Required", "true") + w.Header().Set("X-License-Feature", license.FeatureAIPatrol) + runs = redactPatrolRunHistory(runs) + } + if err := utils.WriteJSONResponse(w, runs); err != nil { log.Error().Err(err).Msg("Failed to write patrol run history response") } diff --git a/internal/api/ai_handlers_test.go b/internal/api/ai_handlers_test.go index 985f909..112fed3 100644 --- a/internal/api/ai_handlers_test.go +++ b/internal/api/ai_handlers_test.go @@ -43,9 +43,9 @@ func TestAISettingsHandler_GetAndUpdateSettings_RoundTrip(t *testing.T) { // Update settings to enable AI via Ollama. { body, _ := json.Marshal(AISettingsUpdateRequest{ - Enabled: ptr(true), - Provider: ptr("ollama"), - Model: ptr("ollama:llama3"), + Enabled: ptr(true), + Provider: ptr("ollama"), + Model: ptr("ollama:llama3"), OllamaBaseURL: ptr("http://localhost:11434"), }) req := httptest.NewRequest(http.MethodPut, "/api/settings/ai", bytes.NewReader(body)) @@ -159,8 +159,8 @@ func TestAISettingsHandler_Execute_Ollama(t *testing.T) { "role": "assistant", "content": "hello from ollama", }, - "done": true, - "done_reason": "stop", + "done": true, + "done_reason": "stop", "prompt_eval_count": 3, "eval_count": 5, }) @@ -300,3 +300,861 @@ func TestAISettingsHandler_TestProvider_Ollama(t *testing.T) { t.Fatalf("unexpected response: %+v", resp) } } + +// ======================================== +// HandleGetAICostSummary tests +// ======================================== + +func TestHandleGetAICostSummary_MethodNotAllowed(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + req := httptest.NewRequest(http.MethodPost, "/api/ai/cost/summary", nil) + rec := httptest.NewRecorder() + handler.HandleGetAICostSummary(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestHandleGetAICostSummary_NoAIService(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/cost/summary", nil) + rec := httptest.NewRecorder() + handler.HandleGetAICostSummary(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + + var resp struct { + Days int `json:"days"` + PricingAsOf string `json:"pricing_as_of"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Days != 30 { + t.Fatalf("expected Days=30 default, got %d", resp.Days) + } +} + +func TestHandleGetAICostSummary_CustomDays(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/cost/summary?days=7", nil) + rec := httptest.NewRecorder() + handler.HandleGetAICostSummary(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + + var resp struct { + Days int `json:"days"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Days != 7 { + t.Fatalf("expected Days=7, got %d", resp.Days) + } +} + +func TestHandleGetAICostSummary_MaxDays(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + // Test that days > 365 is capped at 365 + req := httptest.NewRequest(http.MethodGet, "/api/ai/cost/summary?days=1000", nil) + rec := httptest.NewRecorder() + handler.HandleGetAICostSummary(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + + var resp struct { + Days int `json:"days"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Days != 365 { + t.Fatalf("expected Days=365 (capped), got %d", resp.Days) + } +} + +// ======================================== +// HandleResetAICostHistory tests +// ======================================== + +func TestHandleResetAICostHistory_MethodNotAllowed(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/cost/reset", nil) + rec := httptest.NewRecorder() + handler.HandleResetAICostHistory(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestHandleResetAICostHistory_Success(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + req := httptest.NewRequest(http.MethodPost, "/api/ai/cost/reset", nil) + rec := httptest.NewRecorder() + handler.HandleResetAICostHistory(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + + var resp struct { + Ok bool `json:"ok"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if !resp.Ok { + t.Fatalf("expected ok=true") + } +} + +// ======================================== +// HandleExportAICostHistory tests +// ======================================== + +func TestHandleExportAICostHistory_MethodNotAllowed(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + req := httptest.NewRequest(http.MethodPost, "/api/ai/cost/export", nil) + rec := httptest.NewRecorder() + handler.HandleExportAICostHistory(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +// ======================================== +// HandleGetSuppressionRules tests +// ======================================== + +func TestHandleGetSuppressionRules_MethodNotAllowed(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + req := httptest.NewRequest(http.MethodPost, "/api/ai/patrol/suppressions", nil) + rec := httptest.NewRecorder() + handler.HandleGetSuppressionRules(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +// ======================================== +// HandleAddSuppressionRule tests +// ======================================== + +func TestHandleAddSuppressionRule_MethodNotAllowed(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/suppressions", nil) + rec := httptest.NewRecorder() + handler.HandleAddSuppressionRule(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +// ======================================== +// HandleDeleteSuppressionRule tests +// ======================================== + +func TestHandleDeleteSuppressionRule_MethodNotAllowed(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/suppressions/rule-123", nil) + rec := httptest.NewRecorder() + handler.HandleDeleteSuppressionRule(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +// ======================================== +// HandleGetDismissedFindings tests +// ======================================== + +func TestHandleGetDismissedFindings_MethodNotAllowed(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + req := httptest.NewRequest(http.MethodPost, "/api/ai/patrol/dismissed", nil) + rec := httptest.NewRecorder() + handler.HandleGetDismissedFindings(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +// ======================================== +// HandleGetGuestKnowledge tests +// ======================================== + +func TestHandleGetGuestKnowledge_MissingGuestID(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/knowledge", nil) + rec := httptest.NewRecorder() + handler.HandleGetGuestKnowledge(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code) + } +} + +// ======================================== +// HandleSaveGuestNote tests +// ======================================== + +func TestHandleSaveGuestNote_InvalidBody(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + req := httptest.NewRequest(http.MethodPost, "/api/ai/knowledge", bytes.NewReader([]byte(`{invalid json}`))) + rec := httptest.NewRecorder() + handler.HandleSaveGuestNote(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code) + } +} + +func TestHandleSaveGuestNote_MissingFields(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + body := []byte(`{"guest_id": "vm-100"}`) + req := httptest.NewRequest(http.MethodPost, "/api/ai/knowledge", bytes.NewReader(body)) + rec := httptest.NewRecorder() + handler.HandleSaveGuestNote(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code) + } +} + +// ======================================== +// HandleDeleteGuestNote tests +// ======================================== + +func TestHandleDeleteGuestNote_InvalidBody(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + req := httptest.NewRequest(http.MethodPost, "/api/ai/knowledge/delete", bytes.NewReader([]byte(`{invalid json}`))) + rec := httptest.NewRecorder() + handler.HandleDeleteGuestNote(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code) + } +} + +func TestHandleDeleteGuestNote_MissingFields(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + body := []byte(`{"guest_id": "vm-100"}`) + req := httptest.NewRequest(http.MethodPost, "/api/ai/knowledge/delete", bytes.NewReader(body)) + rec := httptest.NewRecorder() + handler.HandleDeleteGuestNote(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code) + } +} + +// ======================================== +// HandleClearGuestKnowledge tests +// ======================================== + +func TestHandleClearGuestKnowledge_MethodNotAllowed(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/knowledge/clear", nil) + rec := httptest.NewRecorder() + handler.HandleClearGuestKnowledge(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestHandleClearGuestKnowledge_MissingGuestID(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + body := []byte(`{}`) + req := httptest.NewRequest(http.MethodPost, "/api/ai/knowledge/clear", bytes.NewReader(body)) + rec := httptest.NewRecorder() + handler.HandleClearGuestKnowledge(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code) + } +} + +// ======================================== +// HandleDebugContext tests +// ======================================== + +func TestHandleDebugContext_MethodNotAllowed(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + req := httptest.NewRequest(http.MethodPost, "/api/ai/debug/context", nil) + rec := httptest.NewRecorder() + handler.HandleDebugContext(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +// ======================================== +// HandleGetConnectedAgents tests +// ======================================== + +func TestHandleGetConnectedAgents_MethodNotAllowed(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + req := httptest.NewRequest(http.MethodPost, "/api/ai/agents", nil) + rec := httptest.NewRecorder() + handler.HandleGetConnectedAgents(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestHandleGetConnectedAgents_NoAgentServer(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + // handler created with nil agentServer + handler := NewAISettingsHandler(cfg, persistence, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/agents", nil) + rec := httptest.NewRecorder() + handler.HandleGetConnectedAgents(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + + var resp struct { + Count int `json:"count"` + Note string `json:"note"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Count != 0 { + t.Fatalf("expected count=0, got %d", resp.Count) + } + if resp.Note == "" { + t.Fatalf("expected note to be present") + } +} + +// ======================================== +// HandleRunCommand tests +// ======================================== + +func TestHandleRunCommand_MethodNotAllowed(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/run-command", nil) + rec := httptest.NewRecorder() + handler.HandleRunCommand(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestHandleRunCommand_InvalidBody(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + req := httptest.NewRequest(http.MethodPost, "/api/ai/run-command", bytes.NewReader([]byte(`{invalid json}`))) + rec := httptest.NewRecorder() + handler.HandleRunCommand(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code) + } +} + +// ======================================== +// HandleAnalyzeKubernetesCluster tests +// ======================================== + +func TestHandleAnalyzeKubernetesCluster_MethodNotAllowed(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/kubernetes/analyze", nil) + rec := httptest.NewRecorder() + handler.HandleAnalyzeKubernetesCluster(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestHandleAnalyzeKubernetesCluster_InvalidBody(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + req := httptest.NewRequest(http.MethodPost, "/api/ai/kubernetes/analyze", bytes.NewReader([]byte(`{invalid json}`))) + rec := httptest.NewRecorder() + handler.HandleAnalyzeKubernetesCluster(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code) + } +} + +// ======================================== +// HandleGetRunbooksForFinding tests +// ======================================== + +func TestHandleGetRunbooksForFinding_MethodNotAllowed(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + req := httptest.NewRequest(http.MethodPost, "/api/ai/runbooks", nil) + rec := httptest.NewRecorder() + handler.HandleGetRunbooksForFinding(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestHandleGetRunbooksForFinding_MissingFindingID(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/runbooks", nil) + rec := httptest.NewRecorder() + handler.HandleGetRunbooksForFinding(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code) + } +} + +// ======================================== +// HandleExecuteRunbook tests +// ======================================== + +func TestHandleExecuteRunbook_MethodNotAllowed(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/runbooks/execute", nil) + rec := httptest.NewRecorder() + handler.HandleExecuteRunbook(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestHandleExecuteRunbook_InvalidBody(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + req := httptest.NewRequest(http.MethodPost, "/api/ai/runbooks/execute", bytes.NewReader([]byte(`{invalid json}`))) + rec := httptest.NewRecorder() + handler.HandleExecuteRunbook(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code) + } +} + +// ======================================== +// HandleInvestigateAlert tests +// ======================================== + +func TestHandleInvestigateAlert_MethodNotAllowed(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/investigate", nil) + rec := httptest.NewRecorder() + handler.HandleInvestigateAlert(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestHandleInvestigateAlert_InvalidBody(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + req := httptest.NewRequest(http.MethodPost, "/api/ai/investigate", bytes.NewReader([]byte(`{invalid json}`))) + rec := httptest.NewRecorder() + handler.HandleInvestigateAlert(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code) + } +} + +func TestHandleInvestigateAlert_MissingAlertID(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + body := []byte(`{}`) + req := httptest.NewRequest(http.MethodPost, "/api/ai/investigate", bytes.NewReader(body)) + rec := httptest.NewRecorder() + handler.HandleInvestigateAlert(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code) + } +} + +// ======================================== +// AISettingsHandler setter method tests +// ======================================== + +func TestAISettingsHandler_SetConfig(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + // SetConfig with nil should be a no-op + handler.SetConfig(nil) + + // SetConfig with new config should update the handler's config + newCfg := &config.Config{DataPath: tmp, BackendPort: 9999} + handler.SetConfig(newCfg) + // No assertion needed - just verifying it doesn't panic +} + +func TestAISettingsHandler_StopPatrol(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + // StopPatrol should be safe to call even when patrol is not running + handler.StopPatrol() + // No assertion needed - just verifying it doesn't panic +} + +func TestAISettingsHandler_GetAlertTriggeredAnalyzer(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + // Should return the analyzer (may be nil if not initialized) + analyzer := handler.GetAlertTriggeredAnalyzer() + // Just verify it doesn't panic and returns something + _ = analyzer +} + +func TestAISettingsHandler_StartPatrol(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + // Start patrol with a cancellable context + ctx, cancel := context.WithCancel(context.Background()) + handler.StartPatrol(ctx) + + // Give it a brief moment then stop + time.Sleep(10 * time.Millisecond) + cancel() + handler.StopPatrol() +} + +func TestAISettingsHandler_SetPatrolFindingsPersistence(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + // Set nil persistence should not panic + err := handler.SetPatrolFindingsPersistence(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestAISettingsHandler_SetPatrolRunHistoryPersistence(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + // Set nil persistence should not panic + err := handler.SetPatrolRunHistoryPersistence(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestAISettingsHandler_SetPatrolThresholdProvider(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + // Set nil threshold provider should not panic + handler.SetPatrolThresholdProvider(nil) +} + +func TestAISettingsHandler_SetMetricsHistoryProvider(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + // Set nil metrics provider should not panic + handler.SetMetricsHistoryProvider(nil) +} + +func TestAISettingsHandler_SetBaselineStore(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + // Set nil baseline store should not panic + handler.SetBaselineStore(nil) +} + +func TestAISettingsHandler_SetChangeDetector(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + // Set nil change detector should not panic + handler.SetChangeDetector(nil) +} + +func TestAISettingsHandler_SetRemediationLog(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + // Set nil remediation log should not panic + handler.SetRemediationLog(nil) +} + +func TestAISettingsHandler_SetPatternDetector(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + // Set nil pattern detector should not panic + handler.SetPatternDetector(nil) +} + +func TestAISettingsHandler_SetCorrelationDetector(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := NewAISettingsHandler(cfg, persistence, nil) + + // Set nil correlation detector should not panic + handler.SetCorrelationDetector(nil) +} + diff --git a/internal/api/ai_intelligence_handlers.go b/internal/api/ai_intelligence_handlers.go index 6058fa6..00461b4 100644 --- a/internal/api/ai_intelligence_handlers.go +++ b/internal/api/ai_intelligence_handlers.go @@ -6,10 +6,13 @@ import ( "time" "github.com/rcourtman/pulse-go-rewrite/internal/ai" + "github.com/rcourtman/pulse-go-rewrite/internal/license" "github.com/rcourtman/pulse-go-rewrite/internal/utils" "github.com/rs/zerolog/log" ) +const aiIntelligenceUpgradeURL = "https://pulsemonitor.app/pro" + // HandleGetPatterns returns detected failure patterns (GET /api/ai/intelligence/patterns) func (h *AISettingsHandler) HandleGetPatterns(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { @@ -41,10 +44,10 @@ func (h *AISettingsHandler) HandleGetPatterns(w http.ResponseWriter, r *http.Req // Get resource filter if provided resourceID := r.URL.Query().Get("resource_id") - + patterns := detector.GetPatterns() var result []map[string]interface{} - + for key, pattern := range patterns { if resourceID != "" && pattern.ResourceID != resourceID { continue @@ -61,9 +64,22 @@ func (h *AISettingsHandler) HandleGetPatterns(w http.ResponseWriter, r *http.Req }) } + locked := !h.aiService.HasLicenseFeature(license.FeatureAIPatrol) + if locked { + w.Header().Set("X-License-Required", "true") + w.Header().Set("X-License-Feature", license.FeatureAIPatrol) + } + + count := len(result) + if locked { + result = []map[string]interface{}{} + } + if err := utils.WriteJSONResponse(w, map[string]interface{}{ - "patterns": result, - "count": len(result), + "patterns": result, + "count": count, + "license_required": locked, + "upgrade_url": aiIntelligenceUpgradeURL, }); err != nil { log.Error().Err(err).Msg("Failed to write patterns response") } @@ -100,7 +116,7 @@ func (h *AISettingsHandler) HandleGetPredictions(w http.ResponseWriter, r *http. // Get resource filter if provided resourceID := r.URL.Query().Get("resource_id") - + var predictions []ai.FailurePrediction if resourceID != "" { predictions = detector.GetPredictionsForResource(resourceID) @@ -122,9 +138,22 @@ func (h *AISettingsHandler) HandleGetPredictions(w http.ResponseWriter, r *http. }) } + locked := !h.aiService.HasLicenseFeature(license.FeatureAIPatrol) + if locked { + w.Header().Set("X-License-Required", "true") + w.Header().Set("X-License-Feature", license.FeatureAIPatrol) + } + + count := len(result) + if locked { + result = []map[string]interface{}{} + } + if err := utils.WriteJSONResponse(w, map[string]interface{}{ - "predictions": result, - "count": len(result), + "predictions": result, + "count": count, + "license_required": locked, + "upgrade_url": aiIntelligenceUpgradeURL, }); err != nil { log.Error().Err(err).Msg("Failed to write predictions response") } @@ -161,7 +190,7 @@ func (h *AISettingsHandler) HandleGetCorrelations(w http.ResponseWriter, r *http // Get resource filter if provided resourceID := r.URL.Query().Get("resource_id") - + var correlations []*ai.Correlation if resourceID != "" { correlations = detector.GetCorrelationsForResource(resourceID) @@ -187,9 +216,22 @@ func (h *AISettingsHandler) HandleGetCorrelations(w http.ResponseWriter, r *http }) } + locked := !h.aiService.HasLicenseFeature(license.FeatureAIPatrol) + if locked { + w.Header().Set("X-License-Required", "true") + w.Header().Set("X-License-Feature", license.FeatureAIPatrol) + } + + count := len(result) + if locked { + result = []map[string]interface{}{} + } + if err := utils.WriteJSONResponse(w, map[string]interface{}{ - "correlations": result, - "count": len(result), + "correlations": result, + "count": count, + "license_required": locked, + "upgrade_url": aiIntelligenceUpgradeURL, }); err != nil { log.Error().Err(err).Msg("Failed to write correlations response") } @@ -232,7 +274,7 @@ func (h *AISettingsHandler) HandleGetRecentChanges(w http.ResponseWriter, r *htt hours = h } } - + since := time.Now().Add(-time.Duration(hours) * time.Hour) changes := detector.GetRecentChanges(100, since) @@ -251,10 +293,23 @@ func (h *AISettingsHandler) HandleGetRecentChanges(w http.ResponseWriter, r *htt }) } + locked := !h.aiService.HasLicenseFeature(license.FeatureAIPatrol) + if locked { + w.Header().Set("X-License-Required", "true") + w.Header().Set("X-License-Feature", license.FeatureAIPatrol) + } + + count := len(result) + if locked { + result = []map[string]interface{}{} + } + if err := utils.WriteJSONResponse(w, map[string]interface{}{ - "changes": result, - "count": len(result), - "hours": hours, + "changes": result, + "count": count, + "hours": hours, + "license_required": locked, + "upgrade_url": aiIntelligenceUpgradeURL, }); err != nil { log.Error().Err(err).Msg("Failed to write changes response") } @@ -291,10 +346,10 @@ func (h *AISettingsHandler) HandleGetBaselines(w http.ResponseWriter, r *http.Re // Get resource filter if provided resourceID := r.URL.Query().Get("resource_id") - + baselines := store.GetAllBaselines() var result []map[string]interface{} - + for key, baseline := range baselines { if resourceID != "" && baseline.ResourceID != resourceID { continue @@ -312,10 +367,165 @@ func (h *AISettingsHandler) HandleGetBaselines(w http.ResponseWriter, r *http.Re }) } + locked := !h.aiService.HasLicenseFeature(license.FeatureAIPatrol) + if locked { + w.Header().Set("X-License-Required", "true") + w.Header().Set("X-License-Feature", license.FeatureAIPatrol) + } + + count := len(result) + if locked { + result = []map[string]interface{}{} + } + if err := utils.WriteJSONResponse(w, map[string]interface{}{ - "baselines": result, - "count": len(result), + "baselines": result, + "count": count, + "license_required": locked, + "upgrade_url": aiIntelligenceUpgradeURL, }); err != nil { log.Error().Err(err).Msg("Failed to write baselines response") } } + +// HandleGetRemediations returns remediation history (GET /api/ai/intelligence/remediations) +func (h *AISettingsHandler) HandleGetRemediations(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 { + if err := utils.WriteJSONResponse(w, map[string]interface{}{ + "remediations": []interface{}{}, + "message": "Patrol service not initialized", + }); err != nil { + log.Error().Err(err).Msg("Failed to write remediations response") + } + return + } + + remediationLog := patrol.GetRemediationLog() + if remediationLog == nil { + if err := utils.WriteJSONResponse(w, map[string]interface{}{ + "remediations": []interface{}{}, + "message": "Remediation log not initialized", + }); err != nil { + log.Error().Err(err).Msg("Failed to write remediations response") + } + return + } + + resourceID := r.URL.Query().Get("resource_id") + findingID := r.URL.Query().Get("finding_id") + + limit := 20 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { + if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 { + limit = parsed + } + } + if limit > 100 { + limit = 100 + } + + hours := 168 + if hoursStr := r.URL.Query().Get("hours"); hoursStr != "" { + if parsed, err := strconv.Atoi(hoursStr); err == nil && parsed > 0 { + hours = parsed + } + } + since := time.Now().Add(-time.Duration(hours) * time.Hour) + + var records []ai.RemediationRecord + switch { + case findingID != "": + records = remediationLog.GetForFinding(findingID, limit) + case resourceID != "": + records = remediationLog.GetForResource(resourceID, limit) + default: + records = remediationLog.GetRecentRemediations(limit, since) + } + + stats := remediationStatsFromRecords(records) + if findingID == "" && resourceID == "" { + stats = remediationLog.GetRecentRemediationStats(since) + } + + result := make([]map[string]interface{}, 0, len(records)) + for _, rec := range records { + durationMs := int64(0) + if rec.Duration > 0 { + durationMs = rec.Duration.Milliseconds() + } + result = append(result, map[string]interface{}{ + "id": rec.ID, + "timestamp": rec.Timestamp, + "resource_id": rec.ResourceID, + "resource_type": rec.ResourceType, + "resource_name": rec.ResourceName, + "finding_id": rec.FindingID, + "problem": rec.Problem, + "action": rec.Action, + "output": rec.Output, + "outcome": rec.Outcome, + "duration_ms": durationMs, + "note": rec.Note, + "automatic": rec.Automatic, + }) + } + + locked := !h.aiService.HasLicenseFeature(license.FeatureAIPatrol) + if locked { + w.Header().Set("X-License-Required", "true") + w.Header().Set("X-License-Feature", license.FeatureAIPatrol) + } + + count := len(result) + if locked { + result = []map[string]interface{}{} + } + + if err := utils.WriteJSONResponse(w, map[string]interface{}{ + "remediations": result, + "count": count, + "stats": stats, + "license_required": locked, + "upgrade_url": aiIntelligenceUpgradeURL, + }); err != nil { + log.Error().Err(err).Msg("Failed to write remediations response") + } +} + +func remediationStatsFromRecords(records []ai.RemediationRecord) map[string]int { + stats := map[string]int{ + "total": len(records), + "resolved": 0, + "partial": 0, + "failed": 0, + "unknown": 0, + "automatic": 0, + "manual": 0, + } + + for _, rec := range records { + switch rec.Outcome { + case ai.OutcomeResolved: + stats["resolved"]++ + case ai.OutcomePartial: + stats["partial"]++ + case ai.OutcomeFailed: + stats["failed"]++ + default: + stats["unknown"]++ + } + if rec.Automatic { + stats["automatic"]++ + } else { + stats["manual"]++ + } + } + + return stats +} diff --git a/internal/api/ai_intelligence_handlers_test.go b/internal/api/ai_intelligence_handlers_test.go index 4253853..183863f 100644 --- a/internal/api/ai_intelligence_handlers_test.go +++ b/internal/api/ai_intelligence_handlers_test.go @@ -274,6 +274,51 @@ func TestHandleGetRecentChanges_NoPatrolService(t *testing.T) { } } +// TestHandleGetRemediations tests the remediations endpoint +func TestHandleGetRemediations_MethodNotAllowed(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + req := httptest.NewRequest(http.MethodPost, "/api/ai/intelligence/remediations", nil) + rec := httptest.NewRecorder() + + handler.HandleGetRemediations(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d for POST, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestHandleGetRemediations_NoPatrolService(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/intelligence/remediations", nil) + rec := httptest.NewRecorder() + + handler.HandleGetRemediations(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String()) + } + + var resp map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + remediations, ok := resp["remediations"].([]interface{}) + if !ok { + t.Fatal("expected remediations array in response") + } + if len(remediations) != 0 { + t.Fatalf("expected empty remediations, got %d", len(remediations)) + } + if resp["message"] != "Patrol service not initialized" { + t.Fatalf("unexpected message: %v", resp["message"]) + } +} + // TestHandleGetBaselines tests the baselines endpoint func TestHandleGetBaselines_MethodNotAllowed(t *testing.T) { t.Parallel() diff --git a/internal/api/license_handlers_test.go b/internal/api/license_handlers_test.go index 9a74257..5a13b91 100644 --- a/internal/api/license_handlers_test.go +++ b/internal/api/license_handlers_test.go @@ -1,6 +1,7 @@ package api import ( + "bytes" "encoding/json" "net/http" "net/http/httptest" @@ -112,3 +113,410 @@ func TestHandleLicenseFeatures_WithActiveLicense(t *testing.T) { } } } + +// ======================================== +// HandleLicenseStatus tests +// ======================================== + +func TestHandleLicenseStatus_MethodNotAllowed(t *testing.T) { + handler := NewLicenseHandlers(t.TempDir()) + + req := httptest.NewRequest(http.MethodPost, "/api/license/status", nil) + rec := httptest.NewRecorder() + + handler.HandleLicenseStatus(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestHandleLicenseStatus_NoLicense(t *testing.T) { + handler := NewLicenseHandlers(t.TempDir()) + + req := httptest.NewRequest(http.MethodGet, "/api/license/status", nil) + rec := httptest.NewRecorder() + + handler.HandleLicenseStatus(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + + var resp license.LicenseStatus + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + // LicenseStatus uses Valid field and Tier field (no State field) + // For no license, Valid should be false and Tier should be TierFree + if resp.Valid { + t.Fatalf("expected Valid=false for no license") + } + if resp.Tier != license.TierFree { + t.Fatalf("expected tier %q, got %q", license.TierFree, resp.Tier) + } +} + +func TestHandleLicenseStatus_WithActiveLicense(t *testing.T) { + t.Setenv("PULSE_LICENSE_DEV_MODE", "true") + + handler := NewLicenseHandlers(t.TempDir()) + licenseKey, err := license.GenerateLicenseForTesting("test@example.com", license.TierPro, 24*time.Hour) + if err != nil { + t.Fatalf("failed to generate test license: %v", err) + } + if _, err := handler.Service().Activate(licenseKey); err != nil { + t.Fatalf("failed to activate test license: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/license/status", nil) + rec := httptest.NewRecorder() + + handler.HandleLicenseStatus(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + + var resp license.LicenseStatus + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + // LicenseStatus uses Valid field and Tier field + // For active license, Valid should be true and Tier should be TierPro + if !resp.Valid { + t.Fatalf("expected Valid=true for active license") + } + if resp.Email != "test@example.com" { + t.Fatalf("expected email %q, got %q", "test@example.com", resp.Email) + } + if resp.Tier != license.TierPro { + t.Fatalf("expected tier %q, got %q", license.TierPro, resp.Tier) + } +} + +// ======================================== +// HandleActivateLicense tests +// ======================================== + +func TestHandleActivateLicense_MethodNotAllowed(t *testing.T) { + handler := NewLicenseHandlers(t.TempDir()) + + req := httptest.NewRequest(http.MethodGet, "/api/license/activate", nil) + rec := httptest.NewRecorder() + + handler.HandleActivateLicense(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestHandleActivateLicense_EmptyKey(t *testing.T) { + handler := NewLicenseHandlers(t.TempDir()) + + body := []byte(`{"license_key":""}`) + req := httptest.NewRequest(http.MethodPost, "/api/license/activate", bytes.NewReader(body)) + rec := httptest.NewRecorder() + + handler.HandleActivateLicense(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code) + } + + var resp ActivateLicenseResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if resp.Success { + t.Fatalf("expected Success=false for empty key") + } + if resp.Message != "License key is required" { + t.Fatalf("expected message %q, got %q", "License key is required", resp.Message) + } +} + +func TestHandleActivateLicense_InvalidKey(t *testing.T) { + handler := NewLicenseHandlers(t.TempDir()) + + body := []byte(`{"license_key":"invalid-license-key"}`) + req := httptest.NewRequest(http.MethodPost, "/api/license/activate", bytes.NewReader(body)) + rec := httptest.NewRecorder() + + handler.HandleActivateLicense(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code) + } + + var resp ActivateLicenseResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if resp.Success { + t.Fatalf("expected Success=false for invalid key") + } +} + +func TestHandleActivateLicense_InvalidBody(t *testing.T) { + handler := NewLicenseHandlers(t.TempDir()) + + body := []byte(`{invalid json}`) + req := httptest.NewRequest(http.MethodPost, "/api/license/activate", bytes.NewReader(body)) + rec := httptest.NewRecorder() + + handler.HandleActivateLicense(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code) + } + + var resp ActivateLicenseResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if resp.Success { + t.Fatalf("expected Success=false for invalid body") + } + if resp.Message != "Invalid request body" { + t.Fatalf("expected message %q, got %q", "Invalid request body", resp.Message) + } +} + +func TestHandleActivateLicense_ValidKey(t *testing.T) { + t.Setenv("PULSE_LICENSE_DEV_MODE", "true") + + handler := NewLicenseHandlers(t.TempDir()) + licenseKey, err := license.GenerateLicenseForTesting("pro@example.com", license.TierPro, 24*time.Hour) + if err != nil { + t.Fatalf("failed to generate test license: %v", err) + } + + body, _ := json.Marshal(map[string]string{"license_key": licenseKey}) + req := httptest.NewRequest(http.MethodPost, "/api/license/activate", bytes.NewReader(body)) + rec := httptest.NewRecorder() + + handler.HandleActivateLicense(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String()) + } + + var resp ActivateLicenseResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if !resp.Success { + t.Fatalf("expected Success=true, got message: %s", resp.Message) + } + if resp.Status == nil { + t.Fatalf("expected Status to be non-nil") + } + if resp.Status.Email != "pro@example.com" { + t.Fatalf("expected email %q, got %q", "pro@example.com", resp.Status.Email) + } +} + +// ======================================== +// HandleClearLicense tests +// ======================================== + +func TestHandleClearLicense_MethodNotAllowed(t *testing.T) { + handler := NewLicenseHandlers(t.TempDir()) + + req := httptest.NewRequest(http.MethodGet, "/api/license/clear", nil) + rec := httptest.NewRecorder() + + handler.HandleClearLicense(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestHandleClearLicense_NoLicense(t *testing.T) { + handler := NewLicenseHandlers(t.TempDir()) + + req := httptest.NewRequest(http.MethodPost, "/api/license/clear", nil) + rec := httptest.NewRecorder() + + handler.HandleClearLicense(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if success, ok := resp["success"].(bool); !ok || !success { + t.Fatalf("expected success=true") + } +} + +func TestHandleClearLicense_WithActiveLicense(t *testing.T) { + t.Setenv("PULSE_LICENSE_DEV_MODE", "true") + + handler := NewLicenseHandlers(t.TempDir()) + licenseKey, err := license.GenerateLicenseForTesting("test@example.com", license.TierPro, 24*time.Hour) + if err != nil { + t.Fatalf("failed to generate test license: %v", err) + } + if _, err := handler.Service().Activate(licenseKey); err != nil { + t.Fatalf("failed to activate test license: %v", err) + } + + // Verify license is active + if !handler.Service().IsValid() { + t.Fatalf("expected license to be valid before clearing") + } + + req := httptest.NewRequest(http.MethodPost, "/api/license/clear", nil) + rec := httptest.NewRecorder() + + handler.HandleClearLicense(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + + // Verify license is cleared + if handler.Service().IsValid() { + t.Fatalf("expected license to be invalid after clearing") + } + + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if success, ok := resp["success"].(bool); !ok || !success { + t.Fatalf("expected success=true") + } +} + +// ======================================== +// RequireLicenseFeature middleware tests +// ======================================== + +func TestRequireLicenseFeature_NoLicense(t *testing.T) { + handler := NewLicenseHandlers(t.TempDir()) + + handlerCalled := false + wrappedHandler := RequireLicenseFeature(handler.Service(), license.FeatureAIPatrol, func(w http.ResponseWriter, r *http.Request) { + handlerCalled = true + w.WriteHeader(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/api/test", nil) + rec := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(rec, req) + + if rec.Code != http.StatusPaymentRequired { + t.Fatalf("expected status %d, got %d", http.StatusPaymentRequired, rec.Code) + } + if handlerCalled { + t.Fatalf("expected handler not to be called when license is missing") + } +} + +func TestRequireLicenseFeature_WithLicense(t *testing.T) { + t.Setenv("PULSE_LICENSE_DEV_MODE", "true") + + handler := NewLicenseHandlers(t.TempDir()) + licenseKey, err := license.GenerateLicenseForTesting("test@example.com", license.TierPro, 24*time.Hour) + if err != nil { + t.Fatalf("failed to generate test license: %v", err) + } + if _, err := handler.Service().Activate(licenseKey); err != nil { + t.Fatalf("failed to activate test license: %v", err) + } + + handlerCalled := false + wrappedHandler := RequireLicenseFeature(handler.Service(), license.FeatureAIPatrol, func(w http.ResponseWriter, r *http.Request) { + handlerCalled = true + w.WriteHeader(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/api/test", nil) + rec := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + if !handlerCalled { + t.Fatalf("expected handler to be called when license is valid") + } +} + +// ======================================== +// LicenseGatedEmptyResponse middleware tests +// ======================================== + +func TestLicenseGatedEmptyResponse_NoLicense(t *testing.T) { + handler := NewLicenseHandlers(t.TempDir()) + + handlerCalled := false + wrappedHandler := LicenseGatedEmptyResponse(handler.Service(), license.FeatureAIPatrol, func(w http.ResponseWriter, r *http.Request) { + handlerCalled = true + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":"real"}`)) + }) + + req := httptest.NewRequest(http.MethodGet, "/api/test", nil) + rec := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + if handlerCalled { + t.Fatalf("expected handler not to be called when license is missing") + } + // LicenseGatedEmptyResponse returns empty array, not empty object + if rec.Body.String() != "[]" { + t.Fatalf("expected empty array [], got %s", rec.Body.String()) + } +} + +func TestLicenseGatedEmptyResponse_WithLicense(t *testing.T) { + t.Setenv("PULSE_LICENSE_DEV_MODE", "true") + + handler := NewLicenseHandlers(t.TempDir()) + licenseKey, err := license.GenerateLicenseForTesting("test@example.com", license.TierPro, 24*time.Hour) + if err != nil { + t.Fatalf("failed to generate test license: %v", err) + } + if _, err := handler.Service().Activate(licenseKey); err != nil { + t.Fatalf("failed to activate test license: %v", err) + } + + handlerCalled := false + wrappedHandler := LicenseGatedEmptyResponse(handler.Service(), license.FeatureAIPatrol, func(w http.ResponseWriter, r *http.Request) { + handlerCalled = true + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":"real"}`)) + }) + + req := httptest.NewRequest(http.MethodGet, "/api/test", nil) + rec := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + if !handlerCalled { + t.Fatalf("expected handler to be called when license is valid") + } + if rec.Body.String() != `{"data":"real"}` { + t.Fatalf("expected real data, got %s", rec.Body.String()) + } +} diff --git a/internal/api/router.go b/internal/api/router.go index 77520f0..44eec08 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -29,9 +29,9 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" "github.com/rcourtman/pulse-go-rewrite/internal/ai" "github.com/rcourtman/pulse-go-rewrite/internal/alerts" - "github.com/rcourtman/pulse-go-rewrite/internal/license" "github.com/rcourtman/pulse-go-rewrite/internal/auth" "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/license" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" "github.com/rcourtman/pulse-go-rewrite/internal/system" @@ -1131,6 +1131,9 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/ai/models", RequireAuth(r.config, r.aiSettingsHandler.HandleListModels)) r.mux.HandleFunc("/api/ai/execute", RequireAuth(r.config, r.aiSettingsHandler.HandleExecute)) r.mux.HandleFunc("/api/ai/execute/stream", RequireAuth(r.config, r.aiSettingsHandler.HandleExecuteStream)) + r.mux.HandleFunc("/api/ai/kubernetes/analyze", RequireAuth(r.config, RequireLicenseFeature(r.licenseHandlers.Service(), license.FeatureKubernetesAI, r.aiSettingsHandler.HandleAnalyzeKubernetesCluster))) + r.mux.HandleFunc("/api/ai/runbooks", RequireAuth(r.config, RequireLicenseFeature(r.licenseHandlers.Service(), license.FeatureAIAutoFix, r.aiSettingsHandler.HandleGetRunbooksForFinding))) + r.mux.HandleFunc("/api/ai/runbooks/execute", RequireAuth(r.config, RequireLicenseFeature(r.licenseHandlers.Service(), license.FeatureAIAutoFix, r.aiSettingsHandler.HandleExecuteRunbook))) r.mux.HandleFunc("/api/ai/investigate-alert", RequireAuth(r.config, RequireLicenseFeature(r.licenseHandlers.Service(), license.FeatureAIAlerts, r.aiSettingsHandler.HandleInvestigateAlert))) r.mux.HandleFunc("/api/ai/run-command", RequireAuth(r.config, r.aiSettingsHandler.HandleRunCommand)) r.mux.HandleFunc("/api/ai/knowledge", RequireAuth(r.config, r.aiSettingsHandler.HandleGetGuestKnowledge)) @@ -1152,19 +1155,19 @@ func (r *Router) setupRoutes() { // AI Patrol routes for background monitoring // Note: Status remains accessible so UI can show license/upgrade state - // Read endpoints (findings, history, runs) return empty data with license_required flag + // Read endpoints (findings, history, runs) return redacted preview data when unlicensed // Mutation endpoints (run, acknowledge, dismiss, etc.) return 402 to prevent unauthorized actions r.mux.HandleFunc("/api/ai/patrol/status", RequireAuth(r.config, r.aiSettingsHandler.HandleGetPatrolStatus)) r.mux.HandleFunc("/api/ai/patrol/stream", RequireAuth(r.config, RequireLicenseFeature(r.licenseHandlers.Service(), license.FeatureAIPatrol, r.aiSettingsHandler.HandlePatrolStream))) - r.mux.HandleFunc("/api/ai/patrol/findings", RequireAuth(r.config, LicenseGatedEmptyResponse(r.licenseHandlers.Service(), license.FeatureAIPatrol, r.aiSettingsHandler.HandleGetPatrolFindings))) - r.mux.HandleFunc("/api/ai/patrol/history", RequireAuth(r.config, LicenseGatedEmptyResponse(r.licenseHandlers.Service(), license.FeatureAIPatrol, r.aiSettingsHandler.HandleGetFindingsHistory))) + 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, RequireLicenseFeature(r.licenseHandlers.Service(), license.FeatureAIPatrol, r.aiSettingsHandler.HandleForcePatrol))) r.mux.HandleFunc("/api/ai/patrol/acknowledge", RequireAuth(r.config, RequireLicenseFeature(r.licenseHandlers.Service(), license.FeatureAIPatrol, r.aiSettingsHandler.HandleAcknowledgeFinding))) r.mux.HandleFunc("/api/ai/patrol/dismiss", RequireAuth(r.config, RequireLicenseFeature(r.licenseHandlers.Service(), license.FeatureAIPatrol, r.aiSettingsHandler.HandleDismissFinding))) r.mux.HandleFunc("/api/ai/patrol/suppress", RequireAuth(r.config, RequireLicenseFeature(r.licenseHandlers.Service(), license.FeatureAIPatrol, r.aiSettingsHandler.HandleSuppressFinding))) r.mux.HandleFunc("/api/ai/patrol/snooze", RequireAuth(r.config, RequireLicenseFeature(r.licenseHandlers.Service(), license.FeatureAIPatrol, r.aiSettingsHandler.HandleSnoozeFinding))) r.mux.HandleFunc("/api/ai/patrol/resolve", RequireAuth(r.config, RequireLicenseFeature(r.licenseHandlers.Service(), license.FeatureAIPatrol, r.aiSettingsHandler.HandleResolveFinding))) - r.mux.HandleFunc("/api/ai/patrol/runs", RequireAuth(r.config, LicenseGatedEmptyResponse(r.licenseHandlers.Service(), license.FeatureAIPatrol, r.aiSettingsHandler.HandleGetPatrolRunHistory))) + r.mux.HandleFunc("/api/ai/patrol/runs", RequireAuth(r.config, r.aiSettingsHandler.HandleGetPatrolRunHistory)) // Suppression rules management (also Pro-only since they control LLM behavior) // GET returns empty array for unlicensed, POST returns 402 r.mux.HandleFunc("/api/ai/patrol/suppressions", RequireAuth(r.config, func(w http.ResponseWriter, req *http.Request) { @@ -1206,6 +1209,7 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/ai/intelligence/correlations", RequireAuth(r.config, r.aiSettingsHandler.HandleGetCorrelations)) r.mux.HandleFunc("/api/ai/intelligence/changes", RequireAuth(r.config, r.aiSettingsHandler.HandleGetRecentChanges)) r.mux.HandleFunc("/api/ai/intelligence/baselines", RequireAuth(r.config, r.aiSettingsHandler.HandleGetBaselines)) + r.mux.HandleFunc("/api/ai/intelligence/remediations", RequireAuth(r.config, r.aiSettingsHandler.HandleGetRemediations)) // Agent WebSocket for AI command execution r.mux.HandleFunc("/api/agent/ws", r.handleAgentWebSocket) diff --git a/internal/config/persistence.go b/internal/config/persistence.go index 225911b..9007775 100644 --- a/internal/config/persistence.go +++ b/internal/config/persistence.go @@ -235,24 +235,34 @@ func (c *ConfigPersistence) SaveAlertConfig(config alerts.AlertConfig) error { config.HysteresisMargin = 5.0 } - if config.HostDefaults.CPU == nil || config.HostDefaults.CPU.Trigger <= 0 { + // Host Defaults: Allow Trigger=0 to disable specific alerts + if config.HostDefaults.CPU == nil || config.HostDefaults.CPU.Trigger < 0 { config.HostDefaults.CPU = &alerts.HysteresisThreshold{Trigger: 80, Clear: 75} + } else if config.HostDefaults.CPU.Trigger == 0 { + // Trigger=0 means disabled, set Clear=0 too + config.HostDefaults.CPU.Clear = 0 } else if config.HostDefaults.CPU.Clear <= 0 { config.HostDefaults.CPU.Clear = config.HostDefaults.CPU.Trigger - 5 if config.HostDefaults.CPU.Clear <= 0 { config.HostDefaults.CPU.Clear = 75 } } - if config.HostDefaults.Memory == nil || config.HostDefaults.Memory.Trigger <= 0 { + if config.HostDefaults.Memory == nil || config.HostDefaults.Memory.Trigger < 0 { config.HostDefaults.Memory = &alerts.HysteresisThreshold{Trigger: 85, Clear: 80} + } else if config.HostDefaults.Memory.Trigger == 0 { + // Trigger=0 means disabled, set Clear=0 too + config.HostDefaults.Memory.Clear = 0 } else if config.HostDefaults.Memory.Clear <= 0 { config.HostDefaults.Memory.Clear = config.HostDefaults.Memory.Trigger - 5 if config.HostDefaults.Memory.Clear <= 0 { config.HostDefaults.Memory.Clear = 80 } } - if config.HostDefaults.Disk == nil || config.HostDefaults.Disk.Trigger <= 0 { + if config.HostDefaults.Disk == nil || config.HostDefaults.Disk.Trigger < 0 { config.HostDefaults.Disk = &alerts.HysteresisThreshold{Trigger: 90, Clear: 85} + } else if config.HostDefaults.Disk.Trigger == 0 { + // Trigger=0 means disabled, set Clear=0 too + config.HostDefaults.Disk.Clear = 0 } else if config.HostDefaults.Disk.Clear <= 0 { config.HostDefaults.Disk.Clear = config.HostDefaults.Disk.Trigger - 5 if config.HostDefaults.Disk.Clear <= 0 { @@ -415,24 +425,34 @@ func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) { if config.NodeDefaults.Temperature == nil || config.NodeDefaults.Temperature.Trigger <= 0 { config.NodeDefaults.Temperature = &alerts.HysteresisThreshold{Trigger: 80, Clear: 75} } - if config.HostDefaults.CPU == nil || config.HostDefaults.CPU.Trigger <= 0 { + // Host Defaults: Allow Trigger=0 to disable specific alerts + if config.HostDefaults.CPU == nil || config.HostDefaults.CPU.Trigger < 0 { config.HostDefaults.CPU = &alerts.HysteresisThreshold{Trigger: 80, Clear: 75} + } else if config.HostDefaults.CPU.Trigger == 0 { + // Trigger=0 means disabled, set Clear=0 too + config.HostDefaults.CPU.Clear = 0 } else if config.HostDefaults.CPU.Clear <= 0 { config.HostDefaults.CPU.Clear = config.HostDefaults.CPU.Trigger - 5 if config.HostDefaults.CPU.Clear <= 0 { config.HostDefaults.CPU.Clear = 75 } } - if config.HostDefaults.Memory == nil || config.HostDefaults.Memory.Trigger <= 0 { + if config.HostDefaults.Memory == nil || config.HostDefaults.Memory.Trigger < 0 { config.HostDefaults.Memory = &alerts.HysteresisThreshold{Trigger: 85, Clear: 80} + } else if config.HostDefaults.Memory.Trigger == 0 { + // Trigger=0 means disabled, set Clear=0 too + config.HostDefaults.Memory.Clear = 0 } else if config.HostDefaults.Memory.Clear <= 0 { config.HostDefaults.Memory.Clear = config.HostDefaults.Memory.Trigger - 5 if config.HostDefaults.Memory.Clear <= 0 { config.HostDefaults.Memory.Clear = 80 } } - if config.HostDefaults.Disk == nil || config.HostDefaults.Disk.Trigger <= 0 { + if config.HostDefaults.Disk == nil || config.HostDefaults.Disk.Trigger < 0 { config.HostDefaults.Disk = &alerts.HysteresisThreshold{Trigger: 90, Clear: 85} + } else if config.HostDefaults.Disk.Trigger == 0 { + // Trigger=0 means disabled, set Clear=0 too + config.HostDefaults.Disk.Clear = 0 } else if config.HostDefaults.Disk.Clear <= 0 { config.HostDefaults.Disk.Clear = config.HostDefaults.Disk.Trigger - 5 if config.HostDefaults.Disk.Clear <= 0 { @@ -1425,6 +1445,7 @@ type AIFindingsData struct { // AIFindingRecord is a persisted finding with full history type AIFindingRecord struct { ID string `json:"id"` + Key string `json:"key,omitempty"` Severity string `json:"severity"` Category string `json:"category"` ResourceID string `json:"resource_id"` @@ -1540,6 +1561,7 @@ type PatrolRunRecord struct { NewFindings int `json:"new_findings"` ExistingFindings int `json:"existing_findings"` ResolvedFindings int `json:"resolved_findings"` + AutoFixCount int `json:"auto_fix_count,omitempty"` FindingsSummary string `json:"findings_summary"` FindingIDs []string `json:"finding_ids,omitempty"` ErrorCount int `json:"error_count"` diff --git a/internal/config/persistence_test.go b/internal/config/persistence_test.go index 8fbddcc..238c697 100644 --- a/internal/config/persistence_test.go +++ b/internal/config/persistence_test.go @@ -183,6 +183,56 @@ func TestSaveAlertConfig_NormalizesHostDefaultsClear(t *testing.T) { } } +// TestSaveAlertConfig_HostDefaultsZeroDisablesAlerting verifies that setting +// Host Agent thresholds to 0 is preserved (fixes GitHub issue #864). +// Setting a threshold to 0 should disable alerting for that metric. +func TestSaveAlertConfig_HostDefaultsZeroDisablesAlerting(t *testing.T) { + tempDir := t.TempDir() + cp := config.NewConfigPersistence(tempDir) + if err := cp.EnsureConfigDir(); err != nil { + t.Fatalf("EnsureConfigDir: %v", err) + } + + // Config with Memory=0 to disable memory alerting for host agents + cfg := alerts.AlertConfig{ + Enabled: true, + StorageDefault: alerts.HysteresisThreshold{Trigger: 85, Clear: 80}, + HostDefaults: alerts.ThresholdConfig{ + CPU: &alerts.HysteresisThreshold{Trigger: 80, Clear: 75}, + Memory: &alerts.HysteresisThreshold{Trigger: 0, Clear: 0}, // Disabled + Disk: &alerts.HysteresisThreshold{Trigger: 90, Clear: 85}, + }, + } + + if err := cp.SaveAlertConfig(cfg); err != nil { + t.Fatalf("SaveAlertConfig: %v", err) + } + + loaded, err := cp.LoadAlertConfig() + if err != nil { + t.Fatalf("LoadAlertConfig: %v", err) + } + + // Memory threshold should remain at 0 (disabled), not reset to default + if loaded.HostDefaults.Memory == nil { + t.Fatal("Memory defaults should be preserved (not nil)") + } + if loaded.HostDefaults.Memory.Trigger != 0 { + t.Errorf("Memory trigger = %v, want 0 (disabled)", loaded.HostDefaults.Memory.Trigger) + } + if loaded.HostDefaults.Memory.Clear != 0 { + t.Errorf("Memory clear = %v, want 0 (disabled)", loaded.HostDefaults.Memory.Clear) + } + + // CPU and Disk should still have their values + if loaded.HostDefaults.CPU.Trigger != 80 { + t.Errorf("CPU trigger = %v, want 80", loaded.HostDefaults.CPU.Trigger) + } + if loaded.HostDefaults.Disk.Trigger != 90 { + t.Errorf("Disk trigger = %v, want 90", loaded.HostDefaults.Disk.Trigger) + } +} + func TestAlertConfigPersistenceNormalizesDockerIgnoredPrefixes(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir)