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:
29 files changed
+568
-49
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "寫入",
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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([]), []);
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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), "--");
|
||||
}
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user