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);
|
apiFetchJSONMock.mockResolvedValueOnce({} as any);
|
||||||
await AIAPI.getRecentChanges(12);
|
await AIAPI.getRecentChanges(12);
|
||||||
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/intelligence/changes?hours=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 () => {
|
it('sanitizes runCommand payload consistently', async () => {
|
||||||
|
|
@ -127,4 +131,3 @@ describe('AIAPI', () => {
|
||||||
).rejects.toThrow('No response body');
|
).rejects.toThrow('No response body');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import type {
|
||||||
CorrelationsResponse,
|
CorrelationsResponse,
|
||||||
ChangesResponse,
|
ChangesResponse,
|
||||||
BaselinesResponse,
|
BaselinesResponse,
|
||||||
|
RemediationsResponse,
|
||||||
} from '@/types/aiIntelligence';
|
} from '@/types/aiIntelligence';
|
||||||
|
|
||||||
export class AIAPI {
|
export class AIAPI {
|
||||||
|
|
@ -102,6 +103,22 @@ export class AIAPI {
|
||||||
return apiFetchJSON(`${this.baseUrl}/ai/intelligence/baselines${params}`) as Promise<BaselinesResponse>;
|
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
|
// Start OAuth flow for Claude Pro/Max subscription
|
||||||
// Returns the authorization URL to redirect the user to
|
// Returns the authorization URL to redirect the user to
|
||||||
|
|
@ -135,6 +152,14 @@ export class AIAPI {
|
||||||
}) as Promise<AIExecuteResponse>;
|
}) 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)
|
// Run a single command (for approved commands)
|
||||||
static async runCommand(request: {
|
static async runCommand(request: {
|
||||||
command: string;
|
command: string;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export type FindingCategory = 'performance' | 'capacity' | 'reliability' | 'back
|
||||||
|
|
||||||
export interface Finding {
|
export interface Finding {
|
||||||
id: string;
|
id: string;
|
||||||
|
key?: string;
|
||||||
severity: FindingSeverity;
|
severity: FindingSeverity;
|
||||||
category: FindingCategory;
|
category: FindingCategory;
|
||||||
resource_id: string;
|
resource_id: string;
|
||||||
|
|
@ -34,6 +35,32 @@ export interface Finding {
|
||||||
suppressed: boolean;
|
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 {
|
export interface FindingsSummary {
|
||||||
critical: number;
|
critical: number;
|
||||||
warning: number;
|
warning: number;
|
||||||
|
|
@ -79,6 +106,7 @@ export interface PatrolRunRecord {
|
||||||
new_findings: number;
|
new_findings: number;
|
||||||
existing_findings: number;
|
existing_findings: number;
|
||||||
resolved_findings: number;
|
resolved_findings: number;
|
||||||
|
auto_fix_count?: number;
|
||||||
findings_summary: string;
|
findings_summary: string;
|
||||||
finding_ids: string[];
|
finding_ids: string[];
|
||||||
error_count: number;
|
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)
|
* Dismiss a finding with a reason (LLM memory feature)
|
||||||
* The LLM will be told not to re-raise this issue in future patrols.
|
* The LLM will be told not to re-raise this issue in future patrols.
|
||||||
|
|
|
||||||
|
|
@ -224,10 +224,10 @@ export const AICostDashboard: Component = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding="none" class="overflow-hidden border border-gray-200 dark:border-gray-700" border={false}>
|
<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="flex items-center gap-3">
|
||||||
<div class="p-2 bg-emerald-100 dark:bg-emerald-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-emerald-600 dark:text-emerald-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import type { Component } from 'solid-js';
|
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 { usePersistentSignal } from '@/hooks/usePersistentSignal';
|
||||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||||
import { useColumnVisibility, type ColumnDef } from '@/hooks/useColumnVisibility';
|
import { useColumnVisibility, type ColumnDef } from '@/hooks/useColumnVisibility';
|
||||||
|
|
@ -9,6 +11,7 @@ import type {
|
||||||
KubernetesNode,
|
KubernetesNode,
|
||||||
KubernetesPod,
|
KubernetesPod,
|
||||||
} from '@/types/api';
|
} from '@/types/api';
|
||||||
|
import type { AISettings } from '@/types/ai';
|
||||||
import { Card } from '@/components/shared/Card';
|
import { Card } from '@/components/shared/Card';
|
||||||
import { EmptyState } from '@/components/shared/EmptyState';
|
import { EmptyState } from '@/components/shared/EmptyState';
|
||||||
import { ScrollableTable } from '@/components/shared/ScrollableTable';
|
import { ScrollableTable } from '@/components/shared/ScrollableTable';
|
||||||
|
|
@ -148,6 +151,15 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
|
||||||
const [statusFilter, setStatusFilter] = createSignal<StatusFilter>('all');
|
const [statusFilter, setStatusFilter] = createSignal<StatusFilter>('all');
|
||||||
const [showHidden, setShowHidden] = createSignal(false);
|
const [showHidden, setShowHidden] = createSignal(false);
|
||||||
const [namespaceFilter, setNamespaceFilter] = createSignal<string>('all');
|
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
|
// Column visibility for pods table
|
||||||
const podColumns = useColumnVisibility('k8s-pod-columns', POD_COLUMNS);
|
const podColumns = useColumnVisibility('k8s-pod-columns', POD_COLUMNS);
|
||||||
|
|
@ -199,6 +211,100 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
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
|
// Get all unique namespaces for the filter dropdown
|
||||||
const allNamespaces = createMemo(() => {
|
const allNamespaces = createMemo(() => {
|
||||||
const namespaces = new Set<string>();
|
const namespaces = new Set<string>();
|
||||||
|
|
@ -445,6 +551,103 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="space-y-4">
|
<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 */}
|
{/* Filter Bar */}
|
||||||
<Card padding="sm">
|
<Card padding="sm">
|
||||||
<div class="flex flex-col gap-3">
|
<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"
|
class="overflow-hidden border border-gray-200 dark:border-gray-700"
|
||||||
border={false}
|
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="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
|
<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"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
|
|
@ -598,7 +598,7 @@ export const AISettings: Component = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="p-5 space-y-4" onSubmit={handleSave}>
|
<form class="p-6 space-y-6" onSubmit={handleSave}>
|
||||||
<Show when={loading()}>
|
<Show when={loading()}>
|
||||||
<div class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
|
<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" />
|
<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>
|
||||||
|
|
||||||
<Show when={!loading()}>
|
<Show when={!loading()}>
|
||||||
<div class="space-y-4">
|
<div class="space-y-6">
|
||||||
{/* Default Model Selection - Always visible */}
|
{/* Default Model Selection - Always visible */}
|
||||||
<div class={formField}>
|
<div class={formField}>
|
||||||
<div class="flex items-center justify-between mb-1">
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
|
@ -619,7 +619,7 @@ export const AISettings: Component = () => {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={loadModels}
|
onClick={loadModels}
|
||||||
disabled={modelsLoading()}
|
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"
|
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">
|
<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>
|
</svg>
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Advanced Model Selection</span>
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Advanced Model Selection</span>
|
||||||
<Show when={form.chatModel || form.patrolModel}>
|
<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>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<svg class={`w-4 h-4 text-gray-500 transition-transform ${showAdvancedModels() ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* AI Provider Configuration - Configure API keys for all providers */}
|
{/* 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">
|
<div class="mb-3">
|
||||||
<h4 class="font-medium text-gray-900 dark:text-white flex items-center gap-2">
|
<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" />
|
<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>
|
</svg>
|
||||||
AI Provider Configuration
|
AI Provider Configuration
|
||||||
|
|
@ -841,7 +841,7 @@ export const AISettings: Component = () => {
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<p class="text-xs text-gray-500">
|
<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>
|
</p>
|
||||||
<Show when={settings()?.anthropic_configured}>
|
<Show when={settings()?.anthropic_configured}>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
|
|
@ -917,7 +917,7 @@ export const AISettings: Component = () => {
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<p class="text-xs text-gray-500">
|
<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>
|
</p>
|
||||||
<Show when={settings()?.openai_configured}>
|
<Show when={settings()?.openai_configured}>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
|
|
@ -985,7 +985,7 @@ export const AISettings: Component = () => {
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<p class="text-xs text-gray-500">
|
<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>
|
</p>
|
||||||
<Show when={settings()?.deepseek_configured}>
|
<Show when={settings()?.deepseek_configured}>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
|
|
@ -1053,7 +1053,7 @@ export const AISettings: Component = () => {
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<p class="text-xs text-gray-500">
|
<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>
|
</p>
|
||||||
<Show when={settings()?.gemini_configured}>
|
<Show when={settings()?.gemini_configured}>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
|
|
@ -1121,7 +1121,7 @@ export const AISettings: Component = () => {
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<p class="text-xs text-gray-500">
|
<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>
|
<span class="text-gray-400"> · Free & local</span>
|
||||||
</p>
|
</p>
|
||||||
<Show when={settings()?.ollama_configured}>
|
<Show when={settings()?.ollama_configured}>
|
||||||
|
|
@ -1426,11 +1426,11 @@ export const AISettings: Component = () => {
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Actions - sticky at bottom for easy access */}
|
{/* 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}>
|
<Show when={settings()?.api_key_set || settings()?.oauth_connected}>
|
||||||
<button
|
<button
|
||||||
type="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}
|
onClick={handleTest}
|
||||||
disabled={testing() || saving() || loading()}
|
disabled={testing() || saving() || loading()}
|
||||||
>
|
>
|
||||||
|
|
@ -1448,7 +1448,7 @@ export const AISettings: Component = () => {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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()}
|
disabled={saving() || loading()}
|
||||||
>
|
>
|
||||||
{saving() ? 'Saving...' : 'Save changes'}
|
{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
|
* AIInsightsPanel displays AI-learned predictions and correlations
|
||||||
* Shows failure predictions with confidence levels and resource dependencies
|
* 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 [predictions, setPredictions] = createSignal<FailurePrediction[]>([]);
|
||||||
const [correlations, setCorrelations] = createSignal<ResourceCorrelation[]>([]);
|
const [correlations, setCorrelations] = createSignal<ResourceCorrelation[]>([]);
|
||||||
const [loading, setLoading] = createSignal(false);
|
const [loading, setLoading] = createSignal(false);
|
||||||
const [expanded, setExpanded] = 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 () => {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
try {
|
try {
|
||||||
const [predResp, corrResp] = await Promise.all([
|
const [predResp, corrResp] = await Promise.all([
|
||||||
AIAPI.getPredictions(props.resourceId),
|
AIAPI.getPredictions(props.resourceId),
|
||||||
AIAPI.getCorrelations(props.resourceId),
|
AIAPI.getCorrelations(props.resourceId),
|
||||||
]);
|
]);
|
||||||
setPredictions(predResp.predictions || []);
|
const licenseLocked = Boolean(predResp.license_required || corrResp.license_required);
|
||||||
setCorrelations(corrResp.correlations || []);
|
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) {
|
} catch (e) {
|
||||||
console.error('Failed to load AI insights:', e);
|
console.error('Failed to load AI insights:', e);
|
||||||
|
setError('Failed to load AI insights.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -33,6 +52,8 @@ export const AIInsightsPanel: Component<{ resourceId?: string }> = (props) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalInsights = () => predictions().length + correlations().length;
|
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
|
// Format days until in a human-readable way
|
||||||
const formatDaysUntil = (days: number) => {
|
const formatDaysUntil = (days: number) => {
|
||||||
|
|
@ -61,11 +82,23 @@ export const AIInsightsPanel: Component<{ resourceId?: string }> = (props) => {
|
||||||
unresponsive: 'Unresponsive',
|
unresponsive: 'Unresponsive',
|
||||||
backup_failed: 'Backup Failure',
|
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 (
|
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">
|
<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 */}
|
{/* Header */}
|
||||||
<button
|
<button
|
||||||
|
|
@ -80,9 +113,14 @@ export const AIInsightsPanel: Component<{ resourceId?: string }> = (props) => {
|
||||||
<span class="font-medium text-purple-900 dark:text-purple-100">
|
<span class="font-medium text-purple-900 dark:text-purple-100">
|
||||||
AI Insights
|
AI Insights
|
||||||
</span>
|
</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">
|
<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>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -107,8 +145,35 @@ export const AIInsightsPanel: Component<{ resourceId?: string }> = (props) => {
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</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 */}
|
{/* Predictions */}
|
||||||
<Show when={predictions().length > 0}>
|
<Show when={!locked() && predictions().length > 0}>
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-1">
|
<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">
|
<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>
|
</Show>
|
||||||
|
|
||||||
{/* Correlations */}
|
{/* Correlations */}
|
||||||
<Show when={correlations().length > 0}>
|
<Show when={!locked() && correlations().length > 0}>
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-1">
|
<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">
|
<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>
|
</Show>
|
||||||
|
|
||||||
{/* Empty state */}
|
{/* 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">
|
<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.
|
No predictions or correlations detected yet. The AI will learn patterns over time.
|
||||||
</p>
|
</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;
|
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
|
// API response types
|
||||||
export interface PatternsResponse {
|
interface LicenseGatedResponse {
|
||||||
|
license_required?: boolean;
|
||||||
|
upgrade_url?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatternsResponse extends LicenseGatedResponse {
|
||||||
patterns: FailurePattern[];
|
patterns: FailurePattern[];
|
||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PredictionsResponse {
|
export interface PredictionsResponse extends LicenseGatedResponse {
|
||||||
predictions: FailurePrediction[];
|
predictions: FailurePrediction[];
|
||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CorrelationsResponse {
|
export interface CorrelationsResponse extends LicenseGatedResponse {
|
||||||
correlations: ResourceCorrelation[];
|
correlations: ResourceCorrelation[];
|
||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChangesResponse {
|
export interface ChangesResponse extends LicenseGatedResponse {
|
||||||
changes: InfrastructureChange[];
|
changes: InfrastructureChange[];
|
||||||
count: number;
|
count: number;
|
||||||
hours: number;
|
hours: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BaselinesResponse {
|
export interface BaselinesResponse extends LicenseGatedResponse {
|
||||||
baselines: ResourceBaseline[];
|
baselines: ResourceBaseline[];
|
||||||
count: number;
|
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
|
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
|
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)
|
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
|
// Finding represents an AI-discovered insight about infrastructure
|
||||||
type Finding struct {
|
type Finding struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
Key string `json:"key,omitempty"` // Stable issue key for runbook matching
|
||||||
Severity FindingSeverity `json:"severity"`
|
Severity FindingSeverity `json:"severity"`
|
||||||
Category FindingCategory `json:"category"`
|
Category FindingCategory `json:"category"`
|
||||||
ResourceID string `json:"resource_id"`
|
ResourceID string `json:"resource_id"`
|
||||||
|
|
@ -200,7 +201,7 @@ func (s *FindingsStore) ForceSave() error {
|
||||||
s.saveTimer.Stop()
|
s.saveTimer.Stop()
|
||||||
}
|
}
|
||||||
s.savePending = false
|
s.savePending = false
|
||||||
|
|
||||||
findingsCopy := make(map[string]*Finding, len(s.findings))
|
findingsCopy := make(map[string]*Finding, len(s.findings))
|
||||||
for id, f := range s.findings {
|
for id, f := range s.findings {
|
||||||
copy := *f
|
copy := *f
|
||||||
|
|
@ -229,7 +230,7 @@ func (s *FindingsStore) Add(f *Finding) bool {
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if dismissed - only update if severity has escalated
|
// Check if dismissed - only update if severity has escalated
|
||||||
if existing.DismissedReason != "" {
|
if existing.DismissedReason != "" {
|
||||||
severityOrder := map[FindingSeverity]int{
|
severityOrder := map[FindingSeverity]int{
|
||||||
|
|
@ -251,7 +252,7 @@ func (s *FindingsStore) Add(f *Finding) bool {
|
||||||
existing.UserNote = "" // Clear note since situation changed
|
existing.UserNote = "" // Clear note since situation changed
|
||||||
existing.AcknowledgedAt = nil
|
existing.AcknowledgedAt = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update existing finding
|
// Update existing finding
|
||||||
existing.LastSeenAt = time.Now()
|
existing.LastSeenAt = time.Now()
|
||||||
existing.Description = f.Description
|
existing.Description = f.Description
|
||||||
|
|
@ -448,7 +449,7 @@ func (s *FindingsStore) isSuppressedInternal(resourceID string, category Finding
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also check manual suppression rules
|
// Also check manual suppression rules
|
||||||
for _, rule := range s.suppressionRules {
|
for _, rule := range s.suppressionRules {
|
||||||
resourceMatches := rule.ResourceID == "" || rule.ResourceID == resourceID
|
resourceMatches := rule.ResourceID == "" || rule.ResourceID == resourceID
|
||||||
|
|
@ -457,7 +458,7 @@ func (s *FindingsStore) isSuppressedInternal(resourceID string, category Finding
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -586,7 +587,7 @@ func (s *FindingsStore) Cleanup(maxAge time.Duration) int {
|
||||||
return removed
|
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 -
|
// formatted for injection into LLM prompts. This is the core of the "memory" system -
|
||||||
// it tells the LLM what not to re-raise.
|
// it tells the LLM what not to re-raise.
|
||||||
func (s *FindingsStore) GetDismissedForContext() string {
|
func (s *FindingsStore) GetDismissedForContext() string {
|
||||||
|
|
@ -594,51 +595,51 @@ func (s *FindingsStore) GetDismissedForContext() string {
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
var suppressed, dismissed, snoozed []string
|
var suppressed, dismissed, snoozed []string
|
||||||
|
|
||||||
for _, f := range s.findings {
|
for _, f := range s.findings {
|
||||||
// Skip very old findings (more than 30 days)
|
// Skip very old findings (more than 30 days)
|
||||||
if time.Since(f.LastSeenAt) > 30*24*time.Hour {
|
if time.Since(f.LastSeenAt) > 30*24*time.Hour {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect suppressed findings
|
// Collect suppressed findings
|
||||||
if f.Suppressed {
|
if f.Suppressed {
|
||||||
note := ""
|
note := ""
|
||||||
if f.UserNote != "" {
|
if f.UserNote != "" {
|
||||||
note = " - User note: " + 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))
|
fmt.Sprintf("- %s on %s: %s%s", f.Title, f.ResourceName, f.DismissedReason, note))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect dismissed/acknowledged findings
|
// Collect dismissed/acknowledged findings
|
||||||
if f.DismissedReason != "" {
|
if f.DismissedReason != "" {
|
||||||
note := ""
|
note := ""
|
||||||
if f.UserNote != "" {
|
if f.UserNote != "" {
|
||||||
note = " - User note: " + 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))
|
fmt.Sprintf("- %s on %s (%s)%s", f.Title, f.ResourceName, f.DismissedReason, note))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect snoozed findings
|
// Collect snoozed findings
|
||||||
if f.IsSnoozed() {
|
if f.IsSnoozed() {
|
||||||
snoozed = append(snoozed,
|
snoozed = append(snoozed,
|
||||||
fmt.Sprintf("- %s on %s (snoozed until %s)",
|
fmt.Sprintf("- %s on %s (snoozed until %s)",
|
||||||
f.Title, f.ResourceName, f.SnoozedUntil.Format("Jan 2")))
|
f.Title, f.ResourceName, f.SnoozedUntil.Format("Jan 2")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(suppressed) == 0 && len(dismissed) == 0 && len(snoozed) == 0 {
|
if len(suppressed) == 0 && len(dismissed) == 0 && len(snoozed) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
var result strings.Builder
|
var result strings.Builder
|
||||||
result.WriteString("\n## Previous Findings - User Feedback\n")
|
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")
|
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 {
|
if len(suppressed) > 0 {
|
||||||
result.WriteString("### Permanently Suppressed (never re-raise):\n")
|
result.WriteString("### Permanently Suppressed (never re-raise):\n")
|
||||||
for _, s := range suppressed {
|
for _, s := range suppressed {
|
||||||
|
|
@ -646,7 +647,7 @@ func (s *FindingsStore) GetDismissedForContext() string {
|
||||||
}
|
}
|
||||||
result.WriteString("\n")
|
result.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(dismissed) > 0 {
|
if len(dismissed) > 0 {
|
||||||
result.WriteString("### Dismissed by User:\n")
|
result.WriteString("### Dismissed by User:\n")
|
||||||
for _, d := range dismissed {
|
for _, d := range dismissed {
|
||||||
|
|
@ -654,14 +655,14 @@ func (s *FindingsStore) GetDismissedForContext() string {
|
||||||
}
|
}
|
||||||
result.WriteString("\n")
|
result.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(snoozed) > 0 {
|
if len(snoozed) > 0 {
|
||||||
result.WriteString("### Temporarily Snoozed:\n")
|
result.WriteString("### Temporarily Snoozed:\n")
|
||||||
for _, s := range snoozed {
|
for _, s := range snoozed {
|
||||||
result.WriteString(s + "\n")
|
result.WriteString(s + "\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.String()
|
return result.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ func (a *FindingsPersistenceAdapter) SaveFindings(findings map[string]*Finding)
|
||||||
for id, f := range findings {
|
for id, f := range findings {
|
||||||
records[id] = &config.AIFindingRecord{
|
records[id] = &config.AIFindingRecord{
|
||||||
ID: f.ID,
|
ID: f.ID,
|
||||||
|
Key: f.Key,
|
||||||
Severity: string(f.Severity),
|
Severity: string(f.Severity),
|
||||||
Category: string(f.Category),
|
Category: string(f.Category),
|
||||||
ResourceID: f.ResourceID,
|
ResourceID: f.ResourceID,
|
||||||
|
|
@ -56,6 +57,7 @@ func (a *FindingsPersistenceAdapter) LoadFindings() (map[string]*Finding, error)
|
||||||
for id, r := range data.Findings {
|
for id, r := range data.Findings {
|
||||||
findings[id] = &Finding{
|
findings[id] = &Finding{
|
||||||
ID: r.ID,
|
ID: r.ID,
|
||||||
|
Key: r.Key,
|
||||||
Severity: FindingSeverity(r.Severity),
|
Severity: FindingSeverity(r.Severity),
|
||||||
Category: FindingCategory(r.Category),
|
Category: FindingCategory(r.Category),
|
||||||
ResourceID: r.ResourceID,
|
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
|
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")
|
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"`
|
ResourceType string `json:"resource_type,omitempty"`
|
||||||
ResourceName string `json:"resource_name,omitempty"`
|
ResourceName string `json:"resource_name,omitempty"`
|
||||||
FindingID string `json:"finding_id,omitempty"` // Linked AI finding if any
|
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)
|
Action string `json:"action"` // What was done (command or action)
|
||||||
Output string `json:"output,omitempty"` // Command output if any
|
Output string `json:"output,omitempty"` // Command output if any
|
||||||
Outcome Outcome `json:"outcome"` // Did it work?
|
Outcome Outcome `json:"outcome"` // Did it work?
|
||||||
|
|
@ -221,6 +222,46 @@ func (r *RemediationLog) GetRecentRemediations(limit int, since time.Time) []Rem
|
||||||
return result
|
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
|
// FormatForContext creates AI-consumable summary of remediation history
|
||||||
func (r *RemediationLog) FormatForContext(resourceID string, limit int) string {
|
func (r *RemediationLog) FormatForContext(resourceID string, limit int) string {
|
||||||
records := r.GetForResource(resourceID, limit)
|
records := r.GetForResource(resourceID, limit)
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,10 @@ type ThresholdProvider interface {
|
||||||
// PatrolThresholds holds calculated thresholds for patrol (derived from alert thresholds)
|
// PatrolThresholds holds calculated thresholds for patrol (derived from alert thresholds)
|
||||||
type PatrolThresholds struct {
|
type PatrolThresholds struct {
|
||||||
// Node thresholds
|
// Node thresholds
|
||||||
NodeCPUWatch float64 // CPU % to flag as "watch" (typically alertThreshold - 15)
|
NodeCPUWatch float64 // CPU % to flag as "watch" (typically alertThreshold - 15)
|
||||||
NodeCPUWarning float64 // CPU % to flag as "warning" (typically alertThreshold - 5)
|
NodeCPUWarning float64 // CPU % to flag as "warning" (typically alertThreshold - 5)
|
||||||
NodeMemWatch float64
|
NodeMemWatch float64
|
||||||
NodeMemWarning float64
|
NodeMemWarning float64
|
||||||
// Guest thresholds (VMs/containers)
|
// Guest thresholds (VMs/containers)
|
||||||
GuestMemWatch float64
|
GuestMemWatch float64
|
||||||
GuestMemWarning float64
|
GuestMemWarning float64
|
||||||
|
|
@ -53,11 +53,11 @@ type PatrolThresholds struct {
|
||||||
// DefaultPatrolThresholds returns fallback thresholds when no provider is set
|
// DefaultPatrolThresholds returns fallback thresholds when no provider is set
|
||||||
func DefaultPatrolThresholds() PatrolThresholds {
|
func DefaultPatrolThresholds() PatrolThresholds {
|
||||||
return PatrolThresholds{
|
return PatrolThresholds{
|
||||||
NodeCPUWatch: 75, NodeCPUWarning: 85,
|
NodeCPUWatch: 75, NodeCPUWarning: 85,
|
||||||
NodeMemWatch: 75, NodeMemWarning: 85,
|
NodeMemWatch: 75, NodeMemWarning: 85,
|
||||||
GuestMemWatch: 80, GuestMemWarning: 88,
|
GuestMemWatch: 80, GuestMemWarning: 88,
|
||||||
GuestDiskWatch: 75, GuestDiskWarn: 85, GuestDiskCrit: 92,
|
GuestDiskWatch: 75, GuestDiskWarn: 85, GuestDiskCrit: 92,
|
||||||
StorageWatch: 70, StorageWarning: 80, StorageCritical: 90,
|
StorageWatch: 70, StorageWarning: 80, StorageCritical: 90,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,14 +143,14 @@ func (c PatrolConfig) GetInterval() time.Duration {
|
||||||
// DefaultPatrolConfig returns sensible defaults
|
// DefaultPatrolConfig returns sensible defaults
|
||||||
func DefaultPatrolConfig() PatrolConfig {
|
func DefaultPatrolConfig() PatrolConfig {
|
||||||
return PatrolConfig{
|
return PatrolConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Interval: 15 * time.Minute,
|
Interval: 15 * time.Minute,
|
||||||
AnalyzeNodes: true,
|
AnalyzeNodes: true,
|
||||||
AnalyzeGuests: true,
|
AnalyzeGuests: true,
|
||||||
AnalyzeDocker: true,
|
AnalyzeDocker: true,
|
||||||
AnalyzeStorage: true,
|
AnalyzeStorage: true,
|
||||||
AnalyzePBS: true,
|
AnalyzePBS: true,
|
||||||
AnalyzeHosts: true,
|
AnalyzeHosts: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -177,18 +177,19 @@ type PatrolRunRecord struct {
|
||||||
Type string `json:"type"` // Always "patrol" now (kept for backwards compat)
|
Type string `json:"type"` // Always "patrol" now (kept for backwards compat)
|
||||||
ResourcesChecked int `json:"resources_checked"`
|
ResourcesChecked int `json:"resources_checked"`
|
||||||
// Breakdown by resource type
|
// Breakdown by resource type
|
||||||
NodesChecked int `json:"nodes_checked"`
|
NodesChecked int `json:"nodes_checked"`
|
||||||
GuestsChecked int `json:"guests_checked"`
|
GuestsChecked int `json:"guests_checked"`
|
||||||
DockerChecked int `json:"docker_checked"`
|
DockerChecked int `json:"docker_checked"`
|
||||||
StorageChecked int `json:"storage_checked"`
|
StorageChecked int `json:"storage_checked"`
|
||||||
HostsChecked int `json:"hosts_checked"`
|
HostsChecked int `json:"hosts_checked"`
|
||||||
PBSChecked int `json:"pbs_checked"`
|
PBSChecked int `json:"pbs_checked"`
|
||||||
// Findings from this run
|
// Findings from this run
|
||||||
NewFindings int `json:"new_findings"`
|
NewFindings int `json:"new_findings"`
|
||||||
ExistingFindings int `json:"existing_findings"`
|
ExistingFindings int `json:"existing_findings"`
|
||||||
ResolvedFindings int `json:"resolved_findings"`
|
ResolvedFindings int `json:"resolved_findings"`
|
||||||
FindingsSummary string `json:"findings_summary"` // e.g., "All healthy" or "2 warnings, 1 critical"
|
AutoFixCount int `json:"auto_fix_count,omitempty"`
|
||||||
FindingIDs []string `json:"finding_ids"` // IDs of findings from this run
|
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"`
|
ErrorCount int `json:"error_count"`
|
||||||
Status string `json:"status"` // "healthy", "issues_found", "error"
|
Status string `json:"status"` // "healthy", "issues_found", "error"
|
||||||
// AI Analysis details
|
// AI Analysis details
|
||||||
|
|
@ -209,13 +210,13 @@ type PatrolService struct {
|
||||||
thresholdProvider ThresholdProvider
|
thresholdProvider ThresholdProvider
|
||||||
config PatrolConfig
|
config PatrolConfig
|
||||||
findings *FindingsStore
|
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
|
metricsHistory MetricsHistoryProvider // For trend analysis and predictions
|
||||||
baselineStore *baseline.Store // For anomaly detection via learned baselines
|
baselineStore *baseline.Store // For anomaly detection via learned baselines
|
||||||
changeDetector *ChangeDetector // For tracking infrastructure changes
|
changeDetector *ChangeDetector // For tracking infrastructure changes
|
||||||
remediationLog *RemediationLog // For tracking remediation actions
|
remediationLog *RemediationLog // For tracking remediation actions
|
||||||
patternDetector *PatternDetector // For failure prediction from historical patterns
|
patternDetector *PatternDetector // For failure prediction from historical patterns
|
||||||
correlationDetector *CorrelationDetector // For multi-resource correlation
|
correlationDetector *CorrelationDetector // For multi-resource correlation
|
||||||
|
|
||||||
// Cached thresholds (recalculated when thresholdProvider changes)
|
// Cached thresholds (recalculated when thresholdProvider changes)
|
||||||
thresholds PatrolThresholds
|
thresholds PatrolThresholds
|
||||||
|
|
@ -241,10 +242,10 @@ type PatrolService struct {
|
||||||
|
|
||||||
// PatrolStreamEvent represents a streaming update from the patrol
|
// PatrolStreamEvent represents a streaming update from the patrol
|
||||||
type PatrolStreamEvent struct {
|
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"`
|
Content string `json:"content,omitempty"`
|
||||||
Phase string `json:"phase,omitempty"` // Current phase description
|
Phase string `json:"phase,omitempty"` // Current phase description
|
||||||
Tokens int `json:"tokens,omitempty"` // Token count so far
|
Tokens int `json:"tokens,omitempty"` // Token count so far
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPatrolService creates a new patrol service
|
// NewPatrolService creates a new patrol service
|
||||||
|
|
@ -666,6 +667,7 @@ func (p *PatrolService) runPatrol(ctx context.Context) {
|
||||||
errors int
|
errors int
|
||||||
aiAnalysis *AIAnalysisResult // Stores the AI's analysis for the run record
|
aiAnalysis *AIAnalysisResult // Stores the AI's analysis for the run record
|
||||||
}
|
}
|
||||||
|
var newFindings []*Finding
|
||||||
|
|
||||||
// Get current state
|
// Get current state
|
||||||
if p.stateProvider == nil {
|
if p.stateProvider == nil {
|
||||||
|
|
@ -680,6 +682,7 @@ func (p *PatrolService) runPatrol(ctx context.Context) {
|
||||||
isNew := p.findings.Add(f)
|
isNew := p.findings.Add(f)
|
||||||
if isNew {
|
if isNew {
|
||||||
runStats.newFindings++
|
runStats.newFindings++
|
||||||
|
newFindings = append(newFindings, f)
|
||||||
log.Info().
|
log.Info().
|
||||||
Str("finding_id", f.ID).
|
Str("finding_id", f.ID).
|
||||||
Str("severity", string(f.Severity)).
|
Str("severity", string(f.Severity)).
|
||||||
|
|
@ -700,15 +703,18 @@ func (p *PatrolService) runPatrol(ctx context.Context) {
|
||||||
runStats.storageChecked = len(state.Storage)
|
runStats.storageChecked = len(state.Storage)
|
||||||
runStats.pbsChecked = len(state.PBSInstances)
|
runStats.pbsChecked = len(state.PBSInstances)
|
||||||
runStats.hostsChecked = len(state.Hosts)
|
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
|
runStats.dockerChecked + runStats.storageChecked + runStats.pbsChecked + runStats.hostsChecked
|
||||||
|
|
||||||
|
hasPatrolFeature := p.aiService == nil || p.aiService.HasLicenseFeature(FeatureAIPatrol)
|
||||||
// Check license before running LLM analysis (Pro feature)
|
// Check license before running LLM analysis (Pro feature)
|
||||||
if p.aiService != nil && !p.aiService.HasLicenseFeature(FeatureAIPatrol) {
|
if !hasPatrolFeature {
|
||||||
log.Debug().Msg("AI Patrol: Skipping LLM analysis - requires Pulse Pro license")
|
log.Debug().Msg("AI Patrol: Running heuristic analysis only - requires Pulse Pro license for LLM analysis")
|
||||||
// No LLM analysis for free users - patrol just tracks resource counts
|
for _, f := range p.runHeuristicAnalysis(state) {
|
||||||
|
trackFinding(f)
|
||||||
|
}
|
||||||
} else {
|
} 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
|
// The LLM analyzes the infrastructure and identifies issues
|
||||||
aiResult, aiErr := p.runAIAnalysis(ctx, state)
|
aiResult, aiErr := p.runAIAnalysis(ctx, state)
|
||||||
if aiErr != nil {
|
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
|
// Auto-fix with runbooks when enabled (Pro only)
|
||||||
// Only do this if we have a license - otherwise preserve existing findings
|
var runbookResolved int
|
||||||
var resolvedCount int
|
autoFixEnabled := false
|
||||||
if p.aiService != nil && p.aiService.HasLicenseFeature(FeatureAIPatrol) {
|
if p.aiService != nil {
|
||||||
resolvedCount = p.autoResolveStaleFindings(start)
|
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)
|
cleaned := p.findings.Cleanup(24 * time.Hour)
|
||||||
if cleaned > 0 {
|
if cleaned > 0 {
|
||||||
log.Debug().Int("cleaned", cleaned).Msg("AI Patrol: Cleaned up old findings")
|
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)
|
duration := time.Since(start)
|
||||||
completedAt := time.Now()
|
completedAt := time.Now()
|
||||||
|
|
@ -785,6 +808,7 @@ func (p *PatrolService) runPatrol(ctx context.Context) {
|
||||||
NewFindings: runStats.newFindings,
|
NewFindings: runStats.newFindings,
|
||||||
ExistingFindings: runStats.existingFindings,
|
ExistingFindings: runStats.existingFindings,
|
||||||
ResolvedFindings: resolvedCount,
|
ResolvedFindings: resolvedCount,
|
||||||
|
AutoFixCount: runbookResolved,
|
||||||
FindingsSummary: findingsSummaryStr,
|
FindingsSummary: findingsSummaryStr,
|
||||||
FindingIDs: runStats.findingIDs,
|
FindingIDs: runStats.findingIDs,
|
||||||
ErrorCount: runStats.errors,
|
ErrorCount: runStats.errors,
|
||||||
|
|
@ -838,7 +862,7 @@ func joinParts(parts []string) string {
|
||||||
if len(parts) == 2 {
|
if len(parts) == 2 {
|
||||||
return parts[0] + " and " + parts[1]
|
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]),
|
fmt.Sprintf("%s", parts[0:len(parts)-1]),
|
||||||
parts[len(parts)-1])
|
parts[len(parts)-1])
|
||||||
}
|
}
|
||||||
|
|
@ -849,6 +873,77 @@ func generateFindingID(resourceID, category, issue string) string {
|
||||||
return fmt.Sprintf("%x", hash[:8])
|
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
|
// analyzeNode checks a Proxmox node for issues
|
||||||
func (p *PatrolService) analyzeNode(node models.Node) []*Finding {
|
func (p *PatrolService) analyzeNode(node models.Node) []*Finding {
|
||||||
var findings []*Finding
|
var findings []*Finding
|
||||||
|
|
@ -866,6 +961,7 @@ func (p *PatrolService) analyzeNode(node models.Node) []*Finding {
|
||||||
if node.Status == "offline" || node.Status == "unknown" {
|
if node.Status == "offline" || node.Status == "unknown" {
|
||||||
findings = append(findings, &Finding{
|
findings = append(findings, &Finding{
|
||||||
ID: generateFindingID(node.ID, "reliability", "offline"),
|
ID: generateFindingID(node.ID, "reliability", "offline"),
|
||||||
|
Key: "node-offline",
|
||||||
Severity: FindingSeverityCritical,
|
Severity: FindingSeverityCritical,
|
||||||
Category: FindingCategoryReliability,
|
Category: FindingCategoryReliability,
|
||||||
ResourceID: node.ID,
|
ResourceID: node.ID,
|
||||||
|
|
@ -885,6 +981,7 @@ func (p *PatrolService) analyzeNode(node models.Node) []*Finding {
|
||||||
}
|
}
|
||||||
findings = append(findings, &Finding{
|
findings = append(findings, &Finding{
|
||||||
ID: generateFindingID(node.ID, "performance", "high-cpu"),
|
ID: generateFindingID(node.ID, "performance", "high-cpu"),
|
||||||
|
Key: "high-cpu",
|
||||||
Severity: severity,
|
Severity: severity,
|
||||||
Category: FindingCategoryPerformance,
|
Category: FindingCategoryPerformance,
|
||||||
ResourceID: node.ID,
|
ResourceID: node.ID,
|
||||||
|
|
@ -905,6 +1002,7 @@ func (p *PatrolService) analyzeNode(node models.Node) []*Finding {
|
||||||
}
|
}
|
||||||
findings = append(findings, &Finding{
|
findings = append(findings, &Finding{
|
||||||
ID: generateFindingID(node.ID, "performance", "high-memory"),
|
ID: generateFindingID(node.ID, "performance", "high-memory"),
|
||||||
|
Key: "high-memory",
|
||||||
Severity: severity,
|
Severity: severity,
|
||||||
Category: FindingCategoryPerformance,
|
Category: FindingCategoryPerformance,
|
||||||
ResourceID: node.ID,
|
ResourceID: node.ID,
|
||||||
|
|
@ -943,6 +1041,7 @@ func (p *PatrolService) analyzeGuest(id, name, guestType, node, status string,
|
||||||
}
|
}
|
||||||
findings = append(findings, &Finding{
|
findings = append(findings, &Finding{
|
||||||
ID: generateFindingID(id, "performance", "high-memory"),
|
ID: generateFindingID(id, "performance", "high-memory"),
|
||||||
|
Key: "high-memory",
|
||||||
Severity: severity,
|
Severity: severity,
|
||||||
Category: FindingCategoryPerformance,
|
Category: FindingCategoryPerformance,
|
||||||
ResourceID: id,
|
ResourceID: id,
|
||||||
|
|
@ -967,6 +1066,7 @@ func (p *PatrolService) analyzeGuest(id, name, guestType, node, status string,
|
||||||
}
|
}
|
||||||
findings = append(findings, &Finding{
|
findings = append(findings, &Finding{
|
||||||
ID: generateFindingID(id, "capacity", "high-disk"),
|
ID: generateFindingID(id, "capacity", "high-disk"),
|
||||||
|
Key: "high-disk",
|
||||||
Severity: severity,
|
Severity: severity,
|
||||||
Category: FindingCategoryCapacity,
|
Category: FindingCategoryCapacity,
|
||||||
ResourceID: id,
|
ResourceID: id,
|
||||||
|
|
@ -990,6 +1090,7 @@ func (p *PatrolService) analyzeGuest(id, name, guestType, node, status string,
|
||||||
}
|
}
|
||||||
findings = append(findings, &Finding{
|
findings = append(findings, &Finding{
|
||||||
ID: generateFindingID(id, "backup", "stale"),
|
ID: generateFindingID(id, "backup", "stale"),
|
||||||
|
Key: "backup-stale",
|
||||||
Severity: severity,
|
Severity: severity,
|
||||||
Category: FindingCategoryBackup,
|
Category: FindingCategoryBackup,
|
||||||
ResourceID: id,
|
ResourceID: id,
|
||||||
|
|
@ -1005,6 +1106,7 @@ func (p *PatrolService) analyzeGuest(id, name, guestType, node, status string,
|
||||||
} else if status == "running" && lastBackup == nil {
|
} else if status == "running" && lastBackup == nil {
|
||||||
findings = append(findings, &Finding{
|
findings = append(findings, &Finding{
|
||||||
ID: generateFindingID(id, "backup", "never"),
|
ID: generateFindingID(id, "backup", "never"),
|
||||||
|
Key: "backup-never",
|
||||||
Severity: FindingSeverityWarning,
|
Severity: FindingSeverityWarning,
|
||||||
Category: FindingCategoryBackup,
|
Category: FindingCategoryBackup,
|
||||||
ResourceID: id,
|
ResourceID: id,
|
||||||
|
|
@ -1033,6 +1135,7 @@ func (p *PatrolService) analyzeDockerHost(host models.DockerHost) []*Finding {
|
||||||
if host.Status != "online" && host.Status != "connected" {
|
if host.Status != "online" && host.Status != "connected" {
|
||||||
findings = append(findings, &Finding{
|
findings = append(findings, &Finding{
|
||||||
ID: generateFindingID(host.ID, "reliability", "offline"),
|
ID: generateFindingID(host.ID, "reliability", "offline"),
|
||||||
|
Key: "docker-host-offline",
|
||||||
Severity: FindingSeverityCritical,
|
Severity: FindingSeverityCritical,
|
||||||
Category: FindingCategoryReliability,
|
Category: FindingCategoryReliability,
|
||||||
ResourceID: host.ID,
|
ResourceID: host.ID,
|
||||||
|
|
@ -1050,6 +1153,7 @@ func (p *PatrolService) analyzeDockerHost(host models.DockerHost) []*Finding {
|
||||||
if c.State == "restarting" || c.RestartCount > 3 {
|
if c.State == "restarting" || c.RestartCount > 3 {
|
||||||
findings = append(findings, &Finding{
|
findings = append(findings, &Finding{
|
||||||
ID: generateFindingID(c.ID, "reliability", "restart-loop"),
|
ID: generateFindingID(c.ID, "reliability", "restart-loop"),
|
||||||
|
Key: "restart-loop",
|
||||||
Severity: FindingSeverityWarning,
|
Severity: FindingSeverityWarning,
|
||||||
Category: FindingCategoryReliability,
|
Category: FindingCategoryReliability,
|
||||||
ResourceID: c.ID,
|
ResourceID: c.ID,
|
||||||
|
|
@ -1067,6 +1171,7 @@ func (p *PatrolService) analyzeDockerHost(host models.DockerHost) []*Finding {
|
||||||
if c.MemoryPercent > 90 {
|
if c.MemoryPercent > 90 {
|
||||||
findings = append(findings, &Finding{
|
findings = append(findings, &Finding{
|
||||||
ID: generateFindingID(c.ID, "performance", "high-memory"),
|
ID: generateFindingID(c.ID, "performance", "high-memory"),
|
||||||
|
Key: "high-memory",
|
||||||
Severity: FindingSeverityWatch,
|
Severity: FindingSeverityWatch,
|
||||||
Category: FindingCategoryPerformance,
|
Category: FindingCategoryPerformance,
|
||||||
ResourceID: c.ID,
|
ResourceID: c.ID,
|
||||||
|
|
@ -1107,6 +1212,7 @@ func (p *PatrolService) analyzeStorage(storage models.Storage) []*Finding {
|
||||||
|
|
||||||
findings = append(findings, &Finding{
|
findings = append(findings, &Finding{
|
||||||
ID: generateFindingID(storage.ID, "capacity", "high-usage"),
|
ID: generateFindingID(storage.ID, "capacity", "high-usage"),
|
||||||
|
Key: "storage-high-usage",
|
||||||
Severity: severity,
|
Severity: severity,
|
||||||
Category: FindingCategoryCapacity,
|
Category: FindingCategoryCapacity,
|
||||||
ResourceID: storage.ID,
|
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
|
// 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)
|
// patrolStartTime is used to determine which findings are stale (LastSeenAt < patrolStartTime)
|
||||||
// Returns the count of findings that were resolved
|
// 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
|
// Get all active findings and check if they're stale
|
||||||
activeFindings := p.findings.GetActive(FindingSeverityInfo)
|
activeFindings := p.findings.GetActive(FindingSeverityInfo)
|
||||||
resolvedCount := 0
|
resolvedCount := 0
|
||||||
|
|
||||||
for _, f := range activeFindings {
|
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),
|
// If the finding wasn't updated during this patrol (LastSeenAt is before patrol started),
|
||||||
// it means the condition that caused it has been resolved
|
// it means the condition that caused it has been resolved
|
||||||
if f.LastSeenAt.Before(patrolStartTime) {
|
if f.LastSeenAt.Before(patrolStartTime) {
|
||||||
|
|
@ -1198,7 +1309,7 @@ func (p *PatrolService) GetRunHistory(limit int) []PatrolRunRecord {
|
||||||
// GetAllFindings returns all active findings sorted by severity
|
// GetAllFindings returns all active findings sorted by severity
|
||||||
func (p *PatrolService) GetAllFindings() []*Finding {
|
func (p *PatrolService) GetAllFindings() []*Finding {
|
||||||
findings := p.findings.GetActive(FindingSeverityInfo)
|
findings := p.findings.GetActive(FindingSeverityInfo)
|
||||||
|
|
||||||
// Sort by severity (critical first) then by time
|
// Sort by severity (critical first) then by time
|
||||||
severityOrder := map[FindingSeverity]int{
|
severityOrder := map[FindingSeverity]int{
|
||||||
FindingSeverityCritical: 0,
|
FindingSeverityCritical: 0,
|
||||||
|
|
@ -1206,14 +1317,14 @@ func (p *PatrolService) GetAllFindings() []*Finding {
|
||||||
FindingSeverityWatch: 2,
|
FindingSeverityWatch: 2,
|
||||||
FindingSeverityInfo: 3,
|
FindingSeverityInfo: 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Slice(findings, func(i, j int) bool {
|
sort.Slice(findings, func(i, j int) bool {
|
||||||
if severityOrder[findings[i].Severity] != severityOrder[findings[j].Severity] {
|
if severityOrder[findings[i].Severity] != severityOrder[findings[j].Severity] {
|
||||||
return 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[i].DetectedAt.After(findings[j].DetectedAt)
|
||||||
})
|
})
|
||||||
|
|
||||||
return findings
|
return findings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1221,12 +1332,12 @@ func (p *PatrolService) GetAllFindings() []*Finding {
|
||||||
// Optionally filter by startTime
|
// Optionally filter by startTime
|
||||||
func (p *PatrolService) GetFindingsHistory(startTime *time.Time) []*Finding {
|
func (p *PatrolService) GetFindingsHistory(startTime *time.Time) []*Finding {
|
||||||
findings := p.findings.GetAll(startTime)
|
findings := p.findings.GetAll(startTime)
|
||||||
|
|
||||||
// Sort by detected time (newest first)
|
// Sort by detected time (newest first)
|
||||||
sort.Slice(findings, func(i, j int) bool {
|
sort.Slice(findings, func(i, j int) bool {
|
||||||
return findings[i].DetectedAt.After(findings[j].DetectedAt)
|
return findings[i].DetectedAt.After(findings[j].DetectedAt)
|
||||||
})
|
})
|
||||||
|
|
||||||
return findings
|
return findings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1250,6 +1361,7 @@ func (p *PatrolService) analyzePBSInstance(pbs models.PBSInstance, allBackups []
|
||||||
if pbs.Status != "online" && pbs.Status != "connected" && pbs.Status != "" {
|
if pbs.Status != "online" && pbs.Status != "connected" && pbs.Status != "" {
|
||||||
findings = append(findings, &Finding{
|
findings = append(findings, &Finding{
|
||||||
ID: generateFindingID(pbs.ID, "reliability", "offline"),
|
ID: generateFindingID(pbs.ID, "reliability", "offline"),
|
||||||
|
Key: "pbs-offline",
|
||||||
Severity: FindingSeverityCritical,
|
Severity: FindingSeverityCritical,
|
||||||
Category: FindingCategoryReliability,
|
Category: FindingCategoryReliability,
|
||||||
ResourceID: pbs.ID,
|
ResourceID: pbs.ID,
|
||||||
|
|
@ -1281,6 +1393,7 @@ func (p *PatrolService) analyzePBSInstance(pbs models.PBSInstance, allBackups []
|
||||||
|
|
||||||
findings = append(findings, &Finding{
|
findings = append(findings, &Finding{
|
||||||
ID: generateFindingID(pbs.ID+":"+ds.Name, "capacity", "high-usage"),
|
ID: generateFindingID(pbs.ID+":"+ds.Name, "capacity", "high-usage"),
|
||||||
|
Key: "pbs-datastore-high-usage",
|
||||||
Severity: severity,
|
Severity: severity,
|
||||||
Category: FindingCategoryCapacity,
|
Category: FindingCategoryCapacity,
|
||||||
ResourceID: pbs.ID + ":" + ds.Name,
|
ResourceID: pbs.ID + ":" + ds.Name,
|
||||||
|
|
@ -1297,6 +1410,7 @@ func (p *PatrolService) analyzePBSInstance(pbs models.PBSInstance, allBackups []
|
||||||
if ds.Error != "" {
|
if ds.Error != "" {
|
||||||
findings = append(findings, &Finding{
|
findings = append(findings, &Finding{
|
||||||
ID: generateFindingID(pbs.ID+":"+ds.Name, "reliability", "error"),
|
ID: generateFindingID(pbs.ID+":"+ds.Name, "reliability", "error"),
|
||||||
|
Key: "pbs-datastore-error",
|
||||||
Severity: FindingSeverityCritical,
|
Severity: FindingSeverityCritical,
|
||||||
Category: FindingCategoryReliability,
|
Category: FindingCategoryReliability,
|
||||||
ResourceID: pbs.ID + ":" + ds.Name,
|
ResourceID: pbs.ID + ":" + ds.Name,
|
||||||
|
|
@ -1325,13 +1439,14 @@ func (p *PatrolService) analyzePBSInstance(pbs models.PBSInstance, allBackups []
|
||||||
|
|
||||||
for _, ds := range pbs.Datastores {
|
for _, ds := range pbs.Datastores {
|
||||||
lastBackup, hasBackups := datastoreLastBackup[ds.Name]
|
lastBackup, hasBackups := datastoreLastBackup[ds.Name]
|
||||||
|
|
||||||
if !hasBackups {
|
if !hasBackups {
|
||||||
// No backups found for this datastore - might be intentional (empty datastore)
|
// No backups found for this datastore - might be intentional (empty datastore)
|
||||||
// Only warn if datastore has actual content
|
// Only warn if datastore has actual content
|
||||||
if ds.Used > 0 {
|
if ds.Used > 0 {
|
||||||
findings = append(findings, &Finding{
|
findings = append(findings, &Finding{
|
||||||
ID: generateFindingID(pbs.ID+":"+ds.Name, "backup", "no-recent"),
|
ID: generateFindingID(pbs.ID+":"+ds.Name, "backup", "no-recent"),
|
||||||
|
Key: "pbs-backup-no-recent",
|
||||||
Severity: FindingSeverityWatch,
|
Severity: FindingSeverityWatch,
|
||||||
Category: FindingCategoryBackup,
|
Category: FindingCategoryBackup,
|
||||||
ResourceID: pbs.ID + ":" + ds.Name,
|
ResourceID: pbs.ID + ":" + ds.Name,
|
||||||
|
|
@ -1357,6 +1472,7 @@ func (p *PatrolService) analyzePBSInstance(pbs models.PBSInstance, allBackups []
|
||||||
|
|
||||||
findings = append(findings, &Finding{
|
findings = append(findings, &Finding{
|
||||||
ID: generateFindingID(pbs.ID+":"+ds.Name, "backup", "stale"),
|
ID: generateFindingID(pbs.ID+":"+ds.Name, "backup", "stale"),
|
||||||
|
Key: "pbs-backup-stale",
|
||||||
Severity: severity,
|
Severity: severity,
|
||||||
Category: FindingCategoryBackup,
|
Category: FindingCategoryBackup,
|
||||||
ResourceID: pbs.ID + ":" + ds.Name,
|
ResourceID: pbs.ID + ":" + ds.Name,
|
||||||
|
|
@ -1375,6 +1491,7 @@ func (p *PatrolService) analyzePBSInstance(pbs models.PBSInstance, allBackups []
|
||||||
if job.Status == "error" || job.Error != "" {
|
if job.Status == "error" || job.Error != "" {
|
||||||
findings = append(findings, &Finding{
|
findings = append(findings, &Finding{
|
||||||
ID: generateFindingID(pbs.ID+":job:"+job.ID, "backup", "job-failed"),
|
ID: generateFindingID(pbs.ID+":job:"+job.ID, "backup", "job-failed"),
|
||||||
|
Key: "pbs-job-failed",
|
||||||
Severity: FindingSeverityWarning,
|
Severity: FindingSeverityWarning,
|
||||||
Category: FindingCategoryBackup,
|
Category: FindingCategoryBackup,
|
||||||
ResourceID: pbs.ID + ":job:" + job.ID,
|
ResourceID: pbs.ID + ":job:" + job.ID,
|
||||||
|
|
@ -1392,6 +1509,7 @@ func (p *PatrolService) analyzePBSInstance(pbs models.PBSInstance, allBackups []
|
||||||
if job.Status == "error" || job.Error != "" {
|
if job.Status == "error" || job.Error != "" {
|
||||||
findings = append(findings, &Finding{
|
findings = append(findings, &Finding{
|
||||||
ID: generateFindingID(pbs.ID+":verify:"+job.ID, "backup", "verify-failed"),
|
ID: generateFindingID(pbs.ID+":verify:"+job.ID, "backup", "verify-failed"),
|
||||||
|
Key: "pbs-verify-failed",
|
||||||
Severity: FindingSeverityWarning,
|
Severity: FindingSeverityWarning,
|
||||||
Category: FindingCategoryBackup,
|
Category: FindingCategoryBackup,
|
||||||
ResourceID: pbs.ID + ":verify:" + job.ID,
|
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 != "" {
|
if host.Status != "online" && host.Status != "connected" && host.Status != "" {
|
||||||
findings = append(findings, &Finding{
|
findings = append(findings, &Finding{
|
||||||
ID: generateFindingID(host.ID, "reliability", "offline"),
|
ID: generateFindingID(host.ID, "reliability", "offline"),
|
||||||
|
Key: "host-offline",
|
||||||
Severity: FindingSeverityCritical,
|
Severity: FindingSeverityCritical,
|
||||||
Category: FindingCategoryReliability,
|
Category: FindingCategoryReliability,
|
||||||
ResourceID: host.ID,
|
ResourceID: host.ID,
|
||||||
|
|
@ -1444,6 +1563,7 @@ func (p *PatrolService) analyzeHost(host models.Host) []*Finding {
|
||||||
case "degraded", "DEGRADED":
|
case "degraded", "DEGRADED":
|
||||||
findings = append(findings, &Finding{
|
findings = append(findings, &Finding{
|
||||||
ID: generateFindingID(host.ID+":"+raid.Device, "reliability", "raid-degraded"),
|
ID: generateFindingID(host.ID+":"+raid.Device, "reliability", "raid-degraded"),
|
||||||
|
Key: "raid-degraded",
|
||||||
Severity: FindingSeverityCritical,
|
Severity: FindingSeverityCritical,
|
||||||
Category: FindingCategoryReliability,
|
Category: FindingCategoryReliability,
|
||||||
ResourceID: host.ID + ":" + raid.Device,
|
ResourceID: host.ID + ":" + raid.Device,
|
||||||
|
|
@ -1462,6 +1582,7 @@ func (p *PatrolService) analyzeHost(host models.Host) []*Finding {
|
||||||
}
|
}
|
||||||
findings = append(findings, &Finding{
|
findings = append(findings, &Finding{
|
||||||
ID: generateFindingID(host.ID+":"+raid.Device, "reliability", "raid-rebuilding"),
|
ID: generateFindingID(host.ID+":"+raid.Device, "reliability", "raid-rebuilding"),
|
||||||
|
Key: "raid-rebuilding",
|
||||||
Severity: severity,
|
Severity: severity,
|
||||||
Category: FindingCategoryReliability,
|
Category: FindingCategoryReliability,
|
||||||
ResourceID: host.ID + ":" + raid.Device,
|
ResourceID: host.ID + ":" + raid.Device,
|
||||||
|
|
@ -1476,6 +1597,7 @@ func (p *PatrolService) analyzeHost(host models.Host) []*Finding {
|
||||||
case "inactive", "INACTIVE":
|
case "inactive", "INACTIVE":
|
||||||
findings = append(findings, &Finding{
|
findings = append(findings, &Finding{
|
||||||
ID: generateFindingID(host.ID+":"+raid.Device, "reliability", "raid-inactive"),
|
ID: generateFindingID(host.ID+":"+raid.Device, "reliability", "raid-inactive"),
|
||||||
|
Key: "raid-inactive",
|
||||||
Severity: FindingSeverityCritical,
|
Severity: FindingSeverityCritical,
|
||||||
Category: FindingCategoryReliability,
|
Category: FindingCategoryReliability,
|
||||||
ResourceID: host.ID + ":" + raid.Device,
|
ResourceID: host.ID + ":" + raid.Device,
|
||||||
|
|
@ -1492,6 +1614,7 @@ func (p *PatrolService) analyzeHost(host models.Host) []*Finding {
|
||||||
if raid.FailedDevices > 0 && raid.State != "degraded" {
|
if raid.FailedDevices > 0 && raid.State != "degraded" {
|
||||||
findings = append(findings, &Finding{
|
findings = append(findings, &Finding{
|
||||||
ID: generateFindingID(host.ID+":"+raid.Device, "reliability", "raid-failed-devices"),
|
ID: generateFindingID(host.ID+":"+raid.Device, "reliability", "raid-failed-devices"),
|
||||||
|
Key: "raid-failed-devices",
|
||||||
Severity: FindingSeverityWarning,
|
Severity: FindingSeverityWarning,
|
||||||
Category: FindingCategoryReliability,
|
Category: FindingCategoryReliability,
|
||||||
ResourceID: host.ID + ":" + raid.Device,
|
ResourceID: host.ID + ":" + raid.Device,
|
||||||
|
|
@ -1515,6 +1638,7 @@ func (p *PatrolService) analyzeHost(host models.Host) []*Finding {
|
||||||
}
|
}
|
||||||
findings = append(findings, &Finding{
|
findings = append(findings, &Finding{
|
||||||
ID: generateFindingID(host.ID+":temp:"+sensorName, "reliability", "high-temp"),
|
ID: generateFindingID(host.ID+":temp:"+sensorName, "reliability", "high-temp"),
|
||||||
|
Key: "high-temp",
|
||||||
Severity: severity,
|
Severity: severity,
|
||||||
Category: FindingCategoryReliability,
|
Category: FindingCategoryReliability,
|
||||||
ResourceID: host.ID + ":temp:" + sensorName,
|
ResourceID: host.ID + ":temp:" + sensorName,
|
||||||
|
|
@ -1588,7 +1712,7 @@ func (p *PatrolService) runAIAnalysis(ctx context.Context, state models.StateSna
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.setStreamPhase("idle")
|
p.setStreamPhase("idle")
|
||||||
p.broadcast(PatrolStreamEvent{Type: "error", Content: err.Error()})
|
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:
|
For each issue you identify, output a finding block like this:
|
||||||
|
|
||||||
[FINDING]
|
[FINDING]
|
||||||
|
KEY: <stable issue key>
|
||||||
SEVERITY: critical|warning|watch|info
|
SEVERITY: critical|warning|watch|info
|
||||||
CATEGORY: performance|reliability|security|capacity|configuration
|
CATEGORY: performance|reliability|security|capacity|configuration
|
||||||
RESOURCE: <resource name or ID>
|
RESOURCE: <resource name or ID>
|
||||||
|
|
@ -1653,6 +1778,7 @@ EVIDENCE: <specific data that supports this finding>
|
||||||
[/FINDING]
|
[/FINDING]
|
||||||
|
|
||||||
Guidelines:
|
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)
|
- CRITICAL: Immediate action required (data loss risk, service down)
|
||||||
- WARNING: Should be addressed soon (degraded performance, nearing limits)
|
- WARNING: Should be addressed soon (degraded performance, nearing limits)
|
||||||
- WATCH: Worth monitoring (trends, minor inefficiencies)
|
- 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)))
|
dh.Hostname, dh.Status, len(dh.Containers)))
|
||||||
for _, c := range dh.Containers {
|
for _, c := range dh.Containers {
|
||||||
sb.WriteString(fmt.Sprintf(" - %s: State=%s, CPU=%.1f%%, Memory=%.1f%%, Restarts=%d\n",
|
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")
|
sb.WriteString("\n")
|
||||||
|
|
@ -1811,7 +1937,7 @@ func (p *PatrolService) buildEnrichedContext(state models.StateSnapshot) string
|
||||||
if knowledgeStore != nil {
|
if knowledgeStore != nil {
|
||||||
builder = builder.WithKnowledge(&knowledgeShim{store: knowledgeStore})
|
builder = builder.WithKnowledge(&knowledgeShim{store: knowledgeStore})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add baseline provider for anomaly detection if available
|
// Add baseline provider for anomaly detection if available
|
||||||
if baselineStore != nil {
|
if baselineStore != nil {
|
||||||
adapter := NewBaselineStoreAdapter(baselineStore)
|
adapter := NewBaselineStoreAdapter(baselineStore)
|
||||||
|
|
@ -1829,39 +1955,39 @@ func (p *PatrolService) buildEnrichedContext(state models.StateSnapshot) string
|
||||||
|
|
||||||
// Format for AI consumption
|
// Format for AI consumption
|
||||||
formatted := aicontext.FormatInfrastructureContext(infraCtx)
|
formatted := aicontext.FormatInfrastructureContext(infraCtx)
|
||||||
|
|
||||||
// Append recent changes if change detector is available
|
// Append recent changes if change detector is available
|
||||||
if changeDetector != nil {
|
if changeDetector != nil {
|
||||||
// Detect any new changes from current state
|
// Detect any new changes from current state
|
||||||
snapshots := stateToSnapshots(state)
|
snapshots := stateToSnapshots(state)
|
||||||
newChanges := changeDetector.DetectChanges(snapshots)
|
newChanges := changeDetector.DetectChanges(snapshots)
|
||||||
|
|
||||||
// Get summary of recent changes (last 24 hours)
|
// Get summary of recent changes (last 24 hours)
|
||||||
since := time.Now().Add(-24 * time.Hour)
|
since := time.Now().Add(-24 * time.Hour)
|
||||||
changesSummary := changeDetector.GetChangesSummary(since, 20)
|
changesSummary := changeDetector.GetChangesSummary(since, 20)
|
||||||
|
|
||||||
if changesSummary != "" {
|
if changesSummary != "" {
|
||||||
formatted += "\n## Recent Infrastructure Changes (24h)\n\n" + changesSummary
|
formatted += "\n## Recent Infrastructure Changes (24h)\n\n" + changesSummary
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(newChanges) > 0 {
|
if len(newChanges) > 0 {
|
||||||
log.Debug().Int("new_changes", len(newChanges)).Msg("AI Patrol: Detected infrastructure changes")
|
log.Debug().Int("new_changes", len(newChanges)).Msg("AI Patrol: Detected infrastructure changes")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append failure predictions if pattern detector is available
|
// Append failure predictions if pattern detector is available
|
||||||
p.mu.RLock()
|
p.mu.RLock()
|
||||||
patternDetector := p.patternDetector
|
patternDetector := p.patternDetector
|
||||||
correlationDetector := p.correlationDetector
|
correlationDetector := p.correlationDetector
|
||||||
p.mu.RUnlock()
|
p.mu.RUnlock()
|
||||||
|
|
||||||
if patternDetector != nil {
|
if patternDetector != nil {
|
||||||
predictionsContext := patternDetector.FormatForContext("")
|
predictionsContext := patternDetector.FormatForContext("")
|
||||||
if predictionsContext != "" {
|
if predictionsContext != "" {
|
||||||
formatted += predictionsContext
|
formatted += predictionsContext
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append resource correlations if correlation detector is available
|
// Append resource correlations if correlation detector is available
|
||||||
if correlationDetector != nil {
|
if correlationDetector != nil {
|
||||||
correlationsContext := correlationDetector.FormatForContext("")
|
correlationsContext := correlationDetector.FormatForContext("")
|
||||||
|
|
@ -1882,7 +2008,7 @@ func (p *PatrolService) buildEnrichedContext(state models.StateSnapshot) string
|
||||||
// stateToSnapshots converts state to resource snapshots for change detection
|
// stateToSnapshots converts state to resource snapshots for change detection
|
||||||
func stateToSnapshots(state models.StateSnapshot) []ResourceSnapshot {
|
func stateToSnapshots(state models.StateSnapshot) []ResourceSnapshot {
|
||||||
var snapshots []ResourceSnapshot
|
var snapshots []ResourceSnapshot
|
||||||
|
|
||||||
for _, node := range state.Nodes {
|
for _, node := range state.Nodes {
|
||||||
snapshots = append(snapshots, ResourceSnapshot{
|
snapshots = append(snapshots, ResourceSnapshot{
|
||||||
ID: node.ID,
|
ID: node.ID,
|
||||||
|
|
@ -1893,7 +2019,7 @@ func stateToSnapshots(state models.StateSnapshot) []ResourceSnapshot {
|
||||||
MemoryBytes: node.Memory.Total,
|
MemoryBytes: node.Memory.Total,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, vm := range state.VMs {
|
for _, vm := range state.VMs {
|
||||||
if vm.Template {
|
if vm.Template {
|
||||||
continue
|
continue
|
||||||
|
|
@ -1910,7 +2036,7 @@ func stateToSnapshots(state models.StateSnapshot) []ResourceSnapshot {
|
||||||
LastBackup: vm.LastBackup,
|
LastBackup: vm.LastBackup,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, ct := range state.Containers {
|
for _, ct := range state.Containers {
|
||||||
if ct.Template {
|
if ct.Template {
|
||||||
continue
|
continue
|
||||||
|
|
@ -1927,7 +2053,7 @@ func stateToSnapshots(state models.StateSnapshot) []ResourceSnapshot {
|
||||||
LastBackup: ct.LastBackup,
|
LastBackup: ct.LastBackup,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return snapshots
|
return snapshots
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2049,7 +2175,7 @@ func convertToContextMetricsMap(metricsMap map[string][]MetricPoint) map[string]
|
||||||
func (p *PatrolService) buildPatrolPrompt(summary string) string {
|
func (p *PatrolService) buildPatrolPrompt(summary string) string {
|
||||||
// Get user feedback context (dismissed/snoozed findings)
|
// Get user feedback context (dismissed/snoozed findings)
|
||||||
feedbackContext := p.findings.GetDismissedForContext()
|
feedbackContext := p.findings.GetDismissedForContext()
|
||||||
|
|
||||||
// Get resource notes from knowledge store (per-resource user notes)
|
// Get resource notes from knowledge store (per-resource user notes)
|
||||||
var knowledgeContext string
|
var knowledgeContext string
|
||||||
p.mu.RLock()
|
p.mu.RLock()
|
||||||
|
|
@ -2058,7 +2184,7 @@ func (p *PatrolService) buildPatrolPrompt(summary string) string {
|
||||||
if knowledgeStore != nil {
|
if knowledgeStore != nil {
|
||||||
knowledgeContext = knowledgeStore.FormatAllForContext()
|
knowledgeContext = knowledgeStore.FormatAllForContext()
|
||||||
}
|
}
|
||||||
|
|
||||||
basePrompt := fmt.Sprintf(`Please perform a comprehensive analysis of the following infrastructure and identify any issues, potential problems, or optimization opportunities.
|
basePrompt := fmt.Sprintf(`Please perform a comprehensive analysis of the following infrastructure and identify any issues, potential problems, or optimization opportunities.
|
||||||
|
|
||||||
%s
|
%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)
|
If everything looks healthy with stable trends, say so briefly.`, summary)
|
||||||
|
|
||||||
var contextAdditions strings.Builder
|
var contextAdditions strings.Builder
|
||||||
|
|
||||||
// Append knowledge context (user notes about resources)
|
// Append knowledge context (user notes about resources)
|
||||||
if knowledgeContext != "" {
|
if knowledgeContext != "" {
|
||||||
contextAdditions.WriteString("\n\n")
|
contextAdditions.WriteString("\n\n")
|
||||||
contextAdditions.WriteString(knowledgeContext)
|
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")
|
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)
|
// Append user feedback context (dismissed/snoozed findings)
|
||||||
if feedbackContext != "" {
|
if feedbackContext != "" {
|
||||||
contextAdditions.WriteString("\n\n")
|
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.`)
|
Only report NEW issues or issues where the severity has clearly escalated.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if contextAdditions.Len() > 0 {
|
if contextAdditions.Len() > 0 {
|
||||||
return basePrompt + contextAdditions.String()
|
return basePrompt + contextAdditions.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
return basePrompt
|
return basePrompt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2145,6 +2271,10 @@ func (p *PatrolService) parseFindingBlock(block string) *Finding {
|
||||||
|
|
||||||
severity := extract("SEVERITY")
|
severity := extract("SEVERITY")
|
||||||
category := extract("CATEGORY")
|
category := extract("CATEGORY")
|
||||||
|
key := extract("KEY")
|
||||||
|
if key == "" {
|
||||||
|
key = extract("FINDING_KEY")
|
||||||
|
}
|
||||||
resource := extract("RESOURCE")
|
resource := extract("RESOURCE")
|
||||||
resourceType := extract("RESOURCE_TYPE")
|
resourceType := extract("RESOURCE_TYPE")
|
||||||
title := extract("TITLE")
|
title := extract("TITLE")
|
||||||
|
|
@ -2194,6 +2324,7 @@ func (p *PatrolService) parseFindingBlock(block string) *Finding {
|
||||||
|
|
||||||
return &Finding{
|
return &Finding{
|
||||||
ID: id,
|
ID: id,
|
||||||
|
Key: normalizeFindingKey(key),
|
||||||
Severity: sev,
|
Severity: sev,
|
||||||
Category: cat,
|
Category: cat,
|
||||||
ResourceID: resource,
|
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
|
// formatDurationPatrol formats a duration as a human-readable string for patrol
|
||||||
func formatDurationPatrol(d time.Duration) string {
|
func formatDurationPatrol(d time.Duration) string {
|
||||||
if d < time.Hour {
|
if d < time.Hour {
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ func (a *PatrolHistoryPersistenceAdapter) SavePatrolRunHistory(runs []PatrolRunR
|
||||||
NewFindings: r.NewFindings,
|
NewFindings: r.NewFindings,
|
||||||
ExistingFindings: r.ExistingFindings,
|
ExistingFindings: r.ExistingFindings,
|
||||||
ResolvedFindings: r.ResolvedFindings,
|
ResolvedFindings: r.ResolvedFindings,
|
||||||
|
AutoFixCount: r.AutoFixCount,
|
||||||
FindingsSummary: r.FindingsSummary,
|
FindingsSummary: r.FindingsSummary,
|
||||||
FindingIDs: r.FindingIDs,
|
FindingIDs: r.FindingIDs,
|
||||||
ErrorCount: r.ErrorCount,
|
ErrorCount: r.ErrorCount,
|
||||||
|
|
@ -84,6 +85,7 @@ func (a *PatrolHistoryPersistenceAdapter) LoadPatrolRunHistory() ([]PatrolRunRec
|
||||||
NewFindings: r.NewFindings,
|
NewFindings: r.NewFindings,
|
||||||
ExistingFindings: r.ExistingFindings,
|
ExistingFindings: r.ExistingFindings,
|
||||||
ResolvedFindings: r.ResolvedFindings,
|
ResolvedFindings: r.ResolvedFindings,
|
||||||
|
AutoFixCount: r.AutoFixCount,
|
||||||
FindingsSummary: r.FindingsSummary,
|
FindingsSummary: r.FindingsSummary,
|
||||||
FindingIDs: r.FindingIDs,
|
FindingIDs: r.FindingIDs,
|
||||||
ErrorCount: r.ErrorCount,
|
ErrorCount: r.ErrorCount,
|
||||||
|
|
@ -153,7 +155,7 @@ func (s *PatrolRunHistoryStore) Add(run PatrolRunRecord) {
|
||||||
|
|
||||||
// Prepend (newest first)
|
// Prepend (newest first)
|
||||||
s.runs = append([]PatrolRunRecord{run}, s.runs...)
|
s.runs = append([]PatrolRunRecord{run}, s.runs...)
|
||||||
|
|
||||||
// Trim to max
|
// Trim to max
|
||||||
if len(s.runs) > s.maxRuns {
|
if len(s.runs) > s.maxRuns {
|
||||||
s.runs = 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 {
|
func (s *PatrolRunHistoryStore) GetAll() []PatrolRunRecord {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
result := make([]PatrolRunRecord, len(s.runs))
|
result := make([]PatrolRunRecord, len(s.runs))
|
||||||
copy(result, s.runs)
|
copy(result, s.runs)
|
||||||
return result
|
return result
|
||||||
|
|
@ -177,11 +179,11 @@ func (s *PatrolRunHistoryStore) GetAll() []PatrolRunRecord {
|
||||||
func (s *PatrolRunHistoryStore) GetRecent(n int) []PatrolRunRecord {
|
func (s *PatrolRunHistoryStore) GetRecent(n int) []PatrolRunRecord {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
if n <= 0 || n > len(s.runs) {
|
if n <= 0 || n > len(s.runs) {
|
||||||
n = len(s.runs)
|
n = len(s.runs)
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]PatrolRunRecord, n)
|
result := make([]PatrolRunRecord, n)
|
||||||
copy(result, s.runs[:n])
|
copy(result, s.runs[:n])
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -951,3 +951,109 @@ func TestPatrolService_ResolveFinding_Errors(t *testing.T) {
|
||||||
t.Error("Expected error for non-existent finding")
|
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
|
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
|
// Configure patrol from AI config
|
||||||
patrolCfg := PatrolConfig{
|
patrolCfg := PatrolConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Interval: cfg.GetPatrolInterval(),
|
Interval: cfg.GetPatrolInterval(),
|
||||||
AnalyzeNodes: cfg.PatrolAnalyzeNodes,
|
AnalyzeNodes: cfg.PatrolAnalyzeNodes,
|
||||||
AnalyzeGuests: cfg.PatrolAnalyzeGuests,
|
AnalyzeGuests: cfg.PatrolAnalyzeGuests,
|
||||||
AnalyzeDocker: cfg.PatrolAnalyzeDocker,
|
AnalyzeDocker: cfg.PatrolAnalyzeDocker,
|
||||||
AnalyzeStorage: cfg.PatrolAnalyzeStorage,
|
AnalyzeStorage: cfg.PatrolAnalyzeStorage,
|
||||||
}
|
}
|
||||||
patrol.SetConfig(patrolCfg)
|
patrol.SetConfig(patrolCfg)
|
||||||
|
|
@ -494,11 +494,11 @@ func (s *Service) ReconfigurePatrol() {
|
||||||
|
|
||||||
// Update patrol configuration
|
// Update patrol configuration
|
||||||
patrolCfg := PatrolConfig{
|
patrolCfg := PatrolConfig{
|
||||||
Enabled: cfg.IsPatrolEnabled(),
|
Enabled: cfg.IsPatrolEnabled(),
|
||||||
Interval: cfg.GetPatrolInterval(),
|
Interval: cfg.GetPatrolInterval(),
|
||||||
AnalyzeNodes: cfg.PatrolAnalyzeNodes,
|
AnalyzeNodes: cfg.PatrolAnalyzeNodes,
|
||||||
AnalyzeGuests: cfg.PatrolAnalyzeGuests,
|
AnalyzeGuests: cfg.PatrolAnalyzeGuests,
|
||||||
AnalyzeDocker: cfg.PatrolAnalyzeDocker,
|
AnalyzeDocker: cfg.PatrolAnalyzeDocker,
|
||||||
AnalyzeStorage: cfg.PatrolAnalyzeStorage,
|
AnalyzeStorage: cfg.PatrolAnalyzeStorage,
|
||||||
}
|
}
|
||||||
patrol.SetConfig(patrolCfg)
|
patrol.SetConfig(patrolCfg)
|
||||||
|
|
@ -1668,6 +1668,10 @@ func (s *Service) logRemediation(req ExecuteRequest, command, output string, suc
|
||||||
problem = problem[:200] + "..."
|
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
|
// Get resource name from context if available
|
||||||
resourceName := ""
|
resourceName := ""
|
||||||
if req.Context != nil {
|
if req.Context != nil {
|
||||||
|
|
@ -1689,6 +1693,7 @@ func (s *Service) logRemediation(req ExecuteRequest, command, output string, suc
|
||||||
ResourceName: resourceName,
|
ResourceName: resourceName,
|
||||||
FindingID: req.FindingID,
|
FindingID: req.FindingID,
|
||||||
Problem: problem,
|
Problem: problem,
|
||||||
|
Summary: summary,
|
||||||
Action: command,
|
Action: command,
|
||||||
Output: truncatedOutput,
|
Output: truncatedOutput,
|
||||||
Outcome: outcome,
|
Outcome: outcome,
|
||||||
|
|
@ -1698,10 +1703,170 @@ func (s *Service) logRemediation(req ExecuteRequest, command, output string, suc
|
||||||
log.Debug().
|
log.Debug().
|
||||||
Str("resource_id", req.TargetID).
|
Str("resource_id", req.TargetID).
|
||||||
Str("command", command).
|
Str("command", command).
|
||||||
|
Str("summary", summary).
|
||||||
Bool("success", success).
|
Bool("success", success).
|
||||||
Msg("Logged remediation action to operational memory")
|
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.
|
// 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
|
// This uses the same routing logic as command execution to determine if the target
|
||||||
// can be reached, including cluster peer routing for Proxmox clusters.
|
// 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"},
|
"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
|
// 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
|
execution.Success = true
|
||||||
return execution.Output, execution
|
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:
|
default:
|
||||||
execution.Output = fmt.Sprintf("Unknown tool: %s", tc.Name)
|
execution.Output = fmt.Sprintf("Unknown tool: %s", tc.Name)
|
||||||
return execution.Output, execution
|
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
|
// buildSystemPrompt creates the system prompt based on the request context
|
||||||
func (s *Service) buildSystemPrompt(req ExecuteRequest) string {
|
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
|
## Response Style
|
||||||
- Be DIRECT and CONCISE. No greetings, no "I'll help you", no "Let me check"
|
- Be DIRECT and CONCISE. No greetings, no "I'll help you", no "Let me check"
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -724,6 +725,15 @@ type AIExecuteResponse struct {
|
||||||
PendingApprovals []ai.ApprovalNeededData `json:"pending_approvals,omitempty"` // Commands that require approval (non-streaming)
|
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)
|
// HandleExecute executes an AI prompt (POST /api/ai/execute)
|
||||||
func (h *AISettingsHandler) HandleExecute(w http.ResponseWriter, r *http.Request) {
|
func (h *AISettingsHandler) HandleExecute(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
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)
|
// HandleExecuteStream executes an AI prompt with SSE streaming (POST /api/ai/execute/stream)
|
||||||
func (h *AISettingsHandler) HandleExecuteStream(w http.ResponseWriter, r *http.Request) {
|
func (h *AISettingsHandler) HandleExecuteStream(w http.ResponseWriter, r *http.Request) {
|
||||||
// Handle CORS for dev mode (frontend on different port)
|
// 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)
|
// HandleGetPatrolFindings returns all active findings (GET /api/ai/patrol/findings)
|
||||||
func (h *AISettingsHandler) HandleGetPatrolFindings(w http.ResponseWriter, r *http.Request) {
|
func (h *AISettingsHandler) HandleGetPatrolFindings(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
|
|
@ -2106,6 +2354,12 @@ func (h *AISettingsHandler) HandleGetPatrolFindings(w http.ResponseWriter, r *ht
|
||||||
findings = patrol.GetAllFindings()
|
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 {
|
if err := utils.WriteJSONResponse(w, findings); err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to write patrol findings response")
|
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)
|
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 {
|
if err := utils.WriteJSONResponse(w, findings); err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to write findings history response")
|
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)
|
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 {
|
if err := utils.WriteJSONResponse(w, runs); err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to write patrol run history response")
|
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.
|
// Update settings to enable AI via Ollama.
|
||||||
{
|
{
|
||||||
body, _ := json.Marshal(AISettingsUpdateRequest{
|
body, _ := json.Marshal(AISettingsUpdateRequest{
|
||||||
Enabled: ptr(true),
|
Enabled: ptr(true),
|
||||||
Provider: ptr("ollama"),
|
Provider: ptr("ollama"),
|
||||||
Model: ptr("ollama:llama3"),
|
Model: ptr("ollama:llama3"),
|
||||||
OllamaBaseURL: ptr("http://localhost:11434"),
|
OllamaBaseURL: ptr("http://localhost:11434"),
|
||||||
})
|
})
|
||||||
req := httptest.NewRequest(http.MethodPut, "/api/settings/ai", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPut, "/api/settings/ai", bytes.NewReader(body))
|
||||||
|
|
@ -159,8 +159,8 @@ func TestAISettingsHandler_Execute_Ollama(t *testing.T) {
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": "hello from ollama",
|
"content": "hello from ollama",
|
||||||
},
|
},
|
||||||
"done": true,
|
"done": true,
|
||||||
"done_reason": "stop",
|
"done_reason": "stop",
|
||||||
"prompt_eval_count": 3,
|
"prompt_eval_count": 3,
|
||||||
"eval_count": 5,
|
"eval_count": 5,
|
||||||
})
|
})
|
||||||
|
|
@ -300,3 +300,861 @@ func TestAISettingsHandler_TestProvider_Ollama(t *testing.T) {
|
||||||
t.Fatalf("unexpected response: %+v", resp)
|
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"
|
"time"
|
||||||
|
|
||||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
|
"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/rcourtman/pulse-go-rewrite/internal/utils"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const aiIntelligenceUpgradeURL = "https://pulsemonitor.app/pro"
|
||||||
|
|
||||||
// HandleGetPatterns returns detected failure patterns (GET /api/ai/intelligence/patterns)
|
// HandleGetPatterns returns detected failure patterns (GET /api/ai/intelligence/patterns)
|
||||||
func (h *AISettingsHandler) HandleGetPatterns(w http.ResponseWriter, r *http.Request) {
|
func (h *AISettingsHandler) HandleGetPatterns(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
|
|
@ -41,10 +44,10 @@ func (h *AISettingsHandler) HandleGetPatterns(w http.ResponseWriter, r *http.Req
|
||||||
|
|
||||||
// Get resource filter if provided
|
// Get resource filter if provided
|
||||||
resourceID := r.URL.Query().Get("resource_id")
|
resourceID := r.URL.Query().Get("resource_id")
|
||||||
|
|
||||||
patterns := detector.GetPatterns()
|
patterns := detector.GetPatterns()
|
||||||
var result []map[string]interface{}
|
var result []map[string]interface{}
|
||||||
|
|
||||||
for key, pattern := range patterns {
|
for key, pattern := range patterns {
|
||||||
if resourceID != "" && pattern.ResourceID != resourceID {
|
if resourceID != "" && pattern.ResourceID != resourceID {
|
||||||
continue
|
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{}{
|
if err := utils.WriteJSONResponse(w, map[string]interface{}{
|
||||||
"patterns": result,
|
"patterns": result,
|
||||||
"count": len(result),
|
"count": count,
|
||||||
|
"license_required": locked,
|
||||||
|
"upgrade_url": aiIntelligenceUpgradeURL,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to write patterns response")
|
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
|
// Get resource filter if provided
|
||||||
resourceID := r.URL.Query().Get("resource_id")
|
resourceID := r.URL.Query().Get("resource_id")
|
||||||
|
|
||||||
var predictions []ai.FailurePrediction
|
var predictions []ai.FailurePrediction
|
||||||
if resourceID != "" {
|
if resourceID != "" {
|
||||||
predictions = detector.GetPredictionsForResource(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{}{
|
if err := utils.WriteJSONResponse(w, map[string]interface{}{
|
||||||
"predictions": result,
|
"predictions": result,
|
||||||
"count": len(result),
|
"count": count,
|
||||||
|
"license_required": locked,
|
||||||
|
"upgrade_url": aiIntelligenceUpgradeURL,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to write predictions response")
|
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
|
// Get resource filter if provided
|
||||||
resourceID := r.URL.Query().Get("resource_id")
|
resourceID := r.URL.Query().Get("resource_id")
|
||||||
|
|
||||||
var correlations []*ai.Correlation
|
var correlations []*ai.Correlation
|
||||||
if resourceID != "" {
|
if resourceID != "" {
|
||||||
correlations = detector.GetCorrelationsForResource(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{}{
|
if err := utils.WriteJSONResponse(w, map[string]interface{}{
|
||||||
"correlations": result,
|
"correlations": result,
|
||||||
"count": len(result),
|
"count": count,
|
||||||
|
"license_required": locked,
|
||||||
|
"upgrade_url": aiIntelligenceUpgradeURL,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to write correlations response")
|
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
|
hours = h
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
since := time.Now().Add(-time.Duration(hours) * time.Hour)
|
since := time.Now().Add(-time.Duration(hours) * time.Hour)
|
||||||
changes := detector.GetRecentChanges(100, since)
|
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{}{
|
if err := utils.WriteJSONResponse(w, map[string]interface{}{
|
||||||
"changes": result,
|
"changes": result,
|
||||||
"count": len(result),
|
"count": count,
|
||||||
"hours": hours,
|
"hours": hours,
|
||||||
|
"license_required": locked,
|
||||||
|
"upgrade_url": aiIntelligenceUpgradeURL,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to write changes response")
|
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
|
// Get resource filter if provided
|
||||||
resourceID := r.URL.Query().Get("resource_id")
|
resourceID := r.URL.Query().Get("resource_id")
|
||||||
|
|
||||||
baselines := store.GetAllBaselines()
|
baselines := store.GetAllBaselines()
|
||||||
var result []map[string]interface{}
|
var result []map[string]interface{}
|
||||||
|
|
||||||
for key, baseline := range baselines {
|
for key, baseline := range baselines {
|
||||||
if resourceID != "" && baseline.ResourceID != resourceID {
|
if resourceID != "" && baseline.ResourceID != resourceID {
|
||||||
continue
|
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{}{
|
if err := utils.WriteJSONResponse(w, map[string]interface{}{
|
||||||
"baselines": result,
|
"baselines": result,
|
||||||
"count": len(result),
|
"count": count,
|
||||||
|
"license_required": locked,
|
||||||
|
"upgrade_url": aiIntelligenceUpgradeURL,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to write baselines response")
|
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
|
// TestHandleGetBaselines tests the baselines endpoint
|
||||||
func TestHandleGetBaselines_MethodNotAllowed(t *testing.T) {
|
func TestHandleGetBaselines_MethodNotAllowed(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"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/agentexec"
|
||||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
|
||||||
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
"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/auth"
|
||||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
"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/models"
|
||||||
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
||||||
"github.com/rcourtman/pulse-go-rewrite/internal/system"
|
"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/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", RequireAuth(r.config, r.aiSettingsHandler.HandleExecute))
|
||||||
r.mux.HandleFunc("/api/ai/execute/stream", RequireAuth(r.config, r.aiSettingsHandler.HandleExecuteStream))
|
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/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/run-command", RequireAuth(r.config, r.aiSettingsHandler.HandleRunCommand))
|
||||||
r.mux.HandleFunc("/api/ai/knowledge", RequireAuth(r.config, r.aiSettingsHandler.HandleGetGuestKnowledge))
|
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
|
// AI Patrol routes for background monitoring
|
||||||
// Note: Status remains accessible so UI can show license/upgrade state
|
// 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
|
// 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/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/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/findings", RequireAuth(r.config, 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/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/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/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/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/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/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/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)
|
// Suppression rules management (also Pro-only since they control LLM behavior)
|
||||||
// GET returns empty array for unlicensed, POST returns 402
|
// 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) {
|
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/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/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/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
|
// Agent WebSocket for AI command execution
|
||||||
r.mux.HandleFunc("/api/agent/ws", r.handleAgentWebSocket)
|
r.mux.HandleFunc("/api/agent/ws", r.handleAgentWebSocket)
|
||||||
|
|
|
||||||
|
|
@ -235,24 +235,34 @@ func (c *ConfigPersistence) SaveAlertConfig(config alerts.AlertConfig) error {
|
||||||
config.HysteresisMargin = 5.0
|
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}
|
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 {
|
} else if config.HostDefaults.CPU.Clear <= 0 {
|
||||||
config.HostDefaults.CPU.Clear = config.HostDefaults.CPU.Trigger - 5
|
config.HostDefaults.CPU.Clear = config.HostDefaults.CPU.Trigger - 5
|
||||||
if config.HostDefaults.CPU.Clear <= 0 {
|
if config.HostDefaults.CPU.Clear <= 0 {
|
||||||
config.HostDefaults.CPU.Clear = 75
|
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}
|
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 {
|
} else if config.HostDefaults.Memory.Clear <= 0 {
|
||||||
config.HostDefaults.Memory.Clear = config.HostDefaults.Memory.Trigger - 5
|
config.HostDefaults.Memory.Clear = config.HostDefaults.Memory.Trigger - 5
|
||||||
if config.HostDefaults.Memory.Clear <= 0 {
|
if config.HostDefaults.Memory.Clear <= 0 {
|
||||||
config.HostDefaults.Memory.Clear = 80
|
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}
|
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 {
|
} else if config.HostDefaults.Disk.Clear <= 0 {
|
||||||
config.HostDefaults.Disk.Clear = config.HostDefaults.Disk.Trigger - 5
|
config.HostDefaults.Disk.Clear = config.HostDefaults.Disk.Trigger - 5
|
||||||
if config.HostDefaults.Disk.Clear <= 0 {
|
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 {
|
if config.NodeDefaults.Temperature == nil || config.NodeDefaults.Temperature.Trigger <= 0 {
|
||||||
config.NodeDefaults.Temperature = &alerts.HysteresisThreshold{Trigger: 80, Clear: 75}
|
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}
|
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 {
|
} else if config.HostDefaults.CPU.Clear <= 0 {
|
||||||
config.HostDefaults.CPU.Clear = config.HostDefaults.CPU.Trigger - 5
|
config.HostDefaults.CPU.Clear = config.HostDefaults.CPU.Trigger - 5
|
||||||
if config.HostDefaults.CPU.Clear <= 0 {
|
if config.HostDefaults.CPU.Clear <= 0 {
|
||||||
config.HostDefaults.CPU.Clear = 75
|
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}
|
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 {
|
} else if config.HostDefaults.Memory.Clear <= 0 {
|
||||||
config.HostDefaults.Memory.Clear = config.HostDefaults.Memory.Trigger - 5
|
config.HostDefaults.Memory.Clear = config.HostDefaults.Memory.Trigger - 5
|
||||||
if config.HostDefaults.Memory.Clear <= 0 {
|
if config.HostDefaults.Memory.Clear <= 0 {
|
||||||
config.HostDefaults.Memory.Clear = 80
|
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}
|
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 {
|
} else if config.HostDefaults.Disk.Clear <= 0 {
|
||||||
config.HostDefaults.Disk.Clear = config.HostDefaults.Disk.Trigger - 5
|
config.HostDefaults.Disk.Clear = config.HostDefaults.Disk.Trigger - 5
|
||||||
if config.HostDefaults.Disk.Clear <= 0 {
|
if config.HostDefaults.Disk.Clear <= 0 {
|
||||||
|
|
@ -1425,6 +1445,7 @@ type AIFindingsData struct {
|
||||||
// AIFindingRecord is a persisted finding with full history
|
// AIFindingRecord is a persisted finding with full history
|
||||||
type AIFindingRecord struct {
|
type AIFindingRecord struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
Key string `json:"key,omitempty"`
|
||||||
Severity string `json:"severity"`
|
Severity string `json:"severity"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
ResourceID string `json:"resource_id"`
|
ResourceID string `json:"resource_id"`
|
||||||
|
|
@ -1540,6 +1561,7 @@ type PatrolRunRecord struct {
|
||||||
NewFindings int `json:"new_findings"`
|
NewFindings int `json:"new_findings"`
|
||||||
ExistingFindings int `json:"existing_findings"`
|
ExistingFindings int `json:"existing_findings"`
|
||||||
ResolvedFindings int `json:"resolved_findings"`
|
ResolvedFindings int `json:"resolved_findings"`
|
||||||
|
AutoFixCount int `json:"auto_fix_count,omitempty"`
|
||||||
FindingsSummary string `json:"findings_summary"`
|
FindingsSummary string `json:"findings_summary"`
|
||||||
FindingIDs []string `json:"finding_ids,omitempty"`
|
FindingIDs []string `json:"finding_ids,omitempty"`
|
||||||
ErrorCount int `json:"error_count"`
|
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) {
|
func TestAlertConfigPersistenceNormalizesDockerIgnoredPrefixes(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
cp := config.NewConfigPersistence(tempDir)
|
cp := config.NewConfigPersistence(tempDir)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue