mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
230 lines
6.5 KiB
TypeScript
230 lines
6.5 KiB
TypeScript
import {
|
|
execCommand,
|
|
execPowerShell,
|
|
type HostPlatform,
|
|
} from "@termix/plugin-sdk/host-commands";
|
|
import { toFixedNum } from "../util.js";
|
|
import type { Client } from "ssh2";
|
|
|
|
/**
|
|
* CPU percent is derived from the delta against the previous poll's
|
|
* /proc/stat sample rather than a same-poll double-read, so a poll no longer
|
|
* has to block for a fixed settle window. Keyed by host.
|
|
*/
|
|
const previousStat = new Map<
|
|
number,
|
|
{ total: number; idle: number; timestamp: number }
|
|
>();
|
|
|
|
export function clearCpuSampleCache(hostId?: number): void {
|
|
if (hostId === undefined) {
|
|
previousStat.clear();
|
|
return;
|
|
}
|
|
previousStat.delete(hostId);
|
|
}
|
|
|
|
export 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 };
|
|
}
|
|
|
|
// Parses macOS `top -l 1 -n 0` output, e.g.
|
|
// "CPU usage: 12.34% user, 5.67% sys, 81.99% idle"
|
|
export function parseDarwinCpuUsageLine(
|
|
line: string,
|
|
): { percent: number } | undefined {
|
|
const match = line.match(
|
|
/([\d.]+)%\s*user,\s*([\d.]+)%\s*sys,\s*([\d.]+)%\s*idle/i,
|
|
);
|
|
if (!match) return undefined;
|
|
const user = Number(match[1]);
|
|
const sys = Number(match[2]);
|
|
if (!Number.isFinite(user) || !Number.isFinite(sys)) return undefined;
|
|
return { percent: Math.max(0, Math.min(100, user + sys)) };
|
|
}
|
|
|
|
async function collectDarwinCpuMetrics(client: Client): Promise<{
|
|
percent: number | null;
|
|
cores: number | null;
|
|
load: [number, number, number] | null;
|
|
}> {
|
|
let cpuPercent: number | null = null;
|
|
let cores: number | null = null;
|
|
let loadTriplet: [number, number, number] | null = null;
|
|
|
|
try {
|
|
const [topOut, coresOut, loadOut] = await Promise.race([
|
|
Promise.all([
|
|
execCommand(client, "top -l 1 -n 0"),
|
|
execCommand(client, "sysctl -n hw.ncpu"),
|
|
execCommand(client, "sysctl -n vm.loadavg"),
|
|
]),
|
|
new Promise<never>((_, reject) =>
|
|
setTimeout(
|
|
() => reject(new Error("CPU metrics collection timeout")),
|
|
25000,
|
|
),
|
|
),
|
|
]);
|
|
|
|
const usageLine = topOut.stdout
|
|
.split("\n")
|
|
.find((l) => l.includes("CPU usage:"));
|
|
const parsed = usageLine ? parseDarwinCpuUsageLine(usageLine) : undefined;
|
|
if (parsed) cpuPercent = parsed.percent;
|
|
|
|
const coresNum = Number((coresOut.stdout || "").trim());
|
|
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
|
|
|
|
// sysctl -n vm.loadavg -> "{ 1.23 1.45 1.67 }"
|
|
const loadMatch = loadOut.stdout.match(/([\d.]+)\s+([\d.]+)\s+([\d.]+)/);
|
|
if (loadMatch) {
|
|
loadTriplet = [
|
|
Number(loadMatch[1]),
|
|
Number(loadMatch[2]),
|
|
Number(loadMatch[3]),
|
|
].map((v) => (Number.isFinite(v) ? Number(v) : 0)) as [
|
|
number,
|
|
number,
|
|
number,
|
|
];
|
|
}
|
|
} catch {
|
|
cpuPercent = null;
|
|
loadTriplet = null;
|
|
}
|
|
|
|
return {
|
|
percent: toFixedNum(cpuPercent, 0),
|
|
cores,
|
|
load: loadTriplet,
|
|
};
|
|
}
|
|
|
|
// No POSIX-style load average exists on Windows, so `load` always stays null.
|
|
async function collectWindowsCpuMetrics(client: Client): Promise<{
|
|
percent: number | null;
|
|
cores: number | null;
|
|
load: [number, number, number] | null;
|
|
}> {
|
|
let cpuPercent: number | null = null;
|
|
let cores: number | null = null;
|
|
|
|
try {
|
|
const { stdout } = await execPowerShell(
|
|
client,
|
|
"$cpu=(Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average; $cores=(Get-CimInstance Win32_ComputerSystem).NumberOfLogicalProcessors; [PSCustomObject]@{cpu=$cpu; cores=$cores} | ConvertTo-Json -Compress",
|
|
25000,
|
|
);
|
|
const parsed = JSON.parse(stdout.trim());
|
|
const cpuNum = Number(parsed?.cpu);
|
|
const coresNum = Number(parsed?.cores);
|
|
cpuPercent = Number.isFinite(cpuNum)
|
|
? Math.max(0, Math.min(100, cpuNum))
|
|
: null;
|
|
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
|
|
} catch {
|
|
cpuPercent = null;
|
|
}
|
|
|
|
return {
|
|
percent: toFixedNum(cpuPercent, 0),
|
|
cores,
|
|
load: null,
|
|
};
|
|
}
|
|
|
|
export async function collectCpuMetrics(
|
|
client: Client,
|
|
platform?: HostPlatform,
|
|
hostId?: number,
|
|
): Promise<{
|
|
percent: number | null;
|
|
cores: number | null;
|
|
load: [number, number, number] | null;
|
|
}> {
|
|
if (platform === "darwin") {
|
|
return collectDarwinCpuMetrics(client);
|
|
}
|
|
if (platform === "windows") {
|
|
return collectWindowsCpuMetrics(client);
|
|
}
|
|
|
|
let cpuPercent: number | null = null;
|
|
let cores: number | null = null;
|
|
let loadTriplet: [number, number, number] | null = null;
|
|
|
|
try {
|
|
const readAt = Date.now();
|
|
const [statOut, loadAvgOut, coresOut] = await Promise.race([
|
|
Promise.all([
|
|
execCommand(client, "cat /proc/stat"),
|
|
execCommand(client, "cat /proc/loadavg"),
|
|
execCommand(
|
|
client,
|
|
"nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo",
|
|
),
|
|
]),
|
|
new Promise<never>((_, reject) =>
|
|
setTimeout(
|
|
() => reject(new Error("CPU metrics collection timeout")),
|
|
25000,
|
|
),
|
|
),
|
|
]);
|
|
|
|
const cpuLine = (
|
|
statOut.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
|
|
).trim();
|
|
const current = parseCpuLine(cpuLine);
|
|
const previous =
|
|
hostId !== undefined ? previousStat.get(hostId) : undefined;
|
|
if (current && previous) {
|
|
const totalDiff = current.total - previous.total;
|
|
const idleDiff = current.idle - previous.idle;
|
|
const used = totalDiff - idleDiff;
|
|
if (totalDiff > 0)
|
|
cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100));
|
|
}
|
|
if (current && hostId !== undefined) {
|
|
previousStat.set(hostId, { ...current, timestamp: readAt });
|
|
}
|
|
|
|
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 {
|
|
cpuPercent = null;
|
|
loadTriplet = null;
|
|
}
|
|
|
|
return {
|
|
percent: toFixedNum(cpuPercent, 0),
|
|
cores,
|
|
load: loadTriplet,
|
|
};
|
|
}
|