diff --git a/src/backend/hosts/metrics/index.ts b/src/backend/hosts/metrics/index.ts index a5d49a47..aec27e41 100644 --- a/src/backend/hosts/metrics/index.ts +++ b/src/backend/hosts/metrics/index.ts @@ -53,6 +53,7 @@ import { registerHostMetricsHistoryRoutes } from "./history-routes.js"; import { registerProxmoxStatsRoutes } from "./proxmox-stats-routes.js"; import { registerProxmoxStatsHistoryRoutes } from "./proxmox-stats-history-routes.js"; import { ProxmoxPollingManager } from "./proxmox-stats-polling.js"; +import { hostSessionStatus } from "../terminal/host-session-status.js"; import { AlertEngine } from "./alert-engine.js"; import { notifyAutomationMetrics, @@ -206,13 +207,44 @@ class PollingManager { private statusInFlight = new Set(); private metricsInFlight = new Set(); private initialMetricsRequested = new Set(); + private terminalOnlineHosts = new Set(); + private metricsAuthenticatedHosts = new Set(); + private unsubscribeHostSessionStatus: () => void; constructor() { + this.unsubscribeHostSessionStatus = hostSessionStatus.subscribe( + (hostId, online) => this.setTerminalSessionOnline(hostId, online), + ); this.viewerCleanupInterval = setInterval(() => { this.cleanupInactiveViewers(); }, 60000); } + private setTerminalSessionOnline(hostId: number, online: boolean): void { + if (online) { + this.terminalOnlineHosts.add(hostId); + const config = this.pollingConfigs.get(hostId); + if (config && isTcpPingEnabled(config.statsConfig)) { + this.statusStore.set(hostId, { + status: "online", + lastChecked: new Date().toISOString(), + }); + } + return; + } + + this.terminalOnlineHosts.delete(hostId); + if ( + !this.metricsAuthenticatedHosts.has(hostId) && + this.statusStore.get(hostId)?.status === "online" + ) { + this.statusStore.set(hostId, { + status: "reachable", + lastChecked: new Date().toISOString(), + }); + } + } + /** * Keeps poll concurrency matched to how many hosts are actually being * polled, so a sweep still finishes inside its interval as a fleet grows. @@ -586,10 +618,13 @@ class PollingManager { // authenticates and can promote a host to "online") never runs for // them. const statusEntry: StatusEntry = { - status: statusAfterReachabilityCheck( - isOnline, - this.statusStore.get(refreshedHost.id)?.status, - ), + status: + isOnline && this.terminalOnlineHosts.has(refreshedHost.id) + ? "online" + : statusAfterReachabilityCheck( + isOnline, + this.statusStore.get(refreshedHost.id)?.status, + ), lastChecked: new Date().toISOString(), }; this.statusStore.set(refreshedHost.id, statusEntry); @@ -651,6 +686,7 @@ class PollingManager { try { const metrics = await collectMetrics(refreshedHost, () => { authenticated = true; + this.metricsAuthenticatedHosts.add(refreshedHost.id); this.statusStore.set(refreshedHost.id, { status: statusAfterAuthentication(true), lastChecked: new Date().toISOString(), @@ -673,11 +709,14 @@ class PollingManager { authFailureTracker.reset(refreshedHost.id); } catch (error) { if (!authenticated) { + this.metricsAuthenticatedHosts.delete(refreshedHost.id); this.statusStore.set(refreshedHost.id, { - status: statusAfterAuthentication( - false, - this.statusStore.get(refreshedHost.id)?.status, - ), + status: this.terminalOnlineHosts.has(refreshedHost.id) + ? "online" + : statusAfterAuthentication( + false, + this.statusStore.get(refreshedHost.id)?.status, + ), lastChecked: new Date().toISOString(), }); } @@ -973,6 +1012,7 @@ class PollingManager { } destroy(): void { + this.unsubscribeHostSessionStatus(); clearInterval(this.viewerCleanupInterval); for (const hostId of this.pollingConfigs.keys()) { this.stopPollingForHost(hostId); diff --git a/src/backend/hosts/terminal/host-session-status.test.ts b/src/backend/hosts/terminal/host-session-status.test.ts new file mode 100644 index 00000000..a7fa559d --- /dev/null +++ b/src/backend/hosts/terminal/host-session-status.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from "vitest"; +import { HostSessionStatus } from "./host-session-status.js"; + +describe("HostSessionStatus", () => { + it("reports only the first connection and last disconnection per host", () => { + const status = new HostSessionStatus(); + const listener = vi.fn(); + status.subscribe(listener); + + const closeFirst = status.register(7); + const closeSecond = status.register(7); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenLastCalledWith(7, true); + + closeFirst(); + expect(listener).toHaveBeenCalledTimes(1); + + closeSecond(); + expect(listener).toHaveBeenLastCalledWith(7, false); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it("makes connection cleanup idempotent", () => { + const status = new HostSessionStatus(); + const listener = vi.fn(); + status.subscribe(listener); + + const close = status.register(9); + close(); + close(); + + expect(listener.mock.calls).toEqual([ + [9, true], + [9, false], + ]); + }); +}); diff --git a/src/backend/hosts/terminal/host-session-status.ts b/src/backend/hosts/terminal/host-session-status.ts new file mode 100644 index 00000000..06a90d2a --- /dev/null +++ b/src/backend/hosts/terminal/host-session-status.ts @@ -0,0 +1,38 @@ +type HostSessionStatusListener = (hostId: number, online: boolean) => void; + +export class HostSessionStatus { + private counts = new Map(); + private listeners = new Set(); + + register(hostId: number): () => void { + const count = this.counts.get(hostId) ?? 0; + this.counts.set(hostId, count + 1); + if (count === 0) this.emit(hostId, true); + + let active = true; + return () => { + if (!active) return; + active = false; + + const next = (this.counts.get(hostId) ?? 1) - 1; + if (next > 0) { + this.counts.set(hostId, next); + return; + } + + this.counts.delete(hostId); + this.emit(hostId, false); + }; + } + + subscribe(listener: HostSessionStatusListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private emit(hostId: number, online: boolean): void { + for (const listener of this.listeners) listener(hostId, online); + } +} + +export const hostSessionStatus = new HostSessionStatus(); diff --git a/src/backend/hosts/terminal/index.ts b/src/backend/hosts/terminal/index.ts index d6811078..f3e48775 100644 --- a/src/backend/hosts/terminal/index.ts +++ b/src/backend/hosts/terminal/index.ts @@ -72,6 +72,7 @@ import { createWebSocketDuplex, waitForWebSocketOpen, } from "../cloudflare-websocket.js"; +import { hostSessionStatus } from "./host-session-status.js"; interface ConnectToHostData { cols: number; @@ -1615,9 +1616,11 @@ wss.on("connection", async (ws: WebSocket, req) => { let tailscaleCheckPending = false; let tailscaleForcePasswordAttempted = false; let isTailscaleRetrying = false; + let clearOnlineStatus: (() => void) | null = null; let resolvedHostData: | (Record & { + id?: number; ip?: string; port?: number; username?: string; @@ -1752,6 +1755,8 @@ wss.on("connection", async (ws: WebSocket, req) => { } } + const statusHostId = resolvedHostData?.id ?? id; + // Resolve credentials server-side when frontend doesn't provide them let resolvedCredentials = { username, @@ -1921,6 +1926,7 @@ wss.on("connection", async (ws: WebSocket, req) => { }); sshConn.on("ready", () => { + clearOnlineStatus ??= hostSessionStatus.register(statusHostId); clearTimeout(connectionTimeout); isTailscaleRetrying = false; if (tailscaleCheckPending) { @@ -2817,6 +2823,9 @@ wss.on("connection", async (ws: WebSocket, req) => { return; } + clearOnlineStatus?.(); + clearOnlineStatus = null; + clearTimeout(connectionTimeout); sshLogger.info("SSH connection closed", { operation: "terminal_ssh_disconnected",