feat: live disk I/O rates on array and pools cards
Adds vv_disk_io_rates() — snapshots /proc/diskstats each poll and computes per-device MB/s. Exposed as disk_io in the monitor API. JS: - Per-disk rows: ↓read ↑write inline next to disk name (green/amber, hidden when idle) - Pool rows: aggregate pool I/O next to pool name - Array header: total array I/O in the subtitle line Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
3e9c7b4921
commit
212b61ce15
@@ -25,6 +25,7 @@ echo json_encode([
|
||||
'parity' => vv_parity_status(),
|
||||
'storage' => vv_storage_pools(),
|
||||
'array_disks' => vv_array_disks(),
|
||||
'disk_io' => vv_disk_io_rates(),
|
||||
'watchdog' => vv_watchdog_summary(),
|
||||
'scripts' => vv_scripts_status(),
|
||||
'thresholds' => vv_disk_thresholds(),
|
||||
|
||||
@@ -536,6 +536,41 @@ function vv_array_disks(): array {
|
||||
return array_merge($parity, $data);
|
||||
}
|
||||
|
||||
function vv_disk_io_rates(): array {
|
||||
$snapFile = '/tmp/vv_diskio_snap.json';
|
||||
$now = microtime(true);
|
||||
|
||||
// Read current whole-disk stats from /proc/diskstats
|
||||
$current = [];
|
||||
foreach (@file('/proc/diskstats', FILE_IGNORE_NEW_LINES) ?: [] as $line) {
|
||||
$p = preg_split('/\s+/', trim($line));
|
||||
if (count($p) < 14) continue;
|
||||
$dev = $p[2];
|
||||
// Keep only whole disks: sda/sdb, nvme0n1, md*, not sda1/nvme0n1p1
|
||||
if (!preg_match('/^(sd[a-z]+|nvme\d+n\d+|md\d+)$/', $dev)) continue;
|
||||
$current[$dev] = [(int)$p[5], (int)$p[9]]; // [sectors_read, sectors_written]
|
||||
}
|
||||
|
||||
// Load previous snapshot
|
||||
$snap = @json_decode(@file_get_contents($snapFile) ?: '', true) ?: [];
|
||||
$prevTime = (float)($snap['t'] ?? $now);
|
||||
$prev = $snap['d'] ?? [];
|
||||
|
||||
// Save current snapshot
|
||||
@file_put_contents($snapFile, json_encode(['t' => $now, 'd' => $current], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$dt = max(0.5, $now - $prevTime);
|
||||
$rates = [];
|
||||
foreach ($current as $dev => [$rs, $ws]) {
|
||||
if (!isset($prev[$dev])) continue;
|
||||
[$prs, $pws] = $prev[$dev];
|
||||
$r = max(0.0, ($rs - $prs) * 512 / $dt / 1048576);
|
||||
$w = max(0.0, ($ws - $pws) * 512 / $dt / 1048576);
|
||||
if ($r > 0.01 || $w > 0.01) $rates[$dev] = [round($r, 1), round($w, 1)];
|
||||
}
|
||||
return $rates;
|
||||
}
|
||||
|
||||
function vv_disk_thresholds(): array {
|
||||
$cfg = @file_get_contents('/boot/config/plugins/dynamix/dynamix.cfg') ?: '';
|
||||
$get = function(string $key) use ($cfg): ?int {
|
||||
|
||||
@@ -208,6 +208,7 @@ let vvNetRxHistory = [];
|
||||
let vvNetTxHistory = [];
|
||||
let vvPoolsOpen = {};
|
||||
let vvPoolGroupOpen = {};
|
||||
let vvDiskIo = {}; // {device: [readMBs, writeMBs]}
|
||||
let vvLastStorageDisks = [];
|
||||
|
||||
// ── Canvas chart ──────────────────────────────────────────────────────────────
|
||||
@@ -506,6 +507,27 @@ function vvTempColor(tempC, transport) {
|
||||
|
||||
function vvFmt(v) { return v >= 1024 ? (v / 1024).toFixed(1) + ' TB' : v + ' GB'; }
|
||||
|
||||
function vvFmtRate(mbs) {
|
||||
if (mbs >= 1000) return (mbs / 1024).toFixed(1) + ' GB/s';
|
||||
if (mbs >= 100) return Math.round(mbs) + ' MB/s';
|
||||
return mbs.toFixed(1) + ' MB/s';
|
||||
}
|
||||
function vvIoChip(dev) {
|
||||
const io = vvDiskIo[dev];
|
||||
if (!io) return '';
|
||||
const [r, w] = io;
|
||||
if (r < 0.05 && w < 0.05) return '';
|
||||
const parts = [];
|
||||
if (r >= 0.05) parts.push(`<span style="color:#3a7a3a;">↓${vvFmtRate(r)}</span>`);
|
||||
if (w >= 0.05) parts.push(`<span style="color:#7a4a1a;">↑${vvFmtRate(w)}</span>`);
|
||||
return `<span style="font-size:9px;margin-left:5px;">${parts.join(' ')}</span>`;
|
||||
}
|
||||
function vvIoSum(devices) {
|
||||
let r = 0, w = 0;
|
||||
devices.forEach(dev => { const io = vvDiskIo[dev]; if (io) { r += io[0]; w += io[1]; } });
|
||||
return [r, w];
|
||||
}
|
||||
|
||||
function vvDiskRow(disk) {
|
||||
const tempC = disk.temp;
|
||||
const tempColor = vvTempColor(tempC, disk.transport);
|
||||
@@ -524,7 +546,7 @@ function vvDiskRow(disk) {
|
||||
: `<span style="color:#555;font-size:10px;">${vvFmt(disk.used_gb)} / ${vvFmt(disk.size_gb)}</span>`;
|
||||
return `<div style="margin-bottom:7px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-bottom:3px;">
|
||||
<span style="color:${nameColor};">${disk.name}${spinLabel}</span>
|
||||
<span style="color:${nameColor};display:flex;align-items:center;">${disk.name}${spinLabel}${vvIoChip(disk.device)}</span>
|
||||
${right}
|
||||
<span style="color:${tempColor};font-size:10px;margin-left:6px;flex-shrink:0;">${tempStr}</span>
|
||||
</div>
|
||||
@@ -1183,6 +1205,7 @@ function vvPollMonitor() {
|
||||
|
||||
|
||||
// ── Pools ─────────────────────────────────────────────────────────────────
|
||||
vvDiskIo = d.disk_io ?? {};
|
||||
vvLastStorageDisks = d.storage ?? [];
|
||||
document.getElementById('vv-storage-body').innerHTML = vvRenderPools(vvLastStorageDisks);
|
||||
|
||||
@@ -1200,6 +1223,13 @@ function vvPollMonitor() {
|
||||
const arrPct = totalSizeGb > 0 ? Math.round(totalUsedGb / totalSizeGb * 100) : 0;
|
||||
const arrPctColor = arrPct >= (vvThresholds.util_crit ?? 90) ? '#f44336' : arrPct >= (vvThresholds.util_warn ?? 70) ? '#ff9800' : '#4caf50';
|
||||
|
||||
const [arrIor, arrIow] = vvIoSum(arrayDisks.map(d => d.device));
|
||||
const arrIoParts = [];
|
||||
if (arrIor >= 0.05) arrIoParts.push(`<span style="color:#3a7a3a;">↓${vvFmtRate(arrIor)}</span>`);
|
||||
if (arrIow >= 0.05) arrIoParts.push(`<span style="color:#7a4a1a;">↑${vvFmtRate(arrIow)}</span>`);
|
||||
const arrIoHtml = arrIoParts.length
|
||||
? `· <span style="font-weight:400;">${arrIoParts.join(' ')}</span>` : '';
|
||||
|
||||
const titleEl = document.getElementById('vv-array-title');
|
||||
if (titleEl) titleEl.innerHTML = `Array
|
||||
<span style="font-size:10px;color:#444;font-weight:400;text-transform:none;letter-spacing:0;margin-left:6px;">
|
||||
@@ -1207,6 +1237,7 @@ function vvPollMonitor() {
|
||||
${errDisks > 0 ? `<span style="color:#f44336;margin-left:4px;">· ${errDisks} err</span>` : ''}
|
||||
· <span style="color:${arrPctColor};">${arrPct}%</span> used
|
||||
${maxTemp != null ? `· <span style="color:${maxTempColor};">max ${maxTemp}°</span>` : ''}
|
||||
${arrIoHtml}
|
||||
</span>`;
|
||||
|
||||
const numCols = Math.max(3, Math.ceil(arrayDisks.length / 6));
|
||||
@@ -1681,6 +1712,13 @@ function vvRenderPools(disks) {
|
||||
const allOk = drives.every(d => d.status === 'DISK_OK' || !d.status);
|
||||
const statusColor = allOk ? '#aaa' : '#f44336';
|
||||
const sName = poolName.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
|
||||
const [pIor, pIow] = vvIoSum(drives.map(d => d.device));
|
||||
const poolIoHtml = (() => {
|
||||
const parts = [];
|
||||
if (pIor >= 0.05) parts.push(`<span style="color:#3a7a3a;">↓${vvFmtRate(pIor)}</span>`);
|
||||
if (pIow >= 0.05) parts.push(`<span style="color:#7a4a1a;">↑${vvFmtRate(pIow)}</span>`);
|
||||
return parts.length ? `<span style="font-size:9px;margin-left:5px;">${parts.join(' ')}</span>` : '';
|
||||
})();
|
||||
|
||||
let html = `<div style="margin-bottom:8px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-bottom:3px;
|
||||
@@ -1689,6 +1727,7 @@ function vvRenderPools(disks) {
|
||||
${multi ? `<span style="color:#555;font-size:9px;width:8px;">${isOpen ? '▾' : '▸'}</span>` : '<span style="width:8px;display:inline-block;"></span>'}
|
||||
${poolName}
|
||||
${multi ? `<span style="font-size:9px;color:#444;background:#1a1a1a;padding:0 4px;border-radius:2px;">${drives.length} drives</span>` : ''}
|
||||
${poolIoHtml}
|
||||
</span>
|
||||
<span style="display:flex;align-items:center;gap:8px;">
|
||||
${maxTemp != null ? `<span style="color:${tempColor};font-size:10px;">${maxTemp}°</span>` : ''}
|
||||
|
||||
Reference in New Issue
Block a user