From d2330cf405fd3bbb3decdfe53c841a574b5a31fa Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 10 Dec 2025 08:35:24 +0000 Subject: [PATCH] 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. --- cmd/pulse/main.go | 3 + frontend-modern/src/App.tsx | 3 + frontend-modern/src/api/patrol.ts | 191 +++ frontend-modern/src/components/AI/AIChat.tsx | 142 +- .../src/components/AI/AIStatusIndicator.css | 74 + .../src/components/AI/AIStatusIndicator.tsx | 113 ++ .../src/components/Dashboard/Dashboard.tsx | 2 +- .../src/components/Dashboard/GuestRow.tsx | 4 +- .../components/Settings/APITokenManager.tsx | 2 +- .../src/components/Settings/Settings.tsx | 4 +- .../components/shared/NodeSummaryTable.tsx | 16 +- .../responsive/ResponsiveMetricCell.tsx | 8 +- frontend-modern/src/pages/Alerts.tsx | 1100 ++++++++++----- internal/agentexec/server.go | 28 +- internal/ai/alert_threshold_adapter.go | 76 + internal/ai/findings.go | 444 ++++++ internal/ai/findings_persistence.go | 79 ++ internal/ai/findings_test.go | 252 ++++ internal/ai/knowledge/store.go | 80 +- internal/ai/knowledge/store_test.go | 155 ++ internal/ai/patrol.go | 1243 +++++++++++++++++ internal/ai/sanitize_test.go | 70 + internal/ai/service.go | 36 +- internal/ai/url_discovery.go | 290 ---- internal/api/ai_handlers.go | 101 -- internal/api/router.go | 5 - internal/config/ai.go | 37 +- internal/config/persistence.go | 167 ++- 28 files changed, 3871 insertions(+), 854 deletions(-) create mode 100644 frontend-modern/src/api/patrol.ts create mode 100644 frontend-modern/src/components/AI/AIStatusIndicator.css create mode 100644 frontend-modern/src/components/AI/AIStatusIndicator.tsx create mode 100644 internal/ai/alert_threshold_adapter.go create mode 100644 internal/ai/findings.go create mode 100644 internal/ai/findings_persistence.go create mode 100644 internal/ai/findings_test.go create mode 100644 internal/ai/knowledge/store_test.go create mode 100644 internal/ai/patrol.go create mode 100644 internal/ai/sanitize_test.go delete mode 100644 internal/ai/url_discovery.go diff --git a/cmd/pulse/main.go b/cmd/pulse/main.go index ef3cb0c..45a950e 100644 --- a/cmd/pulse/main.go +++ b/cmd/pulse/main.go @@ -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 diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index ce3f104..71862d4 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -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: {
+ {/* AI Patrol Status Indicator */} + {props.proxyAuthInfo()?.username} diff --git a/frontend-modern/src/api/patrol.ts b/frontend-modern/src/api/patrol.ts new file mode 100644 index 0000000..8671cb4 --- /dev/null +++ b/frontend-modern/src/api/patrol.ts @@ -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 { + 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 { + 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 { + 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 = { + 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 = { + 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(); +} diff --git a/frontend-modern/src/components/AI/AIChat.tsx b/frontend-modern/src/components/AI/AIChat.tsx index 2ac837f..2371e63 100644 --- a/frontend-modern/src/components/AI/AIChat.tsx +++ b/frontend-modern/src/components/AI/AIChat.tsx @@ -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: " 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 = (props) => { pendingTools: [], pendingApprovals: [], toolCalls: [], + thinkingChunks: [], + streamEvents: [], }; setMessages((prev) => [...prev, streamingMessage]); @@ -394,24 +430,37 @@ export const AIChat: Component = (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 = (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 = (props) => { : 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100' }`} > - {/* Show thinking/reasoning content (DeepSeek) */} - -
- - - - - - - - Thinking... - ({message.thinking!.length} chars) - -
- {message.thinking!.length > 2000 ? message.thinking!.substring(0, 2000) + '...' : message.thinking} -
-
+ {/* Render all events in chronological order - thinking and tools interleaved */} + 0}> +
+ + {(evt) => ( + + {sanitizeThinking(evt.thinking && evt.thinking.length > 500 ? evt.thinking.substring(0, 500) + '...' : evt.thinking || '')} +
+ } + > + {/* Tool call */} +
+
+ + + + {evt.tool!.input} +
+ +
+                                  {evt.tool!.output.length > 500 ? evt.tool!.output.substring(0, 500) + '...' : evt.tool!.output}
+                                
+
+
+
+ )} + +
- {/* Show completed tool calls FIRST - chronological order */} - 0}> + {/* Show in-progress tool executions - at the bottom */} + 0}>
- + {(tool) => ( -
-
- - +
+
+ + + {tool.input} + Running...
- -
-                                {tool.output.length > 500 ? tool.output.substring(0, 500) + '...' : tool.output}
-                              
-
)} diff --git a/frontend-modern/src/components/AI/AIStatusIndicator.css b/frontend-modern/src/components/AI/AIStatusIndicator.css new file mode 100644 index 0000000..fa27869 --- /dev/null +++ b/frontend-modern/src/components/AI/AIStatusIndicator.css @@ -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; +} diff --git a/frontend-modern/src/components/AI/AIStatusIndicator.tsx b/frontend-modern/src/components/AI/AIStatusIndicator.tsx new file mode 100644 index 0000000..61484d4 --- /dev/null +++ b/frontend-modern/src/components/AI/AIStatusIndicator.tsx @@ -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( + 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 ( + + + + ); +} + +export default AIStatusIndicator; diff --git a/frontend-modern/src/components/Dashboard/Dashboard.tsx b/frontend-modern/src/components/Dashboard/Dashboard.tsx index 3f56ecd..d4c60ed 100644 --- a/frontend-modern/src/components/Dashboard/Dashboard.tsx +++ b/frontend-modern/src/components/Dashboard/Dashboard.tsx @@ -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} /> ); diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx index cdf00a2..adf4bdd 100644 --- a/frontend-modern/src/components/Dashboard/GuestRow.tsx +++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx @@ -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()) { diff --git a/frontend-modern/src/components/Settings/APITokenManager.tsx b/frontend-modern/src/components/Settings/APITokenManager.tsx index c0b81c4..dba976c 100644 --- a/frontend-modern/src/components/Settings/APITokenManager.tsx +++ b/frontend-modern/src/components/Settings/APITokenManager.tsx @@ -802,7 +802,7 @@ export const APITokenManager: Component = (props) => { - 💡 Separate tokens per integration • Rotate regularly •{' '} + Separate tokens per integration • Rotate regularly •{' '} = (props) => {

- 💡 Tip: Make sure you've saved your credentials + Tip: Make sure you've saved your credentials before restarting!

@@ -7425,7 +7425,7 @@ const Settings: Component = (props) => {
- 💡 Run diagnostics first for more comprehensive export data + Run diagnostics first for more comprehensive export data
diff --git a/frontend-modern/src/components/shared/NodeSummaryTable.tsx b/frontend-modern/src/components/shared/NodeSummaryTable.tsx index 6b6febe..82dd883 100644 --- a/frontend-modern/src/components/shared/NodeSummaryTable.tsx +++ b/frontend-modern/src/components/shared/NodeSummaryTable.tsx @@ -337,11 +337,11 @@ export const NodeSummaryTable: Component = (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 = (props) => { return ( props.onNodeClick(nodeId, isPVEItem ? 'pve' : 'pbs')} > {/* Name */} - +
= (props) => { {/* CPU */} -
+
-