fix: consolidate heartbeat, lower dashboard polling
- useHeartbeatStatus reads from agent metadata (single source of truth) instead of separate endpoint (eliminated split-brain). - Online/offline threshold fixed to 10min to match server (was 15min). - Dashboard polling lowered from 30s to 15s. - Toggle feedback: invalidate agent queries immediately, clear loading state after 2s. - Removed command-table fallback from GetHeartbeatStatus endpoint, dead helper functions, and unused GetRecentHeartbeatCommands.
This commit is contained in:
parent
9c8e08e079
commit
14cd0884b4
7 changed files with 100 additions and 321 deletions
|
|
@ -1,69 +1,35 @@
|
|||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { agentApi } from '@/lib/api';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
|
||||
export interface HeartbeatStatus {
|
||||
enabled: boolean;
|
||||
until: string | null;
|
||||
active: boolean;
|
||||
duration_minutes: number;
|
||||
source?: string | null;
|
||||
}
|
||||
|
||||
type UseHeartbeatResult = UseQueryResult<HeartbeatStatus, Error> & {
|
||||
recentlyTriggered: boolean;
|
||||
setRecentlyTriggered: (value: boolean) => void;
|
||||
/**
|
||||
* Derives heartbeat status from agent metadata instead of a separate endpoint.
|
||||
* The server writes `rapid_polling_enabled`, `rapid_polling_until`, and
|
||||
* `heartbeat_source` to agent metadata on every poll response and toggle.
|
||||
* Reading from the same agent object eliminates the split-brain between
|
||||
* the agent cache and a separate heartbeat cache.
|
||||
*/
|
||||
export const useHeartbeatStatus = (metadata?: Record<string, any>): HeartbeatStatus => {
|
||||
return useMemo(() => {
|
||||
if (!metadata) {
|
||||
return { enabled: false, until: null, active: false, source: null };
|
||||
}
|
||||
|
||||
const enabled = metadata.rapid_polling_enabled === true;
|
||||
const until = metadata.rapid_polling_until || null;
|
||||
const source = metadata.heartbeat_source || null;
|
||||
|
||||
// Active = enabled AND the until timestamp hasn't expired
|
||||
let active = false;
|
||||
if (enabled && until) {
|
||||
active = new Date(until).getTime() > Date.now();
|
||||
}
|
||||
|
||||
return { enabled, until, active, source };
|
||||
}, [metadata]);
|
||||
};
|
||||
|
||||
export const useHeartbeatStatus = (agentId: string, enabled: boolean = true): UseHeartbeatResult => {
|
||||
const [recentlyTriggered, setRecentlyTriggered] = useState(false);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['heartbeat', agentId],
|
||||
queryFn: () => agentApi.getHeartbeatStatus(agentId),
|
||||
enabled: enabled && !!agentId,
|
||||
refetchInterval: (query) => {
|
||||
// Fast polling after button click (wait for agent to report)
|
||||
if (recentlyTriggered) {
|
||||
return 5000; // 5 seconds
|
||||
}
|
||||
|
||||
// Fast polling during active operations
|
||||
const heartbeatData = query.state.data as HeartbeatStatus | undefined;
|
||||
if (heartbeatData?.enabled && heartbeatData?.active) {
|
||||
return 10000; // 10 seconds
|
||||
}
|
||||
|
||||
// Slow polling when idle
|
||||
return 120000; // 2 minutes
|
||||
},
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
// Clear flag when agent reports active
|
||||
if (recentlyTriggered && query.data?.active) {
|
||||
setRecentlyTriggered(false);
|
||||
}
|
||||
|
||||
return {
|
||||
...query,
|
||||
recentlyTriggered,
|
||||
setRecentlyTriggered,
|
||||
};
|
||||
};
|
||||
|
||||
// Hook to manually invalidate heartbeat cache (used after commands)
|
||||
export const useInvalidateHeartbeat = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return (agentId: string) => {
|
||||
// Invalidate heartbeat cache
|
||||
queryClient.invalidateQueries({ queryKey: ['heartbeat', agentId] });
|
||||
|
||||
// Also invalidate agent cache to synchronize data
|
||||
queryClient.invalidateQueries({ queryKey: ['agent', agentId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['agents'] });
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export const useDashboardStats = (): UseQueryResult<DashboardStats, Error> => {
|
|||
return useQuery({
|
||||
queryKey: ['dashboard-stats'],
|
||||
queryFn: statsApi.getDashboardStats,
|
||||
refetchInterval: 30000, // Refresh every 30 seconds
|
||||
staleTime: 15000, // Consider data stale after 15 seconds
|
||||
refetchInterval: 15000, // Refresh every 15 seconds (dashboard is the operator's primary view)
|
||||
staleTime: 10000, // Consider data stale after 10 seconds
|
||||
});
|
||||
};
|
||||
|
|
@ -154,12 +154,6 @@ export const agentApi = {
|
|||
return response.data;
|
||||
},
|
||||
|
||||
// Get heartbeat status for single agent
|
||||
getHeartbeatStatus: async (id: string): Promise<{ enabled: boolean; until: string | null; active: boolean; duration_minutes: number; source?: string | null }> => {
|
||||
const response = await api.get(`/agents/${id}/heartbeat`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Trigger agent reboot
|
||||
rebootAgent: async (id: string, delayMinutes: number = 1, message?: string): Promise<void> => {
|
||||
await api.post(`/agents/${id}/reboot`, {
|
||||
|
|
|
|||
|
|
@ -77,12 +77,14 @@ export const formatRelativeTime = (dateString: string): string => {
|
|||
}
|
||||
};
|
||||
|
||||
// Threshold must match server (stats.go: 10min). Make configurable later via
|
||||
// a server-side setting surfaced on the agent model; hardcode for now.
|
||||
const ONLINE_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
export const isOnline = (lastCheckin: string): boolean => {
|
||||
const lastCheck = new Date(lastCheckin);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - lastCheck.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
return diffMins < 15; // Consider online if checked in within 15 minutes (allows for 5min check-in + buffer)
|
||||
return (now.getTime() - lastCheck.getTime()) < ONLINE_THRESHOLD_MS;
|
||||
};
|
||||
|
||||
// Size formatting utilities
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import { SearchInput, FilterDropdown } from '@/components/primitives';
|
|||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import { useAgents, useAgent, useScanMultipleAgents, useUnregisterAgent } from '@/hooks/useAgents';
|
||||
import { useActiveCommands, useCancelCommand } from '@/hooks/useCommands';
|
||||
import { useHeartbeatStatus, useInvalidateHeartbeat } from '@/hooks/useHeartbeat';
|
||||
import { useHeartbeatStatus } from '@/hooks/useHeartbeat';
|
||||
import { agentApi } from '@/lib/api';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { getStatusColor, formatRelativeTime, isOnline, formatBytes } from '@/lib/utils';
|
||||
|
|
@ -42,21 +42,28 @@ import ChatTimeline from '@/components/ChatTimeline';
|
|||
import AgentSoftwareBindings from '@/components/AgentSoftwareBindings';
|
||||
import { AgentIntegrations } from '@/components/AgentIntegrations';
|
||||
|
||||
type AgentDetailTab = 'overview' | 'storage' | 'updates' | 'software' | 'scanners' | 'history';
|
||||
|
||||
const AGENT_DETAIL_TABS: AgentDetailTab[] = ['overview', 'storage', 'updates', 'software', 'scanners', 'history'];
|
||||
|
||||
const parseAgentDetailTab = (tab: string | null): AgentDetailTab => {
|
||||
return AGENT_DETAIL_TABS.includes(tab as AgentDetailTab) ? tab as AgentDetailTab : 'overview';
|
||||
};
|
||||
|
||||
const Agents: React.FC = () => {
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchQuery, setSearchQuery] = useState(searchParams.get('search') || '');
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300);
|
||||
const [statusFilter, setStatusFilter] = useState<string>(searchParams.get('status') || '');
|
||||
const [osFilter, setOsFilter] = useState<string>('');
|
||||
const [selectedAgents, setSelectedAgents] = useState<string[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'storage' | 'updates' | 'software' | 'scanners' | 'history'>('overview');
|
||||
const activeTab = parseAgentDetailTab(searchParams.get('tab'));
|
||||
const [heartbeatDuration, setHeartbeatDuration] = useState<number>(10); // Default 10 minutes
|
||||
const [showDurationDropdown, setShowDurationDropdown] = useState(false);
|
||||
const [heartbeatLoading, setHeartbeatLoading] = useState(false); // Loading state for heartbeat toggle
|
||||
const [heartbeatCommandId, setHeartbeatCommandId] = useState<string | null>(null); // Track specific heartbeat command
|
||||
const [showUpdateModal, setShowUpdateModal] = useState(false); // Update modal state
|
||||
const [singleAgentUpdate, setSingleAgentUpdate] = useState<string | null>(null); // Single agent update modal
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -89,6 +96,16 @@ const Agents: React.FC = () => {
|
|||
return option?.label || '10 minutes';
|
||||
};
|
||||
|
||||
const selectActiveTab = (tab: AgentDetailTab) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (tab === 'overview') {
|
||||
params.delete('tab');
|
||||
} else {
|
||||
params.set('tab', tab);
|
||||
}
|
||||
setSearchParams(params, { replace: true });
|
||||
};
|
||||
|
||||
// Helper function to get system metadata from agent
|
||||
const getSystemMetadata = (agent: any) => {
|
||||
const metadata = agent.metadata || {};
|
||||
|
|
@ -215,35 +232,10 @@ const Agents: React.FC = () => {
|
|||
const agents = agentsData?.agents || [];
|
||||
const selectedAgent = selectedAgentData || agents.find(a => a.id === id);
|
||||
|
||||
// Get heartbeat status for selected agent (smart polling - only when active)
|
||||
const heartbeatResult = useHeartbeatStatus(selectedAgent?.id || '', !!selectedAgent);
|
||||
const heartbeatStatus = heartbeatResult.data;
|
||||
const { setRecentlyTriggered } = heartbeatResult;
|
||||
const invalidateHeartbeat = useInvalidateHeartbeat();
|
||||
|
||||
|
||||
// Simple completion handling - clear loading state quickly
|
||||
useEffect(() => {
|
||||
if (!heartbeatCommandId) return;
|
||||
|
||||
// Clear loading state quickly since smart polling will handle UI updates
|
||||
const timeout = setTimeout(() => {
|
||||
setHeartbeatCommandId(null);
|
||||
setHeartbeatLoading(false);
|
||||
}, 2000); // 2 seconds - enough time for command to process
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [heartbeatCommandId]);
|
||||
|
||||
// Refresh heartbeat status when switching to overview tab
|
||||
useEffect(() => {
|
||||
if (activeTab === 'overview' && selectedAgent?.id) {
|
||||
// Invalidate heartbeat cache to force fresh data on tab switch
|
||||
invalidateHeartbeat(selectedAgent.id);
|
||||
}
|
||||
}, [activeTab, selectedAgent?.id]);
|
||||
// Heartbeat status derived from agent metadata — no separate endpoint poll.
|
||||
// Server writes rapid_polling_enabled/rapid_polling_until/heartbeat_source
|
||||
// to agent metadata on every poll response and toggle.
|
||||
const heartbeatStatus = useHeartbeatStatus(selectedAgent?.metadata);
|
||||
|
||||
// Filter agents based on OS
|
||||
const filteredAgents = agents.filter(agent => {
|
||||
|
|
@ -339,16 +331,15 @@ const Agents: React.FC = () => {
|
|||
setHeartbeatLoading(true);
|
||||
try {
|
||||
const duration = durationMinutes || heartbeatDuration;
|
||||
const result = await agentApi.toggleHeartbeat(agentId, enabled, duration);
|
||||
await agentApi.toggleHeartbeat(agentId, enabled, duration);
|
||||
|
||||
// Trigger fast polling for 15 seconds to wait for agent response
|
||||
setRecentlyTriggered(true);
|
||||
setTimeout(() => setRecentlyTriggered(false), 15000);
|
||||
|
||||
// Store the command ID for minimal tracking
|
||||
if (result.command_id) {
|
||||
setHeartbeatCommandId(result.command_id);
|
||||
}
|
||||
// Invalidate agent query so the next refetch picks up the new metadata.
|
||||
// The server writes rapid_polling_enabled/rapid_polling_until to agent
|
||||
// metadata immediately on toggle, so the next agent data fetch (30s max)
|
||||
// will reflect the new state. Clear loading after a short delay.
|
||||
queryClient.invalidateQueries({ queryKey: ['agent', agentId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['agents'] });
|
||||
setTimeout(() => setHeartbeatLoading(false), 2000);
|
||||
|
||||
if (enabled) {
|
||||
if (duration === -1) {
|
||||
|
|
@ -362,7 +353,6 @@ const Agents: React.FC = () => {
|
|||
} catch (error: any) {
|
||||
toast.error(`Failed to send heartbeat command: ${error.message || 'Unknown error'}`);
|
||||
setHeartbeatLoading(false);
|
||||
setHeartbeatCommandId(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -499,7 +489,7 @@ const Agents: React.FC = () => {
|
|||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-1 overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setActiveTab('overview')}
|
||||
onClick={() => selectActiveTab('overview')}
|
||||
className={cn(
|
||||
'py-3 px-4 border-b-2 font-medium text-sm transition-colors whitespace-nowrap',
|
||||
activeTab === 'overview'
|
||||
|
|
@ -510,7 +500,7 @@ const Agents: React.FC = () => {
|
|||
<span>Overview</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('storage')}
|
||||
onClick={() => selectActiveTab('storage')}
|
||||
className={cn(
|
||||
'py-3 px-4 border-b-2 font-medium text-sm transition-colors whitespace-nowrap flex items-center space-x-2',
|
||||
activeTab === 'storage'
|
||||
|
|
@ -522,7 +512,7 @@ const Agents: React.FC = () => {
|
|||
<span>Storage & Disks</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('updates')}
|
||||
onClick={() => selectActiveTab('updates')}
|
||||
className={cn(
|
||||
'py-3 px-4 border-b-2 font-medium text-sm transition-colors whitespace-nowrap flex items-center space-x-2',
|
||||
activeTab === 'updates'
|
||||
|
|
@ -534,7 +524,7 @@ const Agents: React.FC = () => {
|
|||
<span>Updates & Packages</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('software')}
|
||||
onClick={() => selectActiveTab('software')}
|
||||
className={cn(
|
||||
'py-3 px-4 border-b-2 font-medium text-sm transition-colors whitespace-nowrap flex items-center space-x-2',
|
||||
activeTab === 'software'
|
||||
|
|
@ -546,7 +536,7 @@ const Agents: React.FC = () => {
|
|||
<span>Tracked Software</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('scanners')}
|
||||
onClick={() => selectActiveTab('scanners')}
|
||||
className={cn(
|
||||
'py-3 px-4 border-b-2 font-medium text-sm transition-colors whitespace-nowrap flex items-center space-x-2',
|
||||
activeTab === 'scanners'
|
||||
|
|
@ -558,7 +548,7 @@ const Agents: React.FC = () => {
|
|||
<span>Agent Health</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('history')}
|
||||
onClick={() => selectActiveTab('history')}
|
||||
className={cn(
|
||||
'py-3 px-4 border-b-2 font-medium text-sm transition-colors whitespace-nowrap flex items-center space-x-2',
|
||||
activeTab === 'history'
|
||||
|
|
@ -946,7 +936,7 @@ const Agents: React.FC = () => {
|
|||
{activeTab === 'updates' && (
|
||||
<AgentUpdatesEnhanced
|
||||
agentId={selectedAgent.id}
|
||||
onNavigateToHistory={() => setActiveTab('history')}
|
||||
onNavigateToHistory={() => selectActiveTab('history')}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -1356,4 +1346,4 @@ const Agents: React.FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
export default Agents;
|
||||
export default Agents;
|
||||
|
|
|
|||
Loading…
Reference in a new issue