feat(monitor): 优化磁盘IO展示与系统分区告警 [4030] (#4031)

* feat(monitor): 优化磁盘IO展示与系统分区告警 [4030]

* fix(monitor): 修复磁盘监控评审问题 [4030]

* fix(monitor): align Partitions bars with Memory; hide bare / label

Label-above-bar for partition list; single-root `/` hides the label and
centers the bar like Memory/CPU (unchanged). Multi-disk keeps top align +
system alerts. Disk IO adds a spacer row to track usage labels.

Review crops under .github/review/ for PR discussion (drop before merge OK).

* fix(monitor): keep bare-/ align; multi-disk labels stay left

Address Chris density feedback: keep the single root label hidden and vertically centered with Memory, and restore side-by-side labels for multiple disks. Remove the Disk I/O spacer and update the review crops.

* chore(monitor): drop PR #4031 discussion review crops

---------

Co-authored-by: wendeng.dong <wendeng.dong@sailuntire.com>
Co-authored-by: 陈大猫 <16399091+binaricat@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-27 23:54:58 -07:00
1 parent 30a74f7c1d
commit 27f26d8e69
29 files changed
+568 -49

No files matched your search

+4
View File
@@ -1,4 +1,8 @@
export const enMonitorMessages = {
"monitor.systemDisks": "System partitions ({count})",
"monitor.systemDisksAlert": "System partition usage exceeds 80%; expanded automatically.",
"monitor.diskIoTotal": "Total",
"monitor.partitionIoHint": "I/O of the block device backing this mount. Mounts sharing a device show the same rates. Unavailable until two valid samples or when no block device can be identified; not supported on macOS.",
"monitor.diskIo": "Disk I/O",
"monitor.diskRead": "Read",
"monitor.diskWrite": "Write",
+4
View File
@@ -1,4 +1,8 @@
export const esMonitorMessages = {
"monitor.systemDisks": "Particiones del sistema ({count})",
"monitor.systemDisksAlert": "El uso de una partición del sistema supera el 80 %; se ha expandido automáticamente.",
"monitor.diskIoTotal": "Total",
"monitor.partitionIoHint": "E/S del dispositivo de bloques de este montaje. Los montajes del mismo dispositivo muestran las mismas tasas. No disponible hasta tener dos muestras válidas o sin un dispositivo identificable; no compatible con macOS.",
"monitor.diskIo": "E/S de disco",
"monitor.diskRead": "Lectura",
"monitor.diskWrite": "Escritura",
+4
View File
@@ -1,4 +1,8 @@
export const ruMonitorMessages = {
"monitor.systemDisks": "Системные разделы ({count})",
"monitor.systemDisksAlert": "Использование системного раздела превышает 80 %; группа раскрыта автоматически.",
"monitor.diskIoTotal": "Всего",
"monitor.partitionIoHint": "Ввод-вывод блочного устройства точки монтирования. Точки одного устройства показывают одинаковые скорости. Недоступно до двух корректных замеров или без определённого устройства; macOS не поддерживается.",
"monitor.diskIo": "Дисковый ввод-вывод",
"monitor.diskRead": "Чтение",
"monitor.diskWrite": "Запись",
@@ -1,4 +1,8 @@
export const zhCNMonitorMessages = {
"monitor.systemDisks": "系统分区({count})",
"monitor.systemDisksAlert": "系统分区使用率超过 80%,已自动展开。",
"monitor.diskIoTotal": "汇总",
"monitor.partitionIoHint": "此挂载点对应块设备的读写速率;共用设备的挂载点显示相同速率。取得两次有效采样前或无法识别块设备时显示 --;暂不支持 macOS。",
"monitor.diskIo": "磁盘 I/O",
"monitor.diskRead": "读取",
"monitor.diskWrite": "写入",
@@ -1,4 +1,8 @@
export const zhTWMonitorMessages = {
"monitor.systemDisks": "系統分割區({count})",
"monitor.systemDisksAlert": "系統分割區使用率超過 80%,已自動展開。",
"monitor.diskIoTotal": "彙總",
"monitor.partitionIoHint": "此掛載點對應區塊裝置的讀寫速率;共用裝置的掛載點顯示相同速率。取得兩次有效取樣前或無法識別區塊裝置時顯示 --;暫不支援 macOS。",
"monitor.diskIo": "磁碟 I/O",
"monitor.diskRead": "讀取",
"monitor.diskWrite": "寫入",
+17
View File
@@ -0,0 +1,17 @@
import { useState } from "react";
import { buildMonitorDiskListLayout, type MonitorDiskListStats } from "../../domain/monitorDisks";
/** Volatile per-host disclosure preference; capacity alerts always force it open. */
export function useMonitorDiskGroups() {
const [expandedHosts, setExpandedHosts] = useState<ReadonlySet<string>>(() => new Set());
return {
layoutFor: (hostId: string, stats?: MonitorDiskListStats | null) =>
buildMonitorDiskListLayout(stats, expandedHosts.has(hostId)),
toggle: (hostId: string) => setExpandedHosts(previous => {
const next = new Set(previous);
if (next.has(hostId)) next.delete(hostId);
else next.add(hostId);
return next;
}),
};
}
+2
View File
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export interface DiskInfo {
diskReadSpeed?: number | null; // Mounted block-device I/O, bytes/sec
diskWriteSpeed?: number | null;
capacityKey?: string; // Filesystem or shared-pool identity for deduplication
filesystemType?: string; // Filesystem type reported by df (for network/FUSE filtering)
mountPoint: string;
+4 -10
View File
@@ -1,14 +1,8 @@
import React from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { formatMonitorRate as rate } from "../../domain/monitorFormatting";
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. */
/** Shared rate formatting for aggregate summaries and per-mount readings. */
export function DiskIoSummary({ stats }: {
stats?: { diskReadSpeed?: number | null; diskWriteSpeed?: number | null } | null;
}) {
@@ -16,8 +10,8 @@ export function DiskIoSummary({ stats }: {
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>
<span className="whitespace-nowrap">{t("monitor.diskRead")} {rate(stats?.diskReadSpeed)}</span>
<span className="whitespace-nowrap">{t("monitor.diskWrite")} {rate(stats?.diskWriteSpeed)}</span>
</div>
);
}
+4 -2
View File
@@ -7,6 +7,7 @@ interface ResourceBarProps {
className?: string;
/** Slightly taller track for GPU cards. */
size?: 'sm' | 'md';
danger?: boolean;
}
function barTone(clamped: number): string {
@@ -20,6 +21,7 @@ export const ResourceBar = memo(function ResourceBar({
value,
className,
size = 'sm',
danger = false,
}: ResourceBarProps) {
const finite = typeof value === 'number' && Number.isFinite(value);
const clamped = finite ? Math.max(0, Math.min(100, value)) : 0;
@@ -37,12 +39,12 @@ export const ResourceBar = memo(function ResourceBar({
<div
className={cn(
'h-full rounded-full transition-[width,background-color] duration-500 ease-out motion-reduce:transition-none',
finite ? barTone(clamped) : 'opacity-0',
finite ? (danger ? 'bg-destructive/80' : barTone(clamped)) : 'opacity-0',
)}
style={{ width: `${clamped}%` }}
/>
</div>
<span className="text-[10px] tabular-nums text-muted-foreground w-10 text-right shrink-0">
<span className={cn('text-[10px] tabular-nums w-10 text-right shrink-0', danger ? 'text-destructive font-medium' : 'text-muted-foreground')}>
{finite ? `${value.toFixed(1)}%` : '--'}
</span>
</div>
@@ -0,0 +1,80 @@
import test from "node:test";
import assert from "node:assert/strict";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { JSDOM } from "jsdom";
import { useMonitorDiskGroups } from "../../application/state/useMonitorDiskGroups";
import { I18nProvider } from "../../application/i18n/I18nProvider";
import { MonitorDiskUsage } from "./MonitorDiskUsage";
import { MonitorDiskIo } from "./MonitorDiskIo";
function Harness({ percent, hideCapacity = false }: { percent: number; hideCapacity?: boolean }) {
const groups = useMonitorDiskGroups();
const stats = { diskPercent: 20, disks: [
{ mountPoint: "/boot/efi", percent, used: 1, total: 2 },
{ mountPoint: "/", percent: 20, used: 20, total: 100 },
{ mountPoint: "/bd10", percent: 10, used: 1, total: 10 },
{ mountPoint: "/boot", percent: 30, used: 1, total: 3 },
{ mountPoint: "/bd02", percent: 10, used: 1, total: 10 },
] };
return <I18nProvider locale="en">{["a", "b"].map(id => {
const layout = groups.layoutFor(id, stats);
return <div key={id} data-host={id}>
{!hideCapacity && <MonitorDiskUsage stats={stats} listLayout={layout} onToggleSystem={() => groups.toggle(id)} />}
<MonitorDiskIo stats={stats} listLayout={layout} showSystemToggle={hideCapacity} onToggleSystem={() => groups.toggle(id)} />
</div>;
})}</I18nProvider>;
}
test("system disclosure is per host, aligns both columns, and reacts to the strict 80% threshold", async () => {
const dom = new JSDOM("<div id='root'></div>", { url: "http://localhost" });
const globals = { window: dom.window, document: dom.window.document, navigator: dom.window.navigator, IS_REACT_ACT_ENVIRONMENT: true };
const originals = Object.fromEntries(Object.keys(globals).map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
const root = createRoot(dom.window.document.getElementById("root")!);
const host = (id: string) => dom.window.document.querySelector(`[data-host=${id}]`)!;
const button = (id: string) => host(id).querySelector<HTMLButtonElement>("[data-monitor-system-disks]")!;
const assertAligned = (id: string, expected: string[]) => {
const capacity = [...host(id).querySelectorAll("[data-monitor-disk]")].map(row => row.getAttribute("data-monitor-disk"));
const io = [...host(id).querySelectorAll("[data-monitor-disk-io]")].map(row => row.getAttribute("data-monitor-disk-io"));
assert.deepEqual(capacity, expected);
assert.deepEqual(io, expected);
assert.equal(host(id).querySelectorAll("[data-monitor-system-io-slot]").length, 1);
};
try {
await act(async () => root.render(<Harness percent={80} />));
assertAligned("a", ["/", "/bd02", "/bd10"]);
assert.equal(button("a").getAttribute("aria-expanded"), "false");
await act(async () => button("a").click());
assertAligned("a", ["/", "/bd02", "/bd10", "/boot", "/boot/efi"]);
assertAligned("b", ["/", "/bd02", "/bd10"]);
await act(async () => button("a").click());
await act(async () => root.render(<Harness percent={81} />));
for (const id of ["a", "b"]) {
assertAligned(id, ["/", "/bd02", "/bd10", "/boot", "/boot/efi"]);
assert.equal(button(id).getAttribute("aria-expanded"), "true");
assert.equal(button(id).disabled, true);
const alert = host(id).querySelector("[data-monitor-disk-alert]")!;
assert.equal(alert.getAttribute("data-monitor-disk"), "/boot/efi");
assert.match(alert.innerHTML, /bg-destructive\/80/);
assert.match(alert.innerHTML, /text-destructive/);
}
await act(async () => root.render(<Harness percent={79} />));
assertAligned("a", ["/", "/bd02", "/bd10"]);
assert.equal(host("a").querySelector("[data-monitor-disk-alert]"), null);
await act(async () => root.render(<Harness percent={79} hideCapacity />));
assert.equal(host("a").querySelector("[data-monitor-disk]"), null);
assert.equal(button("a").getAttribute("aria-expanded"), "false");
await act(async () => button("a").click());
assert.deepEqual([...host("a").querySelectorAll("[data-monitor-disk-io]")].map(row => row.getAttribute("data-monitor-disk-io")), ["/", "/bd02", "/bd10", "/boot", "/boot/efi"]);
assert.equal(button("b").getAttribute("aria-expanded"), "false");
} finally {
await act(async () => root.unmount());
for (const key of Object.keys(globals)) {
const descriptor = originals[key];
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
dom.window.close();
}
});
+59
View File
@@ -0,0 +1,59 @@
import React from "react";
import { ArrowDown, ArrowUp } from "lucide-react";
import { useI18n } from "../../application/i18n/I18nProvider";
import type { MonitorStats } from "../../domain/monitorRuntime";
import { formatMonitorRate } from "../../domain/monitorFormatting";
import { buildMonitorDiskListLayout, type MonitorDiskListLayout } from "../../domain/monitorDisks";
import { MonitorSystemDiskToggle } from "./MonitorSystemDiskToggle";
type DiskRates = { diskReadSpeed?: number | null; diskWriteSpeed?: number | null };
function Rates({ stats }: { stats?: DiskRates | null }) {
const { t } = useI18n();
return <>
<span className="inline-flex items-center gap-1 whitespace-nowrap">
<ArrowDown size={11} aria-hidden="true" className="shrink-0 text-sky-600 dark:text-sky-400" />
<span className="sr-only">{t("monitor.diskRead")} </span>
{formatMonitorRate(stats?.diskReadSpeed)}
</span>
<span className="inline-flex items-center gap-1 whitespace-nowrap">
<ArrowUp size={11} aria-hidden="true" className="shrink-0 text-violet-600 dark:text-violet-400" />
<span className="sr-only">{t("monitor.diskWrite")} </span>
{formatMonitorRate(stats?.diskWriteSpeed)}
</span>
</>;
}
/** Same mount order and row height as the adjacent capacity column. */
export function MonitorDiskIo({ stats, listLayout, showSystemToggle = false, onToggleSystem }: {
stats?: (Pick<MonitorStats, "disks"> & DiskRates) | null;
listLayout?: MonitorDiskListLayout;
showSystemToggle?: boolean;
onToggleSystem?: () => void;
}) {
const { t } = useI18n();
const layout = listLayout ?? buildMonitorDiskListLayout(stats);
return (
<div className="w-full min-w-0 space-y-1.5 text-[10px] tabular-nums text-muted-foreground">
{layout.rows.map((row, index) => {
if (row.kind === "system") return showSystemToggle
? <MonitorSystemDiskToggle key="system" row={row} onToggle={onToggleSystem} />
: <div key="system" data-monitor-system-io-slot aria-hidden="true" className="h-4" />;
const { disk } = row;
return (
<div key={`${disk.mountPoint}-${index}`} data-monitor-disk-io={disk.mountPoint}
className="grid h-4 grid-cols-2 items-center gap-2"
title={`${disk.mountPoint}\n${t("monitor.partitionIoHint")}`}>
<span className="sr-only">{disk.mountPoint} </span>
<Rates stats={disk} />
</div>
);
})}
<div data-monitor-disk-io-total className="flex h-5 items-center justify-between gap-2 border-t border-border/40 pt-1"
title={t("monitor.diskIoHint")}>
<span>{t("monitor.diskIoTotal")}</span>
<div className="flex items-center gap-2"><Rates stats={stats} /></div>
</div>
</div>
);
}
+25 -14
View File
@@ -1,7 +1,9 @@
import React from "react";
import { DiskIoSummary } from "../systemManager/DiskIoSummary";
import type { MonitorStats } from "../../domain/monitorRuntime";
import { buildMonitorDiskListLayout, type MonitorDiskListLayout, type MonitorDiskListRow } from "../../domain/monitorDisks";
import { ResourceBar } from "../systemManager/ResourceBar";
import { DiskIoSummary } from "../systemManager/DiskIoSummary";
import { MonitorSystemDiskToggle } from "./MonitorSystemDiskToggle";
import { useI18n } from "../../application/i18n/I18nProvider";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
@@ -11,35 +13,43 @@ const diskCapacity = (value: number) => value >= 1024
? `${(value * 1024).toFixed(1)} MiB`
: `${value.toFixed(1)} GiB`;
/** Cards show the largest capacities; the list keeps every reported mount. */
/** Cards show the largest capacities; the list puts system mounts first. */
export function MonitorDiskUsage({
stats,
limit,
listLayout,
onToggleSystem,
}: {
stats?: (Pick<MonitorStats, "disks" | "diskPercent"> & Partial<Pick<MonitorStats, "diskUsed" | "diskTotal" | "diskReadSpeed" | "diskWriteSpeed">>) | null;
limit?: number;
listLayout?: MonitorDiskListLayout;
onToggleSystem?: () => void;
}) {
const { t } = useI18n();
const reported = stats?.disks.length
? stats.disks
: [{ mountPoint: "/", percent: stats?.diskPercent ?? null, used: stats?.diskUsed ?? null, total: stats?.diskTotal ?? null }];
const disks = limit == null
? reported
: [...reported].sort((a, b) =>
("total" in b ? b.total : 0) - ("total" in a ? a.total : 0),
).slice(0, limit);
const rows: MonitorDiskListRow[] = limit == null ? (listLayout ?? buildMonitorDiskListLayout(stats)).rows
: [...reported].sort((a, b) => (b.total ?? 0) - (a.total ?? 0))
.slice(0, limit).map(disk => ({ kind: "disk", disk, alert: false }));
const bareRoot = limit == null && rows.length === 1 && rows[0].kind === "disk" && rows[0].disk.mountPoint === "/";
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) => (
{limit != null && <DiskIoSummary stats={stats} />}
{rows.map((row, index) => {
if (row.kind === "system") return (
<MonitorSystemDiskToggle key="system" row={row} onToggle={onToggleSystem} />
);
const { disk, alert } = row;
return (
<Tooltip key={`${disk.mountPoint}-${index}`}>
<TooltipTrigger asChild>
<div data-monitor-disk={disk.mountPoint} className="flex min-w-0 items-center gap-2">
<span className="w-24 shrink-0 truncate text-[10px] text-muted-foreground">
<div data-monitor-disk={disk.mountPoint} data-monitor-disk-alert={alert || undefined} className={`${bareRoot ? "min-w-0" : "flex h-4 min-w-0 items-center gap-2"} ${alert ? "rounded bg-destructive/10" : ""}`}>
{!bareRoot && <span data-monitor-disk-label className={`w-24 shrink-0 truncate text-[10px] ${alert ? "font-medium text-destructive" : "text-muted-foreground"}`}>
{disk.mountPoint}
</span>
<ResourceBar label="" value={disk.percent} className="flex-1" />
</span>}
<ResourceBar label="" value={disk.percent} className={bareRoot ? "h-4 w-full" : "flex-1"} danger={alert} />
</div>
</TooltipTrigger>
<TooltipContent className="max-w-80 bg-popover text-popover-foreground shadow-md ring-1 ring-border/40">
@@ -51,7 +61,8 @@ export function MonitorDiskUsage({
</div>
</TooltipContent>
</Tooltip>
))}
);
})}
</div>
</TooltipProvider>
);
+2 -2
View File
@@ -32,7 +32,7 @@ export function MonitorHostCollection<T extends { id: string }>({
layoutKey: string;
pinnedId?: string;
renderCard: (item: T) => React.ReactNode;
renderCells: (item: T) => Record<MonitorColumn, React.ReactNode>;
renderCells: (item: T, visibleColumns: readonly MonitorColumn[]) => Record<MonitorColumn, React.ReactNode>;
rowProps: (item: T) => React.HTMLAttributes<HTMLTableRowElement>;
renderMenu: (item: T) => React.ReactNode;
}) {
@@ -232,7 +232,7 @@ export function MonitorHostCollection<T extends { id: string }>({
style={{ height: virtualizer.getTotalSize() }}
>
{virtualizer.getVirtualItems().map((row) => {
const cells = renderCells(items[row.index]);
const cells = renderCells(items[row.index], visibleColumns.map(column => column.key));
return (
<ContextMenu key={row.key}>
<ContextMenuTrigger asChild>
+44 -6
View File
@@ -10,6 +10,7 @@ import type { Host } from "../../domain/models";
import type { MonitorPoint } from "../../domain/monitorRuntime";
import { MonitorPanels, MonitorTrend } from "./MonitorPanels";
import { MonitorDiskUsage } from "./MonitorDiskUsage";
import { MonitorDiskIo } from "./MonitorDiskIo";
import { MonitorHostLatency } from "./MonitorHostMetadata";
import { TooltipProvider } from "../ui/tooltip";
@@ -132,7 +133,7 @@ test("disk usage shows every reported partition, including healthy and empty dis
const document = new JSDOM(renderToStaticMarkup(
<MonitorDiskUsage stats={{ disks, diskPercent: 25 }} />,
)).window.document;
assert.deepEqual([...document.querySelectorAll("[data-monitor-disk] > span")].map((e) => e.textContent), ["/", "/data", "/backup"]);
assert.deepEqual([...document.querySelectorAll("[data-monitor-disk-label]")].map((e) => e.textContent), ["/", "/backup", "/data"]);
for (const value of ["25.0%", "95.0%", "0.0%"])
assert.ok(document.body.textContent!.includes(value));
});
@@ -158,6 +159,15 @@ test("cards show the two largest capacities without changing the source disk ord
assert.deepEqual(disks.map((disk) => disk.mountPoint), ["/small", "/data", "/backup"]);
});
test("cards retain aggregate disk I/O while lists keep it in the separate column", () => {
const stats = { disks: [], diskPercent: 10, diskReadSpeed: 1000, diskWriteSpeed: 2e6 };
const markup = (limit?: number) => new JSDOM(renderToStaticMarkup(
<I18nProvider locale="en"><MonitorDiskUsage stats={stats} limit={limit} /></I18nProvider>,
)).window.document;
assert.match(markup(2).querySelector("[data-disk-io]")!.textContent!, /Read 1.0 KB\/s.*Write 2.0 MB\/s/);
assert.equal(markup().querySelector("[data-disk-io]"), null);
});
for (const view of ["list", "charts"] as const) {
test(`${view} keeps saved custom icons, colors and OS logos`, (t) => {
const storage = new Map<string, string>();
@@ -181,6 +191,9 @@ for (const view of ["list", "charts"] as const) {
assert.equal(document.querySelector("header .lucide-gauge"), null);
assert.equal(document.querySelector(".lucide-activity"), null);
if (view === "list") {
const diskIo = document.querySelector("[data-monitor-cell=diskIo]")!;
assert.ok(diskIo.querySelector("[data-monitor-disk-io-total]"));
assert.equal(document.querySelector("[data-monitor-column=diskIo]")?.textContent, "Disk I/O");
const network = document.querySelector("[data-monitor-cell=network]")!;
assert.deepEqual([...network.querySelectorAll(".sr-only")].map((e) => e.textContent), ["Download: ", "Upload: "]);
}
@@ -191,14 +204,39 @@ test("disk I/O distinguishes warming up or unsupported from idle and reports bot
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"]],
[{ diskReadSpeed: 0, diskWriteSpeed: 0 }, ["Read 0.0 KB/s", "Write 0.0 KB/s"]],
[{ diskReadSpeed: 1000, diskWriteSpeed: 2000000 }, ["Read 1.0 KB/s", "Write 2.0 MB/s"]],
] as const) {
const document = new JSDOM(renderToStaticMarkup(
<I18nProvider locale="en"><MonitorDiskUsage stats={{ disks: [], diskPercent: null, ...rates }} /></I18nProvider>,
<I18nProvider locale="en"><MonitorDiskIo stats={{ disks: [{ mountPoint: "/", used: 1, total: 10, percent: 10, ...rates }] }} /></I18nProvider>,
)).window.document;
const io = document.querySelector("[data-disk-io]")!;
const io = document.querySelector("[data-monitor-disk-io]")!;
for (const text of expected) assert.ok(io.textContent?.includes(text));
assert.match(io.getAttribute("title")!, /Linux.*not supported on macOS/);
assert.match(io.getAttribute("title")!, /block device.*not supported on macOS/);
}
});
test("disk capacity and I/O columns share system-first order and isolate the physical aggregate", () => {
const stats = {
diskPercent: 10, diskReadSpeed: 5000000, diskWriteSpeed: 2000000000,
disks: [
{ mountPoint: "/", used: 1, total: 10, percent: 10, diskReadSpeed: 1000, diskWriteSpeed: 0 },
{ mountPoint: "/bd10", used: 2, total: 20, percent: 10, diskReadSpeed: 2000000, diskWriteSpeed: 4000 },
{ mountPoint: "/bd02", used: 1, total: 10, percent: 10, diskReadSpeed: 1000, diskWriteSpeed: 0 },
],
};
const document = new JSDOM(renderToStaticMarkup(
<I18nProvider locale="en"><div data-capacity><MonitorDiskUsage stats={stats} /></div><MonitorDiskIo stats={stats} /></I18nProvider>,
)).window.document;
const capacity = document.querySelector("[data-capacity]")!;
const rows = [...document.querySelectorAll("[data-monitor-disk-io]")];
assert.equal(capacity.querySelectorAll("[data-disk-io], [data-monitor-disk-io]").length, 0);
assert.deepEqual(rows.map(row => row.getAttribute("data-monitor-disk-io")),
[...capacity.querySelectorAll("[data-monitor-disk]")].map(row => row.getAttribute("data-monitor-disk")));
assert.match(rows[0].textContent!, /Read 1.0 KB\/s.*Write 0.0 KB\/s/);
assert.deepEqual(rows.map(row => row.getAttribute("data-monitor-disk-io")), ["/", "/bd02", "/bd10"]);
assert.match(rows[2].textContent!, /Read 2.0 MB\/s.*Write 4.0 KB\/s/);
const total = document.querySelector("[data-monitor-disk-io-total]")!;
assert.match(total.textContent!, /Total.*Read 5.0 MB\/s.*Write 2.0 GB\/s/);
});
+16 -10
View File
@@ -70,6 +70,9 @@ import SelectHostPanel from "../SelectHostPanel";
import { DistroAvatar } from "../DistroAvatar";
import { ResourceBar } from "../systemManager/ResourceBar";
import { MonitorDiskUsage } from "./MonitorDiskUsage";
import { MonitorDiskIo } from "./MonitorDiskIo";
import { useMonitorDiskGroups } from "../../application/state/useMonitorDiskGroups";
import { formatMonitorRate as rate } from "../../domain/monitorFormatting";
import { VaultHeaderSearch, VaultPageHeader, vaultHeaderIconButtonClass, vaultHeaderSecondaryButtonClass } from "./VaultPageHeader";
import { toast } from "../ui/toast";
import { TooltipProvider } from "../ui/tooltip";
@@ -85,12 +88,6 @@ const capacity = (n: number | null | undefined) =>
: n >= 1024
? `${(n / 1024).toFixed(1)} GiB`
: `${n.toFixed(0)} MiB`;
const rate = (n: number | null | undefined) =>
n == null
? "--"
: n >= 1048576
? `${(n / 1048576).toFixed(1)} MiB/s`
: `${(n / 1024).toFixed(1)} KiB/s`;
const monitorAsideHeaderClass =
"relative h-14 py-0 border-b-0 after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-px after:origin-bottom after:[transform:scaleY(.5)] after:bg-border/40 after:content-['']";
@@ -209,6 +206,7 @@ export function MonitorPanels({
const { t } = useI18n();
const panels = useMonitorPanels();
const runtime = useMonitorSnapshot();
const diskGroups = useMonitorDiskGroups();
const [selected, setSelected] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [dragging, setDragging] = useState<{ panelId: string; hostId: string } | null>(null);
@@ -835,7 +833,9 @@ export function MonitorPanels({
</Button>
</article>
)}
renderCells={({ id, host, state }) => ({
renderCells={({ id, host, state }, visibleColumns) => {
const diskLayout = diskGroups.layoutFor(id, state?.stats);
return ({
host: (<td>
<button
type="button"
@@ -900,11 +900,16 @@ export function MonitorPanels({
}
/>
</td>),
disks: (<td>
disks: (<td className={diskLayout.rows.length === 1 && diskLayout.rows[0].kind === "disk" && diskLayout.rows[0].disk.mountPoint === "/" ? undefined : "!items-start"}>
<div className="w-full min-w-0">
<MonitorDiskUsage stats={state?.stats} />
<MonitorDiskUsage stats={state?.stats} listLayout={diskLayout} onToggleSystem={() => diskGroups.toggle(id)} />
</div>
</td>),
diskIo: (<td data-monitor-cell="diskIo" className="!items-start">
<MonitorDiskIo stats={state?.stats} listLayout={diskLayout}
showSystemToggle={!visibleColumns.includes("disks")}
onToggleSystem={() => diskGroups.toggle(id)} />
</td>),
network: (<td
data-monitor-cell="network"
className="tabular-nums whitespace-nowrap"
@@ -964,7 +969,8 @@ export function MonitorPanels({
<Terminal size={14} />
</Button>
</td>)
})}
});
}}
/>
</TooltipProvider>
)}
@@ -0,0 +1,22 @@
import React from "react";
import { ChevronRight, TriangleAlert } from "lucide-react";
import { useI18n } from "../../application/i18n/I18nProvider";
import type { MonitorDiskListRow } from "../../domain/monitorDisks";
export function MonitorSystemDiskToggle({ row, onToggle }: {
row: Extract<MonitorDiskListRow, { kind: "system" }>;
onToggle?: () => void;
}) {
const { t } = useI18n();
return (
<button type="button" data-monitor-system-disks aria-expanded={row.expanded}
disabled={row.alert || !onToggle}
title={row.alert ? t("monitor.systemDisksAlert") : undefined}
onClick={event => { event.stopPropagation(); onToggle?.(); }}
className={`flex h-4 w-full items-center gap-1 rounded text-left text-[10px] outline-none focus-visible:ring-1 focus-visible:ring-ring ${row.alert ? "font-medium text-destructive" : "text-muted-foreground hover:text-foreground"}`}>
<ChevronRight size={11} aria-hidden="true" className={row.expanded ? "rotate-90" : ""} />
{t("monitor.systemDisks", { count: row.count })}
{row.alert && <TriangleAlert size={11} aria-hidden="true" />}
</button>
);
}
+47
View File
@@ -0,0 +1,47 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildMonitorDiskListLayout, sortMonitorDisks } from "./monitorDisks";
test("list mounts put root first, group system paths, and naturally sort data paths", () => {
const paths = ["/bd10", "/home", "/var/log", "/bd03", "/usr/local", "/boot/efi", "/", "/srv", "/bd02", "/etc", "/opt", "/tmp", "/boot", "/bd01", "/var", "/usr", "/bd2/archive10", "/bd2/archive2"];
const disks = paths.map(mountPoint => ({ mountPoint }));
assert.deepEqual(sortMonitorDisks(disks).map(disk => disk.mountPoint), [
"/", "/boot", "/boot/efi", "/etc", "/usr", "/usr/local", "/var", "/var/log", "/home", "/opt", "/srv", "/tmp",
"/bd01", "/bd02", "/bd2/archive2", "/bd2/archive10", "/bd03", "/bd10",
]);
assert.deepEqual(disks.map(disk => disk.mountPoint), paths);
});
test("healthy system mounts collapse while root and naturally sorted data mounts remain visible", () => {
const disks = ["/bd10", "/boot/efi", "/", "/bd02", "/boot"].map(mountPoint => ({ mountPoint, percent: 80 }));
const layout = buildMonitorDiskListLayout({ disks });
assert.deepEqual(layout.rows.map(row => row.kind === "disk" ? row.disk.mountPoint : "system"), ["/", "/bd02", "/bd10", "system"]);
assert.equal(layout.forcedExpanded, false);
assert.equal(layout.expanded, false);
assert.deepEqual(layout.rows.at(-1), { kind: "system", count: 2, expanded: false, alert: false });
const expanded = buildMonitorDiskListLayout({ disks }, true);
assert.deepEqual(expanded.rows.slice(-2).map(row => row.kind === "disk" && row.disk.mountPoint), ["/boot", "/boot/efi"]);
});
test("only finite system usage above 80 forces expansion and highlights the affected mount", () => {
for (const percent of [80.1, 81, 100]) {
const layout = buildMonitorDiskListLayout({ disks: [
{ mountPoint: "/", percent: 99 }, { mountPoint: "/boot", percent: 30 },
{ mountPoint: "/boot/efi", percent }, { mountPoint: "/data", percent: 99 },
] });
assert.equal(layout.forcedExpanded, true);
assert.equal(layout.expanded, true);
assert.deepEqual(layout.rows.filter(row => row.kind === "disk" && row.alert)
.map(row => row.kind === "disk" && row.disk.mountPoint), ["/boot/efi"]);
}
for (const percent of [null, NaN, Infinity, 0, 80]) {
assert.equal(buildMonitorDiskListLayout({ disks: [{ mountPoint: "/boot", percent }] }).forcedExpanded, false);
}
assert.equal(buildMonitorDiskListLayout({ disks: [{ mountPoint: "/", percent: 99 }, { mountPoint: "/data", percent: 99 }] }).forcedExpanded, false);
});
test("similar data mount names are not treated as system directories", () => {
const disks = ["/various", "/data", "/boot-data", "/var/log", "/"].map(mountPoint => ({ mountPoint }));
assert.deepEqual(sortMonitorDisks(disks).map(disk => disk.mountPoint), ["/", "/var/log", "/boot-data", "/data", "/various"]);
assert.deepEqual(sortMonitorDisks([]), []);
});
+69
View File
@@ -0,0 +1,69 @@
// Product display order; descendants stay with their system mount group.
const SYSTEM_MOUNTS = ["/", "/boot", "/etc", "/usr", "/var", "/home", "/opt", "/srv", "/tmp"];
const mountNames = new Intl.Collator("en", { numeric: true });
function mountPriority(mountPoint: string): number {
const rank = SYSTEM_MOUNTS.findIndex(path =>
mountPoint === path || (path !== "/" && mountPoint.startsWith(`${path}/`)),
);
return rank < 0 ? SYSTEM_MOUNTS.length : rank;
}
export interface MonitorDiskRow {
mountPoint: string;
percent: number | null;
used?: number | null;
total?: number | null;
diskReadSpeed?: number | null;
diskWriteSpeed?: number | null;
}
export type MonitorDiskListRow =
| { kind: "disk"; disk: MonitorDiskRow; alert: boolean }
| { kind: "system"; count: number; expanded: boolean; alert: boolean };
export interface MonitorDiskListStats {
disks: readonly MonitorDiskRow[];
diskPercent?: number | null;
diskUsed?: number | null;
diskTotal?: number | null;
}
export function isMonitorSystemDisk(mountPoint: string): boolean {
const priority = mountPriority(mountPoint);
return priority > 0 && priority < SYSTEM_MOUNTS.length;
}
export function monitorSystemDiskAlert(disk: MonitorDiskRow): boolean {
return isMonitorSystemDisk(disk.mountPoint)
&& typeof disk.percent === "number" && Number.isFinite(disk.percent) && disk.percent > 80;
}
/** One layout drives both columns, including a matching slot for the group header. */
export function buildMonitorDiskListLayout(stats?: MonitorDiskListStats | null, manuallyExpanded = false) {
const disks = sortMonitorDisks(stats?.disks.length ? stats.disks : [{
mountPoint: "/", percent: stats?.diskPercent ?? null,
used: stats?.diskUsed ?? null, total: stats?.diskTotal ?? null,
}]);
const system = disks.filter(disk => isMonitorSystemDisk(disk.mountPoint));
const forcedExpanded = system.some(monitorSystemDiskAlert);
const expanded = forcedExpanded || manuallyExpanded;
const rows: MonitorDiskListRow[] = disks
.filter(disk => !isMonitorSystemDisk(disk.mountPoint))
.map(disk => ({ kind: "disk", disk, alert: false }));
if (system.length) {
rows.push({ kind: "system", count: system.length, expanded, alert: forcedExpanded });
if (expanded) rows.push(...system.map(disk => ({ kind: "disk" as const, disk, alert: monitorSystemDiskAlert(disk) })));
}
return { rows, expanded, forcedExpanded };
}
export type MonitorDiskListLayout = ReturnType<typeof buildMonitorDiskListLayout>;
/** Sort a copy so capacity cards and other consumers retain the reported order. */
export function sortMonitorDisks<T extends { mountPoint: string }>(disks: readonly T[]): T[] {
return [...disks].sort((a, b) =>
mountPriority(a.mountPoint) - mountPriority(b.mountPoint)
|| mountNames.compare(a.mountPoint, b.mountPoint),
);
}
+14
View File
@@ -0,0 +1,14 @@
import test from "node:test";
import assert from "node:assert/strict";
import { formatMonitorRate } from "./monitorFormatting";
test("disk and network rates adapt to decimal KB/MB/GB units", () => {
for (const [value, expected] of [
[0, "0.0 KB/s"], [500, "0.5 KB/s"], [1000, "1.0 KB/s"],
[999000, "999.0 KB/s"], [1e6, "1.0 MB/s"], [2.5e6, "2.5 MB/s"],
[1e9, "1.0 GB/s"], [12e9, "12.0 GB/s"],
] as const) assert.equal(formatMonitorRate(value), expected);
for (const value of [null, undefined, NaN, Infinity, -1]) {
assert.equal(formatMonitorRate(value), "--");
}
});
+7
View File
@@ -0,0 +1,7 @@
/** Decimal byte-rate units, shared by disk I/O and network monitoring. */
export function formatMonitorRate(value: number | null | undefined): string {
if (value == null || !Number.isFinite(value) || value < 0) return "--";
if (value >= 1e9) return `${(value / 1e9).toFixed(1)} GB/s`;
if (value >= 1e6) return `${(value / 1e6).toFixed(1)} MB/s`;
return `${(value / 1e3).toFixed(1)} KB/s`;
}
+10
View File
@@ -42,6 +42,16 @@ test("virtual card rows adapt to narrow panes and do not allocate empty columns"
assert.equal(monitorGridColumns(0, 1000), 1);
});
test("old saved layouts gain a resizable disk I/O column after disk capacity", () => {
const keys = MONITOR_COLUMNS.map(column => column.key);
assert.equal(keys[keys.indexOf("disks") + 1], "diskIo");
const widths = normalizeMonitorColumnWidths({ disks: 280, network: 170 });
assert.equal(widths.disks, 280);
assert.equal(widths.network, 170);
assert.equal(widths.diskIo, 230);
assert.deepEqual(normalizeMonitorHiddenColumns(["diskIo"]), ["diskIo"]);
});
test("hidden columns always preserve host and terminal and discard unknown settings", () => {
assert.deepEqual(
normalizeMonitorHiddenColumns([
+1
View File
@@ -6,6 +6,7 @@ export const MONITOR_COLUMNS = [
{ key: "cpu", width: 120, min: 120 },
{ key: "memory", width: 120, min: 120 },
{ key: "disks", width: 240, min: 220 },
{ key: "diskIo", width: 230, min: 220 },
{ key: "network", width: 150, min: 140 },
{ key: "updated", width: 110, min: 95 },
{ key: "status", width: 100, min: 90 },
+23 -1
View File
@@ -6,6 +6,28 @@
// 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)`;
// Match mounts by kernel device number: aliases, LVM and bind mounts need no
// /dev name guessing. Keep these counters separate from the physical total.
const PARTITION_IO_ASSIGNMENT = String.raw`partitionio=$(if [ -r /proc/uptime ]; then awk 'NR==1 {printf "%s;",$1;next} NF>=7 {n=split(FILENAME,a,"/");gsub(/:/,"-",a[n-1]);printf "%s:%s:%s,",a[n-1],$3,$7}' /proc/uptime /sys/dev/block/*/stat 2>/dev/null; fi); diskmounts=$(awk '{gsub(/:/,"-",$3);printf "%s:%s,",$5,$3}' /proc/self/mountinfo 2>/dev/null)`;
function withPartitionIo(disks, mounts, current, previous) {
const devices = new Map();
for (const entry of (mounts || "").split(",")) {
const separator = entry.lastIndexOf(":");
if (separator < 0) continue;
const mount = entry.slice(0, separator).replace(/\\([0-7]{3})/g, (_, octal) => String.fromCharCode(parseInt(octal, 8)));
devices.set(mount, entry.slice(separator + 1));
}
return disks.map(disk => {
const name = devices.get(disk.mountPoint);
const sample = snapshot => snapshot?.devices.has(name)
? { timestamp: snapshot.timestamp, devices: new Map([[name, snapshot.devices.get(name)]]) }
: null;
const rates = diskIoRates(sample(current), sample(previous));
return rates.diskReadSpeed == null ? disk : { ...disk, ...rates };
});
}
function parseDiskIo(text) {
if (typeof text !== "string") return null;
const [time, counters, ...extra] = text.trim().split(";");
@@ -40,4 +62,4 @@ function diskIoRates(current, previous) {
return { diskReadSpeed: read / seconds, diskWriteSpeed: write / seconds };
}
module.exports = { DISK_IO_ASSIGNMENT, parseDiskIo, diskIoRates };
module.exports = { DISK_IO_ASSIGNMENT, PARTITION_IO_ASSIGNMENT, parseDiskIo, diskIoRates, withPartitionIo };
@@ -3,10 +3,58 @@ 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 shellPath = value => "'" + value.replaceAll("\\", "/").replaceAll("'", "'\\''") + "'";
const { execFileSync } = require("node:child_process");
const { DISK_IO_ASSIGNMENT, parseDiskIo, diskIoRates } = require("./monitorDiskIo.cjs");
const { DISK_IO_ASSIGNMENT, PARTITION_IO_ASSIGNMENT, parseDiskIo, diskIoRates, withPartitionIo } = require("./monitorDiskIo.cjs");
const unknown = { diskReadSpeed: null, diskWriteSpeed: null };
test("mount rates isolate partitions, support shared devices and escaped mounts, and tolerate unrelated hotplug", () => {
const disks = ["/", "/data space", "/bind", "/pool"].map(mountPoint => ({ mountPoint }));
const mounts = "/:8-1,/data\\040space:253-0,/bind:8-1,/pool:0-42,";
const previous = parseDiskIo("10;8-1:100:200,253-0:10:20,");
const current = parseDiskIo("12;8-1:104:208,253-0:30:60,8-2:0:0,");
const result = withPartitionIo(disks, mounts, current, previous);
assert.deepEqual(result.slice(0, 3).map(({ diskReadSpeed, diskWriteSpeed }) => ({ diskReadSpeed, diskWriteSpeed })), [
{ diskReadSpeed: 1024, diskWriteSpeed: 2048 },
{ diskReadSpeed: 5120, diskWriteSpeed: 10240 },
{ diskReadSpeed: 1024, diskWriteSpeed: 2048 },
]);
assert.equal(result[3], disks[3]);
for (const snapshot of [null, parseDiskIo("12;8-1:99:200,253-0:10:20,")]) {
assert.deepEqual(withPartitionIo(disks, mounts, snapshot, previous)[0], { mountPoint: "/" });
}
assert.deepEqual(withPartitionIo(disks, mounts, current, null)[0], { mountPoint: "/" });
});
test("partition collection reads hundreds of devices with two awk processes", () => {
const dir = mkdtempSync(join(tmpdir(), "partition-io-"));
try {
const block = join(dir, "block");
mkdirSync(block);
writeFileSync(join(dir, "uptime"), "123.45 100.00\n");
writeFileSync(join(dir, "mountinfo"), "1 0 8:1 / / rw - ext4 /dev/root rw\n2 0 253:0 / /data rw - ext4 /dev/mapper/data rw\n");
const names = Array.from({ length: 256 }, (_, index) => `8-${index + 1}`);
names.push("253-0");
for (const name of names) {
// Use portable fixture names; Linux kernel names use a colon.
mkdirSync(join(block, name.replace(":", "-")));
writeFileSync(join(block, name.replace(":", "-"), "stat"), "1 2 100 4 5 6 200\n");
}
const command = PARTITION_IO_ASSIGNMENT
.replaceAll("/proc/uptime", shellPath(join(dir, "uptime")))
.replaceAll("/sys/dev/block", shellPath(block))
.replaceAll("/proc/self/mountinfo", shellPath(join(dir, "mountinfo")));
const count = shellPath(join(dir, "awk-count"));
const output = execFileSync("sh", ["-c", `awk() { printf x >> ${count}; command awk "$@"; }; ${command}; printf '%s|%s|' "$partitionio" "$diskmounts"; cat ${count}`], { encoding: "utf8" });
const [counters, mounts, processes] = output.split("|");
assert.deepEqual([...parseDiskIo(counters).devices.keys()].sort(), names.sort());
assert.equal(processes, "xx", "device count must not increase awk process count");
assert.equal(mounts, "/:8-1,/data:253-0,");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
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,");
@@ -39,7 +87,7 @@ test("Linux collection includes whole hardware devices, excludes partitions and
}
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 command = DISK_IO_ASSIGNMENT.replaceAll("/proc/uptime", shellPath(join(dir, "uptime"))).replaceAll("/sys/block", shellPath(block));
const output = execFileSync("sh", ["-c", `${command}; printf '%s' "$diskio"`], { encoding: "utf8" });
const parsed = parseDiskIo(output);
assert.equal(parsed.timestamp, 123.45);
+11 -2
View File
@@ -1,5 +1,5 @@
/* eslint-disable no-undef */
const { DISK_IO_ASSIGNMENT, parseDiskIo, diskIoRates } = require("./monitorDiskIo.cjs");
const { DISK_IO_ASSIGNMENT, PARTITION_IO_ASSIGNMENT, parseDiskIo, diskIoRates, withPartitionIo } = require("./monitorDiskIo.cjs");
const { sampleSharedStats } = require("./sharedStats.cjs");
const { GPU_ASSIGNMENT, parseGpuStats, withCachedMonitorGpu, cacheFullGpuStats, startMonitorGpuProbe } = require("./monitorGpuStats.cjs");
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
@@ -1329,8 +1329,9 @@ function createSessionOpsApi(ctx) {
// macOS is skipped: powermetrics needs root, which stats must not.
GPU_ASSIGNMENT,
DISK_IO_ASSIGNMENT,
PARTITION_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|DISKIO:$diskio|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|PARTITIONIO:$partitionio|DISKMOUNTS:$diskmounts|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
@@ -1460,6 +1461,8 @@ function createSessionOpsApi(ctx) {
let topProcesses = []; // Array of { pid, memPercent, command }
let disks = []; // Array of { mountPoint, used, total, percent }
let diskIo = null;
let partitionIo = null;
let diskMounts = "";
let networkInterfaces = []; // Array of { name, rxBytes, txBytes }
let hostname = "";
let osName = "";
@@ -1476,6 +1479,10 @@ 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('PARTITIONIO:')) {
partitionIo = parseDiskIo(part.substring(12));
} else if (part.startsWith('DISKMOUNTS:')) {
diskMounts = part.substring(11);
} else if (part.startsWith('DISKIO:')) {
diskIo = parseDiskIo(part.substring(7));
} else if (part.startsWith('CPURAW:')) {
@@ -1626,6 +1633,8 @@ function createSessionOpsApi(ctx) {
const diskRates = diskIoRates(diskIo, session.prevDiskIo);
session.prevDiskIo = diskIo;
disks = withPartitionIo(disks, diskMounts, partitionIo, session.prevPartitionIo);
session.prevPartitionIo = partitionIo;
// Calculate network speed based on previous reading
const now = Date.now();
@@ -1560,3 +1560,22 @@ test("shared server stats parse Linux disk I/O across samples and leave macOS un
assert.equal(result.stats.diskReadSpeed, null);
assert.equal(result.stats.diskWriteSpeed, null);
});
test("shared server stats attach independent block rates to mounted partitions", async (t) => {
let now = 100000;
t.mock.method(Date, "now", () => now);
const base = LINUX_STATS.replace("DISKS:", "DISKS:/:1:10:10:/dev/root,/data:2:20:10:/dev/mapper/data");
let payload = `${base}|PARTITIONIO:10;8-1:100:200,253-0:10:20,|DISKMOUNTS:/:8-1,/data:253-0,`;
const session = { conn: { exec(command, cb) { cb(null, fakeStream(selectServerStatsFixtureOutput(command, payload))); } }, type: "ssh", _monitorStats: true, _monitorIntervalMs: 1000 };
const api = makeSessionOps(new Map([["partition-io", session]]));
const get = () => api.getServerStats({ sender: {} }, { sessionId: "partition-io" });
let result = await get();
assert.equal(result.success, true);
assert.equal(result.stats.disks[0].diskReadSpeed, undefined);
assert.equal(Object.hasOwn(result.stats.disks[0], "diskReadSpeed"), false);
now += 1000;
payload = `${base}|PARTITIONIO:11;8-1:102:204,253-0:20:40,|DISKMOUNTS:/:8-1,/data:253-0,`;
result = await get();
assert.equal(result.success, true);
assert.deepEqual(result.stats.disks.map(disk => [disk.diskReadSpeed, disk.diskWriteSpeed]), [[1024, 2048], [5120, 10240]]);
});
@@ -84,6 +84,7 @@ async function sampleSharedStats(session, collect, now = Date.now()) {
if (companion && result.success) {
const state = stateFor(companion);
state.context.prevDiskIo = session.prevDiskIo;
state.context.prevPartitionIo = session.prevPartitionIo;
state.context.prevNetStats = session.prevNetStats;
state.context.prevCpuStats = session.prevCpuStats;
state.history.latest = result;
@@ -125,3 +125,22 @@ test('one-second requests never overlap and keep disk baselines on the physical
release();
assert.equal((await reconnect).stats.diskReadSpeed, null);
});
test('a newly established companion transport retains partition I/O baselines', async () => {
resetSharedStatsForTests();
const conn = {};
const baseline = { timestamp: 1, devices: new Map() };
const session = { _monitorStats: true, _monitorIntervalMs: 1000 };
await sampleSharedStats(session, async context => {
context.moshStatsConn = conn;
context.prevPartitionIo = baseline;
return result(42);
});
let sampled = false;
await sampleSharedStats(session, async context => {
sampled = true;
assert.equal(context.prevPartitionIo, baseline);
return result(43);
}, Date.now() + 1001);
assert.equal(sampled, true);
});
+2
View File
@@ -327,6 +327,8 @@ declare global {
diskUsed: number | null; // Disk used in GB
diskTotal: number | null; // Total disk in GB
disks: Array<{ // All mounted disks
diskReadSpeed?: number | null; // Mounted block-device I/O, bytes/sec
diskWriteSpeed?: number | null;
capacityKey?: string; // Filesystem or shared-pool identity
mountPoint: string;
used: number; // Used in GB