diff --git a/Plugin/unraid/api/monitor.php b/Plugin/unraid/api/monitor.php index bfe8825..30fc135 100644 --- a/Plugin/unraid/api/monitor.php +++ b/Plugin/unraid/api/monitor.php @@ -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(), diff --git a/Plugin/unraid/include/common.php b/Plugin/unraid/include/common.php index a70579b..3e29c02 100644 --- a/Plugin/unraid/include/common.php +++ b/Plugin/unraid/include/common.php @@ -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 { diff --git a/Plugin/unraid/pages/monitor.php b/Plugin/unraid/pages/monitor.php index 0dd8277..9aae79c 100644 --- a/Plugin/unraid/pages/monitor.php +++ b/Plugin/unraid/pages/monitor.php @@ -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(`↓${vvFmtRate(r)}`); + if (w >= 0.05) parts.push(`↑${vvFmtRate(w)}`); + return `${parts.join(' ')}`; +} +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) { : `${vvFmt(disk.used_gb)} / ${vvFmt(disk.size_gb)}`; return `
- ${disk.name}${spinLabel} + ${disk.name}${spinLabel}${vvIoChip(disk.device)} ${right} ${tempStr}
@@ -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(`↓${vvFmtRate(arrIor)}`); + if (arrIow >= 0.05) arrIoParts.push(`↑${vvFmtRate(arrIow)}`); + const arrIoHtml = arrIoParts.length + ? `· ${arrIoParts.join(' ')}` : ''; + const titleEl = document.getElementById('vv-array-title'); if (titleEl) titleEl.innerHTML = `Array @@ -1207,6 +1237,7 @@ function vvPollMonitor() { ${errDisks > 0 ? `· ${errDisks} err` : ''} · ${arrPct}% used ${maxTemp != null ? `· max ${maxTemp}°` : ''} + ${arrIoHtml} `; 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(`↓${vvFmtRate(pIor)}`); + if (pIow >= 0.05) parts.push(`↑${vvFmtRate(pIow)}`); + return parts.length ? `${parts.join(' ')}` : ''; + })(); let html = `
${isOpen ? '▾' : '▸'}` : ''} ${poolName} ${multi ? `${drives.length} drives` : ''} + ${poolIoHtml} ${maxTemp != null ? `${maxTemp}°` : ''}