mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-22 03:02:35 +00:00
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:
parent
59a4843f20
commit
28ac86c8ab
19 changed files with 292 additions and 206 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -170,3 +170,6 @@ tmp_*.sh
|
|||
scripts/agent/
|
||||
docs/internal/
|
||||
.agent/
|
||||
|
||||
# Pulse Pro landing page (private)
|
||||
landing-page/
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
[](LICENSE)
|
||||
[](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>
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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[]>([]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[]>([]);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue