diff --git a/src/ui/api/host-metrics-status-api.ts b/src/ui/api/host-metrics-status-api.ts index bd4da386..a3cc1317 100644 --- a/src/ui/api/host-metrics-status-api.ts +++ b/src/ui/api/host-metrics-status-api.ts @@ -256,10 +256,18 @@ export async function stopMetricsPolling( export async function sendMetricsHeartbeat( viewerSessionId: string, -): Promise { +): Promise { try { - await statsApi.post("/metrics/heartbeat", { viewerSessionId }); + const response = await statsApi.post( + "/metrics/heartbeat", + { viewerSessionId }, + { validateStatus: (status) => status === 200 || status === 404 }, + ); + return response.status !== 404; } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 404) { + return false; + } handleApiError(error, "send metrics heartbeat"); throw error; } diff --git a/src/ui/dashboard/DashboardTab.tsx b/src/ui/dashboard/DashboardTab.tsx index f321fd0a..c40add30 100644 --- a/src/ui/dashboard/DashboardTab.tsx +++ b/src/ui/dashboard/DashboardTab.tsx @@ -1366,7 +1366,10 @@ export function DashboardTab({ try { const existing = newSessions.get(hostId); - if (!existing) { + if (existing && !(await sendMetricsHeartbeat(existing))) { + newSessions.delete(hostId); + } + if (!newSessions.has(hostId)) { const reg = await registerMetricsViewer(hostId); if (reg.skipped) return null; if (reg.success && reg.viewerSessionId) { @@ -1489,8 +1492,14 @@ export function DashboardTab({ if (!isVisible || viewerSessionsRef.current.size === 0) return; const heartbeat = setInterval(async () => { if (document.visibilityState === "hidden") return; - for (const [, sessionId] of viewerSessionsRef.current) { - sendMetricsHeartbeat(sessionId).catch(() => {}); + for (const [hostId, sessionId] of viewerSessionsRef.current) { + sendMetricsHeartbeat(sessionId) + .then((alive) => { + if (!alive && viewerSessionsRef.current.get(hostId) === sessionId) { + viewerSessionsRef.current.delete(hostId); + } + }) + .catch(() => {}); } }, 30000); return () => clearInterval(heartbeat); diff --git a/src/ui/tests/api/host-metrics-status-api.test.ts b/src/ui/tests/api/host-metrics-status-api.test.ts index f8c54b06..9e0ef6fb 100644 --- a/src/ui/tests/api/host-metrics-status-api.test.ts +++ b/src/ui/tests/api/host-metrics-status-api.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const statsApiMock = vi.hoisted(() => ({ get: vi.fn() })); +const statsApiMock = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn() })); const remoteStatsApiMock = vi.hoisted(() => ({ get: vi.fn() })); const sshHostApiMock = vi.hoisted(() => ({ get: vi.fn() })); const resolveConnectionOriginMock = vi.hoisted(() => vi.fn()); @@ -24,6 +24,7 @@ vi.mock("@/lib/connection-origin", () => ({ import { getAllServerStatuses, getServerMetricsById, + sendMetricsHeartbeat, } from "../../api/host-metrics-status-api"; beforeEach(() => { @@ -98,3 +99,21 @@ describe("metrics request coalescing", () => { expect(statsApiMock.get).toHaveBeenCalledOnce(); }); }); + +describe("metrics viewer heartbeat", () => { + it("reports a swept viewer session without throwing or logging", async () => { + statsApiMock.post.mockResolvedValueOnce({ status: 404 }); + + await expect(sendMetricsHeartbeat("expired-viewer")).resolves.toBe(false); + expect(statsApiMock.post).toHaveBeenCalledWith( + "/metrics/heartbeat", + { viewerSessionId: "expired-viewer" }, + { validateStatus: expect.any(Function) }, + ); + }); + + it("keeps a live viewer session", async () => { + statsApiMock.post.mockResolvedValueOnce({ status: 200 }); + await expect(sendMetricsHeartbeat("live-viewer")).resolves.toBe(true); + }); +});