fix: Allow Host Agent thresholds to be set to 0 to disable alerting. Related to #864
This commit is contained in:
parent
b949255868
commit
19c7cf6970
42 changed files with 8635 additions and 856 deletions
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
200
frontend-modern/src/components/shared/AIImpactTimelinePanel.tsx
Normal file
200
frontend-modern/src/components/shared/AIImpactTimelinePanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
677
frontend-modern/src/components/shared/AIOverviewTable.tsx
Normal file
677
frontend-modern/src/components/shared/AIOverviewTable.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
197
frontend-modern/src/components/shared/AIRecentChangesPanel.tsx
Normal file
197
frontend-modern/src/components/shared/AIRecentChangesPanel.tsx
Normal 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
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
164
internal/ai/cost/resolve_test.go
Normal file
164
internal/ai/cost/resolve_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
109
internal/ai/cost_persistence_test.go
Normal file
109
internal/ai/cost_persistence_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
216
internal/ai/findings_persistence_test.go
Normal file
216
internal/ai/findings_persistence_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
447
internal/ai/kubernetes_analysis.go
Normal file
447
internal/ai/kubernetes_analysis.go
Normal 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)
|
||||
}
|
||||
470
internal/ai/kubernetes_analysis_test.go
Normal file
470
internal/ai/kubernetes_analysis_test.go
Normal 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))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
702
internal/ai/runbooks.go
Normal 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.",
|
||||
},
|
||||
}
|
||||
561
internal/ai/runbooks_test.go
Normal file
561
internal/ai/runbooks_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
|
@ -724,6 +725,15 @@ type AIExecuteResponse struct {
|
|||
PendingApprovals []ai.ApprovalNeededData `json:"pending_approvals,omitempty"` // Commands that require approval (non-streaming)
|
||||
}
|
||||
|
||||
type AIKubernetesAnalyzeRequest struct {
|
||||
ClusterID string `json:"cluster_id"`
|
||||
}
|
||||
|
||||
type AIRunbookExecuteRequest struct {
|
||||
FindingID string `json:"finding_id"`
|
||||
RunbookID string `json:"runbook_id"`
|
||||
}
|
||||
|
||||
// HandleExecute executes an AI prompt (POST /api/ai/execute)
|
||||
func (h *AISettingsHandler) HandleExecute(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
|
|
@ -803,6 +813,153 @@ func (h *AISettingsHandler) HandleExecute(w http.ResponseWriter, r *http.Request
|
|||
}
|
||||
}
|
||||
|
||||
// HandleAnalyzeKubernetesCluster analyzes a Kubernetes cluster with AI (POST /api/ai/kubernetes/analyze)
|
||||
func (h *AISettingsHandler) HandleAnalyzeKubernetesCluster(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Require authentication
|
||||
if !CheckAuth(h.config, w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
if !h.aiService.IsEnabled() {
|
||||
http.Error(w, "AI is not enabled or configured", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
|
||||
var req AIKubernetesAnalyzeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.ClusterID) == "" {
|
||||
http.Error(w, "cluster_id is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 180*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := h.aiService.AnalyzeKubernetesCluster(ctx, req.ClusterID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ai.ErrKubernetesClusterNotFound):
|
||||
http.Error(w, "Kubernetes cluster not found", http.StatusNotFound)
|
||||
return
|
||||
case errors.Is(err, ai.ErrKubernetesStateUnavailable):
|
||||
http.Error(w, "Kubernetes state not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
default:
|
||||
log.Error().Err(err).Str("cluster_id", req.ClusterID).Msg("Kubernetes AI analysis failed")
|
||||
http.Error(w, "AI request failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
response := AIExecuteResponse{
|
||||
Content: resp.Content,
|
||||
Model: resp.Model,
|
||||
InputTokens: resp.InputTokens,
|
||||
OutputTokens: resp.OutputTokens,
|
||||
ToolCalls: resp.ToolCalls,
|
||||
PendingApprovals: resp.PendingApprovals,
|
||||
}
|
||||
|
||||
if err := utils.WriteJSONResponse(w, response); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to write Kubernetes AI response")
|
||||
}
|
||||
}
|
||||
|
||||
// HandleGetRunbooksForFinding returns available runbooks for a finding (GET /api/ai/runbooks)
|
||||
func (h *AISettingsHandler) HandleGetRunbooksForFinding(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if !CheckAuth(h.config, w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
findingID := strings.TrimSpace(r.URL.Query().Get("finding_id"))
|
||||
if findingID == "" {
|
||||
http.Error(w, "finding_id is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
patrol := h.aiService.GetPatrolService()
|
||||
if patrol == nil {
|
||||
http.Error(w, "AI patrol not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
runbooks, err := patrol.GetRunbooksForFinding(findingID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if err := utils.WriteJSONResponse(w, runbooks); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to write runbooks response")
|
||||
}
|
||||
}
|
||||
|
||||
// HandleExecuteRunbook executes a runbook for a finding (POST /api/ai/runbooks/execute)
|
||||
func (h *AISettingsHandler) HandleExecuteRunbook(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if !CheckAuth(h.config, w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
|
||||
var req AIRunbookExecuteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.FindingID) == "" || strings.TrimSpace(req.RunbookID) == "" {
|
||||
http.Error(w, "finding_id and runbook_id are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
patrol := h.aiService.GetPatrolService()
|
||||
if patrol == nil {
|
||||
http.Error(w, "AI patrol not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 300*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := patrol.ExecuteRunbook(ctx, req.FindingID, req.RunbookID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ai.ErrRunbookNotFound):
|
||||
http.Error(w, "Runbook not found", http.StatusNotFound)
|
||||
case errors.Is(err, ai.ErrRunbookNotApplicable):
|
||||
http.Error(w, "Runbook does not apply to finding", http.StatusBadRequest)
|
||||
default:
|
||||
log.Error().Err(err).Str("runbook_id", req.RunbookID).Msg("Runbook execution failed")
|
||||
http.Error(w, "Runbook execution failed: "+err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := utils.WriteJSONResponse(w, result); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to write runbook execution response")
|
||||
}
|
||||
}
|
||||
|
||||
// HandleExecuteStream executes an AI prompt with SSE streaming (POST /api/ai/execute/stream)
|
||||
func (h *AISettingsHandler) HandleExecuteStream(w http.ResponseWriter, r *http.Request) {
|
||||
// Handle CORS for dev mode (frontend on different port)
|
||||
|
|
@ -2081,6 +2238,97 @@ func (h *AISettingsHandler) HandlePatrolStream(w http.ResponseWriter, r *http.Re
|
|||
}
|
||||
}
|
||||
|
||||
func previewTitle(category ai.FindingCategory) string {
|
||||
switch category {
|
||||
case ai.FindingCategoryPerformance:
|
||||
return "Performance issue detected"
|
||||
case ai.FindingCategoryCapacity:
|
||||
return "Capacity issue detected"
|
||||
case ai.FindingCategoryReliability:
|
||||
return "Reliability issue detected"
|
||||
case ai.FindingCategoryBackup:
|
||||
return "Backup issue detected"
|
||||
case ai.FindingCategorySecurity:
|
||||
return "Security issue detected"
|
||||
default:
|
||||
return "Potential issue detected"
|
||||
}
|
||||
}
|
||||
|
||||
func previewResourceName(resourceType string) string {
|
||||
switch resourceType {
|
||||
case "node":
|
||||
return "Node"
|
||||
case "vm":
|
||||
return "VM"
|
||||
case "container", "oci_container":
|
||||
return "Container"
|
||||
case "docker_host":
|
||||
return "Docker host"
|
||||
case "docker_container":
|
||||
return "Docker container"
|
||||
case "storage":
|
||||
return "Storage"
|
||||
case "pbs":
|
||||
return "PBS server"
|
||||
case "pbs_datastore":
|
||||
return "PBS datastore"
|
||||
case "pbs_job":
|
||||
return "PBS job"
|
||||
case "host":
|
||||
return "Host"
|
||||
case "host_raid":
|
||||
return "RAID array"
|
||||
case "host_sensor":
|
||||
return "Host sensor"
|
||||
default:
|
||||
return "Resource"
|
||||
}
|
||||
}
|
||||
|
||||
func redactFindingsForPreview(findings []*ai.Finding) []*ai.Finding {
|
||||
redacted := make([]*ai.Finding, 0, len(findings))
|
||||
for _, finding := range findings {
|
||||
if finding == nil {
|
||||
continue
|
||||
}
|
||||
copy := *finding
|
||||
copy.Key = ""
|
||||
copy.ResourceID = ""
|
||||
copy.ResourceName = previewResourceName(finding.ResourceType)
|
||||
copy.Node = ""
|
||||
copy.Title = previewTitle(finding.Category)
|
||||
copy.Description = "Upgrade to view full analysis."
|
||||
copy.Recommendation = ""
|
||||
copy.Evidence = ""
|
||||
copy.AlertID = ""
|
||||
copy.AcknowledgedAt = nil
|
||||
copy.SnoozedUntil = nil
|
||||
copy.ResolvedAt = nil
|
||||
copy.AutoResolved = false
|
||||
copy.DismissedReason = ""
|
||||
copy.UserNote = ""
|
||||
copy.TimesRaised = 0
|
||||
copy.Suppressed = false
|
||||
copy.Source = "preview"
|
||||
redacted = append(redacted, ©)
|
||||
}
|
||||
return redacted
|
||||
}
|
||||
|
||||
func redactPatrolRunHistory(runs []ai.PatrolRunRecord) []ai.PatrolRunRecord {
|
||||
redacted := make([]ai.PatrolRunRecord, len(runs))
|
||||
for i, run := range runs {
|
||||
copy := run
|
||||
copy.AIAnalysis = ""
|
||||
copy.InputTokens = 0
|
||||
copy.OutputTokens = 0
|
||||
copy.FindingIDs = nil
|
||||
redacted[i] = copy
|
||||
}
|
||||
return redacted
|
||||
}
|
||||
|
||||
// HandleGetPatrolFindings returns all active findings (GET /api/ai/patrol/findings)
|
||||
func (h *AISettingsHandler) HandleGetPatrolFindings(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
|
|
@ -2106,6 +2354,12 @@ func (h *AISettingsHandler) HandleGetPatrolFindings(w http.ResponseWriter, r *ht
|
|||
findings = patrol.GetAllFindings()
|
||||
}
|
||||
|
||||
if !h.aiService.HasLicenseFeature(license.FeatureAIPatrol) {
|
||||
w.Header().Set("X-License-Required", "true")
|
||||
w.Header().Set("X-License-Feature", license.FeatureAIPatrol)
|
||||
findings = redactFindingsForPreview(findings)
|
||||
}
|
||||
|
||||
if err := utils.WriteJSONResponse(w, findings); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to write patrol findings response")
|
||||
}
|
||||
|
|
@ -2479,6 +2733,12 @@ func (h *AISettingsHandler) HandleGetFindingsHistory(w http.ResponseWriter, r *h
|
|||
|
||||
findings := patrol.GetFindingsHistory(startTime)
|
||||
|
||||
if !h.aiService.HasLicenseFeature(license.FeatureAIPatrol) {
|
||||
w.Header().Set("X-License-Required", "true")
|
||||
w.Header().Set("X-License-Feature", license.FeatureAIPatrol)
|
||||
findings = redactFindingsForPreview(findings)
|
||||
}
|
||||
|
||||
if err := utils.WriteJSONResponse(w, findings); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to write findings history response")
|
||||
}
|
||||
|
|
@ -2512,6 +2772,12 @@ func (h *AISettingsHandler) HandleGetPatrolRunHistory(w http.ResponseWriter, r *
|
|||
|
||||
runs := patrol.GetRunHistory(limit)
|
||||
|
||||
if !h.aiService.HasLicenseFeature(license.FeatureAIPatrol) {
|
||||
w.Header().Set("X-License-Required", "true")
|
||||
w.Header().Set("X-License-Feature", license.FeatureAIPatrol)
|
||||
runs = redactPatrolRunHistory(runs)
|
||||
}
|
||||
|
||||
if err := utils.WriteJSONResponse(w, runs); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to write patrol run history response")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue