refactor(ai): Remove over-engineered URL discovery service
Keep only the simple AI-powered approach: - set_resource_url tool lets AI save discovered URLs - Users ask AI directly: 'Find URLs for my containers' - AI uses its intelligence to discover and set URLs Removed: - URLDiscoveryService (rigid port scanning) - Bulk discovery API endpoints - Frontend discovery button The AI itself is smart enough to iterate through resources and discover URLs when asked.
This commit is contained in:
parent
0ae4daca8d
commit
d2330cf405
28 changed files with 3871 additions and 854 deletions
|
|
@ -184,6 +184,9 @@ func runServer() {
|
||||||
// This must be done after router creation since resourceHandlers is created in NewRouter
|
// This must be done after router creation since resourceHandlers is created in NewRouter
|
||||||
router.SetMonitor(reloadableMonitor.GetMonitor())
|
router.SetMonitor(reloadableMonitor.GetMonitor())
|
||||||
|
|
||||||
|
// Start AI patrol service for background infrastructure monitoring
|
||||||
|
router.StartPatrol(ctx)
|
||||||
|
|
||||||
// Create HTTP server with unified configuration
|
// Create HTTP server with unified configuration
|
||||||
// In production, serve everything (frontend + API) on the frontend port
|
// In production, serve everything (frontend + API) on the frontend port
|
||||||
// NOTE: We use ReadHeaderTimeout instead of ReadTimeout to avoid affecting
|
// NOTE: We use ReadHeaderTimeout instead of ReadTimeout to avoid affecting
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ import { useAlertsActivation } from './stores/alertsActivation';
|
||||||
import { UpdateProgressModal } from './components/UpdateProgressModal';
|
import { UpdateProgressModal } from './components/UpdateProgressModal';
|
||||||
import type { UpdateStatus } from './api/updates';
|
import type { UpdateStatus } from './api/updates';
|
||||||
import { AIChat } from './components/AI/AIChat';
|
import { AIChat } from './components/AI/AIChat';
|
||||||
|
import { AIStatusIndicator } from './components/AI/AIStatusIndicator';
|
||||||
import { aiChatStore } from './stores/aiChat';
|
import { aiChatStore } from './stores/aiChat';
|
||||||
import { useResourcesAsLegacy } from './hooks/useResources';
|
import { useResourcesAsLegacy } from './hooks/useResources';
|
||||||
|
|
||||||
|
|
@ -1167,6 +1168,8 @@ function AppLayout(props: {
|
||||||
<div class="header-controls flex items-center gap-2 justify-end sm:col-start-3 sm:col-end-4 sm:w-auto sm:justify-end sm:justify-self-end">
|
<div class="header-controls flex items-center gap-2 justify-end sm:col-start-3 sm:col-end-4 sm:w-auto sm:justify-end sm:justify-self-end">
|
||||||
<Show when={props.hasAuth() && !props.needsAuth()}>
|
<Show when={props.hasAuth() && !props.needsAuth()}>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
{/* AI Patrol Status Indicator */}
|
||||||
|
<AIStatusIndicator />
|
||||||
<Show when={props.proxyAuthInfo()?.username}>
|
<Show when={props.proxyAuthInfo()?.username}>
|
||||||
<span class="text-xs px-2 py-1 text-gray-600 dark:text-gray-400">
|
<span class="text-xs px-2 py-1 text-gray-600 dark:text-gray-400">
|
||||||
{props.proxyAuthInfo()?.username}
|
{props.proxyAuthInfo()?.username}
|
||||||
|
|
|
||||||
191
frontend-modern/src/api/patrol.ts
Normal file
191
frontend-modern/src/api/patrol.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
/**
|
||||||
|
* AI Patrol API client
|
||||||
|
* Provides access to background AI monitoring findings and status
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type FindingSeverity = 'info' | 'watch' | 'warning' | 'critical';
|
||||||
|
export type FindingCategory = 'performance' | 'capacity' | 'reliability' | 'backup' | 'security' | 'general';
|
||||||
|
|
||||||
|
export interface Finding {
|
||||||
|
id: string;
|
||||||
|
severity: FindingSeverity;
|
||||||
|
category: FindingCategory;
|
||||||
|
resource_id: string;
|
||||||
|
resource_name: string;
|
||||||
|
resource_type: string; // node, vm, container, docker_host, docker_container, storage, pbs, host_raid
|
||||||
|
node?: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
recommendation?: string;
|
||||||
|
evidence?: string;
|
||||||
|
detected_at: string;
|
||||||
|
last_seen_at: string;
|
||||||
|
resolved_at?: string;
|
||||||
|
auto_resolved: boolean;
|
||||||
|
acknowledged_at?: string;
|
||||||
|
snoozed_until?: string; // Finding hidden until this time
|
||||||
|
alert_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FindingsSummary {
|
||||||
|
critical: number;
|
||||||
|
warning: number;
|
||||||
|
watch: number;
|
||||||
|
info: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatrolStatus {
|
||||||
|
running: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
last_patrol_at?: string;
|
||||||
|
last_deep_analysis_at?: string;
|
||||||
|
next_patrol_at?: string;
|
||||||
|
last_duration_ms: number;
|
||||||
|
resources_checked: number;
|
||||||
|
findings_count: number;
|
||||||
|
healthy: boolean;
|
||||||
|
summary: FindingsSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current AI patrol status
|
||||||
|
*/
|
||||||
|
export async function getPatrolStatus(): Promise<PatrolStatus> {
|
||||||
|
const resp = await fetch('/api/ai/patrol/status', {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`Failed to get patrol status: ${resp.status}`);
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active findings from the patrol service
|
||||||
|
* Optionally filter by resource ID
|
||||||
|
*/
|
||||||
|
export async function getFindings(resourceId?: string): Promise<Finding[]> {
|
||||||
|
const url = resourceId
|
||||||
|
? `/api/ai/patrol/findings?resource_id=${encodeURIComponent(resourceId)}`
|
||||||
|
: '/api/ai/patrol/findings';
|
||||||
|
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`Failed to get findings: ${resp.status}`);
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger an immediate patrol run
|
||||||
|
*/
|
||||||
|
export async function forcePatrol(deep: boolean = false): Promise<{ success: boolean; message: string }> {
|
||||||
|
const url = deep ? '/api/ai/patrol/run?deep=true' : '/api/ai/patrol/run';
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`Failed to trigger patrol: ${resp.status}`);
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get AI findings history including resolved findings
|
||||||
|
* @param startTime Optional ISO timestamp to filter findings from
|
||||||
|
*/
|
||||||
|
export async function getFindingsHistory(startTime?: string): Promise<Finding[]> {
|
||||||
|
const url = startTime
|
||||||
|
? `/api/ai/patrol/history?start_time=${encodeURIComponent(startTime)}`
|
||||||
|
: '/api/ai/patrol/history';
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`Failed to get findings history: ${resp.status}`);
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acknowledge a finding (marks as seen but keeps visible, like alert acknowledgement)
|
||||||
|
* Finding will auto-resolve when the underlying condition clears.
|
||||||
|
*/
|
||||||
|
export async function acknowledgeFinding(findingId: string): Promise<{ success: boolean; message: string }> {
|
||||||
|
const resp = await fetch('/api/ai/patrol/acknowledge', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ finding_id: findingId }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`Failed to acknowledge finding: ${resp.status}`);
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snooze a finding for a specified duration
|
||||||
|
* @param findingId The ID of the finding to snooze
|
||||||
|
* @param durationHours Duration in hours (e.g., 1, 24, 168 for 7 days)
|
||||||
|
*/
|
||||||
|
export async function snoozeFinding(findingId: string, durationHours: number): Promise<{ success: boolean; message: string }> {
|
||||||
|
const resp = await fetch('/api/ai/patrol/snooze', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ finding_id: findingId, duration_hours: durationHours }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`Failed to snooze finding: ${resp.status}`);
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Severity color mapping for UI
|
||||||
|
*/
|
||||||
|
export const severityColors: Record<FindingSeverity, { bg: string; text: string; border: string }> = {
|
||||||
|
critical: { bg: 'rgba(220, 38, 38, 0.15)', text: '#ef4444', border: 'rgba(220, 38, 38, 0.3)' },
|
||||||
|
warning: { bg: 'rgba(234, 179, 8, 0.15)', text: '#eab308', border: 'rgba(234, 179, 8, 0.3)' },
|
||||||
|
watch: { bg: 'rgba(59, 130, 246, 0.15)', text: '#3b82f6', border: 'rgba(59, 130, 246, 0.3)' },
|
||||||
|
info: { bg: 'rgba(107, 114, 128, 0.15)', text: '#9ca3af', border: 'rgba(107, 114, 128, 0.3)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Category labels for UI
|
||||||
|
*/
|
||||||
|
export const categoryLabels: Record<FindingCategory, string> = {
|
||||||
|
performance: 'Performance',
|
||||||
|
capacity: 'Capacity',
|
||||||
|
reliability: 'Reliability',
|
||||||
|
backup: 'Backup',
|
||||||
|
security: 'Security',
|
||||||
|
general: 'General',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a timestamp for display
|
||||||
|
*/
|
||||||
|
export function formatTimestamp(ts: string): 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();
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,31 @@ const renderMarkdown = (content: string): string => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper to sanitize thinking/reasoning content for display
|
||||||
|
// Removes raw network errors with IP addresses that are not user-friendly
|
||||||
|
const sanitizeThinking = (content: string): string => {
|
||||||
|
// Replace raw TCP connection details like "write tcp 192.168.0.123:7655->192.168.0.134:58004: i/o timeout"
|
||||||
|
// with friendlier messages
|
||||||
|
let sanitized = content.replace(
|
||||||
|
/write tcp [\d.:]+->[\d.:]+: i\/o timeout/g,
|
||||||
|
'connection timed out'
|
||||||
|
);
|
||||||
|
sanitized = sanitized.replace(
|
||||||
|
/read tcp [\d.:]+: i\/o timeout/g,
|
||||||
|
'connection timed out'
|
||||||
|
);
|
||||||
|
sanitized = sanitized.replace(
|
||||||
|
/dial tcp [\d.:]+: connection refused/g,
|
||||||
|
'connection refused'
|
||||||
|
);
|
||||||
|
// Replace "failed to send command: <raw error>" patterns
|
||||||
|
sanitized = sanitized.replace(
|
||||||
|
/failed to send command: write tcp [\d.:->\s]+/g,
|
||||||
|
'failed to send command: connection error'
|
||||||
|
);
|
||||||
|
return sanitized;
|
||||||
|
};
|
||||||
|
|
||||||
// In-progress tool execution (before completion)
|
// In-progress tool execution (before completion)
|
||||||
interface PendingTool {
|
interface PendingTool {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -47,11 +72,20 @@ interface PendingApproval {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Unified event type for chronological display
|
||||||
|
interface StreamDisplayEvent {
|
||||||
|
type: 'thinking' | 'tool';
|
||||||
|
thinking?: string;
|
||||||
|
tool?: AIToolExecution;
|
||||||
|
}
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
role: 'user' | 'assistant';
|
role: 'user' | 'assistant';
|
||||||
content: string;
|
content: string;
|
||||||
thinking?: string; // DeepSeek reasoning/thinking content
|
thinking?: string; // DeepSeek reasoning/thinking content (accumulated)
|
||||||
|
thinkingChunks?: string[]; // Thinking split into sequential blocks for display
|
||||||
|
streamEvents?: StreamDisplayEvent[]; // All events in chronological order
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
model?: string;
|
model?: string;
|
||||||
tokens?: { input: number; output: number };
|
tokens?: { input: number; output: number };
|
||||||
|
|
@ -326,6 +360,8 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||||
pendingTools: [],
|
pendingTools: [],
|
||||||
pendingApprovals: [],
|
pendingApprovals: [],
|
||||||
toolCalls: [],
|
toolCalls: [],
|
||||||
|
thinkingChunks: [],
|
||||||
|
streamEvents: [],
|
||||||
};
|
};
|
||||||
setMessages((prev) => [...prev, streamingMessage]);
|
setMessages((prev) => [...prev, streamingMessage]);
|
||||||
|
|
||||||
|
|
@ -394,24 +430,37 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||||
output: data.output,
|
output: data.output,
|
||||||
success: data.success,
|
success: data.success,
|
||||||
};
|
};
|
||||||
|
// Add to both toolCalls and streamEvents for chronological display
|
||||||
|
const events = msg.streamEvents || [];
|
||||||
return {
|
return {
|
||||||
...msg,
|
...msg,
|
||||||
pendingTools: updatedPending,
|
pendingTools: updatedPending,
|
||||||
toolCalls: [...(msg.toolCalls || []), newToolCall],
|
toolCalls: [...(msg.toolCalls || []), newToolCall],
|
||||||
|
streamEvents: [...events, { type: 'tool' as const, tool: newToolCall }],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'thinking': {
|
case 'thinking': {
|
||||||
const thinking = event.data as string;
|
const chunk = event.data as string;
|
||||||
|
if (!chunk.trim()) return msg; // Skip empty chunks
|
||||||
|
// Each thinking event is a new chunk - add to both arrays
|
||||||
|
const chunks = msg.thinkingChunks || [];
|
||||||
|
const events = msg.streamEvents || [];
|
||||||
return {
|
return {
|
||||||
...msg,
|
...msg,
|
||||||
thinking: (msg.thinking || '') + thinking,
|
thinking: (msg.thinking || '') + chunk,
|
||||||
|
thinkingChunks: [...chunks, chunk.trim()],
|
||||||
|
streamEvents: [...events, { type: 'thinking' as const, thinking: chunk.trim() }],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'content': {
|
case 'content': {
|
||||||
const content = event.data as string;
|
const content = event.data as string;
|
||||||
|
// Append content rather than replace - this allows intermediate AI responses
|
||||||
|
// during tool execution to accumulate, showing the user the full conversation flow
|
||||||
|
const existingContent = msg.content || '';
|
||||||
|
const separator = existingContent && !existingContent.endsWith('\n') ? '\n\n' : '';
|
||||||
return {
|
return {
|
||||||
...msg,
|
...msg,
|
||||||
content: content,
|
content: existingContent + separator + content,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'complete': {
|
case 'complete': {
|
||||||
|
|
@ -653,8 +702,13 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||||
prev.map((msg) => {
|
prev.map((msg) => {
|
||||||
if (msg.id !== assistantId) return msg;
|
if (msg.id !== assistantId) return msg;
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'content':
|
case 'content': {
|
||||||
return { ...msg, content: event.data as string, isStreaming: false };
|
// Append content, but filter out the initial placeholder
|
||||||
|
const content = event.data as string;
|
||||||
|
const existingContent = msg.content === '*Analyzing results...*' ? '' : (msg.content || '');
|
||||||
|
const separator = existingContent && !existingContent.endsWith('\n') ? '\n\n' : '';
|
||||||
|
return { ...msg, content: existingContent + separator + content, isStreaming: false };
|
||||||
|
}
|
||||||
case 'done':
|
case 'done':
|
||||||
return { ...msg, isStreaming: false };
|
return { ...msg, isStreaming: false };
|
||||||
case 'error':
|
case 'error':
|
||||||
|
|
@ -889,46 +943,58 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Show thinking/reasoning content (DeepSeek) */}
|
{/* Render all events in chronological order - thinking and tools interleaved */}
|
||||||
<Show when={message.role === 'assistant' && message.thinking}>
|
<Show when={message.role === 'assistant' && message.streamEvents && message.streamEvents.length > 0}>
|
||||||
<details class="mb-3 rounded border border-blue-300 dark:border-blue-700 overflow-hidden group">
|
|
||||||
<summary class="px-2 py-1.5 text-xs font-medium flex items-center gap-2 bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors">
|
|
||||||
<svg class="w-3.5 h-3.5 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
|
||||||
</svg>
|
|
||||||
<span>Thinking...</span>
|
|
||||||
<span class="text-blue-600 dark:text-blue-400 text-[10px]">({message.thinking!.length} chars)</span>
|
|
||||||
</summary>
|
|
||||||
<div class="px-2 py-2 text-xs bg-blue-50 dark:bg-blue-900/20 text-gray-700 dark:text-gray-300 max-h-48 overflow-y-auto whitespace-pre-wrap break-words font-mono">
|
|
||||||
{message.thinking!.length > 2000 ? message.thinking!.substring(0, 2000) + '...' : message.thinking}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Show completed tool calls FIRST - chronological order */}
|
|
||||||
<Show when={message.role === 'assistant' && message.toolCalls && message.toolCalls.length > 0}>
|
|
||||||
<div class="mb-3 space-y-2">
|
<div class="mb-3 space-y-2">
|
||||||
<For each={message.toolCalls}>
|
<For each={message.streamEvents}>
|
||||||
{(tool) => (
|
{(evt) => (
|
||||||
|
<Show
|
||||||
|
when={evt.type === 'tool' && evt.tool}
|
||||||
|
fallback={
|
||||||
|
// Thinking chunk
|
||||||
|
<div class="px-2 py-1.5 text-xs bg-blue-50 dark:bg-blue-900/20 text-gray-700 dark:text-gray-300 rounded border-l-2 border-blue-400 whitespace-pre-wrap">
|
||||||
|
{sanitizeThinking(evt.thinking && evt.thinking.length > 500 ? evt.thinking.substring(0, 500) + '...' : evt.thinking || '')}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Tool call */}
|
||||||
<div class="rounded border border-gray-300 dark:border-gray-600 overflow-hidden">
|
<div class="rounded border border-gray-300 dark:border-gray-600 overflow-hidden">
|
||||||
<div class={`px-2 py-1 text-xs font-medium flex items-center gap-2 ${tool.success
|
<div class={`px-2 py-1 text-xs font-medium flex items-center gap-2 ${evt.tool!.success
|
||||||
? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200'
|
? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200'
|
||||||
: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200'
|
: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200'
|
||||||
}`}>
|
}`}>
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
<code class="font-mono">{tool.input}</code>
|
<code class="font-mono">{evt.tool!.input}</code>
|
||||||
</div>
|
</div>
|
||||||
<Show when={tool.output}>
|
<Show when={evt.tool!.output}>
|
||||||
<pre class="px-2 py-1 text-xs font-mono bg-gray-50 dark:bg-gray-900 text-gray-700 dark:text-gray-300 overflow-x-auto max-h-32 overflow-y-auto whitespace-pre-wrap break-words">
|
<pre class="px-2 py-1 text-xs font-mono bg-gray-50 dark:bg-gray-900 text-gray-700 dark:text-gray-300 overflow-x-auto max-h-32 overflow-y-auto whitespace-pre-wrap break-words">
|
||||||
{tool.output.length > 500 ? tool.output.substring(0, 500) + '...' : tool.output}
|
{evt.tool!.output.length > 500 ? evt.tool!.output.substring(0, 500) + '...' : evt.tool!.output}
|
||||||
</pre>
|
</pre>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Show in-progress tool executions - at the bottom */}
|
||||||
|
<Show when={message.role === 'assistant' && message.pendingTools && message.pendingTools.length > 0}>
|
||||||
|
<div class="mb-3 space-y-2">
|
||||||
|
<For each={message.pendingTools}>
|
||||||
|
{(tool) => (
|
||||||
|
<div class="rounded border border-purple-400 dark:border-purple-600 overflow-hidden">
|
||||||
|
<div class="px-2 py-1 text-xs font-medium flex items-center gap-2 bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-200">
|
||||||
|
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||||
|
</svg>
|
||||||
|
<code class="font-mono">{tool.input}</code>
|
||||||
|
<span class="text-[10px] text-purple-600 dark:text-purple-400">Running...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
74
frontend-modern/src/components/AI/AIStatusIndicator.css
Normal file
74
frontend-modern/src/components/AI/AIStatusIndicator.css
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
/**
|
||||||
|
* AIStatusIndicator styles
|
||||||
|
* Designed to be minimal and blend with Pulse's header
|
||||||
|
*/
|
||||||
|
|
||||||
|
.ai-status-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-status-indicator:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Healthy state - very subtle */
|
||||||
|
.ai-status--healthy {
|
||||||
|
color: var(--success-color, #22c55e);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-status--healthy:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Watch state - noticeable but not alarming */
|
||||||
|
.ai-status--watch {
|
||||||
|
color: var(--info-color, #3b82f6);
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
border-color: rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-status--watch:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Issues state - draws attention */
|
||||||
|
.ai-status--issues {
|
||||||
|
color: var(--warning-color, #eab308);
|
||||||
|
background: rgba(234, 179, 8, 0.1);
|
||||||
|
border-color: rgba(234, 179, 8, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-status--issues:hover {
|
||||||
|
background: rgba(234, 179, 8, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon */
|
||||||
|
.ai-status-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-status-icon svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Count badge */
|
||||||
|
.ai-status-count {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
113
frontend-modern/src/components/AI/AIStatusIndicator.tsx
Normal file
113
frontend-modern/src/components/AI/AIStatusIndicator.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
/**
|
||||||
|
* AIStatusIndicator - Subtle header component showing AI patrol health
|
||||||
|
*
|
||||||
|
* Design: Minimal presence when healthy, highlighted when issues detected.
|
||||||
|
* Clicking navigates to the Alerts page where AI Insights are displayed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createResource, Show, createMemo, onCleanup } from 'solid-js';
|
||||||
|
import { useNavigate } from '@solidjs/router';
|
||||||
|
import { getPatrolStatus, type PatrolStatus } from '../../api/patrol';
|
||||||
|
import './AIStatusIndicator.css';
|
||||||
|
|
||||||
|
export function AIStatusIndicator() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Poll patrol status every 30 seconds
|
||||||
|
const [status, { refetch }] = createResource<PatrolStatus>(
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
return await getPatrolStatus();
|
||||||
|
} catch {
|
||||||
|
return null as unknown as PatrolStatus;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ initialValue: undefined }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Refetch every 30 seconds with proper cleanup
|
||||||
|
const intervalId = setInterval(() => refetch(), 30000);
|
||||||
|
onCleanup(() => clearInterval(intervalId));
|
||||||
|
|
||||||
|
const hasIssues = createMemo(() => {
|
||||||
|
const s = status();
|
||||||
|
if (!s) return false;
|
||||||
|
return s.summary.critical > 0 || s.summary.warning > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasWatch = createMemo(() => {
|
||||||
|
const s = status();
|
||||||
|
if (!s) return false;
|
||||||
|
return s.summary.watch > 0 && !hasIssues();
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalFindings = createMemo(() => {
|
||||||
|
const s = status();
|
||||||
|
if (!s) return 0;
|
||||||
|
return s.summary.critical + s.summary.warning + s.summary.watch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tooltipText = createMemo(() => {
|
||||||
|
const s = status();
|
||||||
|
if (!s || !s.enabled) return 'AI Patrol disabled';
|
||||||
|
if (!s.running) return 'AI Patrol not running';
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (s.summary.critical > 0) parts.push(`${s.summary.critical} critical`);
|
||||||
|
if (s.summary.warning > 0) parts.push(`${s.summary.warning} warning`);
|
||||||
|
if (s.summary.watch > 0) parts.push(`${s.summary.watch} watch`);
|
||||||
|
|
||||||
|
if (parts.length === 0) return 'AI: All systems healthy';
|
||||||
|
return `AI: ${parts.join(', ')}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusClass = createMemo(() => {
|
||||||
|
if (hasIssues()) return 'ai-status--issues';
|
||||||
|
if (hasWatch()) return 'ai-status--watch';
|
||||||
|
return 'ai-status--healthy';
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
// Navigate to Alerts page with AI Insights subtab selected
|
||||||
|
navigate('/alerts?subtab=ai-insights');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={status()?.enabled}>
|
||||||
|
<button
|
||||||
|
class={`ai-status-indicator ${statusClass()}`}
|
||||||
|
onClick={handleClick}
|
||||||
|
title={tooltipText()}
|
||||||
|
>
|
||||||
|
<span class="ai-status-icon">
|
||||||
|
<Show when={hasIssues()} fallback={
|
||||||
|
<Show when={hasWatch()} fallback={
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z" />
|
||||||
|
<path d="M9 12l2 2 4-4" />
|
||||||
|
</svg>
|
||||||
|
}>
|
||||||
|
{/* Watch icon - eye */}
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
</Show>
|
||||||
|
}>
|
||||||
|
{/* Issues icon - alert */}
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13" />
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
|
</svg>
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
<Show when={totalFindings() > 0}>
|
||||||
|
<span class="ai-status-count">{totalFindings()}</span>
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AIStatusIndicator;
|
||||||
|
|
@ -1171,7 +1171,7 @@ export function Dashboard(props: DashboardProps) {
|
||||||
visibleColumnIds={visibleColumnIds()}
|
visibleColumnIds={visibleColumnIds()}
|
||||||
aboveGuestId={prevGuestId}
|
aboveGuestId={prevGuestId}
|
||||||
belowGuestId={nextGuestId}
|
belowGuestId={nextGuestId}
|
||||||
onRowClick={aiChatStore.isOpen ? handleGuestRowClick : undefined}
|
onRowClick={aiChatStore.enabled ? handleGuestRowClick : undefined}
|
||||||
/>
|
/>
|
||||||
</ComponentErrorBoundary>
|
</ComponentErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -701,8 +701,8 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
? ''
|
? ''
|
||||||
: 'hover:bg-gray-50 dark:hover:bg-gray-700/30';
|
: 'hover:bg-gray-50 dark:hover:bg-gray-700/30';
|
||||||
const stoppedDimming = !isRunning() ? 'opacity-60' : '';
|
const stoppedDimming = !isRunning() ? 'opacity-60' : '';
|
||||||
// Make row clickable if click handler provided
|
// Make row clickable if AI is enabled (for context selection)
|
||||||
const clickable = props.onRowClick ? 'cursor-pointer' : '';
|
const clickable = aiChatStore.enabled ? 'cursor-pointer' : '';
|
||||||
// AI context highlight with merged borders for adjacent rows
|
// AI context highlight with merged borders for adjacent rows
|
||||||
let aiContext = '';
|
let aiContext = '';
|
||||||
if (isInAIContext()) {
|
if (isInAIContext()) {
|
||||||
|
|
|
||||||
|
|
@ -802,7 +802,7 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Card tone="muted" padding="sm" class="text-xs text-gray-600 dark:text-gray-400">
|
<Card tone="muted" padding="sm" class="text-xs text-gray-600 dark:text-gray-400">
|
||||||
💡 Separate tokens per integration • Rotate regularly •{' '}
|
Separate tokens per integration • Rotate regularly •{' '}
|
||||||
<a
|
<a
|
||||||
class="font-medium text-blue-600 underline-offset-2 hover:underline dark:text-blue-400"
|
class="font-medium text-blue-600 underline-offset-2 hover:underline dark:text-blue-400"
|
||||||
href={SCOPES_DOC_URL}
|
href={SCOPES_DOC_URL}
|
||||||
|
|
|
||||||
|
|
@ -5359,7 +5359,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
|
|
||||||
<div class="mt-3 p-2 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded">
|
<div class="mt-3 p-2 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded">
|
||||||
<p class="text-xs text-green-700 dark:text-green-300">
|
<p class="text-xs text-green-700 dark:text-green-300">
|
||||||
💡 <strong>Tip:</strong> Make sure you've saved your credentials
|
<strong>Tip:</strong> Make sure you've saved your credentials
|
||||||
before restarting!
|
before restarting!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -7425,7 +7425,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<Show when={!diagnosticsData()}>
|
<Show when={!diagnosticsData()}>
|
||||||
<div class="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded p-2">
|
<div class="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded p-2">
|
||||||
💡 Run diagnostics first for more comprehensive export data
|
Run diagnostics first for more comprehensive export data
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
|
|
|
||||||
|
|
@ -337,11 +337,11 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
return sortDirection() === 'asc' ? '▲' : '▼';
|
return sortDirection() === 'asc' ? '▲' : '▼';
|
||||||
};
|
};
|
||||||
|
|
||||||
const thClassBase = "px-2 py-1.5 text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap";
|
const thClassBase = "px-2 py-1 text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap";
|
||||||
const thClass = `${thClassBase} text-center`;
|
const thClass = `${thClassBase} text-center`;
|
||||||
|
|
||||||
// Cell class constants for consistency
|
// Cell class constants for consistency
|
||||||
const tdClass = "px-2 py-1.5 align-middle";
|
const tdClass = "px-2 py-1 align-middle";
|
||||||
const metricColumnStyle = { width: "200px", "min-width": "200px", "max-width": "200px" } as const;
|
const metricColumnStyle = { width: "200px", "min-width": "200px", "max-width": "200px" } as const;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -482,11 +482,11 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
class={rowClass()}
|
class={rowClass()}
|
||||||
style={rowStyle()}
|
style={{ ...rowStyle(), height: '29px', 'max-height': '29px' }}
|
||||||
onClick={() => props.onNodeClick(nodeId, isPVEItem ? 'pve' : 'pbs')}
|
onClick={() => props.onNodeClick(nodeId, isPVEItem ? 'pve' : 'pbs')}
|
||||||
>
|
>
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
<td class={`pr-2 py-1.5 align-middle ${showAlertHighlight() ? 'pl-4' : 'pl-3'}`}>
|
<td class={`pr-2 py-1 align-middle ${showAlertHighlight() ? 'pl-4' : 'pl-3'}`}>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<StatusDot
|
<StatusDot
|
||||||
variant={statusIndicator().variant}
|
variant={statusIndicator().variant}
|
||||||
|
|
@ -568,11 +568,11 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
{/* CPU */}
|
{/* CPU */}
|
||||||
<td class={tdClass} style={metricColumnStyle}>
|
<td class={tdClass} style={metricColumnStyle}>
|
||||||
<Show when={isMobile()}>
|
<Show when={isMobile()}>
|
||||||
<div class="md:hidden flex justify-center">
|
<div class="md:hidden h-4 flex items-center justify-center">
|
||||||
<MetricText value={cpuPercentValue} type="cpu" />
|
<MetricText value={cpuPercentValue} type="cpu" />
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="hidden md:block">
|
<div class="hidden md:block h-4">
|
||||||
<Show when={isPVEItem} fallback={
|
<Show when={isPVEItem} fallback={
|
||||||
<ResponsiveMetricCell
|
<ResponsiveMetricCell
|
||||||
value={cpuPercentValue}
|
value={cpuPercentValue}
|
||||||
|
|
@ -596,11 +596,11 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
{/* Memory */}
|
{/* Memory */}
|
||||||
<td class={tdClass} style={metricColumnStyle}>
|
<td class={tdClass} style={metricColumnStyle}>
|
||||||
<Show when={isMobile()}>
|
<Show when={isMobile()}>
|
||||||
<div class="md:hidden flex justify-center">
|
<div class="md:hidden h-4 flex items-center justify-center">
|
||||||
<MetricText value={memoryPercentValue} type="memory" />
|
<MetricText value={memoryPercentValue} type="memory" />
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="hidden md:block">
|
<div class="hidden md:block h-4">
|
||||||
<Show when={isPVEItem} fallback={
|
<Show when={isPVEItem} fallback={
|
||||||
<ResponsiveMetricCell
|
<ResponsiveMetricCell
|
||||||
value={memoryPercentValue}
|
value={memoryPercentValue}
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,9 @@ export const ResponsiveMetricCell: Component<ResponsiveMetricCellProps> = (props
|
||||||
const isRunning = () => props.isRunning !== false; // Default to true if not specified
|
const isRunning = () => props.isRunning !== false; // Default to true if not specified
|
||||||
|
|
||||||
const defaultFallback = (
|
const defaultFallback = (
|
||||||
|
<div class="h-4 flex items-center justify-center">
|
||||||
<span class="text-xs text-gray-400 dark:text-gray-500">—</span>
|
<span class="text-xs text-gray-400 dark:text-gray-500">—</span>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -148,7 +150,9 @@ export const DualMetricCell: Component<{
|
||||||
const isRunning = () => props.isRunning !== false;
|
const isRunning = () => props.isRunning !== false;
|
||||||
|
|
||||||
const defaultFallback = (
|
const defaultFallback = (
|
||||||
|
<div class="h-4 flex items-center justify-center">
|
||||||
<span class="text-xs text-gray-400 dark:text-gray-500">—</span>
|
<span class="text-xs text-gray-400 dark:text-gray-500">—</span>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const defaultMobileContent = (
|
const defaultMobileContent = (
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ import History from 'lucide-solid/icons/history';
|
||||||
import Gauge from 'lucide-solid/icons/gauge';
|
import Gauge from 'lucide-solid/icons/gauge';
|
||||||
import Send from 'lucide-solid/icons/send';
|
import Send from 'lucide-solid/icons/send';
|
||||||
import Calendar from 'lucide-solid/icons/calendar';
|
import Calendar from 'lucide-solid/icons/calendar';
|
||||||
|
import { getPatrolStatus, getFindings, getFindingsHistory, acknowledgeFinding, snoozeFinding, type Finding, type PatrolStatus, severityColors, formatTimestamp } from '@/api/patrol';
|
||||||
|
import { aiChatStore } from '@/stores/aiChat';
|
||||||
|
|
||||||
type AlertTab = 'overview' | 'thresholds' | 'destinations' | 'schedule' | 'history';
|
type AlertTab = 'overview' | 'thresholds' | 'destinations' | 'schedule' | 'history';
|
||||||
|
|
||||||
|
|
@ -1453,7 +1455,9 @@ export function Alerts() {
|
||||||
];
|
];
|
||||||
|
|
||||||
const flatTabs = tabGroups.flatMap((group) => group.items);
|
const flatTabs = tabGroups.flatMap((group) => group.items);
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = createSignal(true);
|
// Sidebar always starts expanded for discoverability (consistent with Settings)
|
||||||
|
// Users can collapse during session but it resets on page reload
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = createSignal(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
|
@ -1746,16 +1750,58 @@ export function Alerts() {
|
||||||
<div class={`transition-opacity ${isAlertsActive() ? 'opacity-100' : 'opacity-50 pointer-events-none'
|
<div class={`transition-opacity ${isAlertsActive() ? 'opacity-100' : 'opacity-50 pointer-events-none'
|
||||||
}`}>
|
}`}>
|
||||||
|
|
||||||
<Card padding="none" class="relative lg:flex">
|
<Card padding="none" class="relative lg:flex overflow-hidden">
|
||||||
<div
|
<div
|
||||||
class={`hidden lg:flex lg:flex-col ${sidebarCollapsed() ? 'w-16' : 'w-72'} ${sidebarCollapsed() ? 'lg:min-w-[4rem] lg:max-w-[4rem] lg:basis-[4rem]' : 'lg:min-w-[18rem] lg:max-w-[18rem] lg:basis-[18rem]'} relative border-b border-gray-200 dark:border-gray-700 lg:border-b-0 lg:border-r lg:border-gray-200 dark:lg:border-gray-700 lg:align-top flex-shrink-0 transition-all duration-300`}
|
class={`hidden lg:flex lg:flex-col ${sidebarCollapsed() ? 'w-16' : 'w-72'} ${sidebarCollapsed() ? 'lg:min-w-[4rem] lg:max-w-[4rem] lg:basis-[4rem]' : 'lg:min-w-[18rem] lg:max-w-[18rem] lg:basis-[18rem]'} relative border-b border-gray-200 dark:border-gray-700 lg:border-b-0 lg:border-r lg:border-gray-200 dark:lg:border-gray-700 lg:align-top flex-shrink-0 transition-all duration-200`}
|
||||||
onMouseEnter={() => setSidebarCollapsed(false)}
|
|
||||||
onMouseLeave={() => setSidebarCollapsed(true)}
|
|
||||||
aria-label="Alerts navigation"
|
aria-label="Alerts navigation"
|
||||||
aria-expanded={!sidebarCollapsed()}
|
aria-expanded={!sidebarCollapsed()}
|
||||||
>
|
>
|
||||||
<div class={`sticky top-24 ${sidebarCollapsed() ? 'px-2' : 'px-5'} py-6 space-y-6 transition-all duration-300`}>
|
<div
|
||||||
<div id="alerts-sidebar-menu" class="space-y-6">
|
class={`sticky top-0 ${sidebarCollapsed() ? 'px-2' : 'px-4'} py-5 space-y-5 transition-all duration-200`}
|
||||||
|
>
|
||||||
|
<Show when={!sidebarCollapsed()}>
|
||||||
|
<div class="flex items-center justify-between pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Alerts</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSidebarCollapsed(true)}
|
||||||
|
class="p-1 rounded-md text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
aria-label="Collapse sidebar"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={sidebarCollapsed()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSidebarCollapsed(false)}
|
||||||
|
class="w-full p-2 rounded-md text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
aria-label="Expand sidebar"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 mx-auto"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M13 5l7 7-7 7M5 5l7 7-7 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
<div id="alerts-sidebar-menu" class="space-y-5">
|
||||||
<For each={tabGroups}>
|
<For each={tabGroups}>
|
||||||
{(group) => (
|
{(group) => (
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
|
@ -1772,24 +1818,18 @@ export function Alerts() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-current={activeTab() === item.id ? 'page' : undefined}
|
aria-current={activeTab() === item.id ? 'page' : undefined}
|
||||||
class={`flex w-full items-center ${sidebarCollapsed() ? 'justify-center' : 'gap-2.5'} rounded-md ${sidebarCollapsed() ? 'px-2 py-2.5' : 'px-3 py-2'} text-sm font-medium transition-colors ${activeTab() === item.id
|
disabled={disabled()}
|
||||||
|
class={`flex w-full items-center ${sidebarCollapsed() ? 'justify-center' : 'gap-2.5'} rounded-md ${sidebarCollapsed() ? 'px-2 py-2.5' : 'px-3 py-2'} text-sm font-medium transition-colors ${disabled()
|
||||||
|
? 'opacity-60 cursor-not-allowed text-gray-400 dark:text-gray-600'
|
||||||
|
: activeTab() === item.id
|
||||||
? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-200'
|
? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-200'
|
||||||
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700/60 dark:hover:text-gray-100'
|
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700/60 dark:hover:text-gray-100'
|
||||||
} ${disabled() ? 'opacity-50 cursor-not-allowed pointer-events-none' : ''}`}
|
}`}
|
||||||
disabled={disabled()}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (disabled()) return;
|
if (disabled()) return;
|
||||||
handleTabChange(item.id);
|
handleTabChange(item.id);
|
||||||
}}
|
}}
|
||||||
title={
|
title={sidebarCollapsed() ? item.label : undefined}
|
||||||
sidebarCollapsed()
|
|
||||||
? disabled()
|
|
||||||
? 'Activate alerts to configure'
|
|
||||||
: item.label
|
|
||||||
: disabled()
|
|
||||||
? 'Activate alerts to configure'
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
<Show when={!sidebarCollapsed()}>
|
<Show when={!sidebarCollapsed()}>
|
||||||
|
|
@ -1807,7 +1847,7 @@ export function Alerts() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 overflow-hidden">
|
||||||
<Show when={flatTabs.length > 0}>
|
<Show when={flatTabs.length > 0}>
|
||||||
<div class="lg:hidden border-b border-gray-200 dark:border-gray-700">
|
<div class="lg:hidden border-b border-gray-200 dark:border-gray-700">
|
||||||
<div class="p-1">
|
<div class="p-1">
|
||||||
|
|
@ -1999,6 +2039,30 @@ function OverviewTab(props: {
|
||||||
// Loading states for buttons
|
// Loading states for buttons
|
||||||
const [processingAlerts, setProcessingAlerts] = createSignal<Set<string>>(new Set());
|
const [processingAlerts, setProcessingAlerts] = createSignal<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// AI Patrol findings state
|
||||||
|
const [aiFindings, setAiFindings] = createSignal<Finding[]>([]);
|
||||||
|
const [patrolStatus, setPatrolStatus] = createSignal<PatrolStatus | null>(null);
|
||||||
|
const [processingFindings, setProcessingFindings] = createSignal<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Fetch AI findings on mount and every 30 seconds
|
||||||
|
onMount(() => {
|
||||||
|
const fetchAiData = async () => {
|
||||||
|
try {
|
||||||
|
const [status, findings] = await Promise.all([
|
||||||
|
getPatrolStatus(),
|
||||||
|
getFindings()
|
||||||
|
]);
|
||||||
|
setPatrolStatus(status);
|
||||||
|
setAiFindings(findings);
|
||||||
|
} catch (e) {
|
||||||
|
// AI patrol may not be enabled - silently fail
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchAiData();
|
||||||
|
const interval = setInterval(fetchAiData, 30000);
|
||||||
|
onCleanup(() => clearInterval(interval));
|
||||||
|
});
|
||||||
|
|
||||||
// Get alert stats from actual active alerts
|
// Get alert stats from actual active alerts
|
||||||
const alertStats = createMemo(() => {
|
const alertStats = createMemo(() => {
|
||||||
// Access the store properly for reactivity
|
// Access the store properly for reactivity
|
||||||
|
|
@ -2012,6 +2076,7 @@ function OverviewTab(props: {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const filteredAlerts = createMemo(() => {
|
const filteredAlerts = createMemo(() => {
|
||||||
const alerts = Object.values(props.activeAlerts);
|
const alerts = Object.values(props.activeAlerts);
|
||||||
// Sort: unacknowledged first, then by start time (newest first)
|
// Sort: unacknowledged first, then by start time (newest first)
|
||||||
|
|
@ -2033,34 +2098,23 @@ function OverviewTab(props: {
|
||||||
|
|
||||||
const [bulkAckProcessing, setBulkAckProcessing] = createSignal(false);
|
const [bulkAckProcessing, setBulkAckProcessing] = createSignal(false);
|
||||||
|
|
||||||
|
// Sub-tab for switching between AI Insights and Active Alerts
|
||||||
|
type OverviewSubTab = 'ai-insights' | 'active-alerts';
|
||||||
|
|
||||||
|
// Read subtab from URL query parameter to allow deep linking
|
||||||
|
const location = useLocation();
|
||||||
|
const getInitialSubTab = (): OverviewSubTab => {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const subtab = params.get('subtab');
|
||||||
|
if (subtab === 'ai-insights') return 'ai-insights';
|
||||||
|
return 'active-alerts';
|
||||||
|
};
|
||||||
|
const [overviewSubTab, setOverviewSubTab] = createSignal<OverviewSubTab>(getInitialSubTab());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards - only show cards not duplicated in sub-tabs */}
|
||||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-4">
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-3">
|
||||||
<Card padding="sm" class="sm:p-4">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Active Alerts</p>
|
|
||||||
<p class="text-xl sm:text-2xl font-semibold text-gray-600 dark:text-gray-300">
|
|
||||||
{alertStats().active}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="w-8 h-8 sm:w-10 sm:h-10 bg-red-100 dark:bg-red-900/50 rounded-full flex items-center justify-center">
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
class="sm:w-5 sm:h-5 text-red-600 dark:text-red-400"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
|
|
||||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card padding="sm" class="sm:p-4">
|
<Card padding="sm" class="sm:p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
|
|
@ -2138,7 +2192,233 @@ function OverviewTab(props: {
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Alerts */}
|
{/* Sub-tabs for AI Insights vs Active Alerts */}
|
||||||
|
<Show when={patrolStatus()?.enabled}>
|
||||||
|
<div class="flex items-center gap-1 border-b border-gray-200 dark:border-gray-700/50 pb-1">
|
||||||
|
<button
|
||||||
|
class={`px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${overviewSubTab() === 'active-alerts'
|
||||||
|
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 border border-b-0 border-gray-200 dark:border-gray-700'
|
||||||
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
onClick={() => setOverviewSubTab('active-alerts')}
|
||||||
|
>
|
||||||
|
Active Alerts
|
||||||
|
<Show when={alertStats().active > 0}>
|
||||||
|
<span class="ml-2 px-1.5 py-0.5 text-xs font-medium rounded bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300">
|
||||||
|
{alertStats().active}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class={`px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${overviewSubTab() === 'ai-insights'
|
||||||
|
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 border border-b-0 border-gray-200 dark:border-gray-700'
|
||||||
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
onClick={() => setOverviewSubTab('ai-insights')}
|
||||||
|
>
|
||||||
|
AI Insights
|
||||||
|
<Show when={aiFindings().length > 0}>
|
||||||
|
<span class="ml-2 px-1.5 py-0.5 text-xs font-medium rounded bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300">
|
||||||
|
{aiFindings().length}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* AI Insights Section - show when AI tab selected and there are findings */}
|
||||||
|
<Show when={overviewSubTab() === 'ai-insights' && patrolStatus()?.enabled}>
|
||||||
|
<div>
|
||||||
|
<SectionHeader
|
||||||
|
title="AI Insights"
|
||||||
|
size="md"
|
||||||
|
class="mb-3"
|
||||||
|
/>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mb-3 flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>Proactively detected by AI patrol • Last check: {patrolStatus()?.last_patrol_at ? formatTimestamp(patrolStatus()!.last_patrol_at!) : 'never'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<For each={aiFindings()}>
|
||||||
|
{(finding) => {
|
||||||
|
const colors = severityColors[finding.severity];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={`border rounded-lg p-4 transition-all ${finding.acknowledged_at ? 'opacity-60' : ''}`}
|
||||||
|
style={{
|
||||||
|
'background-color': colors.bg,
|
||||||
|
'border-color': colors.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-start">
|
||||||
|
<div class="flex items-start flex-1">
|
||||||
|
{/* AI badge */}
|
||||||
|
<div class="mr-3 mt-0.5">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold"
|
||||||
|
style={{ 'background-color': colors.border, color: colors.text }}
|
||||||
|
>
|
||||||
|
AI
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="text-sm font-medium truncate"
|
||||||
|
style={{ color: colors.text }}
|
||||||
|
>
|
||||||
|
{finding.resource_name}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
({finding.category})
|
||||||
|
</span>
|
||||||
|
<Show when={finding.node}>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-500">
|
||||||
|
on {finding.node}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<span
|
||||||
|
class="px-2 py-0.5 text-xs rounded capitalize"
|
||||||
|
style={{ 'background-color': colors.border, color: colors.text }}
|
||||||
|
>
|
||||||
|
{finding.severity}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300 mt-1 font-medium">
|
||||||
|
{finding.title}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-0.5">
|
||||||
|
{finding.description}
|
||||||
|
</p>
|
||||||
|
<Show when={finding.recommendation}>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-500 mt-1 italic">
|
||||||
|
{finding.recommendation}
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||||
|
Detected: {formatTimestamp(finding.detected_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 mt-3 sm:mt-0 sm:ml-4 self-end sm:self-start">
|
||||||
|
<button
|
||||||
|
class={`px-3 py-1.5 text-xs font-medium border rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed ${finding.acknowledged_at
|
||||||
|
? 'bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 border-green-300 dark:border-green-700'
|
||||||
|
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
disabled={processingFindings().has(finding.id) || !!finding.acknowledged_at}
|
||||||
|
onClick={async () => {
|
||||||
|
if (processingFindings().has(finding.id)) return;
|
||||||
|
setProcessingFindings((prev) => new Set(prev).add(finding.id));
|
||||||
|
try {
|
||||||
|
await acknowledgeFinding(finding.id);
|
||||||
|
// Update local state to mark as acknowledged (keep visible but dimmed)
|
||||||
|
setAiFindings((prev) => prev.map(f =>
|
||||||
|
f.id === finding.id
|
||||||
|
? { ...f, acknowledged_at: new Date().toISOString() }
|
||||||
|
: f
|
||||||
|
));
|
||||||
|
showSuccess('Finding acknowledged');
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to acknowledge finding:', err);
|
||||||
|
showError('Failed to acknowledge finding');
|
||||||
|
} finally {
|
||||||
|
setProcessingFindings((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(finding.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{processingFindings().has(finding.id) ? 'Acknowledging...' : finding.acknowledged_at ? '✓ Acknowledged' : 'Acknowledge'}
|
||||||
|
</button>
|
||||||
|
{/* Snooze dropdown */}
|
||||||
|
<div class="relative group">
|
||||||
|
<button
|
||||||
|
class="px-3 py-1.5 text-xs font-medium border rounded-lg transition-all bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
|
||||||
|
disabled={processingFindings().has(finding.id)}
|
||||||
|
>
|
||||||
|
Snooze ▾
|
||||||
|
</button>
|
||||||
|
<div class="absolute right-0 mt-1 w-28 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-50">
|
||||||
|
<button
|
||||||
|
class="w-full px-3 py-2 text-left text-xs hover:bg-gray-100 dark:hover:bg-gray-700 rounded-t-lg"
|
||||||
|
onClick={async () => {
|
||||||
|
setProcessingFindings((prev) => new Set(prev).add(finding.id));
|
||||||
|
try {
|
||||||
|
await snoozeFinding(finding.id, 1);
|
||||||
|
setAiFindings((prev) => prev.filter(f => f.id !== finding.id));
|
||||||
|
showSuccess('Snoozed for 1 hour');
|
||||||
|
} catch (err) {
|
||||||
|
showError('Failed to snooze');
|
||||||
|
} finally {
|
||||||
|
setProcessingFindings((prev) => { const n = new Set(prev); n.delete(finding.id); return n; });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
1 hour
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="w-full px-3 py-2 text-left text-xs hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
onClick={async () => {
|
||||||
|
setProcessingFindings((prev) => new Set(prev).add(finding.id));
|
||||||
|
try {
|
||||||
|
await snoozeFinding(finding.id, 24);
|
||||||
|
setAiFindings((prev) => prev.filter(f => f.id !== finding.id));
|
||||||
|
showSuccess('Snoozed for 24 hours');
|
||||||
|
} catch (err) {
|
||||||
|
showError('Failed to snooze');
|
||||||
|
} finally {
|
||||||
|
setProcessingFindings((prev) => { const n = new Set(prev); n.delete(finding.id); return n; });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
24 hours
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="w-full px-3 py-2 text-left text-xs hover:bg-gray-100 dark:hover:bg-gray-700 rounded-b-lg"
|
||||||
|
onClick={async () => {
|
||||||
|
setProcessingFindings((prev) => new Set(prev).add(finding.id));
|
||||||
|
try {
|
||||||
|
await snoozeFinding(finding.id, 168);
|
||||||
|
setAiFindings((prev) => prev.filter(f => f.id !== finding.id));
|
||||||
|
showSuccess('Snoozed for 7 days');
|
||||||
|
} catch (err) {
|
||||||
|
showError('Failed to snooze');
|
||||||
|
} finally {
|
||||||
|
setProcessingFindings((prev) => { const n = new Set(prev); n.delete(finding.id); return n; });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
7 days
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="px-3 py-1.5 text-xs font-medium border rounded-lg transition-all bg-purple-50 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 border-purple-300 dark:border-purple-700 hover:bg-purple-100 dark:hover:bg-purple-900/50"
|
||||||
|
onClick={() => {
|
||||||
|
aiChatStore.openWithPrompt(
|
||||||
|
`Tell me more about this issue: ${finding.title} on ${finding.resource_name}. ${finding.description}`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Investigate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Active Alerts - show when alerts tab selected OR when patrol is disabled (no sub-tabs) */}
|
||||||
|
<Show when={overviewSubTab() === 'active-alerts' || !patrolStatus()?.enabled}>
|
||||||
<div>
|
<div>
|
||||||
<SectionHeader title="Active Alerts" size="md" class="mb-3" />
|
<SectionHeader title="Active Alerts" size="md" class="mb-3" />
|
||||||
<Show
|
<Show
|
||||||
|
|
@ -2388,6 +2668,7 @@ function OverviewTab(props: {
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -3897,8 +4178,16 @@ function HistoryTab() {
|
||||||
deserialize: (raw) => (raw === 'warning' || raw === 'critical' ? raw : 'all'),
|
deserialize: (raw) => (raw === 'warning' || raw === 'critical' ? raw : 'all'),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
const [sourceFilter, setSourceFilter] = usePersistentSignal<'all' | 'alerts' | 'ai'>(
|
||||||
|
'alertHistorySourceFilter',
|
||||||
|
'all',
|
||||||
|
{
|
||||||
|
deserialize: (raw) => (raw === 'alerts' || raw === 'ai' ? raw : 'all'),
|
||||||
|
},
|
||||||
|
);
|
||||||
const [searchTerm, setSearchTerm] = createSignal('');
|
const [searchTerm, setSearchTerm] = createSignal('');
|
||||||
const [alertHistory, setAlertHistory] = createSignal<Alert[]>([]);
|
const [alertHistory, setAlertHistory] = createSignal<Alert[]>([]);
|
||||||
|
const [aiFindingsHistory, setAiFindingsHistory] = createSignal<Finding[]>([]);
|
||||||
const [loading, setLoading] = createSignal(true);
|
const [loading, setLoading] = createSignal(true);
|
||||||
const [selectedBarIndex, setSelectedBarIndex] = createSignal<number | null>(null);
|
const [selectedBarIndex, setSelectedBarIndex] = createSignal<number | null>(null);
|
||||||
const MS_PER_HOUR = 60 * 60 * 1000;
|
const MS_PER_HOUR = 60 * 60 * 1000;
|
||||||
|
|
@ -3935,18 +4224,27 @@ function HistoryTab() {
|
||||||
};
|
};
|
||||||
|
|
||||||
let fetchRequestId = 0;
|
let fetchRequestId = 0;
|
||||||
const fetchAlertHistory = async (range: string) => {
|
const fetchHistory = async (range: string) => {
|
||||||
const requestId = ++fetchRequestId;
|
const requestId = ++fetchRequestId;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const history = await AlertsAPI.getHistory(buildHistoryParams(range));
|
// Fetch both alert history and AI findings history in parallel
|
||||||
|
const params = buildHistoryParams(range);
|
||||||
|
const startTimeStr = params.startTime;
|
||||||
|
|
||||||
|
const [alertHistoryData, aiFindingsData] = await Promise.all([
|
||||||
|
AlertsAPI.getHistory(params),
|
||||||
|
getFindingsHistory(startTimeStr),
|
||||||
|
]);
|
||||||
|
|
||||||
if (requestId === fetchRequestId) {
|
if (requestId === fetchRequestId) {
|
||||||
setAlertHistory(history);
|
setAlertHistory(alertHistoryData);
|
||||||
|
setAiFindingsHistory(aiFindingsData);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (requestId === fetchRequestId) {
|
if (requestId === fetchRequestId) {
|
||||||
logger.error('Failed to load alert history:', err);
|
logger.error('Failed to load history:', err);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (requestId === fetchRequestId) {
|
if (requestId === fetchRequestId) {
|
||||||
|
|
@ -3981,7 +4279,7 @@ function HistoryTab() {
|
||||||
|
|
||||||
// Load alert history on mount
|
// Load alert history on mount
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
fetchAlertHistory(timeFilter());
|
fetchHistory(timeFilter());
|
||||||
|
|
||||||
// Add keyboard event listeners
|
// Add keyboard event listeners
|
||||||
const handleKeydown = (e: KeyboardEvent) => {
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
|
|
@ -4023,7 +4321,7 @@ function HistoryTab() {
|
||||||
skipInitialFetchEffect = false;
|
skipInitialFetchEffect = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fetchAlertHistory(range);
|
fetchHistory(range);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Format duration for display
|
// Format duration for display
|
||||||
|
|
@ -4140,25 +4438,56 @@ function HistoryTab() {
|
||||||
return 'Unknown';
|
return 'Unknown';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Extended alert type for display
|
// Unified history item type that can be either an alert or an AI finding
|
||||||
interface ExtendedAlert extends Alert {
|
type HistoryItemSource = 'alert' | 'ai';
|
||||||
status?: string;
|
interface HistoryItem {
|
||||||
duration?: string;
|
id: string;
|
||||||
resourceType?: string;
|
source: HistoryItemSource;
|
||||||
|
status: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime?: string;
|
||||||
|
duration: string;
|
||||||
|
resourceName: string;
|
||||||
|
resourceType: string;
|
||||||
|
resourceId?: string;
|
||||||
|
node?: string;
|
||||||
|
severity: string; // warning, critical for alerts; severity for findings
|
||||||
|
// Aliases for backward compat with existing rendering code
|
||||||
|
level: string; // same as severity
|
||||||
|
type: string; // same as title
|
||||||
|
message?: string; // same as description
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
acknowledged?: boolean;
|
||||||
|
autoResolved?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare all alerts without filtering
|
// Prepare all history items (alerts + AI findings) based on source filter
|
||||||
const allAlertsData = createMemo(() => {
|
const allHistoryData = createMemo(() => {
|
||||||
// Combine active and historical alerts
|
const items: HistoryItem[] = [];
|
||||||
const allAlerts: ExtendedAlert[] = [];
|
const currentSource = sourceFilter();
|
||||||
|
|
||||||
|
// Add alerts if not filtering to AI only
|
||||||
|
if (currentSource === 'all' || currentSource === 'alerts') {
|
||||||
// Add active alerts
|
// Add active alerts
|
||||||
Object.values(activeAlerts || {}).forEach((alert) => {
|
Object.values(activeAlerts || {}).forEach((alert) => {
|
||||||
allAlerts.push({
|
items.push({
|
||||||
...alert,
|
id: alert.id,
|
||||||
|
source: 'alert',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
startTime: alert.startTime,
|
||||||
duration: formatDuration(alert.startTime),
|
duration: formatDuration(alert.startTime),
|
||||||
|
resourceName: alert.resourceName,
|
||||||
resourceType: getResourceType(alert.resourceName, alert.metadata),
|
resourceType: getResourceType(alert.resourceName, alert.metadata),
|
||||||
|
resourceId: alert.resourceId,
|
||||||
|
node: alert.node,
|
||||||
|
severity: alert.level,
|
||||||
|
level: alert.level,
|
||||||
|
type: alert.type,
|
||||||
|
message: alert.message,
|
||||||
|
title: alert.type,
|
||||||
|
description: alert.message,
|
||||||
|
acknowledged: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -4167,39 +4496,97 @@ function HistoryTab() {
|
||||||
|
|
||||||
// Add historical alerts
|
// Add historical alerts
|
||||||
alertHistory().forEach((alert) => {
|
alertHistory().forEach((alert) => {
|
||||||
// Skip if this alert is already in active alerts (avoid duplicates)
|
if (activeAlertIds.has(alert.id)) return;
|
||||||
if (activeAlertIds.has(alert.id)) {
|
|
||||||
return;
|
items.push({
|
||||||
|
id: alert.id,
|
||||||
|
source: 'alert',
|
||||||
|
status: alert.acknowledged ? 'acknowledged' : 'resolved',
|
||||||
|
startTime: alert.startTime,
|
||||||
|
endTime: alert.lastSeen,
|
||||||
|
duration: formatDuration(alert.startTime, alert.lastSeen),
|
||||||
|
resourceName: alert.resourceName,
|
||||||
|
resourceType: getResourceType(alert.resourceName, alert.metadata),
|
||||||
|
resourceId: alert.resourceId,
|
||||||
|
node: alert.node,
|
||||||
|
severity: alert.level,
|
||||||
|
level: alert.level,
|
||||||
|
type: alert.type,
|
||||||
|
message: alert.message,
|
||||||
|
title: alert.type,
|
||||||
|
description: alert.message,
|
||||||
|
acknowledged: alert.acknowledged,
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
allAlerts.push({
|
// Add AI findings if not filtering to alerts only
|
||||||
...alert,
|
if (currentSource === 'all' || currentSource === 'ai') {
|
||||||
status: alert.acknowledged ? 'acknowledged' : 'resolved',
|
aiFindingsHistory().forEach((finding) => {
|
||||||
duration: formatDuration(alert.startTime, alert.lastSeen),
|
const isSnoozed = finding.snoozed_until && new Date(finding.snoozed_until) > new Date();
|
||||||
resourceType: getResourceType(alert.resourceName, alert.metadata),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return allAlerts;
|
let status = 'active';
|
||||||
|
if (finding.resolved_at) {
|
||||||
|
status = finding.auto_resolved ? 'auto-resolved' : 'resolved';
|
||||||
|
} else if (isSnoozed) {
|
||||||
|
status = 'snoozed';
|
||||||
|
} else if (finding.acknowledged_at) {
|
||||||
|
status = 'acknowledged';
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
id: finding.id,
|
||||||
|
source: 'ai',
|
||||||
|
status,
|
||||||
|
startTime: finding.detected_at,
|
||||||
|
endTime: finding.resolved_at,
|
||||||
|
duration: formatDuration(finding.detected_at, finding.resolved_at),
|
||||||
|
resourceName: finding.resource_name,
|
||||||
|
resourceType: finding.resource_type,
|
||||||
|
resourceId: finding.resource_id,
|
||||||
|
node: finding.node,
|
||||||
|
severity: finding.severity,
|
||||||
|
level: finding.severity, // Map severity to level for compatibility
|
||||||
|
type: `AI: ${finding.title}`, // Prefix with AI to distinguish
|
||||||
|
message: finding.description,
|
||||||
|
title: finding.title,
|
||||||
|
description: finding.description,
|
||||||
|
acknowledged: !!finding.acknowledged_at,
|
||||||
|
autoResolved: finding.auto_resolved,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply severity & search filters (time filtering is layered separately)
|
// Apply severity & search filters (time filtering is layered separately)
|
||||||
const severityAndSearchFilteredAlerts = createMemo(() => {
|
const severityAndSearchFilteredItems = createMemo(() => {
|
||||||
let filtered = allAlertsData();
|
let filtered = allHistoryData();
|
||||||
|
|
||||||
|
// Filter by severity (map AI severity to alert levels for consistent filtering)
|
||||||
if (severityFilter() !== 'all') {
|
if (severityFilter() !== 'all') {
|
||||||
filtered = filtered.filter((a) => a.level === severityFilter());
|
const sevFilter = severityFilter();
|
||||||
|
filtered = filtered.filter((item) => {
|
||||||
|
// For alerts, use level; for AI findings, map severity
|
||||||
|
if (item.source === 'alert') {
|
||||||
|
return item.severity === sevFilter;
|
||||||
|
} else {
|
||||||
|
// AI findings: map warning->warning, critical->critical
|
||||||
|
return item.severity === sevFilter;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchTerm()) {
|
if (searchTerm()) {
|
||||||
const term = searchTerm().toLowerCase();
|
const term = searchTerm().toLowerCase();
|
||||||
filtered = filtered.filter((alert) => {
|
filtered = filtered.filter((item) => {
|
||||||
const name = alert.resourceName?.toLowerCase() ?? '';
|
const name = item.resourceName?.toLowerCase() ?? '';
|
||||||
const message = alert.message?.toLowerCase() ?? '';
|
const title = item.title?.toLowerCase() ?? '';
|
||||||
const type = alert.type?.toLowerCase() ?? '';
|
const description = item.description?.toLowerCase() ?? '';
|
||||||
const nodeName = alert.node?.toLowerCase() ?? '';
|
const nodeName = item.node?.toLowerCase() ?? '';
|
||||||
return (
|
return (
|
||||||
name.includes(term) || message.includes(term) || type.includes(term) || nodeName.includes(term)
|
name.includes(term) || title.includes(term) || description.includes(term) || nodeName.includes(term)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -4209,7 +4596,7 @@ function HistoryTab() {
|
||||||
|
|
||||||
// Apply filters to get the final alert data
|
// Apply filters to get the final alert data
|
||||||
const alertData = createMemo(() => {
|
const alertData = createMemo(() => {
|
||||||
let filtered = severityAndSearchFilteredAlerts();
|
let filtered = severityAndSearchFilteredItems();
|
||||||
const currentTimeFilter = timeFilter();
|
const currentTimeFilter = timeFilter();
|
||||||
|
|
||||||
// Selected bar filter (takes precedence over time filter)
|
// Selected bar filter (takes precedence over time filter)
|
||||||
|
|
@ -4343,7 +4730,7 @@ function HistoryTab() {
|
||||||
const alertTrends = createMemo(() => {
|
const alertTrends = createMemo(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const msPerHour = MS_PER_HOUR;
|
const msPerHour = MS_PER_HOUR;
|
||||||
const filteredAlerts = severityAndSearchFilteredAlerts();
|
const filteredAlerts = severityAndSearchFilteredItems();
|
||||||
const niceBucketSizes = [1, 2, 3, 6, 12, 24, 48, 72, 168, 336, 720, 1440]; // hours
|
const niceBucketSizes = [1, 2, 3, 6, 12, 24, 48, 72, 168, 336, 720, 1440]; // hours
|
||||||
const maxBuckets = 30;
|
const maxBuckets = 30;
|
||||||
|
|
||||||
|
|
@ -4760,6 +5147,16 @@ function HistoryTab() {
|
||||||
<option value="warning">Warning Only</option>
|
<option value="warning">Warning Only</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={sourceFilter()}
|
||||||
|
onChange={(e) => setSourceFilter(e.currentTarget.value as 'all' | 'alerts' | 'ai')}
|
||||||
|
class="w-full sm:w-auto px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
>
|
||||||
|
<option value="all">All Sources</option>
|
||||||
|
<option value="alerts">Alerts Only</option>
|
||||||
|
<option value="ai">AI Insights Only</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
<div class="w-full sm:flex-1 sm:max-w-xs">
|
<div class="w-full sm:flex-1 sm:max-w-xs">
|
||||||
<input
|
<input
|
||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
|
|
@ -4794,13 +5191,16 @@ function HistoryTab() {
|
||||||
>
|
>
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div class="mb-2 border border-gray-200 dark:border-gray-700 rounded overflow-hidden">
|
<div class="mb-2 border border-gray-200 dark:border-gray-700 rounded overflow-hidden">
|
||||||
<ScrollableTable minWidth="900px">
|
<ScrollableTable minWidth="1000px">
|
||||||
<table class="w-full min-w-[900px] text-xs sm:text-sm">
|
<table class="w-full min-w-[1000px] text-xs sm:text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 border-b border-gray-300 dark:border-gray-600">
|
<tr class="bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 border-b border-gray-300 dark:border-gray-600">
|
||||||
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider">
|
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider">
|
||||||
Timestamp
|
Timestamp
|
||||||
</th>
|
</th>
|
||||||
|
<th class="p-1 px-2 text-center text-[10px] sm:text-xs font-medium uppercase tracking-wider">
|
||||||
|
Source
|
||||||
|
</th>
|
||||||
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider">
|
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider">
|
||||||
Resource
|
Resource
|
||||||
</th>
|
</th>
|
||||||
|
|
@ -4834,7 +5234,7 @@ function HistoryTab() {
|
||||||
{/* Date divider */}
|
{/* Date divider */}
|
||||||
<tr class="bg-gray-50 dark:bg-gray-900/40">
|
<tr class="bg-gray-50 dark:bg-gray-900/40">
|
||||||
<td
|
<td
|
||||||
colspan="9"
|
colspan="10"
|
||||||
class="py-1.5 pr-3 pl-4 text-[12px] sm:text-sm font-semibold text-slate-700 dark:text-slate-100"
|
class="py-1.5 pr-3 pl-4 text-[12px] sm:text-sm font-semibold text-slate-700 dark:text-slate-100"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||||
|
|
@ -4842,8 +5242,14 @@ function HistoryTab() {
|
||||||
{group.label}
|
{group.label}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-[10px] font-medium text-slate-500 dark:text-slate-400">
|
<span class="text-[10px] font-medium text-slate-500 dark:text-slate-400">
|
||||||
{group.alerts.length}{' '}
|
{(() => {
|
||||||
{group.alerts.length === 1 ? 'alert' : 'alerts'}
|
const alertCount = group.alerts.filter(a => a.source === 'alert').length;
|
||||||
|
const aiCount = group.alerts.filter(a => a.source === 'ai').length;
|
||||||
|
const parts = [];
|
||||||
|
if (alertCount > 0) parts.push(`${alertCount} alert${alertCount === 1 ? '' : 's'}`);
|
||||||
|
if (aiCount > 0) parts.push(`${aiCount} AI insight${aiCount === 1 ? '' : 's'}`);
|
||||||
|
return parts.join(', ') || `${group.alerts.length} item${group.alerts.length === 1 ? '' : 's'}`;
|
||||||
|
})()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -4864,6 +5270,18 @@ function HistoryTab() {
|
||||||
})}
|
})}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
{/* Source */}
|
||||||
|
<td class="p-1 px-2 text-center">
|
||||||
|
<span
|
||||||
|
class={`text-[10px] px-1.5 py-0.5 rounded font-medium ${alert.source === 'ai'
|
||||||
|
? 'bg-violet-100 dark:bg-violet-900/50 text-violet-700 dark:text-violet-300'
|
||||||
|
: 'bg-sky-100 dark:bg-sky-900/50 text-sky-700 dark:text-sky-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{alert.source === 'ai' ? 'AI' : 'Alert'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
{/* Resource */}
|
{/* Resource */}
|
||||||
<td class="p-1 px-2 font-medium text-gray-900 dark:text-gray-100 truncate max-w-[150px]">
|
<td class="p-1 px-2 font-medium text-gray-900 dark:text-gray-100 truncate max-w-[150px]">
|
||||||
{alert.resourceName}
|
{alert.resourceName}
|
||||||
|
|
@ -4939,11 +5357,11 @@ function HistoryTab() {
|
||||||
id: alert.id,
|
id: alert.id,
|
||||||
type: alert.type,
|
type: alert.type,
|
||||||
level: alert.level as 'warning' | 'critical',
|
level: alert.level as 'warning' | 'critical',
|
||||||
resourceId: alert.resourceId,
|
resourceId: alert.resourceId || '',
|
||||||
resourceName: alert.resourceName,
|
resourceName: alert.resourceName,
|
||||||
node: alert.node,
|
node: alert.node || '',
|
||||||
instance: '',
|
instance: '',
|
||||||
message: alert.message,
|
message: alert.message || '',
|
||||||
value: 0,
|
value: 0,
|
||||||
threshold: 0,
|
threshold: 0,
|
||||||
startTime: alert.startTime,
|
startTime: alert.startTime,
|
||||||
|
|
|
||||||
|
|
@ -256,6 +256,10 @@ func (s *Server) pingLoop(ac *agentConn, done chan struct{}) {
|
||||||
ticker := time.NewTicker(5 * time.Second)
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Track consecutive ping failures to detect dead connections faster
|
||||||
|
consecutiveFailures := 0
|
||||||
|
const maxConsecutiveFailures = 3
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-done:
|
case <-done:
|
||||||
|
|
@ -267,9 +271,29 @@ func (s *Server) pingLoop(ac *agentConn, done chan struct{}) {
|
||||||
err := ac.conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(5*time.Second))
|
err := ac.conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(5*time.Second))
|
||||||
ac.writeMu.Unlock()
|
ac.writeMu.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debug().Err(err).Str("agent_id", ac.agent.AgentID).Msg("Failed to send ping to agent")
|
consecutiveFailures++
|
||||||
|
log.Warn().
|
||||||
|
Err(err).
|
||||||
|
Str("agent_id", ac.agent.AgentID).
|
||||||
|
Str("hostname", ac.agent.Hostname).
|
||||||
|
Int("consecutive_failures", consecutiveFailures).
|
||||||
|
Msg("Failed to send ping to agent")
|
||||||
|
|
||||||
|
if consecutiveFailures >= maxConsecutiveFailures {
|
||||||
|
log.Error().
|
||||||
|
Str("agent_id", ac.agent.AgentID).
|
||||||
|
Str("hostname", ac.agent.Hostname).
|
||||||
|
Int("failures", consecutiveFailures).
|
||||||
|
Msg("Agent connection appears dead after multiple ping failures, closing connection")
|
||||||
|
|
||||||
|
// Close the connection - this will cause readLoop to exit and clean up
|
||||||
|
ac.conn.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Reset failure counter on successful ping
|
||||||
|
consecutiveFailures = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
76
internal/ai/alert_threshold_adapter.go
Normal file
76
internal/ai/alert_threshold_adapter.go
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AlertThresholdAdapter adapts alerts.Manager to the ThresholdProvider interface
|
||||||
|
// This allows the patrol service to use user-configured alert thresholds
|
||||||
|
type AlertThresholdAdapter struct {
|
||||||
|
manager *alerts.Manager
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAlertThresholdAdapter creates a new adapter for the alerts manager
|
||||||
|
func NewAlertThresholdAdapter(manager *alerts.Manager) *AlertThresholdAdapter {
|
||||||
|
return &AlertThresholdAdapter{manager: manager}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNodeCPUThreshold returns the CPU alert trigger threshold for nodes (0-100%)
|
||||||
|
func (a *AlertThresholdAdapter) GetNodeCPUThreshold() float64 {
|
||||||
|
if a.manager == nil {
|
||||||
|
return 80 // default
|
||||||
|
}
|
||||||
|
cfg := a.manager.GetConfig()
|
||||||
|
if cfg.NodeDefaults.CPU != nil && cfg.NodeDefaults.CPU.Trigger > 0 {
|
||||||
|
return cfg.NodeDefaults.CPU.Trigger
|
||||||
|
}
|
||||||
|
return 80 // default
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNodeMemoryThreshold returns the memory alert trigger threshold for nodes (0-100%)
|
||||||
|
func (a *AlertThresholdAdapter) GetNodeMemoryThreshold() float64 {
|
||||||
|
if a.manager == nil {
|
||||||
|
return 85 // default
|
||||||
|
}
|
||||||
|
cfg := a.manager.GetConfig()
|
||||||
|
if cfg.NodeDefaults.Memory != nil && cfg.NodeDefaults.Memory.Trigger > 0 {
|
||||||
|
return cfg.NodeDefaults.Memory.Trigger
|
||||||
|
}
|
||||||
|
return 85 // default
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGuestMemoryThreshold returns the memory alert trigger threshold for guests (0-100%)
|
||||||
|
func (a *AlertThresholdAdapter) GetGuestMemoryThreshold() float64 {
|
||||||
|
if a.manager == nil {
|
||||||
|
return 85 // default
|
||||||
|
}
|
||||||
|
cfg := a.manager.GetConfig()
|
||||||
|
if cfg.GuestDefaults.Memory != nil && cfg.GuestDefaults.Memory.Trigger > 0 {
|
||||||
|
return cfg.GuestDefaults.Memory.Trigger
|
||||||
|
}
|
||||||
|
return 85 // default
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGuestDiskThreshold returns the disk alert trigger threshold for guests (0-100%)
|
||||||
|
func (a *AlertThresholdAdapter) GetGuestDiskThreshold() float64 {
|
||||||
|
if a.manager == nil {
|
||||||
|
return 90 // default
|
||||||
|
}
|
||||||
|
cfg := a.manager.GetConfig()
|
||||||
|
if cfg.GuestDefaults.Disk != nil && cfg.GuestDefaults.Disk.Trigger > 0 {
|
||||||
|
return cfg.GuestDefaults.Disk.Trigger
|
||||||
|
}
|
||||||
|
return 90 // default
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStorageThreshold returns the usage alert trigger threshold for storage (0-100%)
|
||||||
|
func (a *AlertThresholdAdapter) GetStorageThreshold() float64 {
|
||||||
|
if a.manager == nil {
|
||||||
|
return 85 // default
|
||||||
|
}
|
||||||
|
cfg := a.manager.GetConfig()
|
||||||
|
if cfg.StorageDefault.Trigger > 0 {
|
||||||
|
return cfg.StorageDefault.Trigger
|
||||||
|
}
|
||||||
|
return 85 // default
|
||||||
|
}
|
||||||
444
internal/ai/findings.go
Normal file
444
internal/ai/findings.go
Normal file
|
|
@ -0,0 +1,444 @@
|
||||||
|
// Package ai provides AI-powered infrastructure monitoring and investigation.
|
||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FindingSeverity represents how urgent a finding is
|
||||||
|
type FindingSeverity string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// FindingSeverityInfo is informational - user may want to know
|
||||||
|
FindingSeverityInfo FindingSeverity = "info"
|
||||||
|
// FindingSeverityWatch means something to keep an eye on
|
||||||
|
FindingSeverityWatch FindingSeverity = "watch"
|
||||||
|
// FindingSeverityWarning means action should be taken soon
|
||||||
|
FindingSeverityWarning FindingSeverity = "warning"
|
||||||
|
// FindingSeverityCritical means immediate action needed
|
||||||
|
FindingSeverityCritical FindingSeverity = "critical"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FindingCategory groups findings by type
|
||||||
|
type FindingCategory string
|
||||||
|
|
||||||
|
const (
|
||||||
|
FindingCategoryPerformance FindingCategory = "performance"
|
||||||
|
FindingCategoryCapacity FindingCategory = "capacity"
|
||||||
|
FindingCategoryReliability FindingCategory = "reliability"
|
||||||
|
FindingCategoryBackup FindingCategory = "backup"
|
||||||
|
FindingCategorySecurity FindingCategory = "security"
|
||||||
|
FindingCategoryGeneral FindingCategory = "general"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Finding represents an AI-discovered insight about infrastructure
|
||||||
|
type Finding struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Severity FindingSeverity `json:"severity"`
|
||||||
|
Category FindingCategory `json:"category"`
|
||||||
|
ResourceID string `json:"resource_id"`
|
||||||
|
ResourceName string `json:"resource_name"`
|
||||||
|
ResourceType string `json:"resource_type"` // node, vm, container, docker, storage, host, pbs, host_raid
|
||||||
|
Node string `json:"node,omitempty"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Recommendation string `json:"recommendation,omitempty"`
|
||||||
|
Evidence string `json:"evidence,omitempty"` // data/commands that led to this finding
|
||||||
|
DetectedAt time.Time `json:"detected_at"`
|
||||||
|
LastSeenAt time.Time `json:"last_seen_at"`
|
||||||
|
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
||||||
|
AutoResolved bool `json:"auto_resolved"`
|
||||||
|
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
|
||||||
|
SnoozedUntil *time.Time `json:"snoozed_until,omitempty"` // Finding hidden until this time
|
||||||
|
// Link to alert if this finding was triggered by or attached to an alert
|
||||||
|
AlertID string `json:"alert_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsActive returns true if the finding is still active (not resolved and not snoozed)
|
||||||
|
func (f *Finding) IsActive() bool {
|
||||||
|
return f.ResolvedAt == nil && !f.IsSnoozed()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSnoozed returns true if the finding is currently snoozed
|
||||||
|
func (f *Finding) IsSnoozed() bool {
|
||||||
|
return f.SnoozedUntil != nil && time.Now().Before(*f.SnoozedUntil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsResolved returns true if the finding has been resolved (ignores snooze)
|
||||||
|
func (f *Finding) IsResolved() bool {
|
||||||
|
return f.ResolvedAt != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindingsPersistence interface for saving/loading findings (avoids circular imports)
|
||||||
|
type FindingsPersistence interface {
|
||||||
|
SaveFindings(findings map[string]*Finding) error
|
||||||
|
LoadFindings() (map[string]*Finding, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindingsStore provides thread-safe storage for AI findings with optional persistence
|
||||||
|
type FindingsStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
findings map[string]*Finding // keyed by ID
|
||||||
|
// Index by resource for quick lookups
|
||||||
|
byResource map[string][]string // resource_id -> []finding_id
|
||||||
|
// Keep track of active findings count by severity (cached, but GetSummary calculates dynamically)
|
||||||
|
activeCounts map[FindingSeverity]int
|
||||||
|
// Persistence layer (optional)
|
||||||
|
persistence FindingsPersistence
|
||||||
|
// Debounce save operations
|
||||||
|
saveTimer *time.Timer
|
||||||
|
savePending bool
|
||||||
|
saveDebounce time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFindingsStore creates a new findings store
|
||||||
|
func NewFindingsStore() *FindingsStore {
|
||||||
|
return &FindingsStore{
|
||||||
|
findings: make(map[string]*Finding),
|
||||||
|
byResource: make(map[string][]string),
|
||||||
|
activeCounts: make(map[FindingSeverity]int),
|
||||||
|
saveDebounce: 5 * time.Second, // Debounce saves by 5 seconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPersistence sets the persistence layer and loads existing findings
|
||||||
|
func (s *FindingsStore) SetPersistence(p FindingsPersistence) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.persistence = p
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
// Load existing findings from disk
|
||||||
|
if p != nil {
|
||||||
|
findings, err := p.LoadFindings()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(findings) > 0 {
|
||||||
|
s.mu.Lock()
|
||||||
|
for id, f := range findings {
|
||||||
|
s.findings[id] = f
|
||||||
|
s.byResource[f.ResourceID] = append(s.byResource[f.ResourceID], id)
|
||||||
|
if f.IsActive() {
|
||||||
|
s.activeCounts[f.Severity]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scheduleSave schedules a debounced save operation
|
||||||
|
func (s *FindingsStore) scheduleSave() {
|
||||||
|
if s.persistence == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already have a save pending
|
||||||
|
if s.savePending {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.savePending = true
|
||||||
|
s.saveTimer = time.AfterFunc(s.saveDebounce, func() {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.savePending = false
|
||||||
|
// Make a copy for saving
|
||||||
|
findingsCopy := make(map[string]*Finding, len(s.findings))
|
||||||
|
for id, f := range s.findings {
|
||||||
|
copy := *f
|
||||||
|
findingsCopy[id] = ©
|
||||||
|
}
|
||||||
|
persistence := s.persistence
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if persistence != nil {
|
||||||
|
if err := persistence.SaveFindings(findingsCopy); err != nil {
|
||||||
|
// Log error but don't fail - persistence is best-effort
|
||||||
|
// (log import would create circular dep, so we silently fail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForceSave immediately saves findings (useful for shutdown)
|
||||||
|
func (s *FindingsStore) ForceSave() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.saveTimer != nil {
|
||||||
|
s.saveTimer.Stop()
|
||||||
|
}
|
||||||
|
s.savePending = false
|
||||||
|
|
||||||
|
findingsCopy := make(map[string]*Finding, len(s.findings))
|
||||||
|
for id, f := range s.findings {
|
||||||
|
copy := *f
|
||||||
|
findingsCopy[id] = ©
|
||||||
|
}
|
||||||
|
persistence := s.persistence
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if persistence != nil {
|
||||||
|
return persistence.SaveFindings(findingsCopy)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add adds or updates a finding
|
||||||
|
// If a finding with the same ID exists, it updates LastSeenAt
|
||||||
|
// Returns true if this is a new finding
|
||||||
|
func (s *FindingsStore) Add(f *Finding) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
|
||||||
|
existing, exists := s.findings[f.ID]
|
||||||
|
if exists {
|
||||||
|
// Update existing finding
|
||||||
|
existing.LastSeenAt = time.Now()
|
||||||
|
existing.Description = f.Description
|
||||||
|
existing.Recommendation = f.Recommendation
|
||||||
|
existing.Evidence = f.Evidence
|
||||||
|
existing.Severity = f.Severity
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.scheduleSave()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// New finding
|
||||||
|
if f.DetectedAt.IsZero() {
|
||||||
|
f.DetectedAt = time.Now()
|
||||||
|
}
|
||||||
|
f.LastSeenAt = time.Now()
|
||||||
|
|
||||||
|
s.findings[f.ID] = f
|
||||||
|
s.byResource[f.ResourceID] = append(s.byResource[f.ResourceID], f.ID)
|
||||||
|
if f.IsActive() {
|
||||||
|
s.activeCounts[f.Severity]++
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.scheduleSave()
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve marks a finding as resolved
|
||||||
|
func (s *FindingsStore) Resolve(id string, auto bool) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
|
||||||
|
f, exists := s.findings[id]
|
||||||
|
if !exists || !f.IsActive() {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
f.ResolvedAt = &now
|
||||||
|
f.AutoResolved = auto
|
||||||
|
s.activeCounts[f.Severity]--
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.scheduleSave()
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acknowledge marks a finding as acknowledged
|
||||||
|
func (s *FindingsStore) Acknowledge(id string) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
|
||||||
|
f, exists := s.findings[id]
|
||||||
|
if !exists {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
f.AcknowledgedAt = &now
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.scheduleSave()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snooze hides a finding for the specified duration
|
||||||
|
// Common durations: 1h, 24h, 7d (168h)
|
||||||
|
func (s *FindingsStore) Snooze(id string, duration time.Duration) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
|
||||||
|
f, exists := s.findings[id]
|
||||||
|
if !exists || f.IsResolved() {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// If was previously active (not snoozed), decrement count
|
||||||
|
if f.SnoozedUntil == nil || time.Now().After(*f.SnoozedUntil) {
|
||||||
|
s.activeCounts[f.Severity]--
|
||||||
|
}
|
||||||
|
|
||||||
|
until := time.Now().Add(duration)
|
||||||
|
f.SnoozedUntil = &until
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.scheduleSave()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsnooze removes the snooze from a finding, making it active again
|
||||||
|
func (s *FindingsStore) Unsnooze(id string) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
|
||||||
|
f, exists := s.findings[id]
|
||||||
|
if !exists || f.IsResolved() {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.SnoozedUntil != nil {
|
||||||
|
f.SnoozedUntil = nil
|
||||||
|
s.activeCounts[f.Severity]++
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.scheduleSave()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a finding by ID
|
||||||
|
func (s *FindingsStore) Get(id string) *Finding {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
if f, exists := s.findings[id]; exists {
|
||||||
|
// Return a copy to prevent mutations
|
||||||
|
copy := *f
|
||||||
|
return ©
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByResource returns all active findings for a resource
|
||||||
|
func (s *FindingsStore) GetByResource(resourceID string) []*Finding {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
ids := s.byResource[resourceID]
|
||||||
|
result := make([]*Finding, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if f, exists := s.findings[id]; exists && f.IsActive() {
|
||||||
|
copy := *f
|
||||||
|
result = append(result, ©)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActive returns all active findings, optionally filtered by severity
|
||||||
|
func (s *FindingsStore) GetActive(minSeverity FindingSeverity) []*Finding {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
severityOrder := map[FindingSeverity]int{
|
||||||
|
FindingSeverityInfo: 0,
|
||||||
|
FindingSeverityWatch: 1,
|
||||||
|
FindingSeverityWarning: 2,
|
||||||
|
FindingSeverityCritical: 3,
|
||||||
|
}
|
||||||
|
minOrder := severityOrder[minSeverity]
|
||||||
|
|
||||||
|
result := make([]*Finding, 0)
|
||||||
|
for _, f := range s.findings {
|
||||||
|
if f.IsActive() && severityOrder[f.Severity] >= minOrder {
|
||||||
|
copy := *f
|
||||||
|
result = append(result, ©)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSummary returns a summary of active findings
|
||||||
|
// Note: This calculates counts dynamically to handle time-based snooze expiration
|
||||||
|
func (s *FindingsStore) GetSummary() FindingsSummary {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
summary := FindingsSummary{
|
||||||
|
Total: len(s.findings),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate active counts dynamically since IsActive() checks time-based snooze
|
||||||
|
for _, f := range s.findings {
|
||||||
|
if f.IsActive() {
|
||||||
|
switch f.Severity {
|
||||||
|
case FindingSeverityCritical:
|
||||||
|
summary.Critical++
|
||||||
|
case FindingSeverityWarning:
|
||||||
|
summary.Warning++
|
||||||
|
case FindingSeverityWatch:
|
||||||
|
summary.Watch++
|
||||||
|
case FindingSeverityInfo:
|
||||||
|
summary.Info++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAll returns all findings including resolved ones (for history)
|
||||||
|
// Results can be filtered by time range using startTime parameter
|
||||||
|
func (s *FindingsStore) GetAll(startTime *time.Time) []*Finding {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
result := make([]*Finding, 0, len(s.findings))
|
||||||
|
for _, f := range s.findings {
|
||||||
|
// If startTime specified, only include findings detected after it
|
||||||
|
if startTime != nil && f.DetectedAt.Before(*startTime) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
copy := *f
|
||||||
|
result = append(result, ©)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup removes old resolved findings
|
||||||
|
func (s *FindingsStore) Cleanup(maxAge time.Duration) int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
cutoff := time.Now().Add(-maxAge)
|
||||||
|
removed := 0
|
||||||
|
|
||||||
|
for id, f := range s.findings {
|
||||||
|
if f.ResolvedAt != nil && f.ResolvedAt.Before(cutoff) {
|
||||||
|
delete(s.findings, id)
|
||||||
|
// Clean up resource index
|
||||||
|
ids := s.byResource[f.ResourceID]
|
||||||
|
for i, fid := range ids {
|
||||||
|
if fid == id {
|
||||||
|
s.byResource[f.ResourceID] = append(ids[:i], ids[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
removed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindingsSummary provides a quick count of findings by severity
|
||||||
|
type FindingsSummary struct {
|
||||||
|
Critical int `json:"critical"`
|
||||||
|
Warning int `json:"warning"`
|
||||||
|
Watch int `json:"watch"`
|
||||||
|
Info int `json:"info"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasIssues returns true if there are any warning or critical findings
|
||||||
|
func (s FindingsSummary) HasIssues() bool {
|
||||||
|
return s.Critical > 0 || s.Warning > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsHealthy returns true if there are no watch, warning, or critical findings
|
||||||
|
func (s FindingsSummary) IsHealthy() bool {
|
||||||
|
return s.Critical == 0 && s.Warning == 0 && s.Watch == 0
|
||||||
|
}
|
||||||
79
internal/ai/findings_persistence.go
Normal file
79
internal/ai/findings_persistence.go
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
// Package ai provides AI-powered infrastructure monitoring and investigation.
|
||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FindingsPersistenceAdapter bridges ConfigPersistence to FindingsPersistence interface
|
||||||
|
type FindingsPersistenceAdapter struct {
|
||||||
|
config *config.ConfigPersistence
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFindingsPersistenceAdapter creates a new adapter
|
||||||
|
func NewFindingsPersistenceAdapter(cfg *config.ConfigPersistence) *FindingsPersistenceAdapter {
|
||||||
|
return &FindingsPersistenceAdapter{config: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveFindings saves findings to disk via ConfigPersistence
|
||||||
|
func (a *FindingsPersistenceAdapter) SaveFindings(findings map[string]*Finding) error {
|
||||||
|
// Convert from Finding to AIFindingRecord
|
||||||
|
records := make(map[string]*config.AIFindingRecord, len(findings))
|
||||||
|
for id, f := range findings {
|
||||||
|
records[id] = &config.AIFindingRecord{
|
||||||
|
ID: f.ID,
|
||||||
|
Severity: string(f.Severity),
|
||||||
|
Category: string(f.Category),
|
||||||
|
ResourceID: f.ResourceID,
|
||||||
|
ResourceName: f.ResourceName,
|
||||||
|
ResourceType: f.ResourceType,
|
||||||
|
Node: f.Node,
|
||||||
|
Title: f.Title,
|
||||||
|
Description: f.Description,
|
||||||
|
Recommendation: f.Recommendation,
|
||||||
|
Evidence: f.Evidence,
|
||||||
|
DetectedAt: f.DetectedAt,
|
||||||
|
LastSeenAt: f.LastSeenAt,
|
||||||
|
ResolvedAt: f.ResolvedAt,
|
||||||
|
AutoResolved: f.AutoResolved,
|
||||||
|
AcknowledgedAt: f.AcknowledgedAt,
|
||||||
|
SnoozedUntil: f.SnoozedUntil,
|
||||||
|
AlertID: f.AlertID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a.config.SaveAIFindings(records)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadFindings loads findings from disk via ConfigPersistence
|
||||||
|
func (a *FindingsPersistenceAdapter) LoadFindings() (map[string]*Finding, error) {
|
||||||
|
data, err := a.config.LoadAIFindings()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert from AIFindingRecord to Finding
|
||||||
|
findings := make(map[string]*Finding, len(data.Findings))
|
||||||
|
for id, r := range data.Findings {
|
||||||
|
findings[id] = &Finding{
|
||||||
|
ID: r.ID,
|
||||||
|
Severity: FindingSeverity(r.Severity),
|
||||||
|
Category: FindingCategory(r.Category),
|
||||||
|
ResourceID: r.ResourceID,
|
||||||
|
ResourceName: r.ResourceName,
|
||||||
|
ResourceType: r.ResourceType,
|
||||||
|
Node: r.Node,
|
||||||
|
Title: r.Title,
|
||||||
|
Description: r.Description,
|
||||||
|
Recommendation: r.Recommendation,
|
||||||
|
Evidence: r.Evidence,
|
||||||
|
DetectedAt: r.DetectedAt,
|
||||||
|
LastSeenAt: r.LastSeenAt,
|
||||||
|
ResolvedAt: r.ResolvedAt,
|
||||||
|
AutoResolved: r.AutoResolved,
|
||||||
|
AcknowledgedAt: r.AcknowledgedAt,
|
||||||
|
SnoozedUntil: r.SnoozedUntil,
|
||||||
|
AlertID: r.AlertID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return findings, nil
|
||||||
|
}
|
||||||
252
internal/ai/findings_test.go
Normal file
252
internal/ai/findings_test.go
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFinding_IsActive(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
finding Finding
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "active finding",
|
||||||
|
finding: Finding{ID: "test-1"},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "resolved finding",
|
||||||
|
finding: Finding{
|
||||||
|
ID: "test-2",
|
||||||
|
ResolvedAt: timePtr(time.Now()),
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "snoozed finding",
|
||||||
|
finding: Finding{
|
||||||
|
ID: "test-3",
|
||||||
|
SnoozedUntil: timePtr(time.Now().Add(1 * time.Hour)),
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expired snooze finding",
|
||||||
|
finding: Finding{
|
||||||
|
ID: "test-4",
|
||||||
|
SnoozedUntil: timePtr(time.Now().Add(-1 * time.Hour)),
|
||||||
|
},
|
||||||
|
expected: true, // Snooze expired, should be active again
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := tt.finding.IsActive(); got != tt.expected {
|
||||||
|
t.Errorf("IsActive() = %v, want %v", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinding_IsSnoozed(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
finding Finding
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "not snoozed",
|
||||||
|
finding: Finding{ID: "test-1"},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "actively snoozed",
|
||||||
|
finding: Finding{
|
||||||
|
ID: "test-2",
|
||||||
|
SnoozedUntil: timePtr(time.Now().Add(1 * time.Hour)),
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "snooze expired",
|
||||||
|
finding: Finding{
|
||||||
|
ID: "test-3",
|
||||||
|
SnoozedUntil: timePtr(time.Now().Add(-1 * time.Hour)),
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := tt.finding.IsSnoozed(); got != tt.expected {
|
||||||
|
t.Errorf("IsSnoozed() = %v, want %v", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindingsStore_Snooze(t *testing.T) {
|
||||||
|
store := NewFindingsStore()
|
||||||
|
|
||||||
|
// Add a finding
|
||||||
|
finding := &Finding{
|
||||||
|
ID: "finding-1",
|
||||||
|
Severity: FindingSeverityWarning,
|
||||||
|
ResourceID: "res-1",
|
||||||
|
ResourceName: "test-resource",
|
||||||
|
Title: "Test Finding",
|
||||||
|
}
|
||||||
|
store.Add(finding)
|
||||||
|
|
||||||
|
// Verify it's active
|
||||||
|
activeFindings := store.GetActive(FindingSeverityInfo)
|
||||||
|
if len(activeFindings) != 1 {
|
||||||
|
t.Fatalf("Expected 1 active finding, got %d", len(activeFindings))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snooze the finding for 1 hour
|
||||||
|
if !store.Snooze("finding-1", 1*time.Hour) {
|
||||||
|
t.Fatal("Snooze should return true for existing finding")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's no longer in active list
|
||||||
|
activeFindings = store.GetActive(FindingSeverityInfo)
|
||||||
|
if len(activeFindings) != 0 {
|
||||||
|
t.Fatalf("Expected 0 active findings after snooze, got %d", len(activeFindings))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify summary counts decreased
|
||||||
|
summary := store.GetSummary()
|
||||||
|
if summary.Warning != 0 {
|
||||||
|
t.Errorf("Expected 0 warning count, got %d", summary.Warning)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snooze non-existent finding
|
||||||
|
if store.Snooze("non-existent", 1*time.Hour) {
|
||||||
|
t.Error("Snooze should return false for non-existent finding")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindingsStore_Unsnooze(t *testing.T) {
|
||||||
|
store := NewFindingsStore()
|
||||||
|
|
||||||
|
// Add and snooze a finding
|
||||||
|
finding := &Finding{
|
||||||
|
ID: "finding-1",
|
||||||
|
Severity: FindingSeverityCritical,
|
||||||
|
ResourceID: "res-1",
|
||||||
|
ResourceName: "test-resource",
|
||||||
|
Title: "Test Finding",
|
||||||
|
}
|
||||||
|
store.Add(finding)
|
||||||
|
store.Snooze("finding-1", 1*time.Hour)
|
||||||
|
|
||||||
|
// Verify it's snoozed
|
||||||
|
activeFindings := store.GetActive(FindingSeverityInfo)
|
||||||
|
if len(activeFindings) != 0 {
|
||||||
|
t.Fatalf("Expected 0 active findings when snoozed, got %d", len(activeFindings))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsnooze
|
||||||
|
if !store.Unsnooze("finding-1") {
|
||||||
|
t.Fatal("Unsnooze should return true for snoozed finding")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's active again
|
||||||
|
activeFindings = store.GetActive(FindingSeverityInfo)
|
||||||
|
if len(activeFindings) != 1 {
|
||||||
|
t.Fatalf("Expected 1 active finding after unsnooze, got %d", len(activeFindings))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify summary counts increased
|
||||||
|
summary := store.GetSummary()
|
||||||
|
if summary.Critical != 1 {
|
||||||
|
t.Errorf("Expected 1 critical count, got %d", summary.Critical)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsnooze non-existent finding
|
||||||
|
if store.Unsnooze("non-existent") {
|
||||||
|
t.Error("Unsnooze should return false for non-existent finding")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindingsStore_SnoozeResolvedFinding(t *testing.T) {
|
||||||
|
store := NewFindingsStore()
|
||||||
|
|
||||||
|
// Add a finding
|
||||||
|
finding := &Finding{
|
||||||
|
ID: "finding-1",
|
||||||
|
Severity: FindingSeverityWarning,
|
||||||
|
ResourceID: "res-1",
|
||||||
|
ResourceName: "test-resource",
|
||||||
|
Title: "Test Finding",
|
||||||
|
}
|
||||||
|
store.Add(finding)
|
||||||
|
|
||||||
|
// Resolve it
|
||||||
|
store.Resolve("finding-1", false)
|
||||||
|
|
||||||
|
// Try to snooze a resolved finding - should fail
|
||||||
|
if store.Snooze("finding-1", 1*time.Hour) {
|
||||||
|
t.Error("Should not be able to snooze a resolved finding")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindingsStore_SummaryConsistentWithActive(t *testing.T) {
|
||||||
|
store := NewFindingsStore()
|
||||||
|
|
||||||
|
// Add mixed severity findings
|
||||||
|
store.Add(&Finding{ID: "f1", Severity: FindingSeverityCritical, ResourceID: "r1", ResourceName: "res1", Title: "Critical"})
|
||||||
|
store.Add(&Finding{ID: "f2", Severity: FindingSeverityWarning, ResourceID: "r2", ResourceName: "res2", Title: "Warning"})
|
||||||
|
store.Add(&Finding{ID: "f3", Severity: FindingSeverityWatch, ResourceID: "r3", ResourceName: "res3", Title: "Watch"})
|
||||||
|
|
||||||
|
// Verify initial consistency
|
||||||
|
active := store.GetActive(FindingSeverityInfo)
|
||||||
|
summary := store.GetSummary()
|
||||||
|
|
||||||
|
if len(active) != summary.Critical+summary.Warning+summary.Watch+summary.Info {
|
||||||
|
t.Errorf("Mismatch: %d active findings, summary totals %d", len(active), summary.Critical+summary.Warning+summary.Watch+summary.Info)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the warning finding
|
||||||
|
store.Resolve("f2", false)
|
||||||
|
|
||||||
|
// Verify consistency after resolution
|
||||||
|
active = store.GetActive(FindingSeverityInfo)
|
||||||
|
summary = store.GetSummary()
|
||||||
|
|
||||||
|
if len(active) != 2 {
|
||||||
|
t.Fatalf("Expected 2 active findings after resolution, got %d", len(active))
|
||||||
|
}
|
||||||
|
|
||||||
|
if summary.Warning != 0 {
|
||||||
|
t.Errorf("Summary shows %d warnings but should be 0 after resolution", summary.Warning)
|
||||||
|
}
|
||||||
|
|
||||||
|
if summary.Critical != 1 {
|
||||||
|
t.Errorf("Summary shows %d critical but should be 1", summary.Critical)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snooze the critical finding
|
||||||
|
store.Snooze("f1", 1*time.Hour)
|
||||||
|
|
||||||
|
// Verify consistency after snooze
|
||||||
|
active = store.GetActive(FindingSeverityInfo)
|
||||||
|
summary = store.GetSummary()
|
||||||
|
|
||||||
|
if len(active) != 1 {
|
||||||
|
t.Errorf("Expected 1 active finding after snooze, got %d", len(active))
|
||||||
|
}
|
||||||
|
|
||||||
|
if summary.Critical != 0 {
|
||||||
|
t.Errorf("Summary shows %d critical but should be 0 after snooze", summary.Critical)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func timePtr(t time.Time) *time.Time {
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
|
@ -357,27 +357,69 @@ func (s *Store) ListGuests() ([]string, error) {
|
||||||
|
|
||||||
// FormatAllForContext returns a summary of all saved knowledge across all guests
|
// FormatAllForContext returns a summary of all saved knowledge across all guests
|
||||||
// This is used when no specific target is selected to give the AI full context
|
// This is used when no specific target is selected to give the AI full context
|
||||||
|
// To prevent context bloat, it limits output to maxGuests and maxBytes
|
||||||
func (s *Store) FormatAllForContext() string {
|
func (s *Store) FormatAllForContext() string {
|
||||||
|
const maxGuests = 10 // Only include the 10 most recently updated guests
|
||||||
|
const maxBytes = 8000 // Cap total output at ~8KB to leave room for other context
|
||||||
|
|
||||||
guests, err := s.ListGuests()
|
guests, err := s.ListGuests()
|
||||||
if err != nil || len(guests) == 0 {
|
if err != nil || len(guests) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
var sections []string
|
// Load all guests with notes and sort by most recently updated
|
||||||
totalNotes := 0
|
type guestWithTime struct {
|
||||||
|
id string
|
||||||
|
knowledge *GuestKnowledge
|
||||||
|
}
|
||||||
|
var guestsWithNotes []guestWithTime
|
||||||
|
|
||||||
for _, guestID := range guests {
|
for _, guestID := range guests {
|
||||||
knowledge, err := s.GetKnowledge(guestID)
|
knowledge, err := s.GetKnowledge(guestID)
|
||||||
if err != nil || len(knowledge.Notes) == 0 {
|
if err != nil || len(knowledge.Notes) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
guestsWithNotes = append(guestsWithNotes, guestWithTime{id: guestID, knowledge: knowledge})
|
||||||
|
}
|
||||||
|
|
||||||
totalNotes += len(knowledge.Notes)
|
if len(guestsWithNotes) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by UpdatedAt descending (most recent first)
|
||||||
|
for i := 0; i < len(guestsWithNotes)-1; i++ {
|
||||||
|
for j := i + 1; j < len(guestsWithNotes); j++ {
|
||||||
|
if guestsWithNotes[j].knowledge.UpdatedAt.After(guestsWithNotes[i].knowledge.UpdatedAt) {
|
||||||
|
guestsWithNotes[i], guestsWithNotes[j] = guestsWithNotes[j], guestsWithNotes[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track how many guests and notes we're including vs total
|
||||||
|
totalGuests := len(guestsWithNotes)
|
||||||
|
totalNotes := 0
|
||||||
|
for _, g := range guestsWithNotes {
|
||||||
|
totalNotes += len(g.knowledge.Notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit to maxGuests
|
||||||
|
truncatedGuests := false
|
||||||
|
if len(guestsWithNotes) > maxGuests {
|
||||||
|
guestsWithNotes = guestsWithNotes[:maxGuests]
|
||||||
|
truncatedGuests = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var sections []string
|
||||||
|
includedNotes := 0
|
||||||
|
currentBytes := 0
|
||||||
|
|
||||||
|
for _, g := range guestsWithNotes {
|
||||||
|
knowledge := g.knowledge
|
||||||
|
|
||||||
// Build a summary for this guest
|
// Build a summary for this guest
|
||||||
guestName := knowledge.GuestName
|
guestName := knowledge.GuestName
|
||||||
if guestName == "" {
|
if guestName == "" {
|
||||||
guestName = guestID
|
guestName = g.id
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group notes by category
|
// Group notes by category
|
||||||
|
|
@ -401,18 +443,44 @@ func (s *Store) FormatAllForContext() string {
|
||||||
if cat == "credential" && len(content) > 6 {
|
if cat == "credential" && len(content) > 6 {
|
||||||
content = content[:2] + "****" + content[len(content)-2:]
|
content = content[:2] + "****" + content[len(content)-2:]
|
||||||
}
|
}
|
||||||
guestSection += fmt.Sprintf("\n- **%s**: %s", note.Title, content)
|
noteLine := fmt.Sprintf("\n- **%s**: %s", note.Title, content)
|
||||||
|
|
||||||
|
// Check if adding this note would exceed our byte limit
|
||||||
|
if currentBytes+len(guestSection)+len(noteLine) > maxBytes {
|
||||||
|
// Stop adding notes, we've hit the limit
|
||||||
|
if includedNotes > 0 {
|
||||||
|
log.Warn().
|
||||||
|
Int("total_notes", totalNotes).
|
||||||
|
Int("included_notes", includedNotes).
|
||||||
|
Int("total_guests", totalGuests).
|
||||||
|
Int("max_bytes", maxBytes).
|
||||||
|
Msg("Knowledge context truncated to prevent bloat - consider cleaning up old notes")
|
||||||
|
}
|
||||||
|
goto finalize
|
||||||
|
}
|
||||||
|
guestSection += noteLine
|
||||||
|
includedNotes++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentBytes += len(guestSection)
|
||||||
sections = append(sections, guestSection)
|
sections = append(sections, guestSection)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
finalize:
|
||||||
if len(sections) == 0 {
|
if len(sections) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
result := fmt.Sprintf("\n\n## Saved Knowledge (%d notes across %d guests)\n", totalNotes, len(sections))
|
// Build result with info about truncation if applicable
|
||||||
|
var header string
|
||||||
|
if truncatedGuests || includedNotes < totalNotes {
|
||||||
|
header = fmt.Sprintf("\n\n## Saved Knowledge (%d/%d notes from %d/%d guests, most recent)\n",
|
||||||
|
includedNotes, totalNotes, len(sections), totalGuests)
|
||||||
|
} else {
|
||||||
|
header = fmt.Sprintf("\n\n## Saved Knowledge (%d notes across %d guests)\n", totalNotes, len(sections))
|
||||||
|
}
|
||||||
|
result := header
|
||||||
result += "This is information learned from previous sessions. Use it to avoid rediscovery.\n"
|
result += "This is information learned from previous sessions. Use it to avoid rediscovery.\n"
|
||||||
result += strings.Join(sections, "\n")
|
result += strings.Join(sections, "\n")
|
||||||
|
|
||||||
|
|
|
||||||
155
internal/ai/knowledge/store_test.go
Normal file
155
internal/ai/knowledge/store_test.go
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
package knowledge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFormatAllForContext_LimitsOutput(t *testing.T) {
|
||||||
|
// Create a temp directory for the test
|
||||||
|
tmpDir, err := os.MkdirTemp("", "knowledge-test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
// Create store without encryption for simplicity
|
||||||
|
store, err := NewStore(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create store: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add more than maxGuests (10) guests with notes
|
||||||
|
for i := 0; i < 15; i++ {
|
||||||
|
guestID := filepath.Base(tmpDir) + "-guest-" + string(rune('A'+i))
|
||||||
|
err := store.SaveNote(guestID, "Guest-"+string(rune('A'+i)), "vm", "service", "Web Server", "http://example.com:8080")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to save note: %v", err)
|
||||||
|
}
|
||||||
|
// Small delay to ensure different UpdatedAt times
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the formatted context
|
||||||
|
result := store.FormatAllForContext()
|
||||||
|
|
||||||
|
if result == "" {
|
||||||
|
t.Fatal("Expected non-empty result")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should mention it's truncated (15 guests but only 10 included)
|
||||||
|
if !contains(result, "/") {
|
||||||
|
t.Error("Expected truncation indicator (e.g., '10/15 notes') in output")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count how many guest sections we have (look for "### Guest-")
|
||||||
|
guestCount := countOccurrences(result, "### Guest-")
|
||||||
|
if guestCount > 10 {
|
||||||
|
t.Errorf("Expected at most 10 guests, got %d", guestCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should be under 8KB
|
||||||
|
if len(result) > 8500 {
|
||||||
|
t.Errorf("Result too large: %d bytes (expected < 8500)", len(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatAllForContext_PrioritizesRecent(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "knowledge-test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
store, err := NewStore(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create store: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an old guest first
|
||||||
|
err = store.SaveNote("old-guest", "OldGuest", "vm", "config", "Setting", "old value")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
// Create a newer guest
|
||||||
|
err = store.SaveNote("new-guest", "NewGuest", "vm", "config", "Setting", "new value")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := store.FormatAllForContext()
|
||||||
|
|
||||||
|
// NewGuest should appear before OldGuest (more recently updated)
|
||||||
|
newIdx := indexOf(result, "NewGuest")
|
||||||
|
oldIdx := indexOf(result, "OldGuest")
|
||||||
|
|
||||||
|
if newIdx == -1 || oldIdx == -1 {
|
||||||
|
t.Fatalf("Both guests should be in result. newIdx=%d, oldIdx=%d", newIdx, oldIdx)
|
||||||
|
}
|
||||||
|
|
||||||
|
if newIdx > oldIdx {
|
||||||
|
t.Error("Expected NewGuest to appear before OldGuest (more recent)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatAllForContext_ByteLimit(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "knowledge-test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
store, err := NewStore(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create store: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create notes with very large content to trigger byte limit
|
||||||
|
largeContent := make([]byte, 2000)
|
||||||
|
for i := range largeContent {
|
||||||
|
largeContent[i] = 'x'
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
guestID := "large-guest-" + string(rune('A'+i))
|
||||||
|
err := store.SaveNote(guestID, "LargeGuest-"+string(rune('A'+i)), "vm", "learning", "Big Note", string(largeContent))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := store.FormatAllForContext()
|
||||||
|
|
||||||
|
// Should be capped at ~8KB (with some tolerance for headers)
|
||||||
|
if len(result) > 9000 {
|
||||||
|
t.Errorf("Result should be capped at ~8KB, got %d bytes", len(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
func contains(s, substr string) bool {
|
||||||
|
return indexOf(s, substr) != -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOf(s, substr string) int {
|
||||||
|
for i := 0; i <= len(s)-len(substr); i++ {
|
||||||
|
if s[i:i+len(substr)] == substr {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func countOccurrences(s, substr string) int {
|
||||||
|
count := 0
|
||||||
|
for i := 0; i <= len(s)-len(substr); i++ {
|
||||||
|
if s[i:i+len(substr)] == substr {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
1243
internal/ai/patrol.go
Normal file
1243
internal/ai/patrol.go
Normal file
File diff suppressed because it is too large
Load diff
70
internal/ai/sanitize_test.go
Normal file
70
internal/ai/sanitize_test.go
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSanitizeError(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input error
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil error",
|
||||||
|
input: nil,
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "i/o timeout with TCP details",
|
||||||
|
input: errors.New("failed to send command: write tcp 192.168.0.123:7655->192.168.0.134:58004: i/o timeout"),
|
||||||
|
expected: "connection to agent timed out - the agent may be disconnected or unreachable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "read tcp i/o timeout",
|
||||||
|
input: errors.New("read tcp 192.168.1.100:8006: i/o timeout"),
|
||||||
|
expected: "network timeout - the target may be unreachable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "connection refused with TCP details",
|
||||||
|
input: errors.New("dial tcp 192.168.1.50:9090: connection refused"),
|
||||||
|
expected: "connection refused - the agent may not be running on the target host",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no such host",
|
||||||
|
input: errors.New("dial tcp: lookup unknown-host: no such host"),
|
||||||
|
expected: "host not found - verify the hostname is correct and DNS is working",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "context deadline exceeded",
|
||||||
|
input: errors.New("context deadline exceeded"),
|
||||||
|
expected: "operation timed out - the command may have taken too long",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regular error passes through",
|
||||||
|
input: errors.New("invalid command"),
|
||||||
|
expected: "invalid command",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "agent not connected error passes through",
|
||||||
|
input: errors.New("agent delly not connected"),
|
||||||
|
expected: "agent delly not connected",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := sanitizeError(tt.input)
|
||||||
|
if tt.input == nil {
|
||||||
|
if result != nil {
|
||||||
|
t.Errorf("expected nil, got %v", result)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if result.Error() != tt.expected {
|
||||||
|
t.Errorf("expected %q, got %q", tt.expected, result.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -41,7 +41,6 @@ type Service struct {
|
||||||
resourceProvider ResourceProvider // Unified resource model provider (Phase 2)
|
resourceProvider ResourceProvider // Unified resource model provider (Phase 2)
|
||||||
patrolService *PatrolService // Background AI monitoring service
|
patrolService *PatrolService // Background AI monitoring service
|
||||||
metadataProvider MetadataProvider // Enables AI to update resource URLs
|
metadataProvider MetadataProvider // Enables AI to update resource URLs
|
||||||
urlDiscoveryService *URLDiscoveryService // Bulk URL discovery service
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a new AI service
|
// NewService creates a new AI service
|
||||||
|
|
@ -83,16 +82,7 @@ func (s *Service) GetPatrolService() *PatrolService {
|
||||||
return s.patrolService
|
return s.patrolService
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetURLDiscoveryService returns the URL discovery service for bulk discovery
|
|
||||||
func (s *Service) GetURLDiscoveryService() *URLDiscoveryService {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
// Lazy initialization
|
|
||||||
if s.urlDiscoveryService == nil {
|
|
||||||
s.urlDiscoveryService = NewURLDiscoveryService(s)
|
|
||||||
}
|
|
||||||
return s.urlDiscoveryService
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetPatrolThresholdProvider sets the threshold provider for patrol
|
// SetPatrolThresholdProvider sets the threshold provider for patrol
|
||||||
// This should be called with an AlertThresholdAdapter to connect patrol to user-configured thresholds
|
// This should be called with an AlertThresholdAdapter to connect patrol to user-configured thresholds
|
||||||
|
|
|
||||||
|
|
@ -1,290 +0,0 @@
|
||||||
package ai
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
// URLDiscoveryResult represents the result of URL discovery for a single resource
|
|
||||||
type URLDiscoveryResult struct {
|
|
||||||
ResourceType string `json:"resource_type"` // "guest", "docker", "host"
|
|
||||||
ResourceID string `json:"resource_id"`
|
|
||||||
ResourceName string `json:"resource_name,omitempty"`
|
|
||||||
Status string `json:"status"` // "found", "not_found", "skipped", "error"
|
|
||||||
URL string `json:"url,omitempty"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// URLDiscoveryProgress represents progress of bulk discovery
|
|
||||||
type URLDiscoveryProgress struct {
|
|
||||||
Total int `json:"total"`
|
|
||||||
Completed int `json:"completed"`
|
|
||||||
Found int `json:"found"`
|
|
||||||
Errors int `json:"errors"`
|
|
||||||
Results []URLDiscoveryResult `json:"results"`
|
|
||||||
Running bool `json:"running"`
|
|
||||||
StartedAt time.Time `json:"started_at,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// URLDiscoveryService handles bulk URL discovery operations
|
|
||||||
type URLDiscoveryService struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
aiService *Service
|
|
||||||
progress *URLDiscoveryProgress
|
|
||||||
cancelFunc context.CancelFunc
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewURLDiscoveryService creates a new URL discovery service
|
|
||||||
func NewURLDiscoveryService(aiService *Service) *URLDiscoveryService {
|
|
||||||
return &URLDiscoveryService{
|
|
||||||
aiService: aiService,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetProgress returns the current discovery progress
|
|
||||||
func (s *URLDiscoveryService) GetProgress() *URLDiscoveryProgress {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
if s.progress == nil {
|
|
||||||
return &URLDiscoveryProgress{Running: false}
|
|
||||||
}
|
|
||||||
// Return a copy
|
|
||||||
copy := *s.progress
|
|
||||||
return ©
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsRunning returns true if discovery is in progress
|
|
||||||
func (s *URLDiscoveryService) IsRunning() bool {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
return s.progress != nil && s.progress.Running
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel stops the current discovery operation
|
|
||||||
func (s *URLDiscoveryService) Cancel() {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
if s.cancelFunc != nil {
|
|
||||||
s.cancelFunc()
|
|
||||||
s.cancelFunc = nil
|
|
||||||
}
|
|
||||||
if s.progress != nil {
|
|
||||||
s.progress.Running = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DiscoverURLs starts bulk URL discovery for resources without URLs
|
|
||||||
func (s *URLDiscoveryService) DiscoverURLs(ctx context.Context, resourceType string, skipExisting bool) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
if s.progress != nil && s.progress.Running {
|
|
||||||
s.mu.Unlock()
|
|
||||||
return fmt.Errorf("discovery already in progress")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create cancellable context
|
|
||||||
ctx, cancel := context.WithCancel(ctx)
|
|
||||||
s.cancelFunc = cancel
|
|
||||||
|
|
||||||
s.progress = &URLDiscoveryProgress{
|
|
||||||
Running: true,
|
|
||||||
StartedAt: time.Now(),
|
|
||||||
Results: []URLDiscoveryResult{},
|
|
||||||
}
|
|
||||||
s.mu.Unlock()
|
|
||||||
|
|
||||||
// Run discovery in background
|
|
||||||
go s.runDiscovery(ctx, resourceType, skipExisting)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// runDiscovery performs the actual discovery work
|
|
||||||
func (s *URLDiscoveryService) runDiscovery(ctx context.Context, resourceType string, skipExisting bool) {
|
|
||||||
defer func() {
|
|
||||||
s.mu.Lock()
|
|
||||||
s.progress.Running = false
|
|
||||||
s.cancelFunc = nil
|
|
||||||
s.mu.Unlock()
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Get resources to scan from the resource provider
|
|
||||||
s.aiService.mu.RLock()
|
|
||||||
rp := s.aiService.resourceProvider
|
|
||||||
mp := s.aiService.metadataProvider
|
|
||||||
s.aiService.mu.RUnlock()
|
|
||||||
|
|
||||||
if rp == nil {
|
|
||||||
log.Error().Msg("URL discovery: resource provider not available")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if mp == nil {
|
|
||||||
log.Error().Msg("URL discovery: metadata provider not available")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resources := rp.GetAll()
|
|
||||||
if resources == nil {
|
|
||||||
log.Error().Msg("URL discovery: no resources available")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter resources
|
|
||||||
var toScan []struct {
|
|
||||||
Type string
|
|
||||||
ID string
|
|
||||||
Name string
|
|
||||||
IP string
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, r := range resources {
|
|
||||||
// Only scan workloads (VMs, containers) and hosts
|
|
||||||
if !r.IsWorkload() && !r.IsInfrastructure() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by type if specified
|
|
||||||
if resourceType != "" && resourceType != "all" {
|
|
||||||
typeMatches := false
|
|
||||||
switch strings.ToLower(resourceType) {
|
|
||||||
case "guest":
|
|
||||||
typeMatches = r.Type == "vm" || r.Type == "container"
|
|
||||||
case "docker":
|
|
||||||
typeMatches = r.Type == "docker-container" || r.Type == "docker-service"
|
|
||||||
case "host":
|
|
||||||
typeMatches = r.Type == "host" || r.Type == "node" || r.Type == "docker-host"
|
|
||||||
default:
|
|
||||||
typeMatches = strings.EqualFold(string(r.Type), resourceType)
|
|
||||||
}
|
|
||||||
if !typeMatches {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Need an IP to scan
|
|
||||||
ip := ""
|
|
||||||
if r.Identity != nil && len(r.Identity.IPs) > 0 {
|
|
||||||
ip = r.Identity.IPs[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
toScan = append(toScan, struct {
|
|
||||||
Type string
|
|
||||||
ID string
|
|
||||||
Name string
|
|
||||||
IP string
|
|
||||||
}{
|
|
||||||
Type: string(r.Type),
|
|
||||||
ID: r.ID,
|
|
||||||
Name: r.Name,
|
|
||||||
IP: ip,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update total
|
|
||||||
s.mu.Lock()
|
|
||||||
s.progress.Total = len(toScan)
|
|
||||||
s.mu.Unlock()
|
|
||||||
|
|
||||||
log.Info().
|
|
||||||
Int("total", len(toScan)).
|
|
||||||
Str("resourceType", resourceType).
|
|
||||||
Bool("skipExisting", skipExisting).
|
|
||||||
Msg("Starting bulk URL discovery")
|
|
||||||
|
|
||||||
// Process each resource
|
|
||||||
for _, res := range toScan {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
log.Info().Msg("URL discovery cancelled")
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
result := s.discoverSingleResource(ctx, res.Type, res.ID, res.Name, res.IP)
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
s.progress.Completed++
|
|
||||||
s.progress.Results = append(s.progress.Results, result)
|
|
||||||
if result.Status == "found" {
|
|
||||||
s.progress.Found++
|
|
||||||
} else if result.Status == "error" {
|
|
||||||
s.progress.Errors++
|
|
||||||
}
|
|
||||||
s.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info().
|
|
||||||
Int("total", len(toScan)).
|
|
||||||
Int("found", s.progress.Found).
|
|
||||||
Int("errors", s.progress.Errors).
|
|
||||||
Msg("Bulk URL discovery completed")
|
|
||||||
}
|
|
||||||
|
|
||||||
// discoverSingleResource attempts to discover URL for a single resource
|
|
||||||
func (s *URLDiscoveryService) discoverSingleResource(ctx context.Context, resType, resID, resName, ip string) URLDiscoveryResult {
|
|
||||||
result := URLDiscoveryResult{
|
|
||||||
ResourceType: resType,
|
|
||||||
ResourceID: resID,
|
|
||||||
ResourceName: resName,
|
|
||||||
Status: "not_found",
|
|
||||||
}
|
|
||||||
|
|
||||||
if ip == "" {
|
|
||||||
result.Status = "skipped"
|
|
||||||
result.Error = "no IP address"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Common web ports to check
|
|
||||||
ports := []int{80, 443, 8080, 8443, 8096, 8920, 3000, 5000, 9000, 8081, 8000, 7878, 8989, 9117}
|
|
||||||
|
|
||||||
for _, port := range ports {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
result.Status = "error"
|
|
||||||
result.Error = "cancelled"
|
|
||||||
return result
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
scheme := "http"
|
|
||||||
if port == 443 || port == 8443 {
|
|
||||||
scheme = "https"
|
|
||||||
}
|
|
||||||
|
|
||||||
url := fmt.Sprintf("%s://%s:%d/", scheme, ip, port)
|
|
||||||
|
|
||||||
// Try to fetch the URL using the AI service's fetch method
|
|
||||||
checkCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
||||||
_, err := s.aiService.fetchURL(checkCtx, url)
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
// Found a responding service!
|
|
||||||
result.Status = "found"
|
|
||||||
result.URL = url
|
|
||||||
|
|
||||||
// Save it to metadata
|
|
||||||
if err := s.aiService.SetResourceURL(resType, resID, url); err != nil {
|
|
||||||
log.Warn().
|
|
||||||
Err(err).
|
|
||||||
Str("resourceID", resID).
|
|
||||||
Str("url", url).
|
|
||||||
Msg("Failed to save discovered URL")
|
|
||||||
} else {
|
|
||||||
log.Info().
|
|
||||||
Str("resourceType", resType).
|
|
||||||
Str("resourceID", resID).
|
|
||||||
Str("resourceName", resName).
|
|
||||||
Str("url", url).
|
|
||||||
Msg("Discovered and saved URL")
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
@ -1844,104 +1844,3 @@ func (h *AISettingsHandler) HandleGetFindingsHistory(w http.ResponseWriter, r *h
|
||||||
log.Error().Err(err).Msg("Failed to write findings history response")
|
log.Error().Err(err).Msg("Failed to write findings history response")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleStartURLDiscovery starts bulk URL discovery (POST /api/ai/discover-urls/start)
|
|
||||||
func (h *AISettingsHandler) HandleStartURLDiscovery(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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse request
|
|
||||||
var req struct {
|
|
||||||
ResourceType string `json:"resource_type"` // "guest", "docker", "host", or "all"
|
|
||||||
SkipExisting bool `json:"skip_existing"` // Skip resources that already have URLs
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
// Default values
|
|
||||||
req.ResourceType = "all"
|
|
||||||
req.SkipExisting = true
|
|
||||||
}
|
|
||||||
|
|
||||||
discoveryService := h.aiService.GetURLDiscoveryService()
|
|
||||||
if discoveryService == nil {
|
|
||||||
http.Error(w, "URL discovery service not available", http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := discoveryService.DiscoverURLs(r.Context(), req.ResourceType, req.SkipExisting); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusConflict)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info().
|
|
||||||
Str("resourceType", req.ResourceType).
|
|
||||||
Bool("skipExisting", req.SkipExisting).
|
|
||||||
Msg("Started bulk URL discovery")
|
|
||||||
|
|
||||||
if err := utils.WriteJSONResponse(w, map[string]interface{}{
|
|
||||||
"success": true,
|
|
||||||
"message": "URL discovery started",
|
|
||||||
}); err != nil {
|
|
||||||
log.Error().Err(err).Msg("Failed to write URL discovery start response")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleURLDiscoveryStatus returns the current discovery status (GET /api/ai/discover-urls/status)
|
|
||||||
func (h *AISettingsHandler) HandleURLDiscoveryStatus(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Require authentication
|
|
||||||
if !CheckAuth(h.config, w, r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
discoveryService := h.aiService.GetURLDiscoveryService()
|
|
||||||
if discoveryService == nil {
|
|
||||||
http.Error(w, "URL discovery service not available", http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
progress := discoveryService.GetProgress()
|
|
||||||
if err := utils.WriteJSONResponse(w, progress); err != nil {
|
|
||||||
log.Error().Err(err).Msg("Failed to write URL discovery status response")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleCancelURLDiscovery cancels the current discovery operation (POST /api/ai/discover-urls/cancel)
|
|
||||||
func (h *AISettingsHandler) HandleCancelURLDiscovery(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
|
|
||||||
}
|
|
||||||
|
|
||||||
discoveryService := h.aiService.GetURLDiscoveryService()
|
|
||||||
if discoveryService == nil {
|
|
||||||
http.Error(w, "URL discovery service not available", http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
discoveryService.Cancel()
|
|
||||||
|
|
||||||
log.Info().Msg("Cancelled bulk URL discovery")
|
|
||||||
|
|
||||||
if err := utils.WriteJSONResponse(w, map[string]interface{}{
|
|
||||||
"success": true,
|
|
||||||
"message": "URL discovery cancelled",
|
|
||||||
}); err != nil {
|
|
||||||
log.Error().Err(err).Msg("Failed to write URL discovery cancel response")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1103,11 +1103,6 @@ func (r *Router) setupRoutes() {
|
||||||
r.mux.HandleFunc("/api/ai/patrol/dismiss", RequireAuth(r.config, r.aiSettingsHandler.HandleAcknowledgeFinding)) // Backward compat
|
r.mux.HandleFunc("/api/ai/patrol/dismiss", RequireAuth(r.config, r.aiSettingsHandler.HandleAcknowledgeFinding)) // Backward compat
|
||||||
r.mux.HandleFunc("/api/ai/patrol/snooze", RequireAuth(r.config, r.aiSettingsHandler.HandleSnoozeFinding))
|
r.mux.HandleFunc("/api/ai/patrol/snooze", RequireAuth(r.config, r.aiSettingsHandler.HandleSnoozeFinding))
|
||||||
|
|
||||||
// AI URL Discovery routes for bulk scanning
|
|
||||||
r.mux.HandleFunc("/api/ai/discover-urls/start", RequireAdmin(r.config, r.aiSettingsHandler.HandleStartURLDiscovery))
|
|
||||||
r.mux.HandleFunc("/api/ai/discover-urls/status", RequireAuth(r.config, r.aiSettingsHandler.HandleURLDiscoveryStatus))
|
|
||||||
r.mux.HandleFunc("/api/ai/discover-urls/cancel", RequireAdmin(r.config, r.aiSettingsHandler.HandleCancelURLDiscovery))
|
|
||||||
|
|
||||||
// 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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,14 @@ type AIConfig struct {
|
||||||
OAuthAccessToken string `json:"oauth_access_token,omitempty"` // OAuth access token (encrypted at rest)
|
OAuthAccessToken string `json:"oauth_access_token,omitempty"` // OAuth access token (encrypted at rest)
|
||||||
OAuthRefreshToken string `json:"oauth_refresh_token,omitempty"` // OAuth refresh token (encrypted at rest)
|
OAuthRefreshToken string `json:"oauth_refresh_token,omitempty"` // OAuth refresh token (encrypted at rest)
|
||||||
OAuthExpiresAt time.Time `json:"oauth_expires_at,omitempty"` // Token expiration time
|
OAuthExpiresAt time.Time `json:"oauth_expires_at,omitempty"` // Token expiration time
|
||||||
|
|
||||||
|
// Patrol settings for background AI monitoring
|
||||||
|
PatrolEnabled bool `json:"patrol_enabled"` // Enable background AI health patrol
|
||||||
|
PatrolIntervalMinutes int `json:"patrol_interval_minutes,omitempty"` // How often to run quick patrols (default: 15)
|
||||||
|
PatrolAnalyzeNodes bool `json:"patrol_analyze_nodes,omitempty"` // Include Proxmox nodes in patrol
|
||||||
|
PatrolAnalyzeGuests bool `json:"patrol_analyze_guests,omitempty"` // Include VMs/containers in patrol
|
||||||
|
PatrolAnalyzeDocker bool `json:"patrol_analyze_docker,omitempty"` // Include Docker hosts in patrol
|
||||||
|
PatrolAnalyzeStorage bool `json:"patrol_analyze_storage,omitempty"` // Include storage in patrol
|
||||||
}
|
}
|
||||||
|
|
||||||
// AIProvider constants
|
// AIProvider constants
|
||||||
|
|
@ -55,6 +63,13 @@ func NewDefaultAIConfig() *AIConfig {
|
||||||
Provider: AIProviderAnthropic,
|
Provider: AIProviderAnthropic,
|
||||||
Model: DefaultAIModelAnthropic,
|
Model: DefaultAIModelAnthropic,
|
||||||
AuthMethod: AuthMethodAPIKey,
|
AuthMethod: AuthMethodAPIKey,
|
||||||
|
// Patrol defaults - enabled when AI is enabled, check every 15 minutes
|
||||||
|
PatrolEnabled: true,
|
||||||
|
PatrolIntervalMinutes: 15,
|
||||||
|
PatrolAnalyzeNodes: true,
|
||||||
|
PatrolAnalyzeGuests: true,
|
||||||
|
PatrolAnalyzeDocker: true,
|
||||||
|
PatrolAnalyzeStorage: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,3 +145,17 @@ func (c *AIConfig) ClearOAuthTokens() {
|
||||||
func (c *AIConfig) ClearAPIKey() {
|
func (c *AIConfig) ClearAPIKey() {
|
||||||
c.APIKey = ""
|
c.APIKey = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPatrolInterval returns the patrol interval as a duration
|
||||||
|
func (c *AIConfig) GetPatrolInterval() time.Duration {
|
||||||
|
if c.PatrolIntervalMinutes <= 0 {
|
||||||
|
return 15 * time.Minute // default
|
||||||
|
}
|
||||||
|
return time.Duration(c.PatrolIntervalMinutes) * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPatrolEnabled returns true if patrol should run
|
||||||
|
// Note: Patrol uses local heuristics and doesn't require an AI API key
|
||||||
|
func (c *AIConfig) IsPatrolEnabled() bool {
|
||||||
|
return c.PatrolEnabled
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ type ConfigPersistence struct {
|
||||||
oidcFile string
|
oidcFile string
|
||||||
apiTokensFile string
|
apiTokensFile string
|
||||||
aiFile string
|
aiFile string
|
||||||
|
aiFindingsFile string
|
||||||
crypto *crypto.CryptoManager
|
crypto *crypto.CryptoManager
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,6 +76,7 @@ func newConfigPersistence(configDir string) (*ConfigPersistence, error) {
|
||||||
oidcFile: filepath.Join(configDir, "oidc.enc"),
|
oidcFile: filepath.Join(configDir, "oidc.enc"),
|
||||||
apiTokensFile: filepath.Join(configDir, "api_tokens.json"),
|
apiTokensFile: filepath.Join(configDir, "api_tokens.json"),
|
||||||
aiFile: filepath.Join(configDir, "ai.enc"),
|
aiFile: filepath.Join(configDir, "ai.enc"),
|
||||||
|
aiFindingsFile: filepath.Join(configDir, "ai_findings.json"),
|
||||||
crypto: cryptoMgr,
|
crypto: cryptoMgr,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1350,13 +1352,122 @@ func (c *ConfigPersistence) LoadAIConfig() (*AIConfig, error) {
|
||||||
data = decrypted
|
data = decrypted
|
||||||
}
|
}
|
||||||
|
|
||||||
var settings AIConfig
|
// Start with defaults so new fields get proper values
|
||||||
if err := json.Unmarshal(data, &settings); err != nil {
|
settings := NewDefaultAIConfig()
|
||||||
|
if err := json.Unmarshal(data, settings); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info().Str("file", c.aiFile).Bool("enabled", settings.Enabled).Msg("AI configuration loaded")
|
// Migration: Ensure patrol settings have sensible defaults for existing configs
|
||||||
return &settings, nil
|
// PatrolIntervalMinutes=0 means it was never set - use default
|
||||||
|
if settings.PatrolIntervalMinutes <= 0 {
|
||||||
|
settings.PatrolIntervalMinutes = 15
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().Str("file", c.aiFile).Bool("enabled", settings.Enabled).Bool("patrol_enabled", settings.PatrolEnabled).Msg("AI configuration loaded")
|
||||||
|
return settings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AIFindingsData represents persisted AI findings with metadata
|
||||||
|
type AIFindingsData struct {
|
||||||
|
// Version for future migrations
|
||||||
|
Version int `json:"version"`
|
||||||
|
// LastSaved for debugging/diagnostics
|
||||||
|
LastSaved time.Time `json:"last_saved"`
|
||||||
|
// Findings is a map of finding ID to finding data
|
||||||
|
Findings map[string]*AIFindingRecord `json:"findings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AIFindingRecord is a persisted finding with full history
|
||||||
|
type AIFindingRecord struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
ResourceID string `json:"resource_id"`
|
||||||
|
ResourceName string `json:"resource_name"`
|
||||||
|
ResourceType string `json:"resource_type"`
|
||||||
|
Node string `json:"node,omitempty"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Recommendation string `json:"recommendation,omitempty"`
|
||||||
|
Evidence string `json:"evidence,omitempty"`
|
||||||
|
DetectedAt time.Time `json:"detected_at"`
|
||||||
|
LastSeenAt time.Time `json:"last_seen_at"`
|
||||||
|
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
||||||
|
AutoResolved bool `json:"auto_resolved"`
|
||||||
|
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
|
||||||
|
SnoozedUntil *time.Time `json:"snoozed_until,omitempty"`
|
||||||
|
AlertID string `json:"alert_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveAIFindings persists AI findings to disk
|
||||||
|
func (c *ConfigPersistence) SaveAIFindings(findings map[string]*AIFindingRecord) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if err := c.EnsureConfigDir(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
data := AIFindingsData{
|
||||||
|
Version: 1,
|
||||||
|
LastSaved: time.Now(),
|
||||||
|
Findings: findings,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.writeConfigFileLocked(c.aiFindingsFile, jsonData, 0600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug().
|
||||||
|
Str("file", c.aiFindingsFile).
|
||||||
|
Int("count", len(findings)).
|
||||||
|
Msg("AI findings saved")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadAIFindings loads AI findings from disk
|
||||||
|
func (c *ConfigPersistence) LoadAIFindings() (*AIFindingsData, error) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
data, err := os.ReadFile(c.aiFindingsFile)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
// Return empty data if file doesn't exist
|
||||||
|
return &AIFindingsData{
|
||||||
|
Version: 1,
|
||||||
|
Findings: make(map[string]*AIFindingRecord),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var findingsData AIFindingsData
|
||||||
|
if err := json.Unmarshal(data, &findingsData); err != nil {
|
||||||
|
log.Error().Err(err).Str("file", c.aiFindingsFile).Msg("Failed to parse AI findings file")
|
||||||
|
// Return empty data on parse error rather than failing
|
||||||
|
return &AIFindingsData{
|
||||||
|
Version: 1,
|
||||||
|
Findings: make(map[string]*AIFindingRecord),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if findingsData.Findings == nil {
|
||||||
|
findingsData.Findings = make(map[string]*AIFindingRecord)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().
|
||||||
|
Str("file", c.aiFindingsFile).
|
||||||
|
Int("count", len(findingsData.Findings)).
|
||||||
|
Time("last_saved", findingsData.LastSaved).
|
||||||
|
Msg("AI findings loaded")
|
||||||
|
return &findingsData, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadSystemSettings loads system settings from file
|
// LoadSystemSettings loads system settings from file
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue