Files
Termix/src/backend/ssh/server-stats.ts
Karmaa b1226cdbf8 Feature engineering improvements (#376)
* chore: add engineering improvements

- Configure Prettier with unified code style rules
- Add husky + lint-staged for automated pre-commit checks
- Add commitlint to enforce conventional commit messages
- Add PR check workflow for CI automation
- Auto-format all files with Prettier
- Fix TypeScript any types in field-crypto.ts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: enhance development environment

- Add .editorconfig for unified editor settings
- Add .nvmrc to specify Node.js version (20)
- Add useful npm scripts: format, format:check, lint, lint:fix, type-check

* chore: add IDE and Git configuration

- Add VS Code workspace settings for consistent development experience
- Add VS Code extension recommendations (ESLint, Prettier, EditorConfig)
- Add .gitattributes to enforce LF line endings

* refactor: clean up unused variables and empty blocks

- database.ts: Remove unused variables (authManager, format, HTTPS_PORT, etc.)
- database.ts: Fix empty catch blocks with descriptive comments
- database.ts: Add eslint-disable for required middleware parameter
- db/index.ts: Remove unused variables and fix empty catch blocks
- Temporarily remove ESLint from pre-commit to allow incremental fixes

Reduced total errors from 947 to 913 (34 fixes)

* refactor: clean up unused variables and empty blocks in routes

Routes updated:
- credentials.ts: Remove 12 unused variables/imports
- alerts.ts: Remove 1 unused variable
- users.ts: Remove 9 unused variables/imports

Changes:
- Remove unused imports (NextFunction, jwt, UserCrypto, detectKeyType)
- Fix empty catch blocks with descriptive comments
- Prefix reserved parameters with underscore
- Clean up unused error variables in catch blocks

Reduced errors from 913 to 886 (27 fixes)

* refactor: clean up unused variables in routes/ssh.ts

- Remove unused imports (NextFunction, jwt)
- Remove 6 unused variables (result, updateResult, name x3)
- All 8 no-unused-vars errors fixed

* refactor: clean up unused variables and empty blocks in file-manager.ts

- Remove 22 unused variables (linkCount, hostId, userId, content, escapedTempFile, index, code)
- Fix 1 empty catch block
- Simplify multiple route handlers by removing unused destructured parameters

Reduced errors from 878 to 855 (23 fixes)

* refactor: clean up unused variables and empty blocks in utils

database-migration.ts:
- Remove 3 unused variables (encryptedSize, totalOriginalRows, totalMemoryRows)

lazy-field-encryption.ts:
- Fix 6 empty catch blocks with descriptive comments
- Keep error variables where they are used in logging

tunnel.ts:
- Fix multiple empty catch blocks
- Remove empty else blocks
- Partially fixed (10/21 issues resolved)

Reduced errors from 855 to 833 (22 fixes)

* fix: restore error variable in catch block for logging

Fix TypeScript error where error variable was removed from catch block
but still used in logging statements. The error variable is needed for
proper error logging and re-throwing.

* fix: clean up tunnel.ts empty blocks and unused variables

移除了 tunnel.ts 中的空块和未使用的变量:
- 移除 2 个空 else 块
- 修复 2 个空 if 块并添加注释
- 修复空错误处理器并添加注释
- 将未使用的 err 参数重命名为 _err

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: clean up empty blocks and unused variables in backend utils

修复了后端工具文件中的空块和未使用的变量:
- auth-manager.ts: 移除空 else 块
- system-crypto.ts: 修复空 catch 块并添加注释
- starter.ts: 修复空 catch 块并添加注释
- server-stats.ts: 将未使用的 reject 参数重命名为 _reject
- credentials.ts: 将 connectionTimeout 从 let 改为 const

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: clean up empty catch blocks in frontend components

修复了前端组件中的空 catch 块:
- Tunnel.tsx: 修复空 catch 块并添加注释
- ServerConfig.tsx: 修复空 catch 块并添加注释
- TerminalKeyboard.tsx: 修复空 catch 块并添加注释
- system-crypto.ts: 修复遗漏的空 catch 块

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: clean up empty catch blocks in backend utilities

修复了后端工具文件中的 10 个空 catch 块:
- system-crypto.ts: 修复 1 个空 catch 块
- server-stats.ts: 修复 4 个空 catch 块
- auto-ssl-setup.ts: 修复 1 个空 catch 块
- ssh-key-utils.ts: 修复 4 个空 catch 块

所有空块都添加了描述性注释说明为何忽略错误。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: clean up empty catch blocks in UI hooks and components

修复了 5 个 UI 组件和 hooks 中的空 catch 块:
- useDragToSystemDesktop.ts: 修复 2 个空 catch 块
- HomepageAuth.tsx: 修复 1 个空 catch 块
- HostManagerEditor.tsx: 修复 2 个空 catch 块

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: clean up empty blocks in file manager and credential editor

修复了 5 个空块:
- FileManagerGrid.tsx: 移除 1 个空 else 块和 1 个空 if 块
- CredentialEditor.tsx: 修复 1 个空 catch 块,移除 2 个空 if/else 块

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: clean up all empty catch blocks in Terminal components

修复了 Terminal 组件中的所有 8 个空 catch 块:
- Desktop/Apps/Terminal/Terminal.tsx: 修复 5 个空 catch 块
- Mobile/Apps/Terminal/Terminal.tsx: 修复 3 个空 catch 块

所有空块都添加了描述性注释。这是空块修复的最后一批。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: remove useless try/catch wrappers

移除了 3 个无用的 try/catch 包装器:
- users.ts: 移除只重新抛出错误的外层 try/catch
- FileManager.tsx: 移除只重新抛出错误的内层 try/catch
- DiffViewer.tsx: 移除只重新抛出错误的内层 try/catch

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: remove unused imports and mark unused parameters

移除了未使用的导入和标记未使用的参数:
- auto-ssl-setup.ts: 移除未使用的 crypto 导入
- user-crypto.ts: 移除未使用的 users 导入
- user-data-import.ts: 移除未使用的 nanoid 导入
- simple-db-ops.ts: 标记未使用的 userId 和 tableName 参数

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: remove unnecessary escape characters in regex patterns

移除了正则表达式中不必要的转义字符:
- users.ts: 修复 5 个 \/ 不必要的转义
- TabContext.tsx: 修复 1 个 \/ 不必要的转义

在字符串形式的正则表达式中,/ 不需要转义。

---------

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-14 20:37:55 -05:00

1088 lines
29 KiB
TypeScript

import express from "express";
import net from "net";
import cors from "cors";
import cookieParser from "cookie-parser";
import { Client, type ConnectConfig } from "ssh2";
import { getDb } from "../database/db/index.js";
import { sshData, sshCredentials } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { statsLogger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { AuthManager } from "../utils/auth-manager.js";
interface PooledConnection {
client: Client;
lastUsed: number;
inUse: boolean;
hostKey: string;
}
class SSHConnectionPool {
private connections = new Map<string, PooledConnection[]>();
private maxConnectionsPerHost = 3;
private connectionTimeout = 30000;
private cleanupInterval: NodeJS.Timeout;
constructor() {
this.cleanupInterval = setInterval(
() => {
this.cleanup();
},
5 * 60 * 1000,
);
}
private getHostKey(host: SSHHostWithCredentials): string {
return `${host.ip}:${host.port}:${host.username}`;
}
async getConnection(host: SSHHostWithCredentials): Promise<Client> {
const hostKey = this.getHostKey(host);
const connections = this.connections.get(hostKey) || [];
const available = connections.find((conn) => !conn.inUse);
if (available) {
available.inUse = true;
available.lastUsed = Date.now();
return available.client;
}
if (connections.length < this.maxConnectionsPerHost) {
const client = await this.createConnection(host);
const pooled: PooledConnection = {
client,
lastUsed: Date.now(),
inUse: true,
hostKey,
};
connections.push(pooled);
this.connections.set(hostKey, connections);
return client;
}
return new Promise((resolve, _reject) => {
const checkAvailable = () => {
const available = connections.find((conn) => !conn.inUse);
if (available) {
available.inUse = true;
available.lastUsed = Date.now();
resolve(available.client);
} else {
setTimeout(checkAvailable, 100);
}
};
checkAvailable();
});
}
private async createConnection(
host: SSHHostWithCredentials,
): Promise<Client> {
return new Promise((resolve, reject) => {
const client = new Client();
const timeout = setTimeout(() => {
client.end();
reject(new Error("SSH connection timeout"));
}, this.connectionTimeout);
client.on("ready", () => {
clearTimeout(timeout);
resolve(client);
});
client.on("error", (err) => {
clearTimeout(timeout);
reject(err);
});
client.on(
"keyboard-interactive",
(
name: string,
instructions: string,
instructionsLang: string,
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
) => {
const totpPrompt = prompts.find((p) =>
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
p.prompt,
),
);
if (totpPrompt) {
statsLogger.warn(
`Server Stats cannot handle TOTP for host ${host.ip}. Connection will fail.`,
{
operation: "server_stats_totp_detected",
hostId: host.id,
},
);
client.end();
reject(new Error("TOTP authentication required but not supported in Server Stats"));
} else if (host.password) {
const responses = prompts.map(() => host.password || "");
finish(responses);
} else {
finish(prompts.map(() => ""));
}
},
);
try {
client.connect(buildSshConfig(host));
} catch (err) {
clearTimeout(timeout);
reject(err);
}
});
}
releaseConnection(host: SSHHostWithCredentials, client: Client): void {
const hostKey = this.getHostKey(host);
const connections = this.connections.get(hostKey) || [];
const pooled = connections.find((conn) => conn.client === client);
if (pooled) {
pooled.inUse = false;
pooled.lastUsed = Date.now();
}
}
private cleanup(): void {
const now = Date.now();
const maxAge = 10 * 60 * 1000;
for (const [hostKey, connections] of this.connections.entries()) {
const activeConnections = connections.filter((conn) => {
if (!conn.inUse && now - conn.lastUsed > maxAge) {
try {
conn.client.end();
} catch {
// Ignore errors when closing stale connections
}
return false;
}
return true;
});
if (activeConnections.length === 0) {
this.connections.delete(hostKey);
} else {
this.connections.set(hostKey, activeConnections);
}
}
}
destroy(): void {
clearInterval(this.cleanupInterval);
for (const connections of this.connections.values()) {
for (const conn of connections) {
try {
conn.client.end();
} catch {
// Ignore errors when closing connections during cleanup
}
}
}
this.connections.clear();
}
}
class RequestQueue {
private queues = new Map<number, Array<() => Promise<any>>>();
private processing = new Set<number>();
async queueRequest<T>(hostId: number, request: () => Promise<T>): Promise<T> {
return new Promise((resolve, reject) => {
const queue = this.queues.get(hostId) || [];
queue.push(async () => {
try {
const result = await request();
resolve(result);
} catch (error) {
reject(error);
}
});
this.queues.set(hostId, queue);
this.processQueue(hostId);
});
}
private async processQueue(hostId: number): Promise<void> {
if (this.processing.has(hostId)) return;
this.processing.add(hostId);
const queue = this.queues.get(hostId) || [];
while (queue.length > 0) {
const request = queue.shift();
if (request) {
try {
await request();
} catch {
// Ignore errors from queued requests
}
}
}
this.processing.delete(hostId);
if (queue.length > 0) {
this.processQueue(hostId);
}
}
}
interface CachedMetrics {
data: any;
timestamp: number;
hostId: number;
}
class MetricsCache {
private cache = new Map<number, CachedMetrics>();
private ttl = 30000;
get(hostId: number): any | null {
const cached = this.cache.get(hostId);
if (cached && Date.now() - cached.timestamp < this.ttl) {
return cached.data;
}
return null;
}
set(hostId: number, data: any): void {
this.cache.set(hostId, {
data,
timestamp: Date.now(),
hostId,
});
}
clear(hostId?: number): void {
if (hostId) {
this.cache.delete(hostId);
} else {
this.cache.clear();
}
}
}
const connectionPool = new SSHConnectionPool();
const requestQueue = new RequestQueue();
const metricsCache = new MetricsCache();
const authManager = AuthManager.getInstance();
type HostStatus = "online" | "offline";
interface SSHHostWithCredentials {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
credentialId?: number;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
userId: string;
}
type StatusEntry = {
status: HostStatus;
lastChecked: string;
};
function validateHostId(
req: express.Request,
res: express.Response,
next: express.NextFunction,
) {
const id = Number(req.params.id);
if (!id || !Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: "Invalid host ID" });
}
next();
}
const app = express();
app.use(
cors({
origin: (origin, callback) => {
if (!origin) return callback(null, true);
const allowedOrigins = [
"http://localhost:5173",
"http://localhost:3000",
"http://127.0.0.1:5173",
"http://127.0.0.1:3000",
];
if (origin.startsWith("https://")) {
return callback(null, true);
}
if (origin.startsWith("http://")) {
return callback(null, true);
}
if (allowedOrigins.includes(origin)) {
return callback(null, true);
}
callback(new Error("Not allowed by CORS"));
},
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: [
"Content-Type",
"Authorization",
"User-Agent",
"X-Electron-App",
],
}),
);
app.use(cookieParser());
app.use(express.json({ limit: "1mb" }));
app.use(authManager.createAuthMiddleware());
const hostStatuses: Map<number, StatusEntry> = new Map();
async function fetchAllHosts(
userId: string,
): Promise<SSHHostWithCredentials[]> {
try {
const hosts = await SimpleDBOps.select(
getDb().select().from(sshData).where(eq(sshData.userId, userId)),
"ssh_data",
userId,
);
const hostsWithCredentials: SSHHostWithCredentials[] = [];
for (const host of hosts) {
try {
const hostWithCreds = await resolveHostCredentials(host, userId);
if (hostWithCreds) {
hostsWithCredentials.push(hostWithCreds);
}
} catch (err) {
statsLogger.warn(
`Failed to resolve credentials for host ${host.id}: ${err instanceof Error ? err.message : "Unknown error"}`,
);
}
}
return hostsWithCredentials.filter((h) => !!h.id && !!h.ip && !!h.port);
} catch (err) {
statsLogger.error("Failed to fetch hosts from database", err);
return [];
}
}
async function fetchHostById(
id: number,
userId: string,
): Promise<SSHHostWithCredentials | undefined> {
try {
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
statsLogger.debug("User data locked - cannot fetch host", {
operation: "fetchHostById_data_locked",
userId,
hostId: id,
});
return undefined;
}
const hosts = await SimpleDBOps.select(
getDb()
.select()
.from(sshData)
.where(and(eq(sshData.id, id), eq(sshData.userId, userId))),
"ssh_data",
userId,
);
if (hosts.length === 0) {
return undefined;
}
const host = hosts[0];
return await resolveHostCredentials(host, userId);
} catch (err) {
statsLogger.error(`Failed to fetch host ${id}`, err);
return undefined;
}
}
async function resolveHostCredentials(
host: any,
userId: string,
): Promise<SSHHostWithCredentials | undefined> {
try {
const baseHost: any = {
id: host.id,
name: host.name,
ip: host.ip,
port: host.port,
username: host.username,
folder: host.folder || "",
tags:
typeof host.tags === "string"
? host.tags
? host.tags.split(",").filter(Boolean)
: []
: [],
pin: !!host.pin,
authType: host.authType,
enableTerminal: !!host.enableTerminal,
enableTunnel: !!host.enableTunnel,
enableFileManager: !!host.enableFileManager,
defaultPath: host.defaultPath || "/",
tunnelConnections: host.tunnelConnections
? JSON.parse(host.tunnelConnections)
: [],
createdAt: host.createdAt,
updatedAt: host.updatedAt,
userId: host.userId,
};
if (host.credentialId) {
try {
const credentials = await SimpleDBOps.select(
getDb()
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, host.credentialId),
eq(sshCredentials.userId, userId),
),
),
"ssh_credentials",
userId,
);
if (credentials.length > 0) {
const credential = credentials[0];
baseHost.credentialId = credential.id;
baseHost.username = credential.username;
baseHost.authType = credential.auth_type || credential.authType;
if (credential.password) {
baseHost.password = credential.password;
}
if (credential.key) {
baseHost.key = credential.key;
}
if (credential.key_password || credential.keyPassword) {
baseHost.keyPassword =
credential.key_password || credential.keyPassword;
}
if (credential.key_type || credential.keyType) {
baseHost.keyType = credential.key_type || credential.keyType;
}
} else {
addLegacyCredentials(baseHost, host);
}
} catch (error) {
statsLogger.warn(
`Failed to resolve credential ${host.credentialId} for host ${host.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
);
addLegacyCredentials(baseHost, host);
}
} else {
addLegacyCredentials(baseHost, host);
}
return baseHost;
} catch (error) {
statsLogger.error(
`Failed to resolve host credentials for host ${host.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
);
return undefined;
}
}
function addLegacyCredentials(baseHost: any, host: any): void {
baseHost.password = host.password || null;
baseHost.key = host.key || null;
baseHost.keyPassword = host.key_password || host.keyPassword || null;
baseHost.keyType = host.keyType;
}
function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
const base: ConnectConfig = {
host: host.ip,
port: host.port || 22,
username: host.username || "root",
tryKeyboard: true,
readyTimeout: 10_000,
algorithms: {
kex: [
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group1-sha1",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group-exchange-sha1",
"ecdh-sha2-nistp256",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp521",
],
cipher: [
"aes128-ctr",
"aes192-ctr",
"aes256-ctr",
"aes128-gcm@openssh.com",
"aes256-gcm@openssh.com",
"aes128-cbc",
"aes192-cbc",
"aes256-cbc",
"3des-cbc",
],
hmac: [
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256",
"hmac-sha2-512",
"hmac-sha1",
"hmac-md5",
],
compress: ["none", "zlib@openssh.com", "zlib"],
},
} as ConnectConfig;
if (host.authType === "password") {
if (!host.password) {
throw new Error(`No password available for host ${host.ip}`);
}
(base as any).password = host.password;
} else if (host.authType === "key") {
if (!host.key) {
throw new Error(`No SSH key available for host ${host.ip}`);
}
try {
if (!host.key.includes("-----BEGIN") || !host.key.includes("-----END")) {
throw new Error("Invalid private key format");
}
const cleanKey = host.key
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
(base as any).privateKey = Buffer.from(cleanKey, "utf8");
if (host.keyPassword) {
(base as any).passphrase = host.keyPassword;
}
} catch (keyError) {
statsLogger.error(
`SSH key format error for host ${host.ip}: ${keyError instanceof Error ? keyError.message : "Unknown error"}`,
);
throw new Error(`Invalid SSH key format for host ${host.ip}`);
}
} else {
throw new Error(
`Unsupported authentication type '${host.authType}' for host ${host.ip}`,
);
}
return base;
}
async function withSshConnection<T>(
host: SSHHostWithCredentials,
fn: (client: Client) => Promise<T>,
): Promise<T> {
const client = await connectionPool.getConnection(host);
try {
const result = await fn(client);
return result;
} finally {
connectionPool.releaseConnection(host, client);
}
}
function execCommand(
client: Client,
command: string,
): Promise<{
stdout: string;
stderr: string;
code: number | null;
}> {
return new Promise((resolve, reject) => {
client.exec(command, { pty: false }, (err, stream) => {
if (err) return reject(err);
let stdout = "";
let stderr = "";
let exitCode: number | null = null;
stream
.on("close", (code: number | undefined) => {
exitCode = typeof code === "number" ? code : null;
resolve({ stdout, stderr, code: exitCode });
})
.on("data", (data: Buffer) => {
stdout += data.toString("utf8");
})
.stderr.on("data", (data: Buffer) => {
stderr += data.toString("utf8");
});
});
});
}
function parseCpuLine(
cpuLine: string,
): { total: number; idle: number } | undefined {
const parts = cpuLine.trim().split(/\s+/);
if (parts[0] !== "cpu") return undefined;
const nums = parts
.slice(1)
.map((n) => Number(n))
.filter((n) => Number.isFinite(n));
if (nums.length < 4) return undefined;
const idle = (nums[3] ?? 0) + (nums[4] ?? 0);
const total = nums.reduce((a, b) => a + b, 0);
return { total, idle };
}
function toFixedNum(n: number | null | undefined, digits = 2): number | null {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
}
function kibToGiB(kib: number): number {
return kib / (1024 * 1024);
}
async function collectMetrics(host: SSHHostWithCredentials): Promise<{
cpu: {
percent: number | null;
cores: number | null;
load: [number, number, number] | null;
};
memory: {
percent: number | null;
usedGiB: number | null;
totalGiB: number | null;
};
disk: {
percent: number | null;
usedHuman: string | null;
totalHuman: string | null;
};
}> {
const cached = metricsCache.get(host.id);
if (cached) {
return cached;
}
return requestQueue.queueRequest(host.id, async () => {
try {
return await withSshConnection(host, async (client) => {
let cpuPercent: number | null = null;
let cores: number | null = null;
let loadTriplet: [number, number, number] | null = null;
try {
const [stat1, loadAvgOut, coresOut] = await Promise.all([
execCommand(client, "cat /proc/stat"),
execCommand(client, "cat /proc/loadavg"),
execCommand(
client,
"nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo",
),
]);
await new Promise((r) => setTimeout(r, 500));
const stat2 = await execCommand(client, "cat /proc/stat");
const cpuLine1 = (
stat1.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
).trim();
const cpuLine2 = (
stat2.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
).trim();
const a = parseCpuLine(cpuLine1);
const b = parseCpuLine(cpuLine2);
if (a && b) {
const totalDiff = b.total - a.total;
const idleDiff = b.idle - a.idle;
const used = totalDiff - idleDiff;
if (totalDiff > 0)
cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100));
}
const laParts = loadAvgOut.stdout.trim().split(/\s+/);
if (laParts.length >= 3) {
loadTriplet = [
Number(laParts[0]),
Number(laParts[1]),
Number(laParts[2]),
].map((v) => (Number.isFinite(v) ? Number(v) : 0)) as [
number,
number,
number,
];
}
const coresNum = Number((coresOut.stdout || "").trim());
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
} catch (e) {
statsLogger.warn(
`Failed to collect CPU metrics for host ${host.id}`,
e,
);
cpuPercent = null;
cores = null;
loadTriplet = null;
}
let memPercent: number | null = null;
let usedGiB: number | null = null;
let totalGiB: number | null = null;
try {
const memInfo = await execCommand(client, "cat /proc/meminfo");
const lines = memInfo.stdout.split("\n");
const getVal = (key: string) => {
const line = lines.find((l) => l.startsWith(key));
if (!line) return null;
const m = line.match(/\d+/);
return m ? Number(m[0]) : null;
};
const totalKb = getVal("MemTotal:");
const availKb = getVal("MemAvailable:");
if (totalKb && availKb && totalKb > 0) {
const usedKb = totalKb - availKb;
memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100));
usedGiB = kibToGiB(usedKb);
totalGiB = kibToGiB(totalKb);
}
} catch (e) {
statsLogger.warn(
`Failed to collect memory metrics for host ${host.id}`,
e,
);
memPercent = null;
usedGiB = null;
totalGiB = null;
}
let diskPercent: number | null = null;
let usedHuman: string | null = null;
let totalHuman: string | null = null;
let availableHuman: string | null = null;
try {
const [diskOutHuman, diskOutBytes] = await Promise.all([
execCommand(client, "df -h -P / | tail -n +2"),
execCommand(client, "df -B1 -P / | tail -n +2"),
]);
const humanLine =
diskOutHuman.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean)[0] || "";
const bytesLine =
diskOutBytes.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean)[0] || "";
const humanParts = humanLine.split(/\s+/);
const bytesParts = bytesLine.split(/\s+/);
if (humanParts.length >= 6 && bytesParts.length >= 6) {
totalHuman = humanParts[1] || null;
usedHuman = humanParts[2] || null;
availableHuman = humanParts[3] || null;
const totalBytes = Number(bytesParts[1]);
const usedBytes = Number(bytesParts[2]);
if (
Number.isFinite(totalBytes) &&
Number.isFinite(usedBytes) &&
totalBytes > 0
) {
diskPercent = Math.max(
0,
Math.min(100, (usedBytes / totalBytes) * 100),
);
}
}
} catch (e) {
statsLogger.warn(
`Failed to collect disk metrics for host ${host.id}`,
e,
);
diskPercent = null;
usedHuman = null;
totalHuman = null;
availableHuman = null;
}
const result = {
cpu: { percent: toFixedNum(cpuPercent, 0), cores, load: loadTriplet },
memory: {
percent: toFixedNum(memPercent, 0),
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null,
},
disk: {
percent: toFixedNum(diskPercent, 0),
usedHuman,
totalHuman,
availableHuman,
},
};
metricsCache.set(host.id, result);
return result;
});
} catch (error) {
if (error instanceof Error && error.message.includes("TOTP authentication required")) {
throw error;
}
throw error;
}
});
}
function tcpPing(
host: string,
port: number,
timeoutMs = 5000,
): Promise<boolean> {
return new Promise((resolve) => {
const socket = new net.Socket();
let settled = false;
const onDone = (result: boolean) => {
if (settled) return;
settled = true;
try {
socket.destroy();
} catch {
// Ignore errors when destroying socket
}
resolve(result);
};
socket.setTimeout(timeoutMs);
socket.once("connect", () => onDone(true));
socket.once("timeout", () => onDone(false));
socket.once("error", () => onDone(false));
socket.connect(port, host);
});
}
async function pollStatusesOnce(userId?: string): Promise<void> {
if (!userId) {
statsLogger.warn("Skipping status poll - no authenticated user", {
operation: "status_poll",
});
return;
}
const hosts = await fetchAllHosts(userId);
if (hosts.length === 0) {
statsLogger.warn("No hosts retrieved for status polling", {
operation: "status_poll",
userId,
});
return;
}
const now = new Date().toISOString();
const checks = hosts.map(async (h) => {
const isOnline = await tcpPing(h.ip, h.port, 5000);
const now = new Date().toISOString();
const statusEntry: StatusEntry = {
status: isOnline ? "online" : "offline",
lastChecked: now,
};
hostStatuses.set(h.id, statusEntry);
return isOnline;
});
const results = await Promise.allSettled(checks);
const onlineCount = results.filter(
(r) => r.status === "fulfilled" && r.value === true,
).length;
const offlineCount = hosts.length - onlineCount;
statsLogger.success("Status polling completed", {
operation: "status_poll",
totalHosts: hosts.length,
onlineCount,
offlineCount,
});
}
app.get("/status", async (req, res) => {
const userId = (req as any).userId;
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
if (hostStatuses.size === 0) {
await pollStatusesOnce(userId);
}
const result: Record<number, StatusEntry> = {};
for (const [id, entry] of hostStatuses.entries()) {
result[id] = entry;
}
res.json(result);
});
app.get("/status/:id", validateHostId, async (req, res) => {
const id = Number(req.params.id);
const userId = (req as any).userId;
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
try {
const host = await fetchHostById(id, userId);
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
const isOnline = await tcpPing(host.ip, host.port, 5000);
const now = new Date().toISOString();
const statusEntry: StatusEntry = {
status: isOnline ? "online" : "offline",
lastChecked: now,
};
hostStatuses.set(id, statusEntry);
res.json(statusEntry);
} catch (err) {
statsLogger.error("Failed to check host status", err);
res.status(500).json({ error: "Failed to check host status" });
}
});
app.post("/refresh", async (req, res) => {
const userId = (req as any).userId;
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
await pollStatusesOnce(userId);
res.json({ message: "Refreshed" });
});
app.get("/metrics/:id", validateHostId, async (req, res) => {
const id = Number(req.params.id);
const userId = (req as any).userId;
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
try {
const host = await fetchHostById(id, userId);
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
const isOnline = await tcpPing(host.ip, host.port, 5000);
if (!isOnline) {
return res.status(503).json({
error: "Host is offline",
cpu: { percent: null, cores: null, load: null },
memory: { percent: null, usedGiB: null, totalGiB: null },
disk: { percent: null, usedHuman: null, totalHuman: null },
lastChecked: new Date().toISOString(),
});
}
const metrics = await collectMetrics(host);
res.json({ ...metrics, lastChecked: new Date().toISOString() });
} catch (err) {
if (err instanceof Error && err.message.includes("TOTP authentication required")) {
return res.status(403).json({
error: "TOTP_REQUIRED",
message: "Server Stats unavailable for TOTP-enabled servers",
cpu: { percent: null, cores: null, load: null },
memory: { percent: null, usedGiB: null, totalGiB: null },
disk: { percent: null, usedHuman: null, totalHuman: null },
lastChecked: new Date().toISOString(),
});
}
statsLogger.error("Failed to collect metrics", err);
if (err instanceof Error && err.message.includes("timeout")) {
return res.status(504).json({
error: "Metrics collection timeout",
cpu: { percent: null, cores: null, load: null },
memory: { percent: null, usedGiB: null, totalGiB: null },
disk: { percent: null, usedHuman: null, totalHuman: null },
lastChecked: new Date().toISOString(),
});
}
return res.status(500).json({
error: "Failed to collect metrics",
cpu: { percent: null, cores: null, load: null },
memory: { percent: null, usedGiB: null, totalGiB: null },
disk: { percent: null, usedHuman: null, totalHuman: null },
lastChecked: new Date().toISOString(),
});
}
});
process.on("SIGINT", () => {
connectionPool.destroy();
process.exit(0);
});
process.on("SIGTERM", () => {
connectionPool.destroy();
process.exit(0);
});
const PORT = 30005;
app.listen(PORT, async () => {
try {
await authManager.initialize();
} catch (err) {
statsLogger.error("Failed to initialize AuthManager", err, {
operation: "auth_init_error",
});
}
});