Watch
1
0
Fork
You've already forked RedFlag
0

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:
Fimeg 2026-06-08 16:01:01 -04:00
commit 14cd0884b4
7 changed files with 100 additions and 321 deletions

View file

@ -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'] });
};
};

View file

@ -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
});
};

View file

@ -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`, {

View file

@ -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

View file

@ -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;