mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
422 lines
11 KiB
TypeScript
422 lines
11 KiB
TypeScript
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 };
|
|
}
|