mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +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 { 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,10 +618,13 @@ 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,
|
isOnline && this.terminalOnlineHosts.has(refreshedHost.id)
|
||||||
this.statusStore.get(refreshedHost.id)?.status,
|
? "online"
|
||||||
),
|
: statusAfterReachabilityCheck(
|
||||||
|
isOnline,
|
||||||
|
this.statusStore.get(refreshedHost.id)?.status,
|
||||||
|
),
|
||||||
lastChecked: new Date().toISOString(),
|
lastChecked: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
this.statusStore.set(refreshedHost.id, statusEntry);
|
this.statusStore.set(refreshedHost.id, statusEntry);
|
||||||
@@ -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,11 +709,14 @@ 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)
|
||||||
false,
|
? "online"
|
||||||
this.statusStore.get(refreshedHost.id)?.status,
|
: statusAfterAuthentication(
|
||||||
),
|
false,
|
||||||
|
this.statusStore.get(refreshedHost.id)?.status,
|
||||||
|
),
|
||||||
lastChecked: new Date().toISOString(),
|
lastChecked: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user