feat: ship host metrics as a first-party in-process plugin

This commit is contained in:
LukeGus committed 2026-09-21 11:30:46 -05:00
1 parent a2479c19b0
commit 19b70430a1
145 files changed
+675 -349

No files matched your search

@@ -0,0 +1,229 @@
import type { Client } from "ssh2";
import {
execCommand,
execPowerShell,
toFixedNum,
type HostPlatform,
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
/**
* 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,
};
}
@@ -0,0 +1,530 @@
import type { Client } from "ssh2";
import {
execCommand,
execPowerShell,
toFixedNum,
type HostPlatform,
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
const PSEUDO_FS_RE = /^(tmpfs|devtmpfs|overlay|udev|none|shm)$/;
export interface DfRow {
filesystem: string;
type: string;
mount: string;
parts: string[];
}
export interface DiskFilesystem {
filesystem: string;
type: string;
mount: string;
percent: number | null;
usedHuman: string | null;
totalHuman: string | null;
availableHuman: string | null;
usedBytes: number | null;
totalBytes: number | null;
availableBytes: number | null;
label?: string;
}
export interface MonitoredMount {
path: string;
label?: string;
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
// Parses `df -T -P`-style output: Filesystem, Type, then the size columns,
// with Mounted-on last. The Type column (e.g. ext4, nfs4, cifs) is what lets
// callers filter network shares out by filesystem type rather than guessing
// from the source path.
export function parseDfLines(output: string): DfRow[] {
return output
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.map((line) => {
const parts = line.split(/\s+/);
return {
filesystem: parts[0] || "",
type: parts[1] || "",
mount: parts[6] || "",
parts,
};
})
.filter((row) => row.parts.length >= 7 && !PSEUDO_FS_RE.test(row.type));
}
// Finds the index of the most-utilized real filesystem in a GNU `df -TkP`-style
// row set (parts[2] = total KB, parts[3] = used KB), so a nearly-full
// secondary mount (e.g. /data) isn't hidden behind a healthy root filesystem.
export function findWorstMountIndex(bytesRows: DfRow[]): {
index: number;
usedBytes: number;
totalBytes: number;
} {
let worstIndex = -1;
let worstUsedBytes = -1;
let worstTotalBytes = 0;
bytesRows.forEach((row, index) => {
const totalBytes = Number(row.parts[2]);
const usedBytes = Number(row.parts[3]);
if (
!Number.isFinite(totalBytes) ||
!Number.isFinite(usedBytes) ||
totalBytes <= 0
) {
return;
}
const usedRatio = usedBytes / totalBytes;
const worstRatio =
worstTotalBytes > 0 ? worstUsedBytes / worstTotalBytes : -1;
if (usedRatio > worstRatio) {
worstIndex = index;
worstUsedBytes = usedBytes;
worstTotalBytes = totalBytes;
}
});
return {
index: worstIndex,
usedBytes: worstUsedBytes,
totalBytes: worstTotalBytes,
};
}
// Merges a GNU `df -TkP` size-row set (parts[2..4] in `blockSizeBytes` units)
// and a `df -hT -P` human-row set into one filesystem list. Byte rows drive
// the maths; human rows only supply the display strings, matched by mount
// point so a mismatched row count can't shift the columns.
export function buildFilesystemList(
bytesRows: DfRow[],
humanRows: DfRow[],
blockSizeBytes = 1,
): DiskFilesystem[] {
const aligned = humanRows.length === bytesRows.length;
return bytesRows
.map((row, index) => {
const totalBytes = Number(row.parts[2]) * blockSizeBytes;
const usedBytes = Number(row.parts[3]) * blockSizeBytes;
const availableBytes = Number(row.parts[4]) * blockSizeBytes;
if (!Number.isFinite(totalBytes) || totalBytes <= 0) return null;
const humanRow = aligned
? humanRows[index]
: humanRows.find((h) => h.mount === row.mount);
const percent = Number.isFinite(usedBytes)
? Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100))
: null;
return {
filesystem: row.filesystem,
type: row.type,
mount: row.mount,
percent: toFixedNum(percent, 0),
usedHuman: humanRow?.parts[3] || null,
totalHuman: humanRow?.parts[2] || null,
availableHuman: humanRow?.parts[4] || null,
usedBytes: Number.isFinite(usedBytes) ? usedBytes : null,
totalBytes,
availableBytes: Number.isFinite(availableBytes) ? availableBytes : null,
};
})
.filter((fs): fs is DiskFilesystem => fs !== null);
}
// The headline disk figure should be the root filesystem - that is what users
// mean by "the server's disk". Only when there is no root mount (containers,
// chroots) do we fall back to the most-utilized mount.
export function selectPrimaryFilesystem(
filesystems: DiskFilesystem[],
): DiskFilesystem | null {
if (filesystems.length === 0) return null;
const root = filesystems.find((fs) => fs.mount === "/");
if (root) return root;
let best = filesystems[0];
for (const fs of filesystems) {
const ratio = (fs.usedBytes ?? 0) / (fs.totalBytes || 1);
const bestRatio = (best.usedBytes ?? 0) / (best.totalBytes || 1);
if (ratio > bestRatio) best = fs;
}
return best;
}
// Excluded mounts are user-configured per host: an exact mount-path match
// (e.g. "/mnt/nas") or a filesystem-type substring match (e.g. "nfs" matches
// nfs/nfs4, "cifs" matches cifs/smb3), so network shares can be dropped from
// the headline percent and the filesystem list without hiding local disks.
export function filterExcludedFilesystems(
filesystems: DiskFilesystem[],
excludedMounts?: string[] | null,
): DiskFilesystem[] {
if (!excludedMounts || excludedMounts.length === 0) return filesystems;
const normalized = excludedMounts
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
if (normalized.length === 0) return filesystems;
return filesystems.filter((fs) => {
const mount = fs.mount.toLowerCase();
const type = fs.type.toLowerCase();
return !normalized.some(
(entry) => mount === entry || (type && type.includes(entry)),
);
});
}
export function mergeMonitoredFilesystems(
filesystems: DiskFilesystem[],
monitored: MonitoredMount[],
customFilesystems: Array<DiskFilesystem | null>,
): DiskFilesystem[] {
const result = [...filesystems];
monitored.forEach((entry, index) => {
const path = entry.path.trim();
const custom = customFilesystems[index];
if (!path || !custom) return;
const existing = result.find((fs) => fs.mount === path);
if (existing) {
existing.label = entry.label?.trim() || undefined;
return;
}
result.push({
...custom,
mount: path,
label: entry.label?.trim() || undefined,
});
});
return result;
}
function humanizeBytes(bytes: number): string {
const units = ["B", "K", "M", "G", "T", "P"];
let value = Math.max(0, bytes);
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
const rounded =
unitIndex > 0 && value < 10 ? value.toFixed(1) : Math.round(value);
return `${rounded}${units[unitIndex]}`;
}
// BSD/macOS `df -T` takes a filesystem-type argument to *filter* by, unlike
// GNU `df -T` which *prints* a type column - so macOS needs its own command
// (`df -Pk`, no type column) and gets the type separately from `mount`.
const DARWIN_SKIP_MOUNT_RE =
/^\/System\/Volumes\/(VM|Preboot|Update|xarts|iSCPreboot|Hardware)(\/|$)/;
const DARWIN_SKIP_TYPE_RE = /^(devfs|autofs)$/;
export function parseDarwinMountTypes(output: string): Map<string, string> {
const map = new Map<string, string>();
for (const line of output.split("\n")) {
// Device names can contain spaces (e.g. autofs's "map auto_home"), so
// match the literal " on " / " (" separators rather than a single token.
const match = line.match(/^.+? on (.+?) \(([^,)]+)/);
if (match) map.set(match[1], match[2].trim());
}
return map;
}
export function parseDarwinDfRows(
output: string,
typeByMount: Map<string, string>,
): DiskFilesystem[] {
return output
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.map((line) => {
const parts = line.split(/\s+/);
if (parts.length < 6) return null;
const filesystem = parts[0];
const totalKb = Number(parts[1]);
const usedKb = Number(parts[2]);
const availableKb = Number(parts[3]);
const mount = parts.slice(5).join(" ");
if (!Number.isFinite(totalKb) || totalKb <= 0) return null;
if (DARWIN_SKIP_MOUNT_RE.test(mount)) return null;
const type = typeByMount.get(mount) || "";
if (DARWIN_SKIP_TYPE_RE.test(type)) return null;
const totalBytes = totalKb * 1024;
const usedBytes = Number.isFinite(usedKb) ? usedKb * 1024 : null;
const availableBytes = Number.isFinite(availableKb)
? availableKb * 1024
: null;
const percent =
usedBytes !== null
? Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100))
: null;
return {
filesystem,
type,
mount,
percent: toFixedNum(percent, 0),
usedHuman: usedBytes !== null ? humanizeBytes(usedBytes) : null,
totalHuman: humanizeBytes(totalBytes),
availableHuman:
availableBytes !== null ? humanizeBytes(availableBytes) : null,
usedBytes,
totalBytes,
availableBytes,
};
})
.filter((fs): fs is DiskFilesystem => fs !== null);
}
async function collectDarwinDiskMetrics(
client: Client,
excludedMounts?: string[] | null,
monitoredMounts?: MonitoredMount[] | null,
): Promise<{
percent: number | null;
usedHuman: string | null;
totalHuman: string | null;
availableHuman: string | null;
mount: string | null;
filesystems: DiskFilesystem[];
}> {
try {
const [dfOut, mountOut] = await Promise.all([
execCommand(client, "df -Pk | tail -n +2"),
execCommand(client, "mount"),
]);
const typeByMount = parseDarwinMountTypes(mountOut.stdout);
let detected = parseDarwinDfRows(dfOut.stdout, typeByMount);
const monitored = (monitoredMounts ?? []).filter((entry) =>
Boolean(entry.path.trim()),
);
if (monitored.length > 0) {
const customFilesystems = await Promise.all(
monitored.map(async (entry) => {
const path = shellQuote(entry.path.trim());
try {
const custom = await execCommand(
client,
`df -Pk -- ${path} | tail -n +2`,
);
return parseDarwinDfRows(custom.stdout, typeByMount)[0] ?? null;
} catch {
return null;
}
}),
);
detected = mergeMonitoredFilesystems(
detected,
monitored,
customFilesystems,
);
}
const filesystems = filterExcludedFilesystems(detected, excludedMounts);
const primary = selectPrimaryFilesystem(filesystems);
return {
percent: primary?.percent ?? null,
usedHuman: primary?.usedHuman ?? null,
totalHuman: primary?.totalHuman ?? null,
availableHuman: primary?.availableHuman ?? null,
mount: primary?.mount ?? null,
filesystems,
};
} catch {
return {
percent: null,
usedHuman: null,
totalHuman: null,
availableHuman: null,
mount: null,
filesystems: [],
};
}
}
interface WindowsDiskRow {
drive: string;
total: number;
free: number;
}
const WINDOWS_DISK_SCRIPT =
'Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" | ForEach-Object {' +
" [PSCustomObject]@{drive=$_.DeviceID; total=$_.Size; free=$_.FreeSpace}" +
" } | ConvertTo-Json -Compress";
export function parseWindowsDiskJson(output: string): WindowsDiskRow[] {
const trimmed = output.trim();
if (!trimmed) return [];
try {
const parsed = JSON.parse(trimmed);
const rows = Array.isArray(parsed) ? parsed : [parsed];
return rows
.filter((row): row is Record<string, unknown> => Boolean(row?.drive))
.map((row) => ({
drive: String(row.drive),
total: Number(row.total),
free: Number(row.free),
}))
.filter((row) => Number.isFinite(row.total) && row.total > 0);
} catch {
return [];
}
}
async function collectWindowsDiskMetrics(
client: Client,
excludedMounts?: string[] | null,
): Promise<{
percent: number | null;
usedHuman: string | null;
totalHuman: string | null;
availableHuman: string | null;
mount: string | null;
filesystems: DiskFilesystem[];
}> {
try {
const { stdout } = await execPowerShell(client, WINDOWS_DISK_SCRIPT);
const rows = parseWindowsDiskJson(stdout);
const filesystems: DiskFilesystem[] = rows.map((row) => {
const usedBytes = row.total - row.free;
const percent = Math.max(0, Math.min(100, (usedBytes / row.total) * 100));
return {
filesystem: row.drive,
type: "NTFS",
mount: row.drive,
percent: toFixedNum(percent, 0),
usedHuman: humanizeBytes(usedBytes),
totalHuman: humanizeBytes(row.total),
availableHuman: humanizeBytes(row.free),
usedBytes,
totalBytes: row.total,
availableBytes: row.free,
};
});
const filtered = filterExcludedFilesystems(filesystems, excludedMounts);
const primary =
filtered.find((fs) => fs.mount.toUpperCase().startsWith("C")) ??
filtered[0] ??
null;
return {
percent: primary?.percent ?? null,
usedHuman: primary?.usedHuman ?? null,
totalHuman: primary?.totalHuman ?? null,
availableHuman: primary?.availableHuman ?? null,
mount: primary?.mount ?? null,
filesystems: filtered,
};
} catch {
return {
percent: null,
usedHuman: null,
totalHuman: null,
availableHuman: null,
mount: null,
filesystems: [],
};
}
}
export async function collectDiskMetrics(
client: Client,
excludedMounts?: string[] | null,
monitoredMounts?: MonitoredMount[] | null,
platform?: HostPlatform,
): Promise<{
percent: number | null;
usedHuman: string | null;
totalHuman: string | null;
availableHuman: string | null;
mount: string | null;
filesystems: DiskFilesystem[];
}> {
if (platform === "windows") {
return collectWindowsDiskMetrics(client, excludedMounts);
}
if (platform === "darwin") {
return collectDarwinDiskMetrics(client, excludedMounts, monitoredMounts);
}
try {
const [diskOutHuman, diskOutBytes] = await Promise.all([
execCommand(client, "df -hT -P | tail -n +2"),
execCommand(client, "df -TkP | tail -n +2"),
]);
const humanRows = parseDfLines(diskOutHuman.stdout);
const bytesRows = parseDfLines(diskOutBytes.stdout);
let detected = buildFilesystemList(bytesRows, humanRows, 1024);
const monitored = (monitoredMounts ?? []).filter((entry) =>
Boolean(entry.path.trim()),
);
if (monitored.length > 0) {
const customFilesystems = await Promise.all(
monitored.map(async (entry) => {
const path = shellQuote(entry.path.trim());
try {
const [customHuman, customBytes] = await Promise.all([
execCommand(client, `df -hT -P -- ${path} | tail -n +2`),
execCommand(client, `df -TkP -- ${path} | tail -n +2`),
]);
return (
buildFilesystemList(
parseDfLines(customBytes.stdout),
parseDfLines(customHuman.stdout),
1024,
)[0] ?? null
);
} catch {
return null;
}
}),
);
detected = mergeMonitoredFilesystems(
detected,
monitored,
customFilesystems,
);
}
const filesystems = filterExcludedFilesystems(detected, excludedMounts);
const primary = selectPrimaryFilesystem(filesystems);
return {
percent: primary?.percent ?? null,
usedHuman: primary?.usedHuman ?? null,
totalHuman: primary?.totalHuman ?? null,
availableHuman: primary?.availableHuman ?? null,
mount: primary?.mount ?? null,
filesystems,
};
} catch {
return {
percent: null,
usedHuman: null,
totalHuman: null,
availableHuman: null,
mount: null,
filesystems: [],
};
}
}
@@ -0,0 +1,254 @@
import type { Client } from "ssh2";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
import type {
FirewallMetrics,
FirewallChain,
FirewallRule,
} from "../../../../src/types/stats-widgets.js";
function parseIptablesRule(line: string): FirewallRule | null {
if (!line.startsWith("-A ")) return null;
const rule: FirewallRule = {
chain: "",
target: "",
protocol: "all",
source: "0.0.0.0/0",
destination: "0.0.0.0/0",
};
const chainMatch = line.match(/^-A\s+(\S+)/);
if (chainMatch) {
rule.chain = chainMatch[1];
}
const targetMatch = line.match(/-j\s+(\S+)/);
if (targetMatch) {
rule.target = targetMatch[1];
}
const protocolMatch = line.match(/-p\s+(\S+)/);
if (protocolMatch) {
rule.protocol = protocolMatch[1];
}
const sourceMatch = line.match(/-s\s+(\S+)/);
if (sourceMatch) {
rule.source = sourceMatch[1];
}
const destMatch = line.match(/-d\s+(\S+)/);
if (destMatch) {
rule.destination = destMatch[1];
}
const dportMatch = line.match(/--dport\s+(\S+)/);
if (dportMatch) {
rule.dport = dportMatch[1];
}
const sportMatch = line.match(/--sport\s+(\S+)/);
if (sportMatch) {
rule.sport = sportMatch[1];
}
const stateMatch = line.match(/--state\s+(\S+)/);
if (stateMatch) {
rule.state = stateMatch[1];
}
const interfaceMatch = line.match(/-i\s+(\S+)/);
if (interfaceMatch) {
rule.interface = interfaceMatch[1];
}
return rule;
}
function parseIptablesOutput(output: string): FirewallChain[] {
const chains: Map<string, FirewallChain> = new Map();
const lines = output.split("\n");
for (const line of lines) {
const trimmed = line.trim();
const policyMatch = trimmed.match(/^:(\S+)\s+(\S+)/);
if (policyMatch) {
const [, chainName, policy] = policyMatch;
chains.set(chainName, {
name: chainName,
policy: policy,
rules: [],
});
continue;
}
const rule = parseIptablesRule(trimmed);
if (rule) {
let chain = chains.get(rule.chain);
if (!chain) {
chain = {
name: rule.chain,
policy: "ACCEPT",
rules: [],
};
chains.set(rule.chain, chain);
}
chain.rules.push(rule);
}
}
return Array.from(chains.values());
}
function parseNftablesOutput(output: string): FirewallChain[] {
const chains: FirewallChain[] = [];
let currentChain: FirewallChain | null = null;
const lines = output.split("\n");
for (const line of lines) {
const trimmed = line.trim();
const chainMatch = trimmed.match(
/chain\s+(\S+)\s*\{?\s*(?:type\s+\S+\s+hook\s+(\S+))?/,
);
if (chainMatch) {
if (currentChain) {
chains.push(currentChain);
}
currentChain = {
name: chainMatch[1].toUpperCase(),
policy: "ACCEPT",
rules: [],
};
continue;
}
if (currentChain && trimmed.startsWith("policy ")) {
const policyMatch = trimmed.match(/policy\s+(\S+)/);
if (policyMatch) {
currentChain.policy = policyMatch[1].toUpperCase();
}
continue;
}
if (currentChain && trimmed && !trimmed.startsWith("}")) {
const rule: FirewallRule = {
chain: currentChain.name,
target: "",
protocol: "all",
source: "0.0.0.0/0",
destination: "0.0.0.0/0",
};
if (trimmed.includes("accept")) rule.target = "ACCEPT";
else if (trimmed.includes("drop")) rule.target = "DROP";
else if (trimmed.includes("reject")) rule.target = "REJECT";
const tcpMatch = trimmed.match(/tcp\s+dport\s+(\S+)/);
if (tcpMatch) {
rule.protocol = "tcp";
rule.dport = tcpMatch[1];
}
const udpMatch = trimmed.match(/udp\s+dport\s+(\S+)/);
if (udpMatch) {
rule.protocol = "udp";
rule.dport = udpMatch[1];
}
const saddrMatch = trimmed.match(/saddr\s+(\S+)/);
if (saddrMatch) {
rule.source = saddrMatch[1];
}
const daddrMatch = trimmed.match(/daddr\s+(\S+)/);
if (daddrMatch) {
rule.destination = daddrMatch[1];
}
const iifMatch = trimmed.match(/iif\s+"?(\S+)"?/);
if (iifMatch) {
rule.interface = iifMatch[1].replace(/"/g, "");
}
const ctStateMatch = trimmed.match(/ct\s+state\s+(\S+)/);
if (ctStateMatch) {
rule.state = ctStateMatch[1].toUpperCase();
}
if (rule.target) {
currentChain.rules.push(rule);
}
}
if (trimmed === "}") {
if (currentChain) {
chains.push(currentChain);
currentChain = null;
}
}
}
if (currentChain) {
chains.push(currentChain);
}
return chains;
}
export async function collectFirewallMetrics(
client: Client,
): Promise<FirewallMetrics> {
try {
const iptablesResult = await execCommand(
client,
"iptables-save 2>/dev/null",
15000,
);
if (iptablesResult.stdout && iptablesResult.stdout.includes("*filter")) {
const chains = parseIptablesOutput(iptablesResult.stdout);
const hasRules = chains.some((c) => c.rules.length > 0);
return {
type: "iptables",
status: hasRules ? "active" : "inactive",
chains: chains.filter(
(c) =>
c.name === "INPUT" || c.name === "OUTPUT" || c.name === "FORWARD",
),
};
}
const nftResult = await execCommand(
client,
"nft list ruleset 2>/dev/null",
15000,
);
if (nftResult.stdout && nftResult.stdout.trim()) {
const chains = parseNftablesOutput(nftResult.stdout);
const hasRules = chains.some((c) => c.rules.length > 0);
return {
type: "nftables",
status: hasRules ? "active" : "inactive",
chains,
};
}
return {
type: "none",
status: "unknown",
chains: [],
};
} catch {
return {
type: "none",
status: "unknown",
chains: [],
};
}
}
@@ -0,0 +1,189 @@
import type { Client } from "ssh2";
import {
execCommand,
toFixedNum,
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
export interface GpuDevice {
index: number;
uuid: string;
name: string;
driverVersion: string | null;
utilizationPercent: number | null;
memoryUsedMiB: number | null;
memoryTotalMiB: number | null;
memoryPercent: number | null;
temperatureCelsius: number | null;
powerDrawWatts: number | null;
powerLimitWatts: number | null;
fanPercent: number | null;
}
export interface GpuProcess {
/** Null when nvidia-smi reports a uuid that matches none of the listed GPUs. */
gpuIndex: number | null;
pid: number;
name: string;
memoryUsedMiB: number | null;
}
export interface GpuMetrics {
source: "nvidia-smi" | "none";
gpus: GpuDevice[];
processes: GpuProcess[];
}
const SECTION_SEPARATOR = "---";
// The free-text name is queried last so commas inside it cannot shift the
// numeric columns; the parsers rejoin everything past the fixed fields.
const GPU_QUERY_FIELDS = [
"index",
"uuid",
"driver_version",
"utilization.gpu",
"memory.used",
"memory.total",
"temperature.gpu",
"power.draw",
"power.limit",
"fan.speed",
"name",
];
const APPS_QUERY_FIELDS = ["gpu_uuid", "pid", "used_memory", "process_name"];
const NVIDIA_SMI_COMMAND = [
"command -v nvidia-smi >/dev/null 2>&1 || exit 0;",
`nvidia-smi --query-gpu=${GPU_QUERY_FIELDS.join(",")} --format=csv,noheader,nounits 2>/dev/null;`,
`echo ${SECTION_SEPARATOR};`,
`nvidia-smi --query-compute-apps=${APPS_QUERY_FIELDS.join(",")} --format=csv,noheader,nounits 2>/dev/null`,
].join(" ");
const NO_GPU: GpuMetrics = { source: "none", gpus: [], processes: [] };
/** Reads a numeric field; nvidia-smi writes "[N/A]", "[Not Supported]" etc. for missing ones. */
function parseReading(raw: string | undefined, digits = 1): number | null {
const value = raw?.trim();
if (!value || value.startsWith("[")) return null;
return toFixedNum(Number(value), digits);
}
function parseInteger(raw: string | undefined): number | null {
const value = raw?.trim();
return value && /^\d+$/.test(value) ? Number(value) : null;
}
function splitCsvRow(line: string, fixedFields: number): string[] | null {
const parts = line.split(",").map((part) => part.trim());
if (parts.length <= fixedFields) return null;
return [...parts.slice(0, fixedFields), parts.slice(fixedFields).join(", ")];
}
export function parseNvidiaSmiGpus(output: string): GpuDevice[] {
const fixedFields = GPU_QUERY_FIELDS.length - 1;
const gpus: GpuDevice[] = [];
for (const line of output.split("\n")) {
const row = splitCsvRow(line, fixedFields);
if (!row) continue;
const [
rawIndex,
uuid,
driverVersion,
utilization,
memoryUsed,
memoryTotal,
temperature,
powerDraw,
powerLimit,
fan,
name,
] = row;
const index = parseInteger(rawIndex);
if (index === null || !uuid) continue;
const memoryUsedMiB = parseReading(memoryUsed, 0);
const memoryTotalMiB = parseReading(memoryTotal, 0);
gpus.push({
index,
uuid,
name,
driverVersion:
driverVersion && !driverVersion.startsWith("[") ? driverVersion : null,
utilizationPercent: parseReading(utilization),
memoryUsedMiB,
memoryTotalMiB,
memoryPercent:
memoryUsedMiB !== null &&
memoryTotalMiB !== null &&
memoryTotalMiB !== 0
? toFixedNum((memoryUsedMiB / memoryTotalMiB) * 100, 1)
: null,
temperatureCelsius: parseReading(temperature),
powerDrawWatts: parseReading(powerDraw),
powerLimitWatts: parseReading(powerLimit),
fanPercent: parseReading(fan),
});
}
return gpus;
}
export function parseNvidiaSmiProcesses(
output: string,
gpus: GpuDevice[],
): GpuProcess[] {
const indexByUuid = new Map(gpus.map((gpu) => [gpu.uuid, gpu.index]));
const processes: GpuProcess[] = [];
for (const line of output.split("\n")) {
const row = splitCsvRow(line, APPS_QUERY_FIELDS.length - 1);
if (!row) continue;
const [uuid, rawPid, memoryUsed, name] = row;
const pid = parseInteger(rawPid);
if (pid === null) continue;
processes.push({
gpuIndex: indexByUuid.get(uuid) ?? null,
pid,
name,
memoryUsedMiB: parseReading(memoryUsed, 0),
});
}
return processes;
}
export function parseNvidiaSmiOutput(stdout: string): GpuMetrics {
const lines = stdout.split("\n");
const separator = lines.findIndex(
(line) => line.trim() === SECTION_SEPARATOR,
);
const gpuSection =
separator === -1 ? stdout : lines.slice(0, separator).join("\n");
const appsSection =
separator === -1 ? "" : lines.slice(separator + 1).join("\n");
const gpus = parseNvidiaSmiGpus(gpuSection);
if (gpus.length === 0) return NO_GPU;
return {
source: "nvidia-smi",
gpus,
processes: parseNvidiaSmiProcesses(appsSection, gpus),
};
}
export async function collectGpuMetrics(client: Client): Promise<GpuMetrics> {
try {
// nvidia-smi can take a few seconds on hosts without persistence mode.
const result = await execCommand(client, NVIDIA_SMI_COMMAND, 15000);
return parseNvidiaSmiOutput(result.stdout);
} catch {
// expected when the host has no NVIDIA driver or the query times out
return NO_GPU;
}
}
@@ -0,0 +1,149 @@
import type { Client } from "ssh2";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
export interface LoginRecord {
user: string;
ip: string;
time: string;
status: "success" | "failed";
}
export interface LoginStats {
recentLogins: LoginRecord[];
failedLogins: LoginRecord[];
totalLogins: number;
uniqueIPs: number;
}
export async function collectLoginStats(client: Client): Promise<LoginStats> {
const recentLogins: LoginRecord[] = [];
const failedLogins: LoginRecord[] = [];
const ipSet = new Set<string>();
try {
const lastOut = await execCommand(
client,
"last -n 20 -F -w | grep -v 'reboot' | grep -v 'wtmp' | head -20",
);
const lastLines = lastOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
for (const line of lastLines) {
const parts = line.split(/\s+/);
if (parts.length >= 10) {
const user = parts[0];
const tty = parts[1];
const ip =
parts[2] === ":" || parts[2].startsWith(":") ? "local" : parts[2];
const timeStart = parts.indexOf(
parts.find((p) => /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/.test(p)) || "",
);
if (timeStart > 0 && parts.length > timeStart + 4) {
const timeStr = parts.slice(timeStart, timeStart + 5).join(" ");
if (user && user !== "wtmp" && tty !== "system") {
let parsedTime: string;
try {
const date = new Date(timeStr);
parsedTime = isNaN(date.getTime())
? timeStr || "unknown"
: date.toISOString();
} catch {
parsedTime = timeStr || "unknown";
}
recentLogins.push({
user,
ip,
time: parsedTime,
status: "success",
});
if (ip !== "local") {
ipSet.add(ip);
}
}
}
}
}
} catch {
// expected
}
try {
const failedOut = await execCommand(
client,
"grep 'Failed password' /var/log/auth.log 2>/dev/null | tail -10 || grep 'authentication failure' /var/log/secure 2>/dev/null | tail -10 || echo ''",
);
const failedLines = failedOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
for (const line of failedLines) {
let user = "unknown";
let ip = "unknown";
let timeStr = "";
const userMatch = line.match(/for (?:invalid user )?(\S+)/);
if (userMatch) {
user = userMatch[1];
}
const ipMatch = line.match(/from (\d+\.\d+\.\d+\.\d+)/);
if (ipMatch) {
ip = ipMatch[1];
}
const dateMatch = line.match(/^(\w+)\s+(\d+)\s+(\d+:\d+:\d+)/);
if (dateMatch) {
const [, month, day, time] = dateMatch;
const now = new Date();
const currentYear = now.getFullYear();
const candidate = new Date(`${month} ${day}, ${currentYear} ${time}`);
if (!isNaN(candidate.getTime()) && candidate > now) {
// If parsed date is in the future, it's from last year
timeStr = `${month} ${day}, ${currentYear - 1} ${time}`;
} else {
timeStr = `${month} ${day}, ${currentYear} ${time}`;
}
}
if (user && ip) {
let parsedTime: string;
try {
const date = timeStr ? new Date(timeStr) : null;
parsedTime =
date && !isNaN(date.getTime())
? date.toISOString()
: timeStr || "unknown";
} catch {
parsedTime = timeStr || "unknown";
}
failedLogins.push({
user,
ip,
time: parsedTime,
status: "failed",
});
if (ip !== "unknown") {
ipSet.add(ip);
}
}
}
} catch {
// expected
}
return {
recentLogins: recentLogins.slice(0, 10),
failedLogins: failedLogins.slice(0, 10),
totalLogins: recentLogins.length,
uniqueIPs: ipSet.size,
};
}
@@ -0,0 +1,151 @@
import type { Client } from "ssh2";
import {
execCommand,
execPowerShell,
toFixedNum,
kibToGiB,
type HostPlatform,
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
async function collectDarwinMemoryMetrics(client: Client): Promise<{
percent: number | null;
usedGiB: number | null;
totalGiB: number | null;
}> {
let memPercent: number | null = null;
let usedGiB: number | null = null;
let totalGiB: number | null = null;
try {
const [vmStatOut, memSizeOut] = await Promise.all([
execCommand(client, "vm_stat"),
execCommand(client, "sysctl -n hw.memsize"),
]);
const totalBytes = Number((memSizeOut.stdout || "").trim());
const pageSizeMatch = vmStatOut.stdout.match(/page size of (\d+) bytes/);
const pageSize = pageSizeMatch ? Number(pageSizeMatch[1]) : 4096;
const getPages = (key: string): number | null => {
const line = vmStatOut.stdout
.split("\n")
.find((l) => l.trim().startsWith(key));
if (!line) return null;
const m = line.match(/(\d+)\./);
return m ? Number(m[1]) : null;
};
const active = getPages("Pages active");
const wired = getPages("Pages wired down");
const compressed = getPages("Pages occupied by compressor");
if (
Number.isFinite(totalBytes) &&
totalBytes > 0 &&
active !== null &&
wired !== null
) {
const usedPages = active + wired + (compressed ?? 0);
const usedBytes = usedPages * pageSize;
memPercent = Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100));
usedGiB = kibToGiB(usedBytes / 1024);
totalGiB = kibToGiB(totalBytes / 1024);
}
} catch {
memPercent = null;
usedGiB = null;
totalGiB = null;
}
return {
percent: toFixedNum(memPercent, 0),
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null,
};
}
// Win32_OperatingSystem reports TotalVisibleMemorySize/FreePhysicalMemory in KB.
async function collectWindowsMemoryMetrics(client: Client): Promise<{
percent: number | null;
usedGiB: number | null;
totalGiB: number | null;
}> {
let memPercent: number | null = null;
let usedGiB: number | null = null;
let totalGiB: number | null = null;
try {
const { stdout } = await execPowerShell(
client,
"$os=Get-CimInstance Win32_OperatingSystem; [PSCustomObject]@{total=$os.TotalVisibleMemorySize; free=$os.FreePhysicalMemory} | ConvertTo-Json -Compress",
);
const parsed = JSON.parse(stdout.trim());
const totalKb = Number(parsed?.total);
const freeKb = Number(parsed?.free);
if (Number.isFinite(totalKb) && Number.isFinite(freeKb) && totalKb > 0) {
const usedKb = totalKb - freeKb;
memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100));
usedGiB = kibToGiB(usedKb);
totalGiB = kibToGiB(totalKb);
}
} catch {
memPercent = null;
usedGiB = null;
totalGiB = null;
}
return {
percent: toFixedNum(memPercent, 0),
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null,
};
}
export async function collectMemoryMetrics(
client: Client,
platform?: HostPlatform,
): Promise<{
percent: number | null;
usedGiB: number | null;
totalGiB: number | null;
}> {
if (platform === "darwin") {
return collectDarwinMemoryMetrics(client);
}
if (platform === "windows") {
return collectWindowsMemoryMetrics(client);
}
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 {
memPercent = null;
usedGiB = null;
totalGiB = null;
}
return {
percent: toFixedNum(memPercent, 0),
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null,
};
}
@@ -0,0 +1,421 @@
import type { Client } from "ssh2";
import {
execCommand,
execPowerShell,
type HostPlatform,
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
export interface NetworkCounters {
rx: string;
tx: string;
}
export function parseNetworkCounters(
output: string,
): Map<string, NetworkCounters> {
const counters = new Map<string, NetworkCounters>();
for (const line of output.split("\n").slice(2)) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 10) {
counters.set(parts[0].replace(":", ""), {
rx: parts[1],
tx: parts[9],
});
}
}
return counters;
}
export function counterRate(
before: string | undefined,
after: string | undefined,
elapsedSeconds: number,
): number | null {
const first = Number(before);
const second = Number(after);
if (
!Number.isFinite(first) ||
!Number.isFinite(second) ||
second < first ||
elapsedSeconds <= 0
) {
return null;
}
return Math.round((second - first) / elapsedSeconds);
}
/**
* Rate is derived from the previous poll's counters rather than a same-poll
* double-sample, so a poll no longer has to block for a fixed settle window.
* Keyed by host, since counters from one host are meaningless against another.
*/
const previousSamples = new Map<
number,
{ counters: Map<string, NetworkCounters>; timestamp: number }
>();
const previousWindowsSamples = new Map<
number,
{ rows: WindowsAdapterRow[]; timestamp: number }
>();
export function clearNetworkSampleCache(hostId?: number): void {
if (hostId === undefined) {
previousSamples.clear();
previousWindowsSamples.clear();
return;
}
previousSamples.delete(hostId);
previousWindowsSamples.delete(hostId);
}
export function parseDarwinIfconfig(
output: string,
): Map<string, { ip: string; state: string }> {
const map = new Map<string, { ip: string; state: string }>();
let current: string | null = null;
for (const rawLine of output.split("\n")) {
const ifaceMatch = rawLine.match(/^(\S+):\s*flags=\d+<([^>]*)>/);
if (ifaceMatch) {
current = ifaceMatch[1] === "lo0" ? null : ifaceMatch[1];
if (current) {
const state = ifaceMatch[2].includes("UP") ? "UP" : "DOWN";
map.set(current, { ip: "", state });
}
continue;
}
if (!current) continue;
const inetMatch = rawLine.match(/^\s+inet\s+(\d+\.\d+\.\d+\.\d+)/);
if (inetMatch) {
const existing = map.get(current);
if (existing && !existing.ip) existing.ip = inetMatch[1];
}
}
return map;
}
export function parseDarwinNetstat(
output: string,
): Map<string, NetworkCounters> {
const counters = new Map<string, NetworkCounters>();
for (const line of output.split("\n").slice(1)) {
const parts = line.trim().split(/\s+/);
if (parts.length < 10) continue;
const name = parts[0];
if (name === "lo0" || !parts[2]?.startsWith("<Link")) continue;
if (!counters.has(name)) {
counters.set(name, { rx: parts[6], tx: parts[9] });
}
}
return counters;
}
async function collectDarwinNetworkMetrics(
client: Client,
hostId?: number,
): Promise<{
interfaces: Array<{
name: string;
ip: string;
state: string;
rxBytes: string | null;
txBytes: string | null;
rxRateBps: number | null;
txRateBps: number | null;
}>;
}> {
const interfaces: Array<{
name: string;
ip: string;
state: string;
rxBytes: string | null;
txBytes: string | null;
rxRateBps: number | null;
txRateBps: number | null;
}> = [];
try {
const ifconfigOut = await execCommand(client, "ifconfig -a 2>/dev/null");
const ifMap = parseDarwinIfconfig(ifconfigOut.stdout);
try {
const readAt = Date.now();
const netstatOut = await execCommand(client, "netstat -ib 2>/dev/null");
const current = parseDarwinNetstat(netstatOut.stdout);
const previous =
hostId !== undefined ? previousSamples.get(hostId) : undefined;
const elapsedSeconds = previous
? (readAt - previous.timestamp) / 1000
: 0;
if (ifMap.size === 0) {
for (const name of current.keys()) {
ifMap.set(name, { ip: "", state: "UNKNOWN" });
}
}
for (const [name, data] of ifMap.entries()) {
const c = current.get(name);
const p = previous?.counters.get(name);
interfaces.push({
name,
ip: data.ip,
state: data.state,
rxBytes: c?.rx ?? null,
txBytes: c?.tx ?? null,
rxRateBps: p ? counterRate(p.rx, c?.rx, elapsedSeconds) : null,
txRateBps: p ? counterRate(p.tx, c?.tx, elapsedSeconds) : null,
});
}
if (hostId !== undefined) {
previousSamples.set(hostId, { counters: current, timestamp: readAt });
}
} catch {
for (const [name, data] of ifMap.entries()) {
interfaces.push({
name,
ip: data.ip,
state: data.state,
rxBytes: null,
txBytes: null,
rxRateBps: null,
txRateBps: null,
});
}
}
} catch {
// expected
}
return { interfaces };
}
interface WindowsAdapterRow {
name: string;
ip: string;
state: string;
rx: string;
tx: string;
}
const WINDOWS_ADAPTER_SCRIPT =
"Get-NetAdapter | Where-Object {$_.Status -eq 'Up'} | ForEach-Object {" +
" $stats = Get-NetAdapterStatistics -Name $_.Name -ErrorAction SilentlyContinue;" +
" $addr = (Get-NetIPAddress -InterfaceIndex $_.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue | Select-Object -First 1).IPAddress;" +
" [PSCustomObject]@{name=$_.Name; ip=$addr; state='UP'; rx=$stats.ReceivedBytes; tx=$stats.SentBytes}" +
" } | ConvertTo-Json -Compress";
export function parseWindowsAdapterJson(output: string): WindowsAdapterRow[] {
const trimmed = output.trim();
if (!trimmed) return [];
try {
const parsed = JSON.parse(trimmed);
const rows = Array.isArray(parsed) ? parsed : [parsed];
return rows
.filter((row): row is Record<string, unknown> => Boolean(row?.name))
.map((row) => ({
name: String(row.name),
ip: row.ip ? String(row.ip) : "",
state: String(row.state ?? "UNKNOWN"),
rx: String(row.rx ?? ""),
tx: String(row.tx ?? ""),
}));
} catch {
return [];
}
}
async function collectWindowsNetworkMetrics(
client: Client,
hostId?: number,
): Promise<{
interfaces: Array<{
name: string;
ip: string;
state: string;
rxBytes: string | null;
txBytes: string | null;
rxRateBps: number | null;
txRateBps: number | null;
}>;
}> {
const interfaces: Array<{
name: string;
ip: string;
state: string;
rxBytes: string | null;
txBytes: string | null;
rxRateBps: number | null;
txRateBps: number | null;
}> = [];
try {
const readAt = Date.now();
const result = await execPowerShell(client, WINDOWS_ADAPTER_SCRIPT);
const currentRows = parseWindowsAdapterJson(result.stdout);
const previous =
hostId !== undefined ? previousWindowsSamples.get(hostId) : undefined;
const elapsedSeconds = previous ? (readAt - previous.timestamp) / 1000 : 0;
const previousMap = new Map(
(previous?.rows ?? []).map((row) => [row.name, row]),
);
for (const row of currentRows) {
const previousRow = previousMap.get(row.name);
interfaces.push({
name: row.name,
ip: row.ip,
state: row.state,
rxBytes: row.rx || null,
txBytes: row.tx || null,
rxRateBps: previousRow
? counterRate(previousRow.rx, row.rx, elapsedSeconds)
: null,
txRateBps: previousRow
? counterRate(previousRow.tx, row.tx, elapsedSeconds)
: null,
});
}
if (hostId !== undefined) {
previousWindowsSamples.set(hostId, {
rows: currentRows,
timestamp: readAt,
});
}
} catch {
// expected
}
return { interfaces };
}
export async function collectNetworkMetrics(
client: Client,
platform?: HostPlatform,
hostId?: number,
): Promise<{
interfaces: Array<{
name: string;
ip: string;
state: string;
rxBytes: string | null;
txBytes: string | null;
rxRateBps: number | null;
txRateBps: number | null;
}>;
}> {
if (platform === "darwin") {
return collectDarwinNetworkMetrics(client, hostId);
}
if (platform === "windows") {
return collectWindowsNetworkMetrics(client, hostId);
}
const interfaces: Array<{
name: string;
ip: string;
state: string;
rxBytes: string | null;
txBytes: string | null;
rxRateBps: number | null;
txRateBps: number | null;
}> = [];
try {
const ifconfigOut = await execCommand(
client,
"ip -o addr show 2>/dev/null | awk '{print $2,$4}' | grep -v '^lo' || true",
);
const netStatOut = await execCommand(
client,
"ip -o link show 2>/dev/null | awk '{gsub(/:/, \"\", $2); print $2,$9}' || true",
);
const addrs = ifconfigOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const states = netStatOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const ifMap = new Map<string, { ip: string; state: string }>();
for (const line of addrs) {
const parts = line.split(/\s+/);
if (parts.length >= 2) {
const name = parts[0];
const ip = parts[1].split("/")[0];
if (!ifMap.has(name)) ifMap.set(name, { ip, state: "UNKNOWN" });
}
}
for (const line of states) {
const parts = line.split(/\s+/);
if (parts.length >= 2) {
const name = parts[0];
if (name === "lo") continue;
const state = parts[1];
const existing = ifMap.get(name);
if (existing) {
existing.state = state;
} else {
ifMap.set(name, { ip: "", state });
}
}
}
try {
const readAt = Date.now();
const procNet = await execCommand(client, "cat /proc/net/dev");
const rxTxMap = parseNetworkCounters(procNet.stdout);
const previous =
hostId !== undefined ? previousSamples.get(hostId) : undefined;
const elapsedSeconds = previous
? (readAt - previous.timestamp) / 1000
: 0;
if (ifMap.size === 0) {
for (const name of rxTxMap.keys()) {
if (name !== "lo") ifMap.set(name, { ip: "", state: "UNKNOWN" });
}
}
for (const [name, data] of ifMap.entries()) {
const rxTx = rxTxMap.get(name);
const previousCounters = previous?.counters.get(name);
interfaces.push({
name,
ip: data.ip,
state: data.state,
rxBytes: rxTx?.rx ?? null,
txBytes: rxTx?.tx ?? null,
rxRateBps: previousCounters
? counterRate(previousCounters.rx, rxTx?.rx, elapsedSeconds)
: null,
txRateBps: previousCounters
? counterRate(previousCounters.tx, rxTx?.tx, elapsedSeconds)
: null,
});
}
if (hostId !== undefined) {
previousSamples.set(hostId, {
counters: rxTxMap,
timestamp: readAt,
});
}
} catch {
for (const [name, data] of ifMap.entries()) {
interfaces.push({
name,
ip: data.ip,
state: data.state,
rxBytes: null,
txBytes: null,
rxRateBps: null,
txRateBps: null,
});
}
}
} catch {
// expected
}
return { interfaces };
}
@@ -0,0 +1,154 @@
import type { Client } from "ssh2";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
import type {
PortsMetrics,
ListeningPort,
} from "../../../../src/types/stats-widgets.js";
function parseSsOutput(output: string): ListeningPort[] {
const ports: ListeningPort[] = [];
const lines = output.split("\n").slice(1);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const parts = trimmed.split(/\s+/);
if (parts.length < 5) continue;
const protocol = parts[0]?.toLowerCase();
if (protocol !== "tcp" && protocol !== "udp") continue;
const state = parts[1];
const localAddr = parts[4];
if (!localAddr) continue;
const lastColon = localAddr.lastIndexOf(":");
if (lastColon === -1) continue;
const address = localAddr.substring(0, lastColon);
const portStr = localAddr.substring(lastColon + 1);
const port = parseInt(portStr, 10);
if (isNaN(port)) continue;
const portEntry: ListeningPort = {
protocol: protocol as "tcp" | "udp",
localAddress: address.replace(/^\[|\]$/g, ""),
localPort: port,
state: protocol === "tcp" ? state : undefined,
};
const processInfo = parts[6];
if (processInfo && processInfo.startsWith("users:")) {
const pidMatch = processInfo.match(/pid=(\d+)/);
const nameMatch = processInfo.match(/\("([^"]+)"/);
if (pidMatch) portEntry.pid = parseInt(pidMatch[1], 10);
if (nameMatch) portEntry.process = nameMatch[1];
}
ports.push(portEntry);
}
return ports;
}
function parseNetstatOutput(output: string): ListeningPort[] {
const ports: ListeningPort[] = [];
const lines = output.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const parts = trimmed.split(/\s+/);
if (parts.length < 4) continue;
const proto = parts[0]?.toLowerCase();
if (!proto) continue;
let protocol: "tcp" | "udp";
if (proto.startsWith("tcp")) {
protocol = "tcp";
} else if (proto.startsWith("udp")) {
protocol = "udp";
} else {
continue;
}
const localAddr = parts[3];
if (!localAddr) continue;
const lastColon = localAddr.lastIndexOf(":");
if (lastColon === -1) continue;
const address = localAddr.substring(0, lastColon);
const portStr = localAddr.substring(lastColon + 1);
const port = parseInt(portStr, 10);
if (isNaN(port)) continue;
const portEntry: ListeningPort = {
protocol,
localAddress: address,
localPort: port,
};
if (protocol === "tcp" && parts.length >= 6) {
portEntry.state = parts[5];
}
const pidProgram = parts[parts.length - 1];
if (pidProgram && pidProgram.includes("/")) {
const [pidStr, process] = pidProgram.split("/");
const pid = parseInt(pidStr, 10);
if (!isNaN(pid)) portEntry.pid = pid;
if (process) portEntry.process = process;
}
ports.push(portEntry);
}
return ports;
}
export async function collectPortsMetrics(
client: Client,
): Promise<PortsMetrics> {
try {
const ssResult = await execCommand(client, "ss -tulnp 2>/dev/null", 15000);
if (ssResult.stdout && ssResult.stdout.includes("Local")) {
const ports = parseSsOutput(ssResult.stdout);
return {
source: "ss",
ports: ports.sort((a, b) => a.localPort - b.localPort),
};
}
const netstatResult = await execCommand(
client,
"netstat -tulnp 2>/dev/null",
15000,
);
if (netstatResult.stdout && netstatResult.stdout.includes("Local")) {
const ports = parseNetstatOutput(netstatResult.stdout);
return {
source: "netstat",
ports: ports.sort((a, b) => a.localPort - b.localPort),
};
}
return {
source: "none",
ports: [],
};
} catch {
return {
source: "none",
ports: [],
};
}
}
@@ -0,0 +1,68 @@
import type { Client } from "ssh2";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
export async function collectProcessesMetrics(client: Client): Promise<{
total: number | null;
running: number | null;
top: Array<{
pid: string;
user: string;
cpu: string;
mem: string;
command: string;
}>;
}> {
let totalProcesses: number | null = null;
let runningProcesses: number | null = null;
const topProcesses: Array<{
pid: string;
user: string;
cpu: string;
mem: string;
command: string;
}> = [];
try {
const psOut = await execCommand(
client,
"(ps aux --sort=-%cpu 2>/dev/null || ps aux) | head -n 11",
);
const psLines = psOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
if (psLines.length > 1) {
for (let i = 1; i < Math.min(psLines.length, 11); i++) {
const parts = psLines[i].split(/\s+/);
if (parts.length >= 11) {
const cpuVal = Number(parts[2]);
const memVal = Number(parts[3]);
topProcesses.push({
pid: parts[1],
user: parts[0],
cpu: Number.isFinite(cpuVal) ? cpuVal.toString() : "0",
mem: Number.isFinite(memVal) ? memVal.toString() : "0",
command: parts.slice(10).join(" ").substring(0, 50),
});
}
}
}
const procCount = await execCommand(client, "ps aux | wc -l");
const runningCount = await execCommand(client, "ps aux | grep -c ' R '");
const totalCount = Number(procCount.stdout.trim()) - 1;
totalProcesses = Number.isFinite(totalCount) ? totalCount : null;
const runningCount2 = Number(runningCount.stdout.trim());
runningProcesses = Number.isFinite(runningCount2) ? runningCount2 : null;
} catch {
// expected
}
return {
total: totalProcesses,
running: runningProcesses,
top: topProcesses,
};
}
@@ -0,0 +1,69 @@
import type { Client } from "ssh2";
import {
execCommand,
execPowerShell,
type HostPlatform,
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
async function collectWindowsSystemMetrics(client: Client): Promise<{
hostname: string | null;
kernel: string | null;
os: string | null;
}> {
let hostname: string | null = null;
let kernel: string | null = null;
let os: string | null = null;
try {
const { stdout } = await execPowerShell(
client,
"$os=Get-CimInstance Win32_OperatingSystem; [PSCustomObject]@{hostname=$env:COMPUTERNAME; kernel=[System.Environment]::OSVersion.Version.ToString(); os=$os.Caption} | ConvertTo-Json -Compress",
);
const parsed = JSON.parse(stdout.trim());
hostname = parsed?.hostname ? String(parsed.hostname) : null;
kernel = parsed?.kernel ? String(parsed.kernel) : null;
os = parsed?.os ? String(parsed.os).trim() : null;
} catch {
// expected
}
return { hostname, kernel, os };
}
export async function collectSystemMetrics(
client: Client,
platform?: HostPlatform,
): Promise<{
hostname: string | null;
kernel: string | null;
os: string | null;
}> {
if (platform === "windows") {
return collectWindowsSystemMetrics(client);
}
let hostname: string | null = null;
let kernel: string | null = null;
let os: string | null = null;
try {
const hostnameOut = await execCommand(client, "hostname");
const kernelOut = await execCommand(client, "uname -r");
const osOut = await execCommand(
client,
"command -v sw_vers >/dev/null 2>&1 && echo \"$(sw_vers -productName) $(sw_vers -productVersion)\" || (cat /etc/os-release 2>/dev/null | grep '^PRETTY_NAME=' | cut -d'\"' -f2)",
);
hostname = hostnameOut.stdout.trim() || null;
kernel = kernelOut.stdout.trim() || null;
os = osOut.stdout.trim() || null;
} catch {
// expected
}
return {
hostname,
kernel,
os,
};
}
@@ -0,0 +1,127 @@
import type { Client } from "ssh2";
import {
execCommand,
toFixedNum,
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
export interface TemperatureSensor {
label: string;
celsius: number;
}
export interface TemperatureMetrics {
source: "sysfs" | "sensors" | "none";
highestCelsius: number | null;
sensors: TemperatureSensor[];
}
function normalizeSensor(
label: string,
celsius: number,
): TemperatureSensor | null {
const cleanLabel = label.trim().replace(/\s+/g, " ");
if (!cleanLabel || !Number.isFinite(celsius)) return null;
return {
label: cleanLabel,
celsius: toFixedNum(celsius, 1) ?? celsius,
};
}
function summarizeSensors(
source: TemperatureMetrics["source"],
sensors: TemperatureSensor[],
): TemperatureMetrics {
const highest = sensors.reduce<number | null>(
(max, sensor) =>
max === null ? sensor.celsius : Math.max(max, sensor.celsius),
null,
);
return {
source: sensors.length > 0 ? source : "none",
highestCelsius:
highest === null ? null : (toFixedNum(highest, 1) ?? highest),
sensors,
};
}
export function parseSysfsThermalOutput(output: string): TemperatureSensor[] {
return output
.split("\n")
.map((line) => {
const [label, rawValue] = line.split("\t");
const value = Number(rawValue);
if (!label || !Number.isFinite(value)) return null;
const celsius = Math.abs(value) >= 1000 ? value / 1000 : value;
return normalizeSensor(label, celsius);
})
.filter((sensor): sensor is TemperatureSensor => sensor !== null);
}
export function parseSensorsOutput(output: string): TemperatureSensor[] {
const seen = new Set<string>();
const sensors: TemperatureSensor[] = [];
let device = "";
for (const line of output.split("\n")) {
const trimmed = line.trim();
if (trimmed && !line.startsWith(" ") && !trimmed.includes(":")) {
device = trimmed;
continue;
}
const match = line.match(/^\s*([^:]+):\s*([+-]?\d+(?:\.\d+)?)\s*°?C\b/i);
if (!match) continue;
const label = device ? `${device}: ${match[1]}` : match[1];
const sensor = normalizeSensor(label, Number(match[2]));
if (!sensor) continue;
const key = `${sensor.label.toLowerCase()}:${sensor.celsius}`;
if (seen.has(key)) continue;
seen.add(key);
sensors.push(sensor);
}
return sensors;
}
export async function collectTemperatureMetrics(
client: Client,
): Promise<TemperatureMetrics> {
try {
const sysfs = await execCommand(
client,
[
"for zone in /sys/class/thermal/thermal_zone*; do",
'[ -r "$zone/temp" ] || continue;',
'label="$(cat "$zone/type" 2>/dev/null || basename "$zone")";',
'value="$(cat "$zone/temp" 2>/dev/null || true)";',
'[ -n "$value" ] && printf "%s\\t%s\\n" "$label" "$value";',
"done",
].join(" "),
10000,
);
const sensors = parseSysfsThermalOutput(sysfs.stdout);
if (sensors.length > 0) {
return summarizeSensors("sysfs", sensors);
}
} catch {
// expected on systems without readable thermal zones
}
try {
const lmSensors = await execCommand(client, "sensors 2>/dev/null", 10000);
const sensors = parseSensorsOutput(lmSensors.stdout);
if (sensors.length > 0) {
return summarizeSensors("sensors", sensors);
}
} catch {
// expected when lm-sensors is not installed
}
return {
source: "none",
highestCelsius: null,
sensors: [],
};
}
@@ -0,0 +1,105 @@
import type { Client } from "ssh2";
import {
execCommand,
execPowerShell,
type HostPlatform,
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
function formatUptime(uptimeSeconds: number): string {
const days = Math.floor(uptimeSeconds / 86400);
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
return `${days}d ${hours}h ${minutes}m`;
}
async function collectDarwinUptimeMetrics(client: Client): Promise<{
seconds: number | null;
formatted: string | null;
}> {
let uptimeSeconds: number | null = null;
let uptimeFormatted: string | null = null;
try {
const bootTimeOut = await execCommand(client, "sysctl -n kern.boottime");
// "{ sec = 1700000000, usec = 123456 } Thu Nov 16 ..."
const match = bootTimeOut.stdout.match(/sec\s*=\s*(\d+)/);
if (match) {
const bootSeconds = Number(match[1]);
if (Number.isFinite(bootSeconds)) {
uptimeSeconds = Math.max(0, Date.now() / 1000 - bootSeconds);
uptimeFormatted = formatUptime(uptimeSeconds);
}
}
} catch {
// expected
}
return {
seconds: uptimeSeconds,
formatted: uptimeFormatted,
};
}
async function collectWindowsUptimeMetrics(client: Client): Promise<{
seconds: number | null;
formatted: string | null;
}> {
let uptimeSeconds: number | null = null;
let uptimeFormatted: string | null = null;
try {
const { stdout } = await execPowerShell(
client,
"$os=Get-CimInstance Win32_OperatingSystem; [PSCustomObject]@{seconds=((Get-Date) - $os.LastBootUpTime).TotalSeconds} | ConvertTo-Json -Compress",
);
const parsed = JSON.parse(stdout.trim());
const seconds = Number(parsed?.seconds);
if (Number.isFinite(seconds)) {
uptimeSeconds = Math.max(0, seconds);
uptimeFormatted = formatUptime(uptimeSeconds);
}
} catch {
// expected
}
return {
seconds: uptimeSeconds,
formatted: uptimeFormatted,
};
}
export async function collectUptimeMetrics(
client: Client,
platform?: HostPlatform,
): Promise<{
seconds: number | null;
formatted: string | null;
}> {
if (platform === "darwin") {
return collectDarwinUptimeMetrics(client);
}
if (platform === "windows") {
return collectWindowsUptimeMetrics(client);
}
let uptimeSeconds: number | null = null;
let uptimeFormatted: string | null = null;
try {
const uptimeOut = await execCommand(client, "cat /proc/uptime");
const uptimeParts = uptimeOut.stdout.trim().split(/\s+/);
if (uptimeParts.length >= 1) {
uptimeSeconds = Number(uptimeParts[0]);
if (Number.isFinite(uptimeSeconds)) {
uptimeFormatted = formatUptime(uptimeSeconds);
}
}
} catch {
// expected
}
return {
seconds: uptimeSeconds,
formatted: uptimeFormatted,
};
}