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
router.SetMonitor(reloadableMonitor.GetMonitor())
// Start AI patrol service for background infrastructure monitoring
router.StartPatrol(ctx)
// Create HTTP server with unified configuration
// In production, serve everything (frontend + API) on the frontend port
// 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 type { UpdateStatus } from './api/updates';
import { AIChat } from './components/AI/AIChat';
import { AIStatusIndicator } from './components/AI/AIStatusIndicator';
import { aiChatStore } from './stores/aiChat';
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">
<Show when={props.hasAuth() && !props.needsAuth()}>
<div class="flex items-center gap-2">
{/* AI Patrol Status Indicator */}
<AIStatusIndicator />
<Show when={props.proxyAuthInfo()?.username}>
<span class="text-xs px-2 py-1 text-gray-600 dark:text-gray-400">
{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)
interface PendingTool {
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 {
id: string;
role: 'user' | 'assistant';
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;
model?: string;
tokens?: { input: number; output: number };
@ -326,6 +360,8 @@ export const AIChat: Component<AIChatProps> = (props) => {
pendingTools: [],
pendingApprovals: [],
toolCalls: [],
thinkingChunks: [],
streamEvents: [],
};
setMessages((prev) => [...prev, streamingMessage]);
@ -394,24 +430,37 @@ export const AIChat: Component<AIChatProps> = (props) => {
output: data.output,
success: data.success,
};
// Add to both toolCalls and streamEvents for chronological display
const events = msg.streamEvents || [];
return {
...msg,
pendingTools: updatedPending,
toolCalls: [...(msg.toolCalls || []), newToolCall],
streamEvents: [...events, { type: 'tool' as const, tool: newToolCall }],
};
}
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 {
...msg,
thinking: (msg.thinking || '') + thinking,
thinking: (msg.thinking || '') + chunk,
thinkingChunks: [...chunks, chunk.trim()],
streamEvents: [...events, { type: 'thinking' as const, thinking: chunk.trim() }],
};
}
case 'content': {
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 {
...msg,
content: content,
content: existingContent + separator + content,
};
}
case 'complete': {
@ -653,8 +702,13 @@ export const AIChat: Component<AIChatProps> = (props) => {
prev.map((msg) => {
if (msg.id !== assistantId) return msg;
switch (event.type) {
case 'content':
return { ...msg, content: event.data as string, isStreaming: false };
case 'content': {
// 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':
return { ...msg, isStreaming: false };
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'
}`}
>
{/* Show thinking/reasoning content (DeepSeek) */}
<Show when={message.role === 'assistant' && message.thinking}>
<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>
{/* Render all events in chronological order - thinking and tools interleaved */}
<Show when={message.role === 'assistant' && message.streamEvents && message.streamEvents.length > 0}>
<div class="mb-3 space-y-2">
<For each={message.streamEvents}>
{(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={`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 completed tool calls FIRST - chronological order */}
<Show when={message.role === 'assistant' && message.toolCalls && message.toolCalls.length > 0}>
{/* 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.toolCalls}>
<For each={message.pendingTools}>
{(tool) => (
<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
? '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" />
<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>
<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>
)}
</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()}
aboveGuestId={prevGuestId}
belowGuestId={nextGuestId}
onRowClick={aiChatStore.isOpen ? handleGuestRowClick : undefined}
onRowClick={aiChatStore.enabled ? handleGuestRowClick : undefined}
/>
</ComponentErrorBoundary>
);

View file

@ -701,8 +701,8 @@ export function GuestRow(props: GuestRowProps) {
? ''
: 'hover:bg-gray-50 dark:hover:bg-gray-700/30';
const stoppedDimming = !isRunning() ? 'opacity-60' : '';
// Make row clickable if click handler provided
const clickable = props.onRowClick ? 'cursor-pointer' : '';
// Make row clickable if AI is enabled (for context selection)
const clickable = aiChatStore.enabled ? 'cursor-pointer' : '';
// AI context highlight with merged borders for adjacent rows
let aiContext = '';
if (isInAIContext()) {

View file

@ -802,7 +802,7 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
</Show>
<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
class="font-medium text-blue-600 underline-offset-2 hover:underline dark:text-blue-400"
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">
<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!
</p>
</div>
@ -7425,7 +7425,7 @@ const Settings: Component<SettingsProps> = (props) => {
<div class="space-y-3">
<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">
💡 Run diagnostics first for more comprehensive export data
Run diagnostics first for more comprehensive export data
</div>
</Show>
<div class="flex gap-2">

View file

@ -337,11 +337,11 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
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`;
// 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;
return (
@ -482,11 +482,11 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
return (
<tr
class={rowClass()}
style={rowStyle()}
style={{ ...rowStyle(), height: '29px', 'max-height': '29px' }}
onClick={() => props.onNodeClick(nodeId, isPVEItem ? 'pve' : 'pbs')}
>
{/* 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">
<StatusDot
variant={statusIndicator().variant}
@ -568,11 +568,11 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
{/* CPU */}
<td class={tdClass} style={metricColumnStyle}>
<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" />
</div>
</Show>
<div class="hidden md:block">
<div class="hidden md:block h-4">
<Show when={isPVEItem} fallback={
<ResponsiveMetricCell
value={cpuPercentValue}
@ -596,11 +596,11 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
{/* Memory */}
<td class={tdClass} style={metricColumnStyle}>
<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" />
</div>
</Show>
<div class="hidden md:block">
<div class="hidden md:block h-4">
<Show when={isPVEItem} fallback={
<ResponsiveMetricCell
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 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 (
@ -148,7 +150,9 @@ export const DualMetricCell: Component<{
const isRunning = () => props.isRunning !== false;
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 = (

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)
defer ticker.Stop()
// Track consecutive ping failures to detect dead connections faster
consecutiveFailures := 0
const maxConsecutiveFailures = 3
for {
select {
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))
ac.writeMu.Unlock()
if err != nil {
log.Debug().Err(err).Str("agent_id", ac.agent.AgentID).Msg("Failed to send ping to agent")
return
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
}
} 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
// 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 {
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()
if err != nil || len(guests) == 0 {
return ""
}
var sections []string
totalNotes := 0
// Load all guests with notes and sort by most recently updated
type guestWithTime struct {
id string
knowledge *GuestKnowledge
}
var guestsWithNotes []guestWithTime
for _, guestID := range guests {
knowledge, err := s.GetKnowledge(guestID)
if err != nil || len(knowledge.Notes) == 0 {
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
guestName := knowledge.GuestName
if guestName == "" {
guestName = guestID
guestName = g.id
}
// Group notes by category
@ -401,18 +443,44 @@ func (s *Store) FormatAllForContext() string {
if cat == "credential" && len(content) > 6 {
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)
}
finalize:
if len(sections) == 0 {
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 += 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
type Service struct {
mu sync.RWMutex
persistence *config.ConfigPersistence
provider providers.Provider
cfg *config.AIConfig
agentServer *agentexec.Server
policy *agentexec.CommandPolicy
stateProvider StateProvider
alertProvider AlertProvider
knowledgeStore *knowledge.Store
resourceProvider ResourceProvider // Unified resource model provider (Phase 2)
patrolService *PatrolService // Background AI monitoring service
metadataProvider MetadataProvider // Enables AI to update resource URLs
urlDiscoveryService *URLDiscoveryService // Bulk URL discovery service
mu sync.RWMutex
persistence *config.ConfigPersistence
provider providers.Provider
cfg *config.AIConfig
agentServer *agentexec.Server
policy *agentexec.CommandPolicy
stateProvider StateProvider
alertProvider AlertProvider
knowledgeStore *knowledge.Store
resourceProvider ResourceProvider // Unified resource model provider (Phase 2)
patrolService *PatrolService // Background AI monitoring service
metadataProvider MetadataProvider // Enables AI to update resource URLs
}
// NewService creates a new AI service
@ -83,16 +82,7 @@ func (s *Service) GetPatrolService() *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
// 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")
}
}
// 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/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
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
// OAuth fields for Claude Pro/Max subscription authentication
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)
OAuthRefreshToken string `json:"oauth_refresh_token,omitempty"` // OAuth refresh token (encrypted at rest)
OAuthExpiresAt time.Time `json:"oauth_expires_at,omitempty"` // Token expiration time
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)
OAuthRefreshToken string `json:"oauth_refresh_token,omitempty"` // OAuth refresh token (encrypted at rest)
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
@ -55,6 +63,13 @@ func NewDefaultAIConfig() *AIConfig {
Provider: AIProviderAnthropic,
Model: DefaultAIModelAnthropic,
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() {
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
type ConfigPersistence struct {
mu sync.RWMutex
tx *importTransaction
configDir string
alertFile string
emailFile string
webhookFile string
appriseFile string
nodesFile string
systemFile string
oidcFile string
apiTokensFile string
aiFile string
crypto *crypto.CryptoManager
mu sync.RWMutex
tx *importTransaction
configDir string
alertFile string
emailFile string
webhookFile string
appriseFile string
nodesFile string
systemFile string
oidcFile string
apiTokensFile string
aiFile string
aiFindingsFile string
crypto *crypto.CryptoManager
}
// NewConfigPersistence creates a new config persistence manager.
@ -65,17 +66,18 @@ func newConfigPersistence(configDir string) (*ConfigPersistence, error) {
}
cp := &ConfigPersistence{
configDir: configDir,
alertFile: filepath.Join(configDir, "alerts.json"),
emailFile: filepath.Join(configDir, "email.enc"),
webhookFile: filepath.Join(configDir, "webhooks.enc"),
appriseFile: filepath.Join(configDir, "apprise.enc"),
nodesFile: filepath.Join(configDir, "nodes.enc"),
systemFile: filepath.Join(configDir, "system.json"),
oidcFile: filepath.Join(configDir, "oidc.enc"),
apiTokensFile: filepath.Join(configDir, "api_tokens.json"),
aiFile: filepath.Join(configDir, "ai.enc"),
crypto: cryptoMgr,
configDir: configDir,
alertFile: filepath.Join(configDir, "alerts.json"),
emailFile: filepath.Join(configDir, "email.enc"),
webhookFile: filepath.Join(configDir, "webhooks.enc"),
appriseFile: filepath.Join(configDir, "apprise.enc"),
nodesFile: filepath.Join(configDir, "nodes.enc"),
systemFile: filepath.Join(configDir, "system.json"),
oidcFile: filepath.Join(configDir, "oidc.enc"),
apiTokensFile: filepath.Join(configDir, "api_tokens.json"),
aiFile: filepath.Join(configDir, "ai.enc"),
aiFindingsFile: filepath.Join(configDir, "ai_findings.json"),
crypto: cryptoMgr,
}
log.Debug().
@ -1350,13 +1352,122 @@ func (c *ConfigPersistence) LoadAIConfig() (*AIConfig, error) {
data = decrypted
}
var settings AIConfig
if err := json.Unmarshal(data, &settings); err != nil {
// Start with defaults so new fields get proper values
settings := NewDefaultAIConfig()
if err := json.Unmarshal(data, settings); err != nil {
return nil, err
}
log.Info().Str("file", c.aiFile).Bool("enabled", settings.Enabled).Msg("AI configuration loaded")
return &settings, nil
// Migration: Ensure patrol settings have sensible defaults for existing configs
// 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