fix: reduce WebSocket reconnection log noise in host agent

Addresses #866 - agents were logging 'WebSocket connection failed' warnings
even during normal reconnection scenarios (server restart, network blip, etc).

Changes:
- Normal close errors (1000, 1001, connection reset) now log at Debug level
- Only log Warning after 3+ consecutive failures
- Changed 'Connecting to Pulse' from Info to Debug to reduce noise
- Successful connections still log at Info level

The WebSocket is only used for AI command execution, not metrics, so
transient disconnections don't affect monitoring functionality.
This commit is contained in:
rcourtman 2025-12-22 14:11:23 +00:00
parent 59a4843f20
commit 28ac86c8ab
19 changed files with 292 additions and 206 deletions

3
.gitignore vendored
View file

@ -170,3 +170,6 @@ tmp_*.sh
scripts/agent/
docs/internal/
.agent/
# Pulse Pro landing page (private)
landing-page/

View file

@ -8,7 +8,7 @@
[![License](https://img.shields.io/github/license/rcourtman/Pulse)](LICENSE)
[![GitHub Sponsors](https://img.shields.io/github/sponsors/rcourtman?label=Sponsor)](https://github.com/sponsors/rcourtman)
[Live Demo](https://demo.pulserelay.pro) • [Documentation](docs/README.md) • [Report Bug](https://github.com/rcourtman/Pulse/issues)
[Live Demo](https://demo.pulserelay.pro) • [Pulse Pro](https://pulserelay.pro) • [Documentation](docs/README.md) • [Report Bug](https://github.com/rcourtman/Pulse/issues)
</div>
---

View file

@ -213,7 +213,7 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
const kubernetesAiEnabled = createMemo(() => licenseFeatures()?.features?.kubernetes_ai === true);
const aiConfigured = createMemo(() => aiSettings()?.configured === true);
const upgradeUrl = createMemo(() => licenseFeatures()?.upgrade_url || 'https://pulsemonitor.app/pro');
const upgradeUrl = createMemo(() => licenseFeatures()?.upgrade_url || 'https://pulserelay.pro');
const clustersForAnalysis = createMemo(() => props.clusters ?? []);
@ -607,8 +607,8 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
onClick={handleAnalyzeCluster}
disabled={analysisLoading() || !analysisClusterId() || !aiConfigured()}
class={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${analysisLoading() || !analysisClusterId() || !aiConfigured()
? 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
? 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{analysisLoading() ? 'Analyzing...' : 'Analyze'}

View file

@ -1258,7 +1258,7 @@ export const AISettings: Component = () => {
Pulse Pro required for alert-triggered analysis.{' '}
<a
class="underline decoration-dotted"
href="https://pulsemonitor.app/pro"
href="https://pulserelay.pro"
target="_blank"
rel="noreferrer"
>
@ -1303,7 +1303,7 @@ export const AISettings: Component = () => {
Pulse Pro required for auto-fix.{' '}
<a
class="underline decoration-dotted"
href="https://pulsemonitor.app/pro"
href="https://pulserelay.pro"
target="_blank"
rel="noreferrer"
>

View file

@ -5,7 +5,7 @@ import { showError, showSuccess } from '@/utils/toast';
import { LicenseAPI, type LicenseStatus } from '@/api/license';
import RefreshCw from 'lucide-solid/icons/refresh-cw';
const PULSE_PRO_URL = 'https://pulsemonitor.app/pro';
const PULSE_PRO_URL = 'https://pulserelay.pro';
const TIER_LABELS: Record<string, string> = {
free: 'Free',

View file

@ -2,7 +2,7 @@ import { Component, createEffect, createSignal, For, Show } from 'solid-js';
import { AIAPI } from '@/api/ai';
import type { RemediationRecord, RemediationStats } from '@/types/aiIntelligence';
const DEFAULT_UPGRADE_URL = 'https://pulsemonitor.app/pro';
const DEFAULT_UPGRADE_URL = 'https://pulserelay.pro';
export const AIImpactTimelinePanel: Component<{ hours?: number; showWhenEmpty?: boolean }> = (props) => {
const [remediations, setRemediations] = createSignal<RemediationRecord[]>([]);

View file

@ -13,7 +13,7 @@ export const AIInsightsPanel: Component<{ resourceId?: string; showWhenEmpty?: b
const [expanded, setExpanded] = createSignal(false);
const [locked, setLocked] = createSignal(false);
const [lockedCount, setLockedCount] = createSignal(0);
const [upgradeUrl, setUpgradeUrl] = createSignal('https://pulsemonitor.app/pro');
const [upgradeUrl, setUpgradeUrl] = createSignal('https://pulserelay.pro');
const [error, setError] = createSignal('');
const showWhenEmpty = () => Boolean(props.showWhenEmpty);
@ -27,7 +27,7 @@ export const AIInsightsPanel: Component<{ resourceId?: string; showWhenEmpty?: b
]);
const licenseLocked = Boolean(predResp.license_required || corrResp.license_required);
setLocked(licenseLocked);
setUpgradeUrl(predResp.upgrade_url || corrResp.upgrade_url || 'https://pulsemonitor.app/pro');
setUpgradeUrl(predResp.upgrade_url || corrResp.upgrade_url || 'https://pulserelay.pro');
if (licenseLocked) {
const predCount = predResp.count || 0;
const corrCount = corrResp.count || 0;

View file

@ -2,7 +2,7 @@ import { Component, createEffect, createSignal, For, Show } from 'solid-js';
import { AIAPI } from '@/api/ai';
import type { FailurePrediction, InfrastructureChange, RemediationRecord, RemediationStats, AnomalyReport } from '@/types/aiIntelligence';
const DEFAULT_UPGRADE_URL = 'https://pulsemonitor.app/pro';
const DEFAULT_UPGRADE_URL = 'https://pulserelay.pro';
interface InsightRow {
id: string;

View file

@ -2,7 +2,7 @@ import { Component, createEffect, createSignal, For, Show } from 'solid-js';
import { AIAPI } from '@/api/ai';
import type { InfrastructureChange } from '@/types/aiIntelligence';
const DEFAULT_UPGRADE_URL = 'https://pulsemonitor.app/pro';
const DEFAULT_UPGRADE_URL = 'https://pulserelay.pro';
export const AIRecentChangesPanel: Component<{ hours?: number; showWhenEmpty?: boolean }> = (props) => {
const [changes, setChanges] = createSignal<InfrastructureChange[]>([]);

View file

@ -2185,6 +2185,8 @@ function OverviewTab(props: {
const [patrolRunHistory, setPatrolRunHistory] = createSignal<PatrolRunRecord[]>([]);
// Track findings user marked as "I Fixed It" - hidden until next patrol verifies
const [pendingFixFindings, setPendingFixFindings] = createSignal<Set<string>>(new Set());
// Map of all findings by ID (including resolved) for displaying patrol run details
const [allFindingsMap, setAllFindingsMap] = createSignal<Map<string, Finding>>(new Map());
const [lastKnownPatrolAt, setLastKnownPatrolAt] = createSignal<string | null>(null);
const [showRunHistory, setShowRunHistory] = createSignal(false);
const [forcePatrolLoading, setForcePatrolLoading] = createSignal(false);
@ -2398,11 +2400,12 @@ function OverviewTab(props: {
// Fetch AI data - extracted for reuse
const fetchAiData = async () => {
try {
const [status, findings, runHistory, rules] = await Promise.all([
const [status, findings, runHistory, rules, findingsHistoryData] = await Promise.all([
getPatrolStatus(),
getFindings(),
getPatrolRunHistory(50), // Fetch more for filtering
getSuppressionRules().catch(() => []) // May not be available
getSuppressionRules().catch(() => []), // May not be available
getFindingsHistory().catch(() => []) // Includes resolved findings
]);
// Check if a new patrol has completed - if so, clear pending fix findings
@ -2420,6 +2423,16 @@ function OverviewTab(props: {
setPatrolRunHistory(runHistory || []);
setSuppressionRules(rules || []);
// Build a map of all findings by ID for looking up resolved findings
const findingsMap = new Map<string, Finding>();
(findings || []).forEach(f => findingsMap.set(f.id, f));
(findingsHistoryData || []).forEach(f => {
if (!findingsMap.has(f.id)) {
findingsMap.set(f.id, f);
}
});
setAllFindingsMap(findingsMap);
// Auto-expand history if most recent run found issues
if (runHistory && runHistory.length > 0 && runHistory[0].status !== 'healthy') {
setShowRunHistory(true);
@ -2736,41 +2749,7 @@ function OverviewTab(props: {
</div>
</Show>
{/* Summary Stats Bar */}
<div class="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-3 mb-4 flex flex-wrap items-center gap-4 text-xs">
<div class="flex items-center gap-1.5">
<span class="text-gray-500 dark:text-gray-400">Runs:</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{patrolRunHistory().length}</span>
</div>
<div class="flex items-center gap-1.5">
<span class="text-gray-500 dark:text-gray-400">Healthy:</span>
<span class="font-medium text-green-600 dark:text-green-400">
{patrolRunHistory().filter(r => r.status === 'healthy').length}
</span>
</div>
<Show when={patrolRunHistory().filter(r => r.status !== 'healthy').length > 0}>
<div class="flex items-center gap-1.5">
<span class="text-gray-500 dark:text-gray-400">Issues:</span>
<span class="font-medium text-yellow-600 dark:text-yellow-400">
{patrolRunHistory().filter(r => r.status !== 'healthy').length}
</span>
</div>
</Show>
<div class="flex items-center gap-1.5">
<span class="text-gray-500 dark:text-gray-400">Last:</span>
<span class="font-medium text-gray-700 dark:text-gray-300">
{patrolStatus()?.last_patrol_at ? formatTimestamp(patrolStatus()!.last_patrol_at!) : 'never'}
</span>
</div>
<Show when={patrolStatus()?.resources_checked}>
<div class="flex items-center gap-1.5">
<span class="text-gray-500 dark:text-gray-400">Resources:</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{patrolStatus()?.resources_checked}</span>
</div>
</Show>
</div>
{/* AI Intelligence Summary removed - patrol findings below provide the same value without noise */}
<Show when={patrolRequiresLicense()}>
@ -3012,7 +2991,7 @@ function OverviewTab(props: {
});
try {
await resolveFinding(finding.id);
showSuccess('Marked as fixed - the next patrol will verify');
showSuccess('✓ Fixed! Issue cleared from insights.');
fetchAiData();
} catch (_err) {
// Still keep it hidden locally since user said they fixed it
@ -3373,13 +3352,9 @@ function OverviewTab(props: {
<thead>
<tr class="bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 border-b border-gray-300 dark:border-gray-600">
<th class="p-1.5 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider w-4"></th>
<th class="p-1.5 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider">Time</th>
<th class="p-1.5 px-2 text-center text-[10px] sm:text-xs font-medium uppercase tracking-wider">Type</th>
<th class="p-1.5 px-2 text-center text-[10px] sm:text-xs font-medium uppercase tracking-wider">Status</th>
<th class="p-1.5 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider">When</th>
<th class="p-1.5 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider">Result</th>
<th class="p-1.5 px-2 text-center text-[10px] sm:text-xs font-medium uppercase tracking-wider">Resources</th>
<th class="p-1.5 px-2 text-center text-[10px] sm:text-xs font-medium uppercase tracking-wider">New</th>
<th class="p-1.5 px-2 text-center text-[10px] sm:text-xs font-medium uppercase tracking-wider">Resolved</th>
<th class="p-1.5 px-2 text-center text-[10px] sm:text-xs font-medium uppercase tracking-wider">Auto-Fix</th>
<th class="p-1.5 px-2 text-center text-[10px] sm:text-xs font-medium uppercase tracking-wider">Duration</th>
</tr>
</thead>
@ -3409,7 +3384,7 @@ function OverviewTab(props: {
Running
</span>
</td>
<td class="p-1.5 px-2 text-center" colspan="6">
<td class="p-1.5 px-2 text-center" colspan="3">
<span class="text-xs text-purple-600 dark:text-purple-400">
{expandedLiveStream() ? 'Click to collapse' : 'Click to view live AI analysis'}
</span>
@ -3418,7 +3393,7 @@ function OverviewTab(props: {
{/* Expanded Live Stream Row */}
<Show when={expandedLiveStream()}>
<tr class="bg-purple-50 dark:bg-purple-900/10 border-b border-gray-200 dark:border-gray-600">
<td colspan="9" class="p-3">
<td colspan="5" class="p-3">
<div class="flex items-center justify-between mb-3">
<span class="text-[10px] text-purple-500 dark:text-purple-400 uppercase tracking-wider flex items-center gap-1.5">
<svg class="w-3 h-3 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -3497,7 +3472,7 @@ function OverviewTab(props: {
error: 'bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300',
};
const statusStyle = statusStyles[run.status] || statusStyles.healthy;
const hasDetails = run.nodes_checked > 0 || run.guests_checked > 0 || run.docker_checked > 0 || run.storage_checked > 0 || run.ai_analysis;
const hasDetails = run.finding_ids && run.finding_ids.length > 0;
return (
<>
@ -3515,15 +3490,7 @@ function OverviewTab(props: {
<td class="p-1.5 px-2 text-gray-600 dark:text-gray-400 font-mono whitespace-nowrap">
{formatTimestamp(run.completed_at)}
</td>
<td class="p-1.5 px-2 text-center">
<span class={`text-[10px] px-1.5 py-0.5 rounded font-medium ${run.type === 'deep'
? 'bg-violet-100 dark:bg-violet-900/50 text-violet-700 dark:text-violet-300'
: 'bg-sky-100 dark:bg-sky-900/50 text-sky-700 dark:text-sky-300'
}`}>
{run.type === 'deep' ? 'Deep' : 'Patrol'}
</span>
</td>
<td class="p-1.5 px-2 text-center">
<td class="p-1.5 px-2">
<span class={`text-[10px] px-1.5 py-0.5 rounded font-medium ${statusStyle}`}>
{run.findings_summary}
</span>
@ -3531,21 +3498,6 @@ function OverviewTab(props: {
<td class="p-1.5 px-2 text-center text-gray-700 dark:text-gray-300">
{run.resources_checked}
</td>
<td class="p-1.5 px-2 text-center">
<Show when={run.new_findings > 0} fallback={<span class="text-gray-400">-</span>}>
<span class="text-yellow-600 dark:text-yellow-400 font-medium">{run.new_findings}</span>
</Show>
</td>
<td class="p-1.5 px-2 text-center">
<Show when={run.resolved_findings > 0} fallback={<span class="text-gray-400">-</span>}>
<span class="text-green-600 dark:text-green-400 font-medium">{run.resolved_findings}</span>
</Show>
</td>
<td class="p-1.5 px-2 text-center">
<Show when={(run.auto_fix_count || 0) > 0} fallback={<span class="text-gray-400">-</span>}>
<span class="text-blue-600 dark:text-blue-400 font-medium">{run.auto_fix_count}</span>
</Show>
</td>
<td class="p-1.5 px-2 text-center text-gray-500 dark:text-gray-400 font-mono">
{(() => {
const totalSeconds = Math.round(run.duration_ms / 1000000000);
@ -3561,113 +3513,79 @@ function OverviewTab(props: {
{/* Expanded Details Row */}
<Show when={expandedRunId() === run.id}>
<tr class="bg-gray-50 dark:bg-gray-800/50">
<td colspan="9" class="p-3">
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
<Show when={run.nodes_checked > 0}>
<div class="flex items-center gap-2">
<span class="px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded text-[10px]">Nodes</span>
<span class="font-medium">{run.nodes_checked}</span>
</div>
</Show>
<Show when={run.guests_checked > 0}>
<div class="flex items-center gap-2">
<span class="px-1.5 py-0.5 bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300 rounded text-[10px]">VMs/CTs</span>
<span class="font-medium">{run.guests_checked}</span>
</div>
</Show>
<Show when={run.docker_checked > 0}>
<div class="flex items-center gap-2">
<span class="px-1.5 py-0.5 bg-cyan-100 dark:bg-cyan-900/50 text-cyan-700 dark:text-cyan-300 rounded text-[10px]">Docker</span>
<span class="font-medium">{run.docker_checked}</span>
</div>
</Show>
<Show when={run.storage_checked > 0}>
<div class="flex items-center gap-2">
<span class="px-1.5 py-0.5 bg-orange-100 dark:bg-orange-900/50 text-orange-700 dark:text-orange-300 rounded text-[10px]">Storage</span>
<span class="font-medium">{run.storage_checked}</span>
</div>
</Show>
<Show when={run.hosts_checked > 0}>
<div class="flex items-center gap-2">
<span class="px-1.5 py-0.5 bg-teal-100 dark:bg-teal-900/50 text-teal-700 dark:text-teal-300 rounded text-[10px]">Hosts</span>
<span class="font-medium">{run.hosts_checked}</span>
</div>
</Show>
<Show when={run.pbs_checked > 0}>
<div class="flex items-center gap-2">
<span class="px-1.5 py-0.5 bg-amber-100 dark:bg-amber-900/50 text-amber-700 dark:text-amber-300 rounded text-[10px]">PBS</span>
<span class="font-medium">{run.pbs_checked}</span>
</div>
</Show>
<Show when={(run.auto_fix_count || 0) > 0}>
<div class="flex items-center gap-2">
<span class="px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded text-[10px]">Auto-Fix</span>
<span class="font-medium">{run.auto_fix_count}</span>
</div>
</Show>
</div>
{/* Only show findings section if we have active findings to display */}
<td colspan="5" class="p-3">
{/* Show findings from this run */}
{(() => {
const activeFindings = (run.finding_ids || [])
.map(id => aiFindings().find(f => f.id === id))
.filter(f => f !== undefined);
const resolvedCount = (run.finding_ids?.length || 0) - activeFindings.length;
const findingsMap = allFindingsMap();
const activeFindings: Finding[] = [];
const resolvedFindings: Finding[] = [];
if (activeFindings.length === 0 && resolvedCount === 0) return null;
(run.finding_ids || []).forEach(id => {
const finding = findingsMap.get(id);
if (finding) {
if (finding.resolved_at) {
resolvedFindings.push(finding);
} else {
activeFindings.push(finding);
}
}
});
if (activeFindings.length === 0 && resolvedFindings.length === 0) return null;
return (
<div class="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
<span class="text-[10px] text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Findings from this run:
</span>
<div class="flex flex-col gap-1 mt-1">
<For each={activeFindings}>
{(finding) => (
<div class="flex items-center gap-2 text-xs">
<span class={`px-1.5 py-0.5 rounded text-[10px] ${finding!.severity === 'critical' ? 'bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300' :
finding!.severity === 'warning' ? 'bg-yellow-100 dark:bg-yellow-900/50 text-yellow-700 dark:text-yellow-300' :
'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300'
}`}>
{finding!.severity}
</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{finding!.title}</span>
<span class="text-gray-500 dark:text-gray-400">on {finding!.resource_name}</span>
</div>
)}
</For>
<Show when={resolvedCount > 0}>
<div class="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div>
{/* Active findings */}
<Show when={activeFindings.length > 0}>
<span class="text-[10px] text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Active findings:
</span>
<div class="flex flex-col gap-1 mt-1">
<For each={activeFindings}>
{(finding) => (
<div class="flex items-center gap-2 text-xs">
<span class={`px-1.5 py-0.5 rounded text-[10px] ${finding.severity === 'critical' ? 'bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300' :
finding.severity === 'warning' ? 'bg-yellow-100 dark:bg-yellow-900/50 text-yellow-700 dark:text-yellow-300' :
'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300'
}`}>
{finding.severity}
</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{finding.title}</span>
<span class="text-gray-500 dark:text-gray-400">on {finding.resource_name}</span>
</div>
)}
</For>
</div>
</Show>
{/* Resolved findings - show value delivered */}
<Show when={resolvedFindings.length > 0}>
<div class={activeFindings.length > 0 ? "mt-3 pt-2 border-t border-gray-200 dark:border-gray-700" : ""}>
<span class="text-[10px] text-green-600 dark:text-green-400 uppercase tracking-wider flex items-center gap-1">
<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="M5 13l4 4L19 7" />
</svg>
{resolvedCount} finding{resolvedCount > 1 ? 's' : ''} since resolved
Resolved ({resolvedFindings.length})
</span>
<div class="flex flex-col gap-1 mt-1">
<For each={resolvedFindings}>
{(finding) => (
<div class="flex items-center gap-2 text-xs opacity-70">
<span class="px-1.5 py-0.5 rounded text-[10px] bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300">
resolved
</span>
<span class="text-gray-600 dark:text-gray-400">{finding.title}</span>
<span class="text-gray-400 dark:text-gray-500">on {finding.resource_name}</span>
</div>
)}
</For>
</div>
</Show>
</div>
</div>
</Show>
</div>
);
})()}
{/* AI Analysis Section */}
<Show when={run.ai_analysis}>
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between mb-2">
<span class="text-[10px] text-gray-500 dark:text-gray-400 uppercase tracking-wider flex items-center gap-1.5">
<svg class="w-3 h-3 text-purple-500" 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>
AI Analysis
</span>
<Show when={run.input_tokens || run.output_tokens}>
<span class="text-[9px] text-gray-400 dark:text-gray-500">
{run.input_tokens?.toLocaleString()} in / {run.output_tokens?.toLocaleString()} out tokens
</span>
</Show>
</div>
<div class="bg-gray-100 dark:bg-gray-900 rounded-lg p-3 max-h-64 overflow-y-auto">
<pre class="text-xs text-gray-700 dark:text-gray-300 whitespace-pre-wrap font-mono leading-relaxed">{run.ai_analysis}</pre>
</div>
</div>
</Show>
</td>
</tr>
</Show>

View file

@ -225,14 +225,8 @@ func (s *FindingsStore) Add(f *Finding) bool {
existing, exists := s.findings[f.ID]
if exists {
// Check if it's permanently suppressed - don't update at all
if existing.Suppressed {
s.mu.Unlock()
return false
}
// Check if dismissed - only update if severity has escalated
if existing.DismissedReason != "" {
// Check if dismissed or suppressed - only update if severity has escalated
if existing.DismissedReason != "" || existing.Suppressed {
severityOrder := map[FindingSeverity]int{
FindingSeverityInfo: 0,
FindingSeverityWatch: 1,
@ -247,8 +241,9 @@ func (s *FindingsStore) Add(f *Finding) bool {
s.scheduleSave()
return false
}
// Severity escalated - clear dismissal and reactivate
// Severity escalated - clear dismissal/suppression and reactivate
existing.DismissedReason = ""
existing.Suppressed = false
existing.UserNote = "" // Clear note since situation changed
existing.AcknowledgedAt = nil
}
@ -370,6 +365,8 @@ func (s *FindingsStore) Unsnooze(id string) bool {
// Dismiss marks a finding as dismissed with a reason and optional note
// Reasons: "not_an_issue", "expected_behavior", "will_fix_later"
// For "expected_behavior" and "not_an_issue", the finding is also suppressed
// (creates a suppression rule) to prevent future alerts of the same type.
func (s *FindingsStore) Dismiss(id, reason, note string) bool {
s.mu.Lock()
@ -387,6 +384,12 @@ func (s *FindingsStore) Dismiss(id, reason, note string) bool {
now := time.Now()
f.AcknowledgedAt = &now
// For "expected_behavior" and "not_an_issue", also create a suppression rule
// This prevents the AI from re-raising similar findings for this resource+category
if reason == "expected_behavior" || reason == "not_an_issue" {
f.Suppressed = true
}
s.mu.Unlock()
s.scheduleSave()
return true

View file

@ -529,26 +529,34 @@ func TestFindingsStore_Add_SuppressedExisting(t *testing.T) {
t.Fatal("Finding should be suppressed")
}
// Try to add the same finding again
// Try to add the same finding again with SAME severity (should stay suppressed)
f1Updated := &Finding{
ID: "f1",
ResourceID: "res-1",
Severity: FindingSeverityCritical,
Severity: FindingSeverityWarning, // Same severity - should stay suppressed
Title: "Updated Title",
Category: FindingCategoryPerformance,
}
isNew := store.Add(f1Updated)
// Should not update suppressed finding
// Should not be treated as new
if isNew {
t.Error("Suppressed finding should not be updated")
t.Error("Suppressed finding should not be treated as new")
}
// Verify the finding wasn't updated
// Verify the finding title wasn't updated (stayed suppressed)
stillSuppressed := store.Get("f1")
if stillSuppressed.Title != "Suppressed Finding" {
t.Error("Suppressed finding title should not change")
t.Error("Suppressed finding title should not change with same severity")
}
// But TimesRaised should still increment
if stillSuppressed.TimesRaised < 1 {
t.Error("TimesRaised should increment even for suppressed findings")
}
// NOTE: Severity ESCALATION (e.g., warning -> critical) would legitimately
// reactivate the finding for safety reasons - tested in TestFindingsStore_Add_SeverityEscalation
}
func TestFindingsStore_Add_DismissedSameSeverity(t *testing.T) {

View file

@ -752,11 +752,15 @@ func (p *PatrolService) runPatrol(ctx context.Context) {
state := p.stateProvider.GetState()
// Helper to track findings
// Note: Only warning+ severity findings count toward newFindings since watch/info are filtered from UI
trackFinding := func(f *Finding) bool {
isNew := p.findings.Add(f)
if isNew {
runStats.newFindings++
newFindings = append(newFindings, f)
// Only count warning+ findings as "new" for user-facing stats
if f.Severity == FindingSeverityWarning || f.Severity == FindingSeverityCritical {
runStats.newFindings++
newFindings = append(newFindings, f)
}
log.Info().
Str("finding_id", f.ID).
Str("severity", string(f.Severity)).
@ -1406,6 +1410,41 @@ func (p *PatrolService) ResolveFinding(findingID string, resolutionNote string)
return nil
}
// DismissFinding dismisses a finding with a reason and note
// This is called when the AI determines the finding is not actually an issue
// For reasons "expected_behavior" or "not_an_issue", a suppression rule is automatically created
func (p *PatrolService) DismissFinding(findingID string, reason string, note string) error {
if findingID == "" {
return fmt.Errorf("finding ID is required")
}
// Validate reason
validReasons := map[string]bool{"not_an_issue": true, "expected_behavior": true, "will_fix_later": true}
if !validReasons[reason] {
return fmt.Errorf("invalid reason: %s", reason)
}
// Check that the finding exists
finding := p.findings.Get(findingID)
if finding == nil {
return fmt.Errorf("finding not found: %s", findingID)
}
// Dismiss the finding (this automatically creates a suppression rule for expected_behavior/not_an_issue)
if !p.findings.Dismiss(findingID, reason, note) {
return fmt.Errorf("failed to dismiss finding: %s", findingID)
}
log.Info().
Str("finding_id", findingID).
Str("reason", reason).
Str("note", note).
Bool("suppressed", reason == "expected_behavior" || reason == "not_an_issue").
Msg("AI dismissed finding")
return nil
}
// GetRunHistory returns the history of patrol runs
// If limit is > 0, returns at most that many records
func (p *PatrolService) GetRunHistory(limit int) []PatrolRunRecord {

View file

@ -2094,13 +2094,13 @@ func (s *Service) getTools() []providers.Tool {
},
{
Name: "resolve_finding",
Description: "Mark an AI patrol finding as resolved after you have successfully fixed the underlying issue. Only use this after confirming the fix worked (e.g., by running a verification command). The finding ID is provided in your context when helping with a patrol finding.",
Description: "Mark an AI patrol finding as resolved after successfully fixing the issue. Use the finding ID shown in your Patrol Finding Context section. Call this after verifying the fix worked - do NOT ask the user for the finding ID.",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"finding_id": map[string]interface{}{
"type": "string",
"description": "The ID of the finding to resolve. Use the finding_id from the request context.",
"description": "The finding ID from your context (shown in ## Patrol Finding Context section).",
},
"resolution_note": map[string]interface{}{
"type": "string",
@ -2110,6 +2110,29 @@ func (s *Service) getTools() []providers.Tool {
"required": []string{"finding_id", "resolution_note"},
},
},
{
Name: "dismiss_finding",
Description: "Dismiss an AI patrol finding when it's not actually an issue or is expected behavior. Use this instead of resolve_finding when the finding is a false positive or the configuration is intentional. This creates a suppression rule to prevent similar findings from being raised again.",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"finding_id": map[string]interface{}{
"type": "string",
"description": "The finding ID from your context (shown in ## Patrol Finding Context section).",
},
"reason": map[string]interface{}{
"type": "string",
"description": "Why the finding is being dismissed.",
"enum": []string{"not_an_issue", "expected_behavior", "will_fix_later"},
},
"note": map[string]interface{}{
"type": "string",
"description": "Explanation of why this is not an issue or is expected behavior (e.g., 'PBS storage restricted to specific nodes is intentional').",
},
},
"required": []string{"finding_id", "reason", "note"},
},
},
}
// Add web search tool for Anthropic provider
@ -2336,6 +2359,62 @@ func (s *Service) executeTool(ctx context.Context, req ExecuteRequest, tc provid
execution.Success = true
return execution.Output, execution
case "dismiss_finding":
findingID, _ := tc.Input["finding_id"].(string)
reason, _ := tc.Input["reason"].(string)
note, _ := tc.Input["note"].(string)
execution.Input = fmt.Sprintf("finding: %s, reason: %s, note: %s", findingID, reason, note)
// If no finding ID provided by AI, check the request context
if findingID == "" {
findingID = req.FindingID
}
if findingID == "" {
execution.Output = "Error: finding_id is required. The finding ID should be provided in the request context when helping fix a patrol finding."
return execution.Output, execution
}
// Validate reason
validReasons := map[string]bool{"not_an_issue": true, "expected_behavior": true, "will_fix_later": true}
if !validReasons[reason] {
execution.Output = "Error: reason must be one of: not_an_issue, expected_behavior, will_fix_later"
return execution.Output, execution
}
if note == "" {
execution.Output = "Error: note is required. Please explain why this finding is being dismissed."
return execution.Output, execution
}
// Get the patrol service to dismiss the finding
s.mu.RLock()
patrolService := s.patrolService
s.mu.RUnlock()
if patrolService == nil {
execution.Output = "Error: Patrol service not available"
return execution.Output, execution
}
// Dismiss the finding (this will also create a suppression rule for expected_behavior/not_an_issue)
err := patrolService.DismissFinding(findingID, reason, note)
if err != nil {
execution.Output = fmt.Sprintf("Error dismissing finding: %s", err)
return execution.Output, execution
}
// Format a helpful response based on reason
var resultMsg string
if reason == "will_fix_later" {
resultMsg = fmt.Sprintf("Finding dismissed as '%s'. The AI will continue to monitor this issue.\nID: %s\nNote: %s", reason, findingID, note)
} else {
resultMsg = fmt.Sprintf("Finding dismissed as '%s' and suppression rule created. Similar findings for this resource will not be raised again.\nID: %s\nNote: %s", reason, findingID, note)
}
execution.Output = resultMsg
execution.Success = true
return execution.Output, execution
default:
execution.Output = fmt.Sprintf("Unknown tool: %s", tc.Name)
return execution.Output, execution
@ -2952,6 +3031,19 @@ This is a 3-command job. Don't over-investigate.`
}
// If we're helping fix a patrol finding, tell the AI the finding ID so it can resolve or dismiss it
if req.FindingID != "" {
prompt += fmt.Sprintf("\n\n## Patrol Finding Context\n"+
"You are helping with patrol finding **%s**.\n\n"+
"**After investigating, use ONE of these tools:**\n"+
"- `resolve_finding` - Use when you've actually FIXED the underlying issue\n"+
"- `dismiss_finding` - Use when the finding is a FALSE POSITIVE or EXPECTED BEHAVIOR\n\n"+
"**Examples:**\n"+
"- Issue fixed: `resolve_finding(finding_id=\"%s\", resolution_note=\"Restarted service\")`\n"+
"- False positive: `dismiss_finding(finding_id=\"%s\", reason=\"expected_behavior\", note=\"Storage restricted to specific node is intentional\")`\n",
req.FindingID, req.FindingID, req.FindingID)
}
// Add any provided context in a structured way
if len(req.Context) > 0 {
prompt += "\n\n## Current Metrics and State"

View file

@ -2112,7 +2112,7 @@ func (h *AISettingsHandler) HandleGetPatrolStatus(w http.ResponseWriter, r *http
LicenseStatus: licenseStatus,
}
if !hasPatrolFeature {
response.UpgradeURL = "https://pulsemonitor.app/pro"
response.UpgradeURL = "https://pulserelay.pro"
}
response.Summary.Critical = summary.Critical
response.Summary.Warning = summary.Warning

View file

@ -11,7 +11,7 @@ import (
"github.com/rs/zerolog/log"
)
const aiIntelligenceUpgradeURL = "https://pulsemonitor.app/pro"
const aiIntelligenceUpgradeURL = "https://pulserelay.pro"
// HandleGetPatterns returns detected failure patterns (GET /api/ai/intelligence/patterns)
func (h *AISettingsHandler) HandleGetPatterns(w http.ResponseWriter, r *http.Request) {

View file

@ -91,7 +91,7 @@ func (h *LicenseHandlers) HandleLicenseFeatures(w http.ResponseWriter, r *http.R
license.FeatureAIAutoFix: h.service.HasFeature(license.FeatureAIAutoFix),
license.FeatureKubernetesAI: h.service.HasFeature(license.FeatureKubernetesAI),
},
UpgradeURL: "https://pulsemonitor.app/pro",
UpgradeURL: "https://pulserelay.pro",
}
w.Header().Set("Content-Type", "application/json")
@ -217,7 +217,7 @@ func RequireLicenseFeature(service *license.Service, feature string, next http.H
"error": "license_required",
"message": err.Error(),
"feature": feature,
"upgrade_url": "https://pulsemonitor.app/pro",
"upgrade_url": "https://pulserelay.pro",
})
return
}

View file

@ -1193,7 +1193,7 @@ func (r *Router) setupRoutes() {
"error": "license_required",
"message": err.Error(),
"feature": license.FeatureAIPatrol,
"upgrade_url": "https://pulsemonitor.app/pro",
"upgrade_url": "https://pulserelay.pro",
})
return
}

View file

@ -104,6 +104,7 @@ type commandResultPayload struct {
// Run starts the command client and maintains the WebSocket connection
func (c *CommandClient) Run(ctx context.Context) error {
consecutiveFailures := 0
for {
select {
case <-ctx.Done():
@ -111,16 +112,38 @@ func (c *CommandClient) Run(ctx context.Context) error {
default:
}
if err := c.connectAndHandle(ctx); err != nil {
err := c.connectAndHandle(ctx)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
c.logger.Warn().Err(err).Msg("WebSocket connection failed, reconnecting in 10s")
consecutiveFailures++
// Distinguish between transient issues and persistent failures
// Normal close errors (server restart, reconnection) are expected and logged at debug
errStr := err.Error()
isNormalClose := strings.Contains(errStr, "close 1000") ||
strings.Contains(errStr, "close 1001") ||
strings.Contains(errStr, "use of closed network connection") ||
strings.Contains(errStr, "connection reset by peer")
if isNormalClose {
c.logger.Debug().Err(err).Msg("WebSocket closed, reconnecting in 10s")
} else if consecutiveFailures >= 3 {
c.logger.Warn().Err(err).Int("failures", consecutiveFailures).Msg("WebSocket connection failed repeatedly, reconnecting in 10s")
} else {
c.logger.Debug().Err(err).Msg("WebSocket connection interrupted, reconnecting in 10s")
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(10 * time.Second):
}
} else {
// Connection closed cleanly (shouldn't happen in normal operation)
consecutiveFailures = 0
}
}
}
@ -132,7 +155,7 @@ func (c *CommandClient) connectAndHandle(ctx context.Context) error {
return fmt.Errorf("build websocket url: %w", err)
}
c.logger.Info().Str("url", wsURL).Msg("Connecting to Pulse command server")
c.logger.Debug().Str("url", wsURL).Msg("Connecting to Pulse command server")
// Create dialer with TLS config
dialer := websocket.Dialer{