fix: Allow Host Agent thresholds to be set to 0 to disable alerting. Related to #864

This commit is contained in:
rcourtman 2025-12-20 20:25:20 +00:00
parent b949255868
commit 19c7cf6970
42 changed files with 8635 additions and 856 deletions

View file

@ -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');
});
});

View file

@ -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<BaselinesResponse>;
}
// Get remediation history
static async getRemediations(options?: {
resourceId?: string;
findingId?: string;
hours?: number;
limit?: number;
}): Promise<RemediationsResponse> {
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<RemediationsResponse>;
}
// 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<AIExecuteResponse>;
}
// Analyze a Kubernetes cluster with AI
static async analyzeKubernetesCluster(clusterId: string): Promise<AIExecuteResponse> {
return apiFetchJSON(`${this.baseUrl}/ai/kubernetes/analyze`, {
method: 'POST',
body: JSON.stringify({ cluster_id: clusterId }),
}) as Promise<AIExecuteResponse>;
}
// Run a single command (for approved commands)
static async runCommand(request: {
command: string;

View file

@ -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<RunbookInfo[]> {
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<RunbookExecutionResult> {
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.

View file

@ -224,10 +224,10 @@ export const AICostDashboard: Component = () => {
return (
<Card padding="none" class="overflow-hidden border border-gray-200 dark:border-gray-700" border={false}>
<div class="bg-gradient-to-r from-emerald-50 to-teal-50 dark:from-emerald-900/20 dark:to-teal-900/20 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
<div class="p-2 bg-emerald-100 dark:bg-emerald-900/40 rounded-lg">
<svg class="w-5 h-5 text-emerald-600 dark:text-emerald-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div class="p-2 bg-blue-100 dark:bg-blue-900/40 rounded-lg">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 1.343-3 3v1a3 3 0 006 0v-1c0-1.657-1.343-3-3-3zM5 12a7 7 0 0114 0v3a2 2 0 01-2 2H7a2 2 0 01-2-2v-3z" />
</svg>
</div>

View file

@ -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<KubernetesClustersProps> = (props) =>
const [statusFilter, setStatusFilter] = createSignal<StatusFilter>('all');
const [showHidden, setShowHidden] = createSignal(false);
const [namespaceFilter, setNamespaceFilter] = createSignal<string>('all');
const [licenseFeatures, setLicenseFeatures] = createSignal<LicenseFeatureStatus | null>(null);
const [licenseLoading, setLicenseLoading] = createSignal(true);
const [aiSettings, setAiSettings] = createSignal<AISettings | null>(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<KubernetesClustersProps> = (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<string>();
@ -445,6 +551,103 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
return (
<div class="space-y-4">
<Show when={clustersForAnalysis().length > 0}>
<Card padding="sm">
<div class="flex flex-col gap-3">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
Kubernetes AI Analysis
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
Generate a health summary and next actions for a cluster.
</div>
</div>
<Show when={!licenseLoading() && !kubernetesAiEnabled()}>
<a
href={upgradeUrl()}
target="_blank"
rel="noreferrer"
class="inline-flex items-center gap-1 text-xs font-medium text-blue-600 dark:text-blue-300 hover:text-blue-700 dark:hover:text-blue-200"
>
Upgrade to Pro
</a>
</Show>
</div>
<Show when={licenseLoading() || aiLoading()}>
<div class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-2">
<span class="h-3 w-3 border-2 border-current border-t-transparent rounded-full animate-spin" />
Loading AI and license status...
</div>
</Show>
<Show when={!licenseLoading() && !kubernetesAiEnabled()}>
<div class="text-sm text-gray-600 dark:text-gray-300">
Kubernetes AI analysis requires Pulse Pro.
</div>
</Show>
<Show when={!licenseLoading() && kubernetesAiEnabled()}>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-2">
<select
value={analysisClusterId()}
onChange={(e) => setAnalysisClusterId(e.currentTarget.value)}
class="px-2.5 py-1.5 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500"
>
<For each={clustersForAnalysis()}>
{(cluster) => (
<option value={cluster.id}>{getClusterOptionLabel(cluster)}</option>
)}
</For>
</select>
<button
type="button"
onClick={handleAnalyzeCluster}
disabled={analysisLoading() || !analysisClusterId() || !aiConfigured()}
class={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
analysisLoading() || !analysisClusterId() || !aiConfigured()
? 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{analysisLoading() ? 'Analyzing...' : 'Analyze'}
</button>
<Show when={analysisLoading()}>
<span class="text-xs text-gray-500 dark:text-gray-400">Running analysis...</span>
</Show>
</div>
<Show when={!aiLoading() && !aiConfigured()}>
<div class="text-xs text-amber-600 dark:text-amber-400">
AI is not configured. Configure it in Settings -> AI.
</div>
</Show>
<Show when={analysisError()}>
<div class="text-xs text-red-600 dark:text-red-400">
{analysisError()}
</div>
</Show>
<Show when={analysisResult()}>
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2">
<Show when={analysisMeta()}>
<div class="text-[11px] text-gray-500 dark:text-gray-400 mb-2">
Model: {analysisMeta()!.model} · Tokens: {analysisMeta()!.inputTokens + analysisMeta()!.outputTokens}
</div>
</Show>
<div class="text-sm text-gray-700 dark:text-gray-200 whitespace-pre-wrap">
{analysisResult()}
</div>
</div>
</Show>
</div>
</Show>
</div>
</Card>
</Show>
{/* Filter Bar */}
<Card padding="sm">
<div class="flex flex-col gap-3">

View file

@ -532,11 +532,11 @@ export const AISettings: Component = () => {
class="overflow-hidden border border-gray-200 dark:border-gray-700"
border={false}
>
<div class="bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-900/20 dark:to-pink-900/20 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
<div class="p-2 bg-purple-100 dark:bg-purple-900/40 rounded-lg">
<div class="p-2 bg-blue-100 dark:bg-blue-900/40 rounded-lg">
<svg
class="w-5 h-5 text-purple-600 dark:text-purple-300"
class="w-5 h-5 text-blue-600 dark:text-blue-300"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -598,7 +598,7 @@ export const AISettings: Component = () => {
</div>
</div>
<form class="p-5 space-y-4" onSubmit={handleSave}>
<form class="p-6 space-y-6" onSubmit={handleSave}>
<Show when={loading()}>
<div class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
<span class="h-4 w-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
@ -607,7 +607,7 @@ export const AISettings: Component = () => {
</Show>
<Show when={!loading()}>
<div class="space-y-4">
<div class="space-y-6">
{/* Default Model Selection - Always visible */}
<div class={formField}>
<div class="flex items-center justify-between mb-1">
@ -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"
>
<svg class={`w-3 h-3 ${modelsLoading() ? 'animate-spin' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -702,7 +702,7 @@ export const AISettings: Component = () => {
</svg>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Advanced Model Selection</span>
<Show when={form.chatModel || form.patrolModel}>
<span class="px-1.5 py-0.5 text-[10px] font-semibold bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-300 rounded">Customized</span>
<span class="px-1.5 py-0.5 text-[10px] font-semibold bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">Customized</span>
</Show>
</div>
<svg class={`w-4 h-4 text-gray-500 transition-transform ${showAdvancedModels() ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -791,10 +791,10 @@ export const AISettings: Component = () => {
</div>
{/* AI Provider Configuration - Configure API keys for all providers */}
<div class={`${formField} p-4 rounded-lg border bg-gradient-to-br from-purple-50 to-indigo-50 dark:from-purple-900/20 dark:to-indigo-900/20 border-purple-200 dark:border-purple-800`}>
<div class={`${formField} p-5 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/40`}>
<div class="mb-3">
<h4 class="font-medium text-gray-900 dark:text-white flex items-center gap-2">
<svg class="w-5 h-5 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
AI Provider Configuration
@ -841,7 +841,7 @@ export const AISettings: Component = () => {
/>
<div class="flex items-center justify-between">
<p class="text-xs text-gray-500">
<a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noopener" class="text-purple-600 hover:underline">Get API key </a>
<a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noopener" class="text-blue-600 dark:text-blue-400 hover:underline">Get API key </a>
</p>
<Show when={settings()?.anthropic_configured}>
<div class="flex gap-1">
@ -917,7 +917,7 @@ export const AISettings: Component = () => {
/>
<div class="flex items-center justify-between">
<p class="text-xs text-gray-500">
<a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener" class="text-purple-600 hover:underline">Get API key </a>
<a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener" class="text-blue-600 dark:text-blue-400 hover:underline">Get API key </a>
</p>
<Show when={settings()?.openai_configured}>
<div class="flex gap-1">
@ -985,7 +985,7 @@ export const AISettings: Component = () => {
/>
<div class="flex items-center justify-between">
<p class="text-xs text-gray-500">
<a href="https://platform.deepseek.com/api_keys" target="_blank" rel="noopener" class="text-purple-600 hover:underline">Get API key </a>
<a href="https://platform.deepseek.com/api_keys" target="_blank" rel="noopener" class="text-blue-600 dark:text-blue-400 hover:underline">Get API key </a>
</p>
<Show when={settings()?.deepseek_configured}>
<div class="flex gap-1">
@ -1053,7 +1053,7 @@ export const AISettings: Component = () => {
/>
<div class="flex items-center justify-between">
<p class="text-xs text-gray-500">
<a href="https://aistudio.google.com/app/apikey" target="_blank" rel="noopener" class="text-purple-600 hover:underline">Get API key </a>
<a href="https://aistudio.google.com/app/apikey" target="_blank" rel="noopener" class="text-blue-600 dark:text-blue-400 hover:underline">Get API key </a>
</p>
<Show when={settings()?.gemini_configured}>
<div class="flex gap-1">
@ -1121,7 +1121,7 @@ export const AISettings: Component = () => {
/>
<div class="flex items-center justify-between">
<p class="text-xs text-gray-500">
<a href="https://ollama.ai" target="_blank" rel="noopener" class="text-purple-600 hover:underline">Learn about Ollama </a>
<a href="https://ollama.ai" target="_blank" rel="noopener" class="text-blue-600 dark:text-blue-400 hover:underline">Learn about Ollama </a>
<span class="text-gray-400"> · Free & local</span>
</p>
<Show when={settings()?.ollama_configured}>
@ -1426,11 +1426,11 @@ export const AISettings: Component = () => {
</Show>
{/* Actions - sticky at bottom for easy access */}
<div class="sticky bottom-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 -mx-6 px-6 py-4 mt-4 flex flex-wrap items-center justify-between gap-3">
<div class="sticky bottom-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 -mx-6 px-6 py-4 mt-6 flex flex-wrap items-center justify-between gap-3">
<Show when={settings()?.api_key_set || settings()?.oauth_connected}>
<button
type="button"
class="px-4 py-2 text-sm border border-purple-300 dark:border-purple-600 text-purple-700 dark:text-purple-300 rounded-md hover:bg-purple-50 dark:hover:bg-purple-900/30 disabled:opacity-50 disabled:cursor-not-allowed"
class="px-4 py-2 text-sm border border-blue-300 dark:border-blue-700 text-blue-700 dark:text-blue-300 rounded-md hover:bg-blue-50 dark:hover:bg-blue-900/30 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleTest}
disabled={testing() || saving() || loading()}
>
@ -1448,7 +1448,7 @@ export const AISettings: Component = () => {
</button>
<button
type="submit"
class="px-4 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed"
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={saving() || loading()}
>
{saving() ? 'Saving...' : 'Save changes'}

View file

@ -0,0 +1,200 @@
import { Component, createEffect, createSignal, For, Show } from 'solid-js';
import { AIAPI } from '@/api/ai';
import type { RemediationRecord, RemediationStats } from '@/types/aiIntelligence';
const DEFAULT_UPGRADE_URL = 'https://pulsemonitor.app/pro';
export const AIImpactTimelinePanel: Component<{ hours?: number; showWhenEmpty?: boolean }> = (props) => {
const [remediations, setRemediations] = createSignal<RemediationRecord[]>([]);
const [stats, setStats] = createSignal<RemediationStats | null>(null);
const [loading, setLoading] = createSignal(false);
const [locked, setLocked] = createSignal(false);
const [upgradeUrl, setUpgradeUrl] = createSignal(DEFAULT_UPGRADE_URL);
const [error, setError] = createSignal('');
const showWhenEmpty = () => Boolean(props.showWhenEmpty);
const hours = () => props.hours ?? 168;
const loadData = async () => {
setLoading(true);
setError('');
try {
const response = await AIAPI.getRemediations({ hours: hours(), limit: 6 });
setLocked(Boolean(response.license_required));
setUpgradeUrl(response.upgrade_url || DEFAULT_UPGRADE_URL);
setStats(response.stats || null);
setRemediations(response.remediations || []);
} catch (e) {
console.error('Failed to load AI impact timeline:', e);
setError('Failed to load AI impact timeline.');
} finally {
setLoading(false);
}
};
createEffect(() => {
void loadData();
});
const shouldShow = () => showWhenEmpty() || loading() || remediations().length > 0 || (stats()?.total || 0) > 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 outcomeBadge = (outcome: string) => {
const styles: Record<string, string> = {
resolved: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
partial: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
failed: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
};
return styles[outcome] || 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300';
};
const truncateText = (text: string, limit: number) => {
if (text.length <= limit) return text;
return `${text.slice(0, limit - 3)}...`;
};
const statValue = (key: keyof RemediationStats) => stats()?.[key] ?? 0;
return (
<Show when={shouldShow()}>
<div class="bg-gradient-to-r from-emerald-50 to-sky-50 dark:from-emerald-900/20 dark:to-sky-900/20 border border-emerald-200 dark:border-emerald-700 rounded-lg overflow-hidden">
<div class="px-4 py-3 flex items-center justify-between border-b border-emerald-200/70 dark:border-emerald-700/70">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span class="font-medium text-emerald-900 dark:text-emerald-100">Pulse AI Impact</span>
<Show when={locked()}>
<span class="px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 rounded-full">
Locked
</span>
</Show>
</div>
<span class="text-xs text-emerald-600 dark:text-emerald-300">{Math.round(hours() / 24)} days</span>
</div>
<div class="px-4 pb-4 space-y-3">
<Show when={loading()}>
<div class="text-sm text-emerald-700 dark:text-emerald-200 flex items-center gap-2">
<span class="h-4 w-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
Loading impact...
</div>
</Show>
<Show when={error() && !loading()}>
<div class="text-sm text-red-600 dark:text-red-400">
{error()}
</div>
</Show>
<Show when={stats() && !loading()}>
<div class="grid grid-cols-2 gap-2 text-xs">
<div class="rounded-lg bg-white/70 dark:bg-gray-900/40 border border-emerald-100 dark:border-emerald-800 px-2 py-1">
<span class="text-emerald-600 dark:text-emerald-300">Resolved</span>
<span class="ml-1 font-semibold text-emerald-900 dark:text-emerald-100">{statValue('resolved')}</span>
</div>
<div class="rounded-lg bg-white/70 dark:bg-gray-900/40 border border-emerald-100 dark:border-emerald-800 px-2 py-1">
<span class="text-emerald-600 dark:text-emerald-300">Auto-fix</span>
<span class="ml-1 font-semibold text-emerald-900 dark:text-emerald-100">{statValue('automatic')}</span>
</div>
<div class="rounded-lg bg-white/70 dark:bg-gray-900/40 border border-emerald-100 dark:border-emerald-800 px-2 py-1">
<span class="text-emerald-600 dark:text-emerald-300">Partial</span>
<span class="ml-1 font-semibold text-emerald-900 dark:text-emerald-100">{statValue('partial')}</span>
</div>
<div class="rounded-lg bg-white/70 dark:bg-gray-900/40 border border-emerald-100 dark:border-emerald-800 px-2 py-1">
<span class="text-emerald-600 dark:text-emerald-300">Failed</span>
<span class="ml-1 font-semibold text-emerald-900 dark:text-emerald-100">{statValue('failed')}</span>
</div>
</div>
</Show>
<Show when={locked() && !loading()}>
<div class="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-3 text-sm text-amber-800 dark:text-amber-200">
<div class="flex items-center justify-between gap-3">
<div>
<p class="font-medium">Pulse Pro required</p>
<p class="text-xs text-amber-700 dark:text-amber-300 mt-1">
Pulse AI has handled {statValue('resolved')} remediations in the last {Math.round(hours() / 24)} days. Upgrade to view the receipts.
</p>
</div>
<a
class="text-xs font-medium text-amber-800 dark:text-amber-200 underline"
href={upgradeUrl()}
target="_blank"
rel="noreferrer"
>
Upgrade
</a>
</div>
</div>
</Show>
<Show when={!locked() && !loading()}>
<Show
when={remediations().length > 0}
fallback={
<p class="text-sm text-emerald-700 dark:text-emerald-200 text-center py-2">
No remediations logged yet. Pulse AI will highlight fixes here as they happen.
</p>
}
>
<div class="space-y-2">
<For each={remediations()}>
{(rec) => (
<div class="bg-white/70 dark:bg-gray-900/40 border border-emerald-100 dark:border-emerald-800 rounded-lg p-3">
<div class="flex items-start justify-between gap-2">
<div class="flex-1">
<div class="flex items-center gap-2 flex-wrap">
<span class={`px-2 py-0.5 text-[10px] font-semibold rounded-full ${outcomeBadge(rec.outcome)}`}>
{rec.outcome}
</span>
<Show when={rec.automatic}>
<span class="px-2 py-0.5 text-[10px] font-semibold rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
Auto-Fix
</span>
</Show>
<span class="text-xs font-medium text-emerald-900 dark:text-emerald-100">
{rec.action}
</span>
</div>
<p class="text-xs text-emerald-700 dark:text-emerald-200 mt-1">
{rec.problem}
</p>
<Show when={rec.note}>
<p class="text-[11px] text-emerald-600 dark:text-emerald-300 mt-1">
{rec.note}
</p>
</Show>
<Show when={rec.output}>
<p class="text-[11px] text-emerald-600 dark:text-emerald-300 mt-1">
Evidence: {truncateText(rec.output || '', 140)}
</p>
</Show>
</div>
<span class="text-[11px] text-emerald-600 dark:text-emerald-300 whitespace-nowrap">
{formatRelativeTime(rec.timestamp)}
</span>
</div>
</div>
)}
</For>
</div>
</Show>
</Show>
</div>
</div>
</Show>
);
};

View file

@ -6,23 +6,42 @@ import type { FailurePrediction, ResourceCorrelation } from '@/types/aiIntellige
* AIInsightsPanel displays AI-learned predictions and correlations
* Shows failure predictions with confidence levels and resource dependencies
*/
export const AIInsightsPanel: Component<{ resourceId?: string }> = (props) => {
export const AIInsightsPanel: Component<{ resourceId?: string; showWhenEmpty?: boolean }> = (props) => {
const [predictions, setPredictions] = createSignal<FailurePrediction[]>([]);
const [correlations, setCorrelations] = createSignal<ResourceCorrelation[]>([]);
const [loading, setLoading] = createSignal(false);
const [expanded, setExpanded] = createSignal(false);
const [locked, setLocked] = createSignal(false);
const [lockedCount, setLockedCount] = createSignal(0);
const [upgradeUrl, setUpgradeUrl] = createSignal('https://pulsemonitor.app/pro');
const [error, setError] = createSignal('');
const showWhenEmpty = () => Boolean(props.showWhenEmpty);
const loadData = async () => {
setLoading(true);
setError('');
try {
const [predResp, corrResp] = await Promise.all([
AIAPI.getPredictions(props.resourceId),
AIAPI.getCorrelations(props.resourceId),
]);
setPredictions(predResp.predictions || []);
setCorrelations(corrResp.correlations || []);
const licenseLocked = Boolean(predResp.license_required || corrResp.license_required);
setLocked(licenseLocked);
setUpgradeUrl(predResp.upgrade_url || corrResp.upgrade_url || 'https://pulsemonitor.app/pro');
if (licenseLocked) {
const predCount = predResp.count || 0;
const corrCount = corrResp.count || 0;
setLockedCount(predCount + corrCount);
setPredictions([]);
setCorrelations([]);
} else {
setLockedCount(0);
setPredictions(predResp.predictions || []);
setCorrelations(corrResp.correlations || []);
}
} catch (e) {
console.error('Failed to load AI insights:', e);
setError('Failed to load AI insights.');
} finally {
setLoading(false);
}
@ -33,6 +52,8 @@ export const AIInsightsPanel: Component<{ resourceId?: string }> = (props) => {
});
const totalInsights = () => predictions().length + correlations().length;
const displayedCount = () => (locked() ? lockedCount() : totalInsights());
const shouldShow = () => showWhenEmpty() || loading() || displayedCount() > 0;
// Format days until in a human-readable way
const formatDaysUntil = (days: number) => {
@ -61,11 +82,23 @@ export const AIInsightsPanel: Component<{ resourceId?: string }> = (props) => {
unresponsive: 'Unresponsive',
backup_failed: 'Backup Failure',
};
return names[eventType] || eventType;
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(' ');
};
return (
<Show when={totalInsights() > 0 || loading()}>
<Show when={shouldShow()}>
<div class="bg-gradient-to-r from-purple-50 to-indigo-50 dark:from-purple-900/20 dark:to-indigo-900/20 border border-purple-200 dark:border-purple-700 rounded-lg overflow-hidden">
{/* Header */}
<button
@ -80,9 +113,14 @@ export const AIInsightsPanel: Component<{ resourceId?: string }> = (props) => {
<span class="font-medium text-purple-900 dark:text-purple-100">
AI Insights
</span>
<Show when={totalInsights() > 0}>
<Show when={displayedCount() > 0}>
<span class="px-2 py-0.5 text-xs font-medium bg-purple-200 dark:bg-purple-700 text-purple-800 dark:text-purple-200 rounded-full">
{totalInsights()}
{displayedCount()}
</span>
</Show>
<Show when={locked()}>
<span class="px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 rounded-full">
Locked
</span>
</Show>
</div>
@ -107,8 +145,35 @@ export const AIInsightsPanel: Component<{ resourceId?: string }> = (props) => {
</div>
</Show>
<Show when={error() && !loading()}>
<div class="text-sm text-red-600 dark:text-red-400">
{error()}
</div>
</Show>
<Show when={locked() && !loading()}>
<div class="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-3 text-sm text-amber-800 dark:text-amber-200">
<div class="flex items-center justify-between gap-3">
<div>
<p class="font-medium">Pulse Pro required</p>
<p class="text-xs text-amber-700 dark:text-amber-300 mt-1">
Predictive intelligence is available with Pulse Pro. {lockedCount() || 0} insights are ready to review.
</p>
</div>
<a
class="text-xs font-medium text-amber-800 dark:text-amber-200 underline"
href={upgradeUrl()}
target="_blank"
rel="noreferrer"
>
Upgrade
</a>
</div>
</div>
</Show>
{/* Predictions */}
<Show when={predictions().length > 0}>
<Show when={!locked() && predictions().length > 0}>
<div>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -146,7 +211,7 @@ export const AIInsightsPanel: Component<{ resourceId?: string }> = (props) => {
</Show>
{/* Correlations */}
<Show when={correlations().length > 0}>
<Show when={!locked() && correlations().length > 0}>
<div>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -184,7 +249,7 @@ export const AIInsightsPanel: Component<{ resourceId?: string }> = (props) => {
</Show>
{/* Empty state */}
<Show when={!loading() && totalInsights() === 0}>
<Show when={!loading() && !locked() && totalInsights() === 0}>
<p class="text-sm text-gray-500 dark:text-gray-400 text-center py-2">
No predictions or correlations detected yet. The AI will learn patterns over time.
</p>

View file

@ -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<FailurePrediction[]>([]);
const [correlations, setCorrelations] = createSignal<ResourceCorrelation[]>([]);
const [remediations, setRemediations] = createSignal<RemediationRecord[]>([]);
const [remediationStats, setRemediationStats] = createSignal<RemediationStats | null>(null);
const [changes, setChanges] = createSignal<InfrastructureChange[]>([]);
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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 (
<Show when={shouldShow()}>
<div class="bg-white dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden shadow-sm">
{/* Header */}
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-purple-50 via-emerald-50 to-teal-50 dark:from-purple-900/20 dark:via-emerald-900/20 dark:to-teal-900/20">
<div class="flex items-center justify-between flex-wrap gap-2">
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5">
<svg class="w-5 h-5 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
<span class="font-semibold text-gray-900 dark:text-gray-100">AI Intelligence Summary</span>
</div>
<Show when={totalItems() > 0}>
<span class="px-2 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-full">
{totalItems()} items
</span>
</Show>
<Show when={anyLocked()}>
<span class="px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 rounded-full">
Some Locked
</span>
</Show>
</div>
{/* Quick Stats */}
<Show when={statsDisplay()}>
<div class="flex items-center gap-3 text-xs">
<div class="flex items-center gap-1">
<span class="text-green-600 dark:text-green-400"></span>
<span class="text-gray-600 dark:text-gray-400">{statsDisplay()!.resolved} resolved</span>
</div>
<div class="flex items-center gap-1">
<span class="text-blue-600 dark:text-blue-400"></span>
<span class="text-gray-600 dark:text-gray-400">{statsDisplay()!.automatic} auto-fix</span>
</div>
</div>
</Show>
</div>
</div>
{/* Loading */}
<Show when={loading()}>
<div class="px-4 py-6 text-center">
<div class="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<span class="h-4 w-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
Loading AI intelligence...
</div>
</div>
</Show>
{/* Error */}
<Show when={error() && !loading()}>
<div class="px-4 py-4">
<div class="text-sm text-red-600 dark:text-red-400">{error()}</div>
</div>
</Show>
{/* Locked notice (if all are locked) */}
<Show when={insightsLocked() && impactLocked() && memoryLocked() && !loading()}>
<div class="px-4 py-4">
<div class="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-3 text-sm text-amber-800 dark:text-amber-200">
<div class="flex items-center justify-between gap-3">
<div>
<p class="font-medium">Pulse Pro required</p>
<p class="text-xs text-amber-700 dark:text-amber-300 mt-1">
Full AI intelligence features including predictions, impact tracking, and operational memory require Pulse Pro.
</p>
</div>
<a
class="text-xs font-medium text-amber-800 dark:text-amber-200 underline whitespace-nowrap"
href={upgradeUrl()}
target="_blank"
rel="noreferrer"
>
Upgrade
</a>
</div>
</div>
</div>
</Show>
{/* Table */}
<Show when={!loading() && (totalItems() > 0 || !anyLocked())}>
<Show
when={totalItems() > 0}
fallback={
<div class="px-4 py-8 text-center">
<div class="inline-flex flex-col items-center gap-2">
<svg class="w-10 h-10 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
<p class="text-sm text-gray-500 dark:text-gray-400">
No AI insights yet. The AI will learn patterns and surface intelligence over time.
</p>
</div>
</div>
}
>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="bg-gray-50 dark:bg-gray-800/80 text-gray-600 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
<th class="px-4 py-2 text-left text-xs font-medium uppercase tracking-wider w-24">Type</th>
<th class="px-4 py-2 text-left text-xs font-medium uppercase tracking-wider">Details</th>
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wider w-24">When</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700/50">
<For each={unifiedRows()}>
{(row) => (
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/40 transition-colors">
<td class="px-4 py-2.5">
<span class={`inline-flex px-2 py-0.5 text-[10px] font-semibold rounded-full whitespace-nowrap ${row.typeBadgeClass}`}>
{row.typeBadge}
</span>
</td>
<td class="px-4 py-2.5">
<div class="flex flex-col gap-0.5">
<span class={`font-medium text-gray-800 dark:text-gray-200 ${row.badgeClass}`}>
{row.title}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400 line-clamp-1">
{row.subtitle}
</span>
</div>
<Show when={row.confidence !== undefined}>
<span class="text-[10px] text-gray-400 dark:text-gray-500">
{Math.round(row.confidence! * 100)}% confidence
</span>
</Show>
</td>
<td class="px-4 py-2.5 text-right">
<span class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
{row.timestamp}
</span>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</Show>
</Show>
{/* Section locked notices (partial lock) */}
<Show when={!loading() && anyLocked() && !(insightsLocked() && impactLocked() && memoryLocked())}>
<div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700 bg-amber-50/50 dark:bg-amber-900/10">
<div class="flex flex-wrap items-center gap-4 text-xs">
<Show when={insightsLocked()}>
<div class="flex items-center gap-1 text-amber-700 dark:text-amber-300">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15V11m0 0V7m0 4h4m-4 0H8" />
</svg>
<span>{insightsLockedCount()} AI Insights locked</span>
</div>
</Show>
<Show when={impactLocked()}>
<div class="flex items-center gap-1 text-amber-700 dark:text-amber-300">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>Impact details locked</span>
</div>
</Show>
<Show when={memoryLocked()}>
<div class="flex items-center gap-1 text-amber-700 dark:text-amber-300">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 4H7a2 2 0 01-2-2V7a2 2 0 012-2h5l5 5v9a2 2 0 01-2 2z" />
</svg>
<span>{memoryLockedCount()} changes locked</span>
</div>
</Show>
<a
class="ml-auto text-amber-800 dark:text-amber-200 font-medium underline"
href={upgradeUrl()}
target="_blank"
rel="noreferrer"
>
Upgrade to Pulse Pro
</a>
</div>
</div>
</Show>
</div>
</Show>
);
};

View file

@ -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<InfrastructureChange[]>([]);
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<string, string> = {
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<string, string> = {
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 (
<Show when={shouldShow()}>
<div class="bg-gradient-to-r from-slate-50 to-teal-50 dark:from-slate-900/20 dark:to-teal-900/20 border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
<div class="px-4 py-3 flex items-center justify-between border-b border-slate-200/70 dark:border-slate-700/70">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-teal-600 dark:text-teal-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 4H7a2 2 0 01-2-2V7a2 2 0 012-2h5l5 5v9a2 2 0 01-2 2z" />
</svg>
<span class="font-medium text-slate-900 dark:text-slate-100">Operational Memory</span>
<Show when={lockedCount() > 0 && locked()}>
<span class="px-2 py-0.5 text-xs font-medium bg-teal-200 dark:bg-teal-700 text-teal-900 dark:text-teal-100 rounded-full">
{lockedCount()}
</span>
</Show>
<Show when={!locked() && displayedChanges().length > 0}>
<span class="px-2 py-0.5 text-xs font-medium bg-teal-200 dark:bg-teal-700 text-teal-900 dark:text-teal-100 rounded-full">
{displayedChanges().length}
</span>
</Show>
<Show when={locked()}>
<span class="px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 rounded-full">
Locked
</span>
</Show>
</div>
<span class="text-xs text-slate-500 dark:text-slate-400">{hours()}h window</span>
</div>
<div class="px-4 pb-4 space-y-3">
<Show when={loading()}>
<div class="text-sm text-slate-500 dark:text-slate-400 flex items-center gap-2">
<span class="h-4 w-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
Loading changes...
</div>
</Show>
<Show when={error() && !loading()}>
<div class="text-sm text-red-600 dark:text-red-400">
{error()}
</div>
</Show>
<Show when={locked() && !loading()}>
<div class="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-3 text-sm text-amber-800 dark:text-amber-200">
<div class="flex items-center justify-between gap-3">
<div>
<p class="font-medium">Pulse Pro required</p>
<p class="text-xs text-amber-700 dark:text-amber-300 mt-1">
Operational memory is available with Pulse Pro. {lockedCount() || 0} changes are ready to review.
</p>
</div>
<a
class="text-xs font-medium text-amber-800 dark:text-amber-200 underline"
href={upgradeUrl()}
target="_blank"
rel="noreferrer"
>
Upgrade
</a>
</div>
</div>
</Show>
<Show when={!locked() && !loading()}>
<Show
when={displayedChanges().length > 0}
fallback={
<p class="text-sm text-slate-500 dark:text-slate-400 text-center py-2">
No recent changes detected. The AI will surface configuration and state changes here.
</p>
}
>
<div class="space-y-2">
<For each={displayedChanges()}>
{(change) => (
<div class="bg-white/60 dark:bg-slate-900/40 border border-slate-100 dark:border-slate-800 rounded-lg p-3">
<div class="flex items-start justify-between gap-3">
<div class="flex-1">
<div class="flex items-center gap-2 flex-wrap">
<span class={`px-2 py-0.5 text-[10px] font-semibold rounded-full ${changeTypeBadge(change.change_type)}`}>
{changeTypeLabel(change.change_type)}
</span>
<span class="text-sm font-medium text-slate-800 dark:text-slate-200">
{change.resource_name || change.resource_id}
</span>
</div>
<p class="text-xs text-slate-600 dark:text-slate-400 mt-1">
{change.description || 'Change detected by AI Patrol.'}
</p>
</div>
<span class="text-xs text-slate-500 dark:text-slate-400 whitespace-nowrap">
{formatRelativeTime(change.detected_at)}
</span>
</div>
</div>
)}
</For>
</div>
</Show>
</Show>
</div>
</div>
</Show>
);
};

File diff suppressed because it is too large Load diff

View file

@ -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;
}

View file

@ -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)
}
})
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}
})
}
}

View file

@ -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))
}
}

View file

@ -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)
}
}

View file

@ -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()
}

View file

@ -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,

View file

@ -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")
}
}

View file

@ -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))
}
}

View file

@ -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)
}

View file

@ -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))
}
})
}

View file

@ -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))
}
}

View file

@ -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)

View file

@ -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: <stable issue key>
SEVERITY: critical|warning|watch|info
CATEGORY: performance|reliability|security|capacity|configuration
RESOURCE: <resource name or ID>
@ -1653,6 +1778,7 @@ EVIDENCE: <specific data that supports this finding>
[/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 {

View file

@ -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

View file

@ -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)
}
})
}
}

View file

@ -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)
}
}
}

702
internal/ai/runbooks.go Normal file
View file

@ -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.",
},
}

View file

@ -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)
}
})
}
}

View file

@ -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"

View file

@ -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, &copy)
}
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")
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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()

View file

@ -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())
}
}

View file

@ -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)

View file

@ -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"`

View file

@ -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)