fix: reflect live SSH sessions in host status (#1359)

This commit is contained in:
ZacharyZcR
2026-08-28 10:45:36 +08:00
committed by GitHub
parent 5582087025
commit 6f387f1058
4 changed files with 133 additions and 8 deletions
+42 -2
View File
@@ -53,6 +53,7 @@ import { registerHostMetricsHistoryRoutes } from "./history-routes.js";
import { registerProxmoxStatsRoutes } from "./proxmox-stats-routes.js"; import { registerProxmoxStatsRoutes } from "./proxmox-stats-routes.js";
import { registerProxmoxStatsHistoryRoutes } from "./proxmox-stats-history-routes.js"; import { registerProxmoxStatsHistoryRoutes } from "./proxmox-stats-history-routes.js";
import { ProxmoxPollingManager } from "./proxmox-stats-polling.js"; import { ProxmoxPollingManager } from "./proxmox-stats-polling.js";
import { hostSessionStatus } from "../terminal/host-session-status.js";
import { AlertEngine } from "./alert-engine.js"; import { AlertEngine } from "./alert-engine.js";
import { import {
notifyAutomationMetrics, notifyAutomationMetrics,
@@ -206,13 +207,44 @@ class PollingManager {
private statusInFlight = new Set<number>(); private statusInFlight = new Set<number>();
private metricsInFlight = new Set<number>(); private metricsInFlight = new Set<number>();
private initialMetricsRequested = new Set<number>(); private initialMetricsRequested = new Set<number>();
private terminalOnlineHosts = new Set<number>();
private metricsAuthenticatedHosts = new Set<number>();
private unsubscribeHostSessionStatus: () => void;
constructor() { constructor() {
this.unsubscribeHostSessionStatus = hostSessionStatus.subscribe(
(hostId, online) => this.setTerminalSessionOnline(hostId, online),
);
this.viewerCleanupInterval = setInterval(() => { this.viewerCleanupInterval = setInterval(() => {
this.cleanupInactiveViewers(); this.cleanupInactiveViewers();
}, 60000); }, 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 * Keeps poll concurrency matched to how many hosts are actually being
* polled, so a sweep still finishes inside its interval as a fleet grows. * polled, so a sweep still finishes inside its interval as a fleet grows.
@@ -586,7 +618,10 @@ class PollingManager {
// authenticates and can promote a host to "online") never runs for // authenticates and can promote a host to "online") never runs for
// them. // them.
const statusEntry: StatusEntry = { const statusEntry: StatusEntry = {
status: statusAfterReachabilityCheck( status:
isOnline && this.terminalOnlineHosts.has(refreshedHost.id)
? "online"
: statusAfterReachabilityCheck(
isOnline, isOnline,
this.statusStore.get(refreshedHost.id)?.status, this.statusStore.get(refreshedHost.id)?.status,
), ),
@@ -651,6 +686,7 @@ class PollingManager {
try { try {
const metrics = await collectMetrics(refreshedHost, () => { const metrics = await collectMetrics(refreshedHost, () => {
authenticated = true; authenticated = true;
this.metricsAuthenticatedHosts.add(refreshedHost.id);
this.statusStore.set(refreshedHost.id, { this.statusStore.set(refreshedHost.id, {
status: statusAfterAuthentication(true), status: statusAfterAuthentication(true),
lastChecked: new Date().toISOString(), lastChecked: new Date().toISOString(),
@@ -673,8 +709,11 @@ class PollingManager {
authFailureTracker.reset(refreshedHost.id); authFailureTracker.reset(refreshedHost.id);
} catch (error) { } catch (error) {
if (!authenticated) { if (!authenticated) {
this.metricsAuthenticatedHosts.delete(refreshedHost.id);
this.statusStore.set(refreshedHost.id, { this.statusStore.set(refreshedHost.id, {
status: statusAfterAuthentication( status: this.terminalOnlineHosts.has(refreshedHost.id)
? "online"
: statusAfterAuthentication(
false, false,
this.statusStore.get(refreshedHost.id)?.status, this.statusStore.get(refreshedHost.id)?.status,
), ),
@@ -973,6 +1012,7 @@ class PollingManager {
} }
destroy(): void { destroy(): void {
this.unsubscribeHostSessionStatus();
clearInterval(this.viewerCleanupInterval); clearInterval(this.viewerCleanupInterval);
for (const hostId of this.pollingConfigs.keys()) { for (const hostId of this.pollingConfigs.keys()) {
this.stopPollingForHost(hostId); this.stopPollingForHost(hostId);
@@ -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],
]);
});
});
@@ -0,0 +1,38 @@
type HostSessionStatusListener = (hostId: number, online: boolean) => void;
export class HostSessionStatus {
private counts = new Map<number, number>();
private listeners = new Set<HostSessionStatusListener>();
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();
+9
View File
@@ -72,6 +72,7 @@ import {
createWebSocketDuplex, createWebSocketDuplex,
waitForWebSocketOpen, waitForWebSocketOpen,
} from "../cloudflare-websocket.js"; } from "../cloudflare-websocket.js";
import { hostSessionStatus } from "./host-session-status.js";
interface ConnectToHostData { interface ConnectToHostData {
cols: number; cols: number;
@@ -1615,9 +1616,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
let tailscaleCheckPending = false; let tailscaleCheckPending = false;
let tailscaleForcePasswordAttempted = false; let tailscaleForcePasswordAttempted = false;
let isTailscaleRetrying = false; let isTailscaleRetrying = false;
let clearOnlineStatus: (() => void) | null = null;
let resolvedHostData: let resolvedHostData:
| (Record<string, unknown> & { | (Record<string, unknown> & {
id?: number;
ip?: string; ip?: string;
port?: number; port?: number;
username?: string; 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 // Resolve credentials server-side when frontend doesn't provide them
let resolvedCredentials = { let resolvedCredentials = {
username, username,
@@ -1921,6 +1926,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
}); });
sshConn.on("ready", () => { sshConn.on("ready", () => {
clearOnlineStatus ??= hostSessionStatus.register(statusHostId);
clearTimeout(connectionTimeout); clearTimeout(connectionTimeout);
isTailscaleRetrying = false; isTailscaleRetrying = false;
if (tailscaleCheckPending) { if (tailscaleCheckPending) {
@@ -2817,6 +2823,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
return; return;
} }
clearOnlineStatus?.();
clearOnlineStatus = null;
clearTimeout(connectionTimeout); clearTimeout(connectionTimeout);
sshLogger.info("SSH connection closed", { sshLogger.info("SSH connection closed", {
operation: "terminal_ssh_disconnected", operation: "terminal_ssh_disconnected",