mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
fix: reflect live SSH sessions in host status (#1359)
This commit is contained in:
@@ -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<number>();
|
||||
private metricsInFlight = new Set<number>();
|
||||
private initialMetricsRequested = new Set<number>();
|
||||
private terminalOnlineHosts = new Set<number>();
|
||||
private metricsAuthenticatedHosts = new Set<number>();
|
||||
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);
|
||||
|
||||
@@ -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();
|
||||
@@ -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<string, unknown> & {
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user