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:
rcourtman 2025-12-10 08:35:24 +00:00
parent 0ae4daca8d
commit d2330cf405
28 changed files with 3871 additions and 854 deletions

View file

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

View file

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

View 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();
}

View file

@ -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,45 +943,57 @@ 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"> <div class="mb-3 space-y-2">
<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"> <For each={message.streamEvents}>
<svg class="w-3.5 h-3.5 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24"> {(evt) => (
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> <Show
</svg> when={evt.type === 'tool' && evt.tool}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> fallback={
<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" /> // Thinking chunk
</svg> <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">
<span>Thinking...</span> {sanitizeThinking(evt.thinking && evt.thinking.length > 500 ? evt.thinking.substring(0, 500) + '...' : evt.thinking || '')}
<span class="text-blue-600 dark:text-blue-400 text-[10px]">({message.thinking!.length} chars)</span> </div>
</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} {/* Tool call */}
</div> <div class="rounded border border-gray-300 dark:border-gray-600 overflow-hidden">
</details> <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-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">
<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>
<code class="font-mono">{evt.tool!.input}</code>
</div>
<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">
{evt.tool!.output.length > 500 ? evt.tool!.output.substring(0, 500) + '...' : evt.tool!.output}
</pre>
</Show>
</div>
</Show>
)}
</For>
</div>
</Show> </Show>
{/* Show completed tool calls FIRST - chronological order */} {/* Show in-progress tool executions - at the bottom */}
<Show when={message.role === 'assistant' && message.toolCalls && message.toolCalls.length > 0}> <Show when={message.role === 'assistant' && message.pendingTools && message.pendingTools.length > 0}>
<div class="mb-3 space-y-2"> <div class="mb-3 space-y-2">
<For each={message.toolCalls}> <For each={message.pendingTools}>
{(tool) => ( {(tool) => (
<div class="rounded border border-gray-300 dark:border-gray-600 overflow-hidden"> <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 ${tool.success <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">
? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200' <svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200' <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 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" />
</svg> </svg>
<code class="font-mono">{tool.input}</code> <code class="font-mono">{tool.input}</code>
<span class="text-[10px] text-purple-600 dark:text-purple-400">Running...</span>
</div> </div>
<Show when={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">
{tool.output.length > 500 ? tool.output.substring(0, 500) + '...' : tool.output}
</pre>
</Show>
</div> </div>
)} )}
</For> </For>

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

View 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;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = (
<span class="text-xs text-gray-400 dark:text-gray-500"></span> <div class="h-4 flex items-center justify-center">
<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 = (
<span class="text-xs text-gray-400 dark:text-gray-500"></span> <div class="h-4 flex items-center justify-center">
<span class="text-xs text-gray-400 dark:text-gray-500"></span>
</div>
); );
const defaultMobileContent = ( const defaultMobileContent = (

File diff suppressed because it is too large Load diff

View file

@ -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,8 +271,28 @@ 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++
return 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
}
} else {
// Reset failure counter on successful ping
consecutiveFailures = 0
} }
} }
} }

View 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
View 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] = &copy
}
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] = &copy
}
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 &copy
}
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, &copy)
}
}
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, &copy)
}
}
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, &copy)
}
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
}

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

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

View file

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

View 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

File diff suppressed because it is too large Load diff

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

View file

@ -29,19 +29,18 @@ type StateProvider interface {
// Service orchestrates AI interactions // Service orchestrates AI interactions
type Service struct { type Service struct {
mu sync.RWMutex mu sync.RWMutex
persistence *config.ConfigPersistence persistence *config.ConfigPersistence
provider providers.Provider provider providers.Provider
cfg *config.AIConfig cfg *config.AIConfig
agentServer *agentexec.Server agentServer *agentexec.Server
policy *agentexec.CommandPolicy policy *agentexec.CommandPolicy
stateProvider StateProvider stateProvider StateProvider
alertProvider AlertProvider alertProvider AlertProvider
knowledgeStore *knowledge.Store knowledgeStore *knowledge.Store
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

View file

@ -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 &copy
}
// 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
}

View file

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

View file

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

View file

@ -24,10 +24,18 @@ type AIConfig struct {
CustomContext string `json:"custom_context"` // user-provided context about their infrastructure CustomContext string `json:"custom_context"` // user-provided context about their infrastructure
// OAuth fields for Claude Pro/Max subscription authentication // OAuth fields for Claude Pro/Max subscription authentication
AuthMethod AuthMethod `json:"auth_method,omitempty"` // "api_key" or "oauth" (for anthropic only) AuthMethod AuthMethod `json:"auth_method,omitempty"` // "api_key" or "oauth" (for anthropic only)
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
}

View file

@ -20,19 +20,20 @@ import (
// ConfigPersistence handles saving and loading configuration // ConfigPersistence handles saving and loading configuration
type ConfigPersistence struct { type ConfigPersistence struct {
mu sync.RWMutex mu sync.RWMutex
tx *importTransaction tx *importTransaction
configDir string configDir string
alertFile string alertFile string
emailFile string emailFile string
webhookFile string webhookFile string
appriseFile string appriseFile string
nodesFile string nodesFile string
systemFile string systemFile string
oidcFile string oidcFile string
apiTokensFile string apiTokensFile string
aiFile string aiFile string
crypto *crypto.CryptoManager aiFindingsFile string
crypto *crypto.CryptoManager
} }
// NewConfigPersistence creates a new config persistence manager. // NewConfigPersistence creates a new config persistence manager.
@ -65,17 +66,18 @@ func newConfigPersistence(configDir string) (*ConfigPersistence, error) {
} }
cp := &ConfigPersistence{ cp := &ConfigPersistence{
configDir: configDir, configDir: configDir,
alertFile: filepath.Join(configDir, "alerts.json"), alertFile: filepath.Join(configDir, "alerts.json"),
emailFile: filepath.Join(configDir, "email.enc"), emailFile: filepath.Join(configDir, "email.enc"),
webhookFile: filepath.Join(configDir, "webhooks.enc"), webhookFile: filepath.Join(configDir, "webhooks.enc"),
appriseFile: filepath.Join(configDir, "apprise.enc"), appriseFile: filepath.Join(configDir, "apprise.enc"),
nodesFile: filepath.Join(configDir, "nodes.enc"), nodesFile: filepath.Join(configDir, "nodes.enc"),
systemFile: filepath.Join(configDir, "system.json"), systemFile: filepath.Join(configDir, "system.json"),
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"),
crypto: cryptoMgr, aiFindingsFile: filepath.Join(configDir, "ai_findings.json"),
crypto: cryptoMgr,
} }
log.Debug(). log.Debug().
@ -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