Initial commit with translated description

This commit is contained in:
2026-03-29 10:19:19 +08:00
commit 5aa1f324c6
81 changed files with 27526 additions and 0 deletions

448
src/vitals.js Normal file
View File

@@ -0,0 +1,448 @@
/**
* System vitals collection for OpenClaw Command Center
* Collects CPU, memory, disk, and temperature metrics
*/
const { runCmd, formatBytes } = require("./utils");
// Vitals cache to reduce blocking
let cachedVitals = null;
let lastVitalsUpdate = 0;
const VITALS_CACHE_TTL = 30000; // 30 seconds - vitals don't change fast
let vitalsRefreshing = false;
// Async background refresh of system vitals (non-blocking)
async function refreshVitalsAsync() {
if (vitalsRefreshing) return;
vitalsRefreshing = true;
const vitals = {
hostname: "",
uptime: "",
disk: { used: 0, free: 0, total: 0, percent: 0, kbPerTransfer: 0, iops: 0, throughputMBps: 0 },
cpu: { loadAvg: [0, 0, 0], cores: 0, usage: 0 },
memory: { used: 0, free: 0, total: 0, percent: 0, pressure: "normal" },
temperature: null,
};
// Detect platform for cross-platform support
const isLinux = process.platform === "linux";
const isMacOS = process.platform === "darwin";
try {
// Platform-specific commands
const coresCmd = isLinux ? "nproc" : "sysctl -n hw.ncpu";
const memCmd = isLinux
? "cat /proc/meminfo | grep MemTotal | awk '{print $2}'"
: "sysctl -n hw.memsize";
const topCmd = isLinux
? "top -bn1 | head -3 | grep -E '^%?Cpu|^ ?CPU' || echo ''"
: 'top -l 1 -n 0 2>/dev/null | grep "CPU usage" || echo ""';
// Linux: prefer mpstat (1s average) to avoid spiky single-frame top parsing.
const mpstatCmd = isLinux
? "(command -v mpstat >/dev/null 2>&1 && mpstat 1 1 | tail -1 | sed 's/^Average: *//') || echo ''"
: "";
// Run commands in parallel for speed
const [hostname, uptimeRaw, coresRaw, memTotalRaw, memInfoRaw, dfRaw, topOutput, mpstatOutput] =
await Promise.all([
runCmd("hostname", { fallback: "unknown" }),
runCmd("uptime", { fallback: "" }),
runCmd(coresCmd, { fallback: "1" }),
runCmd(memCmd, { fallback: "0" }),
isLinux
? runCmd("cat /proc/meminfo", { fallback: "" })
: runCmd("vm_stat", { fallback: "" }),
runCmd("df -k ~ | tail -1", { fallback: "" }),
runCmd(topCmd, { fallback: "" }),
isLinux ? runCmd(mpstatCmd, { fallback: "" }) : Promise.resolve(""),
]);
vitals.hostname = hostname;
// Parse uptime
const uptimeMatch = uptimeRaw.match(/up\s+([^,]+)/);
if (uptimeMatch) vitals.uptime = uptimeMatch[1].trim();
const loadMatch = uptimeRaw.match(/load averages?:\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/);
if (loadMatch)
vitals.cpu.loadAvg = [
parseFloat(loadMatch[1]),
parseFloat(loadMatch[2]),
parseFloat(loadMatch[3]),
];
// CPU
vitals.cpu.cores = parseInt(coresRaw, 10) || 1;
vitals.cpu.usage = Math.min(100, Math.round((vitals.cpu.loadAvg[0] / vitals.cpu.cores) * 100));
// CPU percent (platform-specific)
// Linux: prefer mpstat output (averaged over 1 second). Fallback to parsing top.
if (isLinux) {
// mpstat: ... %usr %nice %sys %iowait %irq %soft %steal %guest %gnice %idle
if (mpstatOutput) {
// After sed, mpstatOutput should look like:
// "all 7.69 0.00 2.05 ... 89.74" (CPU %usr %nice %sys ... %idle)
const parts = mpstatOutput.trim().split(/\s+/);
const user = parts.length > 1 ? parseFloat(parts[1]) : NaN;
const sys = parts.length > 3 ? parseFloat(parts[3]) : NaN;
const idle = parts.length ? parseFloat(parts[parts.length - 1]) : NaN;
if (!Number.isNaN(user)) vitals.cpu.userPercent = user;
if (!Number.isNaN(sys)) vitals.cpu.sysPercent = sys;
if (!Number.isNaN(idle)) {
vitals.cpu.idlePercent = idle;
vitals.cpu.usage = Math.max(0, Math.min(100, Math.round(100 - idle)));
}
}
if (topOutput && (vitals.cpu.idlePercent === null || vitals.cpu.idlePercent === undefined)) {
// Linux top: %Cpu(s): 5.9 us, 2.0 sy, 0.0 ni, 91.5 id, 0.5 wa, ...
const userMatch = topOutput.match(/([\d.]+)\s*us/);
const sysMatch = topOutput.match(/([\d.]+)\s*sy/);
const idleMatch = topOutput.match(/([\d.]+)\s*id/);
vitals.cpu.userPercent = userMatch ? parseFloat(userMatch[1]) : null;
vitals.cpu.sysPercent = sysMatch ? parseFloat(sysMatch[1]) : null;
vitals.cpu.idlePercent = idleMatch ? parseFloat(idleMatch[1]) : null;
if (vitals.cpu.userPercent !== null && vitals.cpu.sysPercent !== null) {
vitals.cpu.usage = Math.round(vitals.cpu.userPercent + vitals.cpu.sysPercent);
}
}
} else if (topOutput) {
// macOS: CPU usage: 5.9% user, 2.0% sys, 91.5% idle
const userMatch = topOutput.match(/([\d.]+)%\s*user/);
const sysMatch = topOutput.match(/([\d.]+)%\s*sys/);
const idleMatch = topOutput.match(/([\d.]+)%\s*idle/);
vitals.cpu.userPercent = userMatch ? parseFloat(userMatch[1]) : null;
vitals.cpu.sysPercent = sysMatch ? parseFloat(sysMatch[1]) : null;
vitals.cpu.idlePercent = idleMatch ? parseFloat(idleMatch[1]) : null;
if (vitals.cpu.userPercent !== null && vitals.cpu.sysPercent !== null) {
vitals.cpu.usage = Math.round(vitals.cpu.userPercent + vitals.cpu.sysPercent);
}
}
// Disk
const dfParts = dfRaw.split(/\s+/);
if (dfParts.length >= 4) {
vitals.disk.total = parseInt(dfParts[1], 10) * 1024;
vitals.disk.used = parseInt(dfParts[2], 10) * 1024;
vitals.disk.free = parseInt(dfParts[3], 10) * 1024;
vitals.disk.percent = Math.round((parseInt(dfParts[2], 10) / parseInt(dfParts[1], 10)) * 100);
}
// Memory (platform-specific)
if (isLinux) {
const memTotalKB = parseInt(memTotalRaw, 10) || 0;
const memAvailableMatch = memInfoRaw.match(/MemAvailable:\s+(\d+)/);
const memFreeMatch = memInfoRaw.match(/MemFree:\s+(\d+)/);
vitals.memory.total = memTotalKB * 1024;
const memAvailable = parseInt(memAvailableMatch?.[1] || memFreeMatch?.[1] || 0, 10) * 1024;
vitals.memory.used = vitals.memory.total - memAvailable;
vitals.memory.free = memAvailable;
vitals.memory.percent =
vitals.memory.total > 0 ? Math.round((vitals.memory.used / vitals.memory.total) * 100) : 0;
} else {
const pageSizeMatch = memInfoRaw.match(/page size of (\d+) bytes/);
const pageSize = pageSizeMatch ? parseInt(pageSizeMatch[1], 10) : 4096;
const activePages = parseInt((memInfoRaw.match(/Pages active:\s+(\d+)/) || [])[1] || 0, 10);
const wiredPages = parseInt(
(memInfoRaw.match(/Pages wired down:\s+(\d+)/) || [])[1] || 0,
10,
);
const compressedPages = parseInt(
(memInfoRaw.match(/Pages occupied by compressor:\s+(\d+)/) || [])[1] || 0,
10,
);
vitals.memory.total = parseInt(memTotalRaw, 10) || 0;
vitals.memory.used = (activePages + wiredPages + compressedPages) * pageSize;
vitals.memory.free = vitals.memory.total - vitals.memory.used;
vitals.memory.percent =
vitals.memory.total > 0 ? Math.round((vitals.memory.used / vitals.memory.total) * 100) : 0;
}
vitals.memory.pressure =
vitals.memory.percent > 90 ? "critical" : vitals.memory.percent > 75 ? "warning" : "normal";
// Secondary async calls (chip info, iostat)
// NOTE: iostat needs an explicit count, otherwise it runs forever.
// IMPORTANT: Avoid shell pipelines (e.g. `| tail -1`) — when Node kills
// the shell on timeout, pipeline children like `iostat` survive as orphans.
// We wrap with timeout/gtimeout as a belt-and-suspenders safeguard on top of runCmd timeout.
const timeoutPrefix = isLinux
? "timeout 5"
: "$(command -v gtimeout >/dev/null 2>&1 && echo gtimeout 5)";
const iostatArgs = isLinux ? "-d -o JSON 1 2" : "-d -c 2 2";
const iostatCmd = `${timeoutPrefix} iostat ${iostatArgs} 2>/dev/null || echo ''`;
const [perfCores, effCores, chip, iostatRaw] = await Promise.all([
isMacOS
? runCmd("sysctl -n hw.perflevel0.logicalcpu 2>/dev/null || echo 0", { fallback: "0" })
: Promise.resolve("0"),
isMacOS
? runCmd("sysctl -n hw.perflevel1.logicalcpu 2>/dev/null || echo 0", { fallback: "0" })
: Promise.resolve("0"),
isMacOS
? runCmd(
'system_profiler SPHardwareDataType 2>/dev/null | grep "Chip:" | cut -d: -f2 || echo ""',
{ fallback: "" },
)
: Promise.resolve(""),
runCmd(iostatCmd, { fallback: "", timeout: 5000 }),
]);
if (isLinux) {
const cpuBrand = await runCmd(
"cat /proc/cpuinfo | grep 'model name' | head -1 | cut -d: -f2",
{ fallback: "" },
);
if (cpuBrand) vitals.cpu.brand = cpuBrand.trim();
}
vitals.cpu.pCores = parseInt(perfCores, 10) || null;
vitals.cpu.eCores = parseInt(effCores, 10) || null;
if (chip) vitals.cpu.chip = chip;
if (isLinux) {
try {
const iostatJson = JSON.parse(iostatRaw);
const samples = iostatJson.sysstat.hosts[0].statistics;
const disks = samples[samples.length - 1].disk;
const disk = disks
.filter((d) => !d.disk_device.startsWith("loop"))
.sort((a, b) => b.tps - a.tps)[0];
if (disk) {
const kbReadPerSec = disk["kB_read/s"] || 0;
const kbWrtnPerSec = disk["kB_wrtn/s"] || 0;
vitals.disk.iops = disk.tps || 0;
vitals.disk.throughputMBps = (kbReadPerSec + kbWrtnPerSec) / 1024;
vitals.disk.kbPerTransfer = disk.tps > 0 ? (kbReadPerSec + kbWrtnPerSec) / disk.tps : 0;
}
} catch {
// JSON parse failed
}
} else {
// iostat output has multiple lines (header + samples); take the last non-empty line
const iostatLines = iostatRaw.split("\n").filter((l) => l.trim());
const lastLine = iostatLines.length > 0 ? iostatLines[iostatLines.length - 1] : "";
const iostatParts = lastLine.split(/\s+/).filter(Boolean);
if (iostatParts.length >= 3) {
vitals.disk.kbPerTransfer = parseFloat(iostatParts[0]) || 0;
vitals.disk.iops = parseFloat(iostatParts[1]) || 0;
vitals.disk.throughputMBps = parseFloat(iostatParts[2]) || 0;
}
}
// Temperature
vitals.temperature = null;
vitals.temperatureNote = null;
const isAppleSilicon = vitals.cpu.chip && /apple/i.test(vitals.cpu.chip);
if (isAppleSilicon) {
vitals.temperatureNote = "Apple Silicon (requires elevated access)";
try {
const pmOutput = await runCmd(
'sudo -n powermetrics --samplers smc -i 1 -n 1 2>/dev/null | grep -i "die temp" | head -1',
{ fallback: "", timeout: 5000 },
);
const tempMatch = pmOutput.match(/([\d.]+)/);
if (tempMatch) {
vitals.temperature = parseFloat(tempMatch[1]);
vitals.temperatureNote = null;
}
} catch (e) {}
} else if (isMacOS) {
const home = require("os").homedir();
try {
const temp = await runCmd(
`osx-cpu-temp 2>/dev/null || ${home}/bin/osx-cpu-temp 2>/dev/null`,
{ fallback: "" },
);
if (temp && temp.includes("\u00b0")) {
const tempMatch = temp.match(/([\d.]+)/);
if (tempMatch && parseFloat(tempMatch[1]) > 0) {
vitals.temperature = parseFloat(tempMatch[1]);
}
}
} catch (e) {}
if (!vitals.temperature) {
try {
const ioregRaw = await runCmd(
"ioreg -r -n AppleSmartBattery 2>/dev/null | grep Temperature",
{ fallback: "" },
);
const tempMatch = ioregRaw.match(/"Temperature"\s*=\s*(\d+)/);
if (tempMatch) {
vitals.temperature = Math.round(parseInt(tempMatch[1], 10) / 100);
}
} catch (e) {}
}
} else if (isLinux) {
try {
const temp = await runCmd("cat /sys/class/thermal/thermal_zone0/temp 2>/dev/null", {
fallback: "",
});
if (temp) {
vitals.temperature = Math.round(parseInt(temp, 10) / 1000);
}
} catch (e) {}
}
} catch (e) {
console.error("[Vitals] Async refresh failed:", e.message);
}
// Formatted versions
vitals.memory.usedFormatted = formatBytes(vitals.memory.used);
vitals.memory.totalFormatted = formatBytes(vitals.memory.total);
vitals.memory.freeFormatted = formatBytes(vitals.memory.free);
vitals.disk.usedFormatted = formatBytes(vitals.disk.used);
vitals.disk.totalFormatted = formatBytes(vitals.disk.total);
vitals.disk.freeFormatted = formatBytes(vitals.disk.free);
cachedVitals = vitals;
lastVitalsUpdate = Date.now();
vitalsRefreshing = false;
console.log("[Vitals] Cache refreshed async");
}
// Start background vitals refresh on startup
setTimeout(() => refreshVitalsAsync(), 500);
setInterval(() => refreshVitalsAsync(), VITALS_CACHE_TTL);
function getSystemVitals() {
const now = Date.now();
if (!cachedVitals || now - lastVitalsUpdate > VITALS_CACHE_TTL) {
refreshVitalsAsync();
}
if (cachedVitals) return cachedVitals;
return {
hostname: "loading...",
uptime: "",
disk: {
used: 0,
free: 0,
total: 0,
percent: 0,
usedFormatted: "-",
totalFormatted: "-",
freeFormatted: "-",
},
cpu: { loadAvg: [0, 0, 0], cores: 0, usage: 0 },
memory: {
used: 0,
free: 0,
total: 0,
percent: 0,
pressure: "normal",
usedFormatted: "-",
totalFormatted: "-",
freeFormatted: "-",
},
temperature: null,
};
}
/**
* Check for optional system dependencies.
* Returns structured results and logs hints once at startup.
*/
let cachedDeps = null;
async function checkOptionalDeps() {
const isLinux = process.platform === "linux";
const isMacOS = process.platform === "darwin";
const platform = isLinux ? "linux" : isMacOS ? "darwin" : null;
const results = [];
if (!platform) {
cachedDeps = results;
return results;
}
const fs = require("fs");
const path = require("path");
const depsFile = path.join(__dirname, "..", "config", "system-deps.json");
let depsConfig;
try {
depsConfig = JSON.parse(fs.readFileSync(depsFile, "utf8"));
} catch {
cachedDeps = results;
return results;
}
const deps = depsConfig[platform] || [];
const home = require("os").homedir();
// Detect package manager
let pkgManager = null;
if (isLinux) {
for (const pm of ["apt", "dnf", "yum", "pacman", "apk"]) {
const has = await runCmd(`which ${pm}`, { fallback: "" });
if (has) {
pkgManager = pm;
break;
}
}
} else if (isMacOS) {
const hasBrew = await runCmd("which brew", { fallback: "" });
if (hasBrew) pkgManager = "brew";
}
// Detect chip for condition filtering
let isAppleSilicon = false;
if (isMacOS) {
const chip = await runCmd("sysctl -n machdep.cpu.brand_string", { fallback: "" });
isAppleSilicon = /apple/i.test(chip);
}
for (const dep of deps) {
// Skip deps that don't apply to this hardware
if (dep.condition === "intel" && isAppleSilicon) continue;
let installed = false;
const hasBinary = await runCmd(`which ${dep.binary} 2>/dev/null`, { fallback: "" });
if (hasBinary) {
installed = true;
} else if (isMacOS && dep.binary === "osx-cpu-temp") {
const homebin = await runCmd(`test -x ${home}/bin/osx-cpu-temp && echo ok`, {
fallback: "",
});
if (homebin) installed = true;
}
const installCmd = dep.install[pkgManager] || null;
results.push({
id: dep.id,
name: dep.name,
purpose: dep.purpose,
affects: dep.affects,
installed,
installCmd,
url: dep.url || null,
});
}
cachedDeps = results;
// Log hints for missing deps
const missing = results.filter((d) => !d.installed);
if (missing.length > 0) {
console.log("[Startup] Optional dependencies for enhanced vitals:");
for (const dep of missing) {
const action = dep.installCmd || dep.url || "see docs";
console.log(` \u{1F4A1} ${dep.name} \u2014 ${dep.purpose}: ${action}`);
}
}
return results;
}
function getOptionalDeps() {
return cachedDeps;
}
module.exports = {
refreshVitalsAsync,
getSystemVitals,
checkOptionalDeps,
getOptionalDeps,
VITALS_CACHE_TTL,
};