feat(monitor): shared Linux disk I/O and one-second refresh (#3870)

This commit is contained in:
陈大猫 authored and GitHub committed 2026-10-23 12:19:10 +08:00
1 parent e9cd311936
commit 9766f21ecf
21 files changed
+250 -4

No files matched your search

+3 -1
View File
@@ -181,7 +181,9 @@ Organize and navigate your hosts using the view that best fits the moment: grid
## Monitoring View
Inspect CPU, memory, disk, network, and latency readings, with GPU details where available. Choose visible columns, resize them, adjust refresh controls, and open a terminal directly from a monitored host.
Inspect CPU, memory, disk, network, and latency readings, with GPU details where available. Choose visible columns, resize them, select refresh intervals down to 1 second, and open a terminal directly from a monitored host.
Disk I/O shows aggregate read/write throughput for Linux hardware-backed block devices, without counting partitions or stacked virtual devices twice. Rates use two consecutive samples; `--` means unavailable or warming up, not zero activity. Disk I/O is currently unavailable on macOS; existing macOS monitoring is unchanged. The terminal system overview uses the same shared measurements.
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
+5
View File
@@ -1,4 +1,9 @@
export const enMonitorMessages = {
"monitor.diskIo": "Disk I/O",
"monitor.diskRead": "Read",
"monitor.diskWrite": "Write",
"monitor.diskIoHint": "Aggregate Linux hardware-backed block-device I/O, excluding partitions and stacked virtual devices. Unavailable until two valid samples; not supported on macOS.",
"monitor.actions": "Panel actions",
"monitor.unavailable": "Monitoring is unavailable. Try starting again when the connection is restored.",
"monitor.username": "Username",
+5
View File
@@ -1,4 +1,9 @@
export const esMonitorMessages = {
"monitor.diskIo": "E/S de disco",
"monitor.diskRead": "Lectura",
"monitor.diskWrite": "Escritura",
"monitor.diskIoHint": "E/S agregada de dispositivos de bloques físicos de Linux, sin contar particiones ni capas virtuales. No disponible hasta tener dos muestras válidas; no compatible con macOS.",
"monitor.actions": "Acciones del panel",
"monitor.unavailable": "La monitorización no está disponible. Iníciala de nuevo cuando se restablezca la conexión.",
"monitor.username": "Usuario",
+5
View File
@@ -1,4 +1,9 @@
export const ruMonitorMessages = {
"monitor.diskIo": "Дисковый ввод-вывод",
"monitor.diskRead": "Чтение",
"monitor.diskWrite": "Запись",
"monitor.diskIoHint": "Суммарный ввод-вывод физических блочных устройств Linux, без разделов и виртуальных слоёв. Доступен после двух корректных замеров; macOS не поддерживается.",
"monitor.actions": "Действия панели",
"monitor.unavailable": "Мониторинг недоступен. Запустите его снова после восстановления связи.",
"monitor.username": "Пользователь",
@@ -1,4 +1,9 @@
export const zhCNMonitorMessages = {
"monitor.diskIo": "磁盘 I/O",
"monitor.diskRead": "读取",
"monitor.diskWrite": "写入",
"monitor.diskIoHint": "Linux 底层块设备的汇总 I/O,不重复统计分区和叠加的虚拟设备。取得两次有效采样前不可用;暂不支持 macOS。",
"monitor.actions": "面板操作",
"monitor.unavailable": "监控暂不可用,连接恢复后请重新开始。",
"monitor.username": "用户名",
@@ -1,4 +1,9 @@
export const zhTWMonitorMessages = {
"monitor.diskIo": "磁碟 I/O",
"monitor.diskRead": "讀取",
"monitor.diskWrite": "寫入",
"monitor.diskIoHint": "Linux 底層區塊裝置的彙總 I/O,不重複統計分割區與疊加的虛擬裝置。取得兩次有效取樣前無法使用;暫不支援 macOS。",
"monitor.actions": "面板操作",
"monitor.unavailable": "監控暫時無法使用,連線恢復後請重新開始。",
"monitor.username": "使用者名稱",
+4
View File
@@ -54,6 +54,8 @@ export interface ServerStats {
swapTotal: number | null; // Total swap in MB
swapUsed: number | null; // Used swap in MB
topProcesses: ProcessInfo[]; // Top 10 processes by memory
diskReadSpeed?: number | null; // Aggregate Linux hardware-backed block I/O, bytes/sec
diskWriteSpeed?: number | null;
diskPercent: number | null; // Disk usage percentage for root partition
diskUsed: number | null; // Disk used in GB
diskTotal: number | null; // Total disk in GB
@@ -240,6 +242,8 @@ function normalizeServerStats(stats: Partial<ServerStats>): ServerStats {
diskPercent: stats.diskPercent ?? null,
diskUsed: stats.diskUsed ?? null,
diskTotal: stats.diskTotal ?? null,
diskReadSpeed: stats.diskReadSpeed ?? null,
diskWriteSpeed: stats.diskWriteSpeed ?? null,
disks: stats.disks || [],
netRxSpeed: stats.netRxSpeed || 0,
netTxSpeed: stats.netTxSpeed || 0,
@@ -0,0 +1,23 @@
import React from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
function rate(value: number | null | undefined) {
if (value == null || !Number.isFinite(value) || value < 0) return "--";
if (value >= 1024 ** 2) return `${(value / 1024 ** 2).toFixed(1)} MiB/s`;
if (value >= 1024) return `${(value / 1024).toFixed(1)} KiB/s`;
return `${Math.round(value)} B/s`;
}
/** Same shared-sampler rates in monitor cards, tables, and terminal overview. */
export function DiskIoSummary({ stats }: {
stats?: { diskReadSpeed?: number | null; diskWriteSpeed?: number | null } | null;
}) {
const { t } = useI18n();
return (
<div data-disk-io className="flex flex-wrap gap-x-3 gap-y-0.5 text-[10px] tabular-nums text-muted-foreground" title={t("monitor.diskIoHint")}>
<span>{t("monitor.diskIo")}</span>
<span>{t("monitor.diskRead")} {rate(stats?.diskReadSpeed)}</span>
<span>{t("monitor.diskWrite")} {rate(stats?.diskWriteSpeed)}</span>
</div>
);
}
@@ -12,6 +12,7 @@ import { aggregateMountedDiskUsage } from '../../domain/systemDiskUsage';
import { cn } from '../../lib/utils';
import { useServerStats } from '../../application/state/useServerStats';
import { ResourceBar } from './ResourceBar';
import { DiskIoSummary } from './DiskIoSummary';
import {
SystemPanelEmpty,
SystemPanelError,
@@ -335,6 +336,8 @@ export const SystemOverviewTab = memo(function SystemOverviewTab({
/>
</div>
<div className="mt-3"><DiskIoSummary stats={stats} /></div>
<div className="mt-3 grid grid-cols-2 gap-2">
<InfoPill label={t('systemManager.overview.load')} value={formatLoad(stats.loadAverage)} />
<InfoPill label={t('systemManager.overview.uptime')} value={formatDuration(stats.uptimeSeconds, t)} />
+3 -1
View File
@@ -1,4 +1,5 @@
import React from "react";
import { DiskIoSummary } from "../systemManager/DiskIoSummary";
import type { MonitorStats } from "../../domain/monitorRuntime";
import { ResourceBar } from "../systemManager/ResourceBar";
import { useI18n } from "../../application/i18n/I18nProvider";
@@ -15,7 +16,7 @@ export function MonitorDiskUsage({
stats,
limit,
}: {
stats?: (Pick<MonitorStats, "disks" | "diskPercent"> & Partial<Pick<MonitorStats, "diskUsed" | "diskTotal">>) | null;
stats?: (Pick<MonitorStats, "disks" | "diskPercent"> & Partial<Pick<MonitorStats, "diskUsed" | "diskTotal" | "diskReadSpeed" | "diskWriteSpeed">>) | null;
limit?: number;
}) {
const { t } = useI18n();
@@ -30,6 +31,7 @@ export function MonitorDiskUsage({
return (
<TooltipProvider delayDuration={150}>
<div className={limit === 2 ? "min-h-[38px] space-y-1.5" : "space-y-1.5"}>
<DiskIoSummary stats={stats} />
{disks.map((disk, index) => (
<Tooltip key={`${disk.mountPoint}-${index}`}>
<TooltipTrigger asChild>
+16
View File
@@ -186,3 +186,19 @@ for (const view of ["list", "charts"] as const) {
}
});
}
test("disk I/O distinguishes warming up or unsupported from idle and reports both rates", () => {
for (const [rates, expected] of [
[{}, ["Read --", "Write --"]],
[{ diskReadSpeed: null, diskWriteSpeed: null }, ["Read --", "Write --"]],
[{ diskReadSpeed: 0, diskWriteSpeed: 0 }, ["Read 0 B/s", "Write 0 B/s"]],
[{ diskReadSpeed: 1024, diskWriteSpeed: 2097152 }, ["Read 1.0 KiB/s", "Write 2.0 MiB/s"]],
] as const) {
const document = new JSDOM(renderToStaticMarkup(
<I18nProvider locale="en"><MonitorDiskUsage stats={{ disks: [], diskPercent: null, ...rates }} /></I18nProvider>,
)).window.document;
const io = document.querySelector("[data-disk-io]")!;
for (const text of expected) assert.ok(io.textContent?.includes(text));
assert.match(io.getAttribute("title")!, /Linux.*not supported on macOS/);
}
});
+1 -1
View File
@@ -624,7 +624,7 @@ export function MonitorPanels({
<SelectValue>{t("monitor.seconds", { count: panel.interval })}</SelectValue>
</SelectTrigger>
<SelectContent>
{MONITOR_INTERVALS.filter((v) => v >= 3).map((v) => (
{MONITOR_INTERVALS.map((v) => (
<SelectItem key={v} value={String(v)}>
{t("monitor.seconds", { count: v })}
</SelectItem>
@@ -0,0 +1,43 @@
"use strict";
// Read whole hardware-backed block devices only. Partitions live below these
// entries; dm/md/loop/zram layers have no device link and would double-count I/O.
// Linux block-stat sectors are always 512 bytes, regardless of device sector size.
// No iostat dependency, extra exec, sampling sleep, or privileged access is needed.
const DISK_IO_ASSIGNMENT = String.raw`diskio=$(if [ -r /proc/uptime ]; then awk '{printf "%s;",$1}' /proc/uptime; for d in /sys/block/*; do [ -e "$d/device" ] && [ -r "$d/stat" ] || continue; awk -v path="$d" 'NF>=7 {n=split(path,a,"/");printf "%s:%s:%s,",a[n],$3,$7}' "$d/stat"; done; fi)`;
function parseDiskIo(text) {
if (typeof text !== "string") return null;
const [time, counters, ...extra] = text.trim().split(";");
const timestamp = Number(time);
if (!time || extra.length || !Number.isFinite(timestamp) || timestamp < 0 || !counters) return null;
const devices = new Map();
for (const entry of counters.replace(/,$/, "").split(",")) {
const fields = entry.split(":");
const [name, read, write] = fields;
if (fields.length !== 3 || !/^[a-zA-Z0-9_.!-]+$/.test(name) || devices.has(name) || !/^\d+$/.test(read) || !/^\d+$/.test(write)) return null;
const readSectors = Number(read);
const writeSectors = Number(write);
if (!Number.isSafeInteger(readSectors) || !Number.isSafeInteger(writeSectors)) return null;
devices.set(name, { readSectors, writeSectors });
}
return { timestamp, devices };
}
function diskIoRates(current, previous) {
const unavailable = { diskReadSpeed: null, diskWriteSpeed: null };
if (!current || !previous || current.devices.size !== previous.devices.size) return unavailable;
const seconds = current.timestamp - previous.timestamp;
if (!(seconds > 0)) return unavailable;
let read = 0;
let write = 0;
for (const [name, counters] of current.devices) {
const before = previous.devices.get(name);
if (!before || counters.readSectors < before.readSectors || counters.writeSectors < before.writeSectors) return unavailable;
read += (counters.readSectors - before.readSectors) * 512;
write += (counters.writeSectors - before.writeSectors) * 512;
}
return { diskReadSpeed: read / seconds, diskWriteSpeed: write / seconds };
}
module.exports = { DISK_IO_ASSIGNMENT, parseDiskIo, diskIoRates };
@@ -0,0 +1,51 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = require("node:fs");
const { tmpdir } = require("node:os");
const { join } = require("node:path");
const { execFileSync } = require("node:child_process");
const { DISK_IO_ASSIGNMENT, parseDiskIo, diskIoRates } = require("./monitorDiskIo.cjs");
const unknown = { diskReadSpeed: null, diskWriteSpeed: null };
test("disk rates use remote fractional sample time and fixed 512-byte sectors", () => {
const before = parseDiskIo("100.25;sda:100:200,nvme0n1:50:100,");
const after = parseDiskIo("101.75;nvme0n1:80:140,sda:130:220,");
assert.deepEqual(diskIoRates(after, before), { diskReadSpeed: 20480, diskWriteSpeed: 20480 });
assert.deepEqual(diskIoRates(parseDiskIo("102.75;nvme0n1:80:140,sda:130:220,"), after), { diskReadSpeed: 0, diskWriteSpeed: 0 });
});
test("first sample, unavailable counters, reset, and topology changes never look idle", () => {
const before = parseDiskIo("100;sda:100:200,");
assert.deepEqual(diskIoRates(before, null), unknown);
assert.deepEqual(diskIoRates(null, before), unknown);
for (const text of ["100;sda:110:210", "99;sda:110:210", "101;sda:99:210", "101;sda:110:199", "101;sdb:110:210", "101;sda:110:210,sdb:0:0"]) {
assert.deepEqual(diskIoRates(parseDiskIo(text), before), unknown, text);
}
for (const text of [undefined, "", "100;", "NaN;sda:1:2", "100;sda:-1:2", "100;sda:1:NaN", "100;sda:1:2,sda:1:2", "100;sda:9007199254740992:1", "100;sda:1:2:3", "100;sda:1:2;extra"]) {
assert.equal(parseDiskIo(text), null, String(text));
}
});
test("Linux collection includes whole hardware devices, excludes partitions and stacked devices", () => {
const dir = mkdtempSync(join(tmpdir(), "disk-io-"));
try {
const block = join(dir, "block");
mkdirSync(block);
writeFileSync(join(dir, "uptime"), "123.45 100.00\n");
for (const name of ["sda", "nvme0n1", "dm-0", "md0", "loop0", "zram0"]) {
mkdirSync(join(block, name));
writeFileSync(join(block, name, "stat"), "1 2 100 4 5 6 200 8 9 10 11\n");
if (["sda", "nvme0n1"].includes(name)) mkdirSync(join(block, name, "device"));
}
mkdirSync(join(block, "sda", "sda1"));
writeFileSync(join(block, "sda", "sda1", "stat"), "1 2 100 4 5 6 200\n");
const command = DISK_IO_ASSIGNMENT.replaceAll("/proc/uptime", join(dir, "uptime")).replaceAll("/sys/block", block);
const output = execFileSync("sh", ["-c", `${command}; printf '%s' "$diskio"`], { encoding: "utf8" });
const parsed = parseDiskIo(output);
assert.equal(parsed.timestamp, 123.45);
assert.deepEqual([...parsed.devices.keys()].sort(), ["nvme0n1", "sda"]);
assert.deepEqual(parsed.devices.get("sda"), { readSectors: 100, writeSectors: 200 });
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
+10 -1
View File
@@ -1,4 +1,5 @@
/* eslint-disable no-undef */
const { DISK_IO_ASSIGNMENT, parseDiskIo, diskIoRates } = require("./monitorDiskIo.cjs");
const { sampleSharedStats } = require("./sharedStats.cjs");
const { GPU_ASSIGNMENT, parseGpuStats, withCachedMonitorGpu, cacheFullGpuStats, startMonitorGpuProbe } = require("./monitorGpuStats.cjs");
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
@@ -1327,8 +1328,9 @@ function createSessionOpsApi(ctx) {
// polling. Hosts without timeout also omit this optional metric.
// macOS is skipped: powermetrics needs root, which stats must not.
GPU_ASSIGNMENT,
DISK_IO_ASSIGNMENT,
// Output all stats (using CPURAW and PERCORERAW instead of CPU and PERCORE)
`echo "CPURAW:$cpuraw|CORES:$cores|PERCORERAW:$percoreraw|MEMINFO:$meminfo|PROCS:$procs|NET:$net|GPU:$gpustat|HOST:$hostname_value|OS:$osname|KERNEL:$kernel|UPTIME:$uptime|LOAD:$loadavg"`
`echo "CPURAW:$cpuraw|CORES:$cores|PERCORERAW:$percoreraw|MEMINFO:$meminfo|PROCS:$procs|NET:$net|GPU:$gpustat|DISKIO:$diskio|HOST:$hostname_value|OS:$osname|KERNEL:$kernel|UPTIME:$uptime|LOAD:$loadavg"`
].filter(command => !session._monitorStats || (!command.startsWith('procs=') && !command.startsWith('gpustat='))).join('; ');
// Client-side run bound shared by the stats and disk probes; the remote
@@ -1457,6 +1459,7 @@ function createSessionOpsApi(ctx) {
let swapUsed = null;
let topProcesses = []; // Array of { pid, memPercent, command }
let disks = []; // Array of { mountPoint, used, total, percent }
let diskIo = null;
let networkInterfaces = []; // Array of { name, rxBytes, txBytes }
let hostname = "";
let osName = "";
@@ -1473,6 +1476,8 @@ function createSessionOpsApi(ctx) {
// macOS: command reports normalized CPU% directly (no delta needed)
const val = parseFloat(part.substring(4).trim());
if (!isNaN(val)) cpuDirect = Math.min(100, Math.max(0, Math.round(val)));
} else if (part.startsWith('DISKIO:')) {
diskIo = parseDiskIo(part.substring(7));
} else if (part.startsWith('CPURAW:')) {
const rawParts = part.substring(7).trim().split(/\s+/);
if (rawParts.length >= 2) {
@@ -1619,6 +1624,9 @@ function createSessionOpsApi(ctx) {
}
}
const diskRates = diskIoRates(diskIo, session.prevDiskIo);
session.prevDiskIo = diskIo;
// Calculate network speed based on previous reading
const now = Date.now();
const prevNet = session.prevNetStats || { interfaces: [], timestamp: 0 };
@@ -1761,6 +1769,7 @@ function createSessionOpsApi(ctx) {
diskUsed, // Disk used in GB for root partition (backward compat)
diskTotal, // Total disk in GB for root partition (backward compat)
disks, // Array of all mounted disks
...diskRates, // Aggregate physical-device I/O; null until two valid Linux samples
networkReady: prevNet.timestamp > 0 && prevNet.interfaces.length > 0,
netRxSpeed, // Total network receive speed (bytes/sec)
netTxSpeed, // Total network transmit speed (bytes/sec)
@@ -1530,3 +1530,33 @@ test("monitor CPU samples return while an optional GPU driver is still blocked",
await pending;
}
});
test("shared server stats parse Linux disk I/O across samples and leave macOS unavailable", async (t) => {
let now = 100000;
t.mock.method(Date, "now", () => now);
let payload = `${LINUX_STATS}|DISKIO:10.5;sda:100:200,`;
const conn = { exec(command, cb) { cb(null, fakeStream(selectServerStatsFixtureOutput(command, payload))); } };
const session = { conn, type: "ssh", _monitorStats: true, _monitorIntervalMs: 1000 };
const api = makeSessionOps(new Map([["diskio", session]]));
const get = () => api.getServerStats({ sender: {} }, { sessionId: "diskio" });
let result = await get();
assert.equal(result.success, true);
assert.equal(result.stats.diskReadSpeed, null);
assert.equal(result.stats.diskWriteSpeed, null);
now += 1000;
payload = `${LINUX_STATS}|DISKIO:11.5;sda:120:240,`;
result = await get();
assert.equal(result.stats.diskReadSpeed, 10240);
assert.equal(result.stats.diskWriteSpeed, 20480);
now += 1000;
payload = `${LINUX_STATS}|DISKIO:12.5;sda:120:240,`;
result = await get();
assert.equal(result.stats.diskReadSpeed, 0);
assert.equal(result.stats.diskWriteSpeed, 0);
now += 1000;
payload = MACOS_STATS;
result = await get();
assert.equal(result.success, true);
assert.equal(result.stats.diskReadSpeed, null);
assert.equal(result.stats.diskWriteSpeed, null);
});
@@ -83,6 +83,7 @@ async function sampleSharedStats(session, collect, now = Date.now()) {
const companion = session.moshStatsConn || session.etStatsConn;
if (companion && result.success) {
const state = stateFor(companion);
state.context.prevDiskIo = session.prevDiskIo;
state.context.prevNetStats = session.prevNetStats;
state.context.prevCpuStats = session.prevCpuStats;
state.history.latest = result;
@@ -93,3 +93,35 @@ test('a snapshot read before sampling still joins the existing endpoint history'
assert.equal(readSharedStats(monitor).points, readSharedStats(terminal).points);
assert.equal(readSharedStats(monitor).result.stats.cpu, 42);
});
test('one-second requests never overlap and keep disk baselines on the physical transport', async () => {
resetSharedStatsForTests();
const conn = {};
let release;
let calls = 0;
const contexts = [];
const collect = context => {
calls++;
contexts.push(context);
const previous = context.prevDiskIo;
context.prevDiskIo = { timestamp: calls };
return new Promise(resolve => { release = () => resolve({ success: true, stats: { diskReadSpeed: previous ? 512 : null, diskWriteSpeed: previous ? 1024 : null } }); });
};
const session = { conn, _monitorStats: true, _monitorIntervalMs: 1000 };
const first = sampleSharedStats(session, collect, 1000);
const overlapping = sampleSharedStats(session, collect, 2000);
assert.equal(calls, 1);
release();
const [a, b] = await Promise.all([first, overlapping]);
assert.equal(a, b);
assert.equal(a.stats.diskReadSpeed, null);
const next = sampleSharedStats(session, collect, Date.now() + 1001);
assert.equal(calls, 2);
release();
assert.equal((await next).stats.diskReadSpeed, 512);
const reconnect = sampleSharedStats({ ...session, conn: {} }, collect, Date.now() + 2002);
assert.equal(calls, 3);
assert.notEqual(contexts[0], contexts[2]);
release();
assert.equal((await reconnect).stats.diskReadSpeed, null);
});
+2
View File
@@ -119,6 +119,8 @@ function seed(
diskPercent: 35,
diskUsed: 35,
diskTotal: 100,
diskReadSpeed: i === 1 ? null : i === 2 ? 0 : 2 * 1024 ** 2,
diskWriteSpeed: i === 1 ? null : i === 2 ? 0 : 512 * 1024,
disks: Array.from({ length: i === 2 ? 10 : 2 }, (_, j) => ({
mountPoint: j ? `/data-${j}` : "/",
used: 35,
+1
View File
@@ -59,6 +59,7 @@ async function main() {
() => run('document.querySelectorAll("[data-monitor-host]").length > 0'),
"monitoring renders",
);
assert.match(await run('document.querySelector("[data-disk-io]").textContent'), /2.0 MiB\/s/);
const screenshot = async (name) => {
owner.webContents.sendInputEvent({ type: "mouseMove", x: 1, y: 1 });
await wait(200);
+2
View File
@@ -321,6 +321,8 @@ declare global {
memPercent: number;
command: string;
}>;
diskReadSpeed?: number | null; // Linux hardware-backed block devices, bytes/sec; null while unavailable/warming up
diskWriteSpeed?: number | null; // Aggregate without partitions or stacked virtual devices
diskPercent: number | null; // Disk usage percentage for root partition
diskUsed: number | null; // Disk used in GB
diskTotal: number | null; // Total disk in GB