Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 8 additions & 27 deletions web/src/components/AgentList.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
'use client';

import { useEffect, useState } from 'react';
import { useWebSocket } from './providers/WebSocketProvider';
import { useCoreFleet } from './providers/CoreFleetProvider';
import { apiFetch } from '@/lib/api';

interface TokenInfo {
hostname: string | null;
last_seen: number;
}

export default function AgentList({
selectedAgent,
Expand All @@ -18,27 +11,15 @@ export default function AgentList({
onSelectAgent: (agentId: string) => void;
}) {
const { agents } = useWebSocket();
const { snapshots } = useCoreFleet();
const [knownHosts, setKnownHosts] = useState<TokenInfo[]>([]);

useEffect(() => {
let cancelled = false;
apiFetch('/api/tokens')
.then((r) => r.json())
.then((rows: TokenInfo[]) => {
if (!cancelled) setKnownHosts(rows);
})
.catch(() => {});
return () => { cancelled = true; };
}, [agents.length]);
const { hosts, snapshots } = useCoreFleet();

const onlineSet = new Set(agents);
const offlineHosts = knownHosts
.filter((t) => t.hostname && !onlineSet.has(`${t.hostname}-id`))
.map((t) => ({
id: `${t.hostname}-id`,
label: t.hostname!,
lastSeen: t.last_seen,
const offlineHosts = hosts
.filter((host) => host.status === 'offline' && !onlineSet.has(host.agent_id))
.map((host) => ({
id: host.agent_id,
label: host.hostname,
lastSeen: host.last_seen_at,
}));

if (agents.length === 0 && offlineHosts.length === 0) {
Expand Down Expand Up @@ -109,7 +90,7 @@ export default function AgentList({
}

function formatAgo(epochSecs: number): string {
const diff = Math.floor(Date.now() / 1000) - epochSecs;
const diff = Math.max(0, Math.floor(Date.now() / 1000) - epochSecs);
if (diff < 60) return 'just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
Expand Down
62 changes: 51 additions & 11 deletions web/src/components/__tests__/AgentList.test.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,30 @@
import '@testing-library/jest-dom/vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import AgentList from '../AgentList';

const providerState = vi.hoisted(() => ({
agents: [] as string[],
hosts: [] as Array<Record<string, unknown>>,
snapshots: {} as Record<string, unknown>,
}));

vi.mock('../providers/WebSocketProvider', () => ({
useWebSocket: () => ({ agents: ['swarm-master-id'] }),
useWebSocket: () => ({ agents: providerState.agents }),
}));

vi.mock('../providers/CoreFleetProvider', () => ({
useCoreFleet: () => ({
snapshots: {
hosts: providerState.hosts,
snapshots: providerState.snapshots,
}),
}));

describe('AgentList', () => {
beforeEach(() => {
providerState.agents = ['swarm-master-id'];
providerState.hosts = [];
providerState.snapshots = {
'swarm-master-id': {
agentId: 'swarm-master-id',
hostname: 'swarm-master',
Expand Down Expand Up @@ -38,15 +53,9 @@ vi.mock('../providers/CoreFleetProvider', () => ({
error: null,
},
},
},
}),
}));

vi.mock('../providers/FleetSnapshotsProvider', () => ({
useFleetSnapshots: () => ({ snapshots: {} }),
}));
};
});

describe('AgentList', () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
Expand All @@ -70,4 +79,35 @@ describe('AgentList', () => {
expect(screen.getByText('MGR')).toBeInTheDocument();
expect(screen.getByText('⚠1')).toBeInTheDocument();
});

it('uses the durable host identity and status for offline rows without guessing from tokens', () => {
providerState.agents = [];
providerState.snapshots = {};
providerState.hosts = [
{
agent_id: 'custom-node-7',
hostname: 'retired-node',
status: 'offline',
protocol_version: 19,
capabilities: ['systemd'],
metadata: {},
first_seen_at: 100,
last_seen_at: Math.floor(Date.now() / 1000) - 120,
disconnected_at: 102,
system: null,
services: null,
docker: null,
swarm: null,
},
];
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);

render(<AgentList selectedAgent={null} onSelectAgent={vi.fn()} />);

expect(screen.getByText('retired-node')).toBeInTheDocument();
expect(screen.getByText('2m ago')).toBeInTheDocument();
expect(screen.getByRole('button')).toBeDisabled();
expect(fetchMock).not.toHaveBeenCalled();
});
});
18 changes: 18 additions & 0 deletions web/src/components/providers/CoreFleetProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type CoreFleetContextValue = {
const CoreFleetContext = createContext<CoreFleetContextValue | null>(null);
const SSE_REFRESH_DELAY_MS = 1_000;
const SSE_REFRESH_MIN_INTERVAL_MS = 10_000;
const FALLBACK_POLL_INTERVAL_MS = 30_000;

function errorMessage(error: unknown): string {
if (error instanceof FleetApiError) return error.code;
Expand Down Expand Up @@ -139,8 +140,25 @@ export function CoreFleetProvider({ children }: { children: React.ReactNode }) {
}, delay);
});

// EventSource normally reconnects itself, but mobile radios and suspended
// tabs can leave the browser believing a half-open stream is still usable.
// A low-frequency reconciliation bounds stale fleet state even when no
// `error` event is delivered. Active SSE updates remain coalesced by load().
const fallbackPoll = setInterval(load, FALLBACK_POLL_INTERVAL_MS);
const recoverNow = () => {
if (typeof navigator === 'undefined' || navigator.onLine !== false) load();
};
const handleVisibility = () => {
if (document.visibilityState === 'visible') recoverNow();
};
window.addEventListener('online', recoverNow);
document.addEventListener('visibilitychange', handleVisibility);

return () => {
generationRef.current += 1;
clearInterval(fallbackPoll);
window.removeEventListener('online', recoverNow);
document.removeEventListener('visibilitychange', handleVisibility);
abortRef.current?.abort();
eventSourceRef.current?.close();
if (eventRefreshTimerRef.current !== null) {
Expand Down
Loading
Loading