diff --git a/README.md b/README.md
index 4b66abd..7929b41 100644
--- a/README.md
+++ b/README.md
@@ -121,6 +121,8 @@ ss -lntp | grep 9105
Панель показывает:
- изменение критичных SMART-счётчиков, результаты self-test и исчезновение дисков;
+- realtime read/write IOPS, MB/s, latency, utilization и глубину очереди каждого диска;
+- недельную историю IOPS/utilization и алерты при устойчивом I/O-давлении;
- автоматический короткий SMART self-test раз в неделю для дисков без свежего теста;
- datasets, vdev-классы, ошибки read/write/checksum, scrub и ARC ZFS;
- доступность шлюза, DNS и интернета, packet loss, latency и внешний IP;
diff --git a/alerts.go b/alerts.go
index 3a83956..617f400 100644
--- a/alerts.go
+++ b/alerts.go
@@ -78,6 +78,13 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler
if len(disk.CounterChanges) > 0 {
add("disk-growth-"+disk.Name, "critical", "Диски", disk.Model+": ухудшились SMART-счётчики", strings.Join(disk.CounterChanges, " · "))
}
+ if disk.IOPressureSeconds >= 60 {
+ severity := "warning"
+ if disk.Utilization >= 98 || disk.LatencyMs >= 100 {
+ severity = "critical"
+ }
+ add("disk-io-pressure-"+disk.Name, severity, "Диски", disk.Model+": длительная I/O-нагрузка", fmt.Sprintf("Нагрузка держится %.0f сек.: %.0f IOPS, %.1f MB/s, latency %.1f ms, util %.1f%%, очередь %.1f.", disk.IOPressureSeconds, disk.ReadIOPS+disk.WriteIOPS, (disk.ReadBytesPerSec+disk.WriteBytesPerSec)/1048576, disk.LatencyMs, disk.Utilization, disk.AverageQueue))
+ }
if disk.SelfTestNeverRun {
add("disk-selftest-"+disk.Name, "warning", "Диски", disk.Model+": нет завершённого SMART self-test", "Короткий тест будет автоматически запущен; проверьте его результат после завершения.")
}
diff --git a/disks.go b/disks.go
index 0c4584b..f7593b5 100644
--- a/disks.go
+++ b/disks.go
@@ -13,46 +13,63 @@ import (
)
type DiskMetrics struct {
- Name string `json:"name"`
- Path string `json:"path"`
- Model string `json:"model"`
- Serial string `json:"serial"`
- Type string `json:"type"`
- Protocol string `json:"protocol"`
- SizeBytes uint64 `json:"sizeBytes"`
- UsedBytes *uint64 `json:"usedBytes"`
- UsagePercent *float64 `json:"usagePercent"`
- SMARTStatus string `json:"smartStatus"`
- SMARTAvailable bool `json:"smartAvailable"`
- AttentionReasons []string `json:"attentionReasons"`
- Temperature *float64 `json:"temperatureCelsius"`
- PowerOnHours *uint64 `json:"powerOnHours"`
- WearUsedPercent *float64 `json:"wearUsedPercent"`
- Reallocated uint64 `json:"reallocated"`
- Pending uint64 `json:"pending"`
- Uncorrectable uint64 `json:"uncorrectable"`
- CRCErrors uint64 `json:"crcErrors"`
- MediaErrors uint64 `json:"mediaErrors"`
- CounterChanges []string `json:"counterChanges"`
- LastSelfTest string `json:"lastSelfTest"`
- LastSelfTestAt string `json:"lastSelfTestAt"`
- SelfTestNeverRun bool `json:"selfTestNeverRun"`
- ShortTestStarted bool `json:"shortTestStarted"`
+ Name string `json:"name"`
+ Path string `json:"path"`
+ Model string `json:"model"`
+ Serial string `json:"serial"`
+ Type string `json:"type"`
+ Protocol string `json:"protocol"`
+ SizeBytes uint64 `json:"sizeBytes"`
+ UsedBytes *uint64 `json:"usedBytes"`
+ UsagePercent *float64 `json:"usagePercent"`
+ SMARTStatus string `json:"smartStatus"`
+ SMARTAvailable bool `json:"smartAvailable"`
+ AttentionReasons []string `json:"attentionReasons"`
+ Temperature *float64 `json:"temperatureCelsius"`
+ PowerOnHours *uint64 `json:"powerOnHours"`
+ WearUsedPercent *float64 `json:"wearUsedPercent"`
+ Reallocated uint64 `json:"reallocated"`
+ Pending uint64 `json:"pending"`
+ Uncorrectable uint64 `json:"uncorrectable"`
+ CRCErrors uint64 `json:"crcErrors"`
+ MediaErrors uint64 `json:"mediaErrors"`
+ CounterChanges []string `json:"counterChanges"`
+ LastSelfTest string `json:"lastSelfTest"`
+ LastSelfTestAt string `json:"lastSelfTestAt"`
+ SelfTestNeverRun bool `json:"selfTestNeverRun"`
+ ShortTestStarted bool `json:"shortTestStarted"`
+ ReadIOPS float64 `json:"readIops"`
+ WriteIOPS float64 `json:"writeIops"`
+ ReadBytesPerSec float64 `json:"readBytesPerSec"`
+ WriteBytesPerSec float64 `json:"writeBytesPerSec"`
+ ReadLatencyMs float64 `json:"readLatencyMs"`
+ WriteLatencyMs float64 `json:"writeLatencyMs"`
+ LatencyMs float64 `json:"latencyMs"`
+ Utilization float64 `json:"utilizationPercent"`
+ QueueDepth uint64 `json:"queueDepth"`
+ AverageQueue float64 `json:"averageQueue"`
+ IOPressureSeconds float64 `json:"ioPressureSeconds"`
}
+type diskIOSnapshot struct{ reads, readSectors, readMS, writes, writeSectors, writeMS, inFlight, ioMS, weightedMS uint64 }
+
type diskCollector struct {
- mu sync.Mutex
- cached []DiskMetrics
- updatedAt time.Time
- previous map[string]DiskMetrics
- missing []string
- lastTests map[string]time.Time
+ mu sync.Mutex
+ cached []DiskMetrics
+ updatedAt time.Time
+ previous map[string]DiskMetrics
+ missing []string
+ lastTests map[string]time.Time
+ ioPrevious map[string]diskIOSnapshot
+ ioUpdatedAt time.Time
+ ioPressureSince map[string]time.Time
}
func (c *diskCollector) collect() []DiskMetrics {
c.mu.Lock()
defer c.mu.Unlock()
if time.Since(c.updatedAt) < time.Minute && c.cached != nil {
+ c.applyDiskIO(c.cached)
return c.cached
}
current := readPhysicalDisks()
@@ -87,10 +104,89 @@ func (c *diskCollector) collect() []DiskMetrics {
}
}
c.cached = current
+ c.applyDiskIO(c.cached)
c.updatedAt = time.Now()
return c.cached
}
+func (c *diskCollector) applyDiskIO(disks []DiskMetrics) {
+ now := time.Now()
+ snapshots := readDiskIOStats("/proc/diskstats")
+ if c.ioPrevious == nil {
+ c.ioPrevious = map[string]diskIOSnapshot{}
+ c.ioPressureSince = map[string]time.Time{}
+ }
+ elapsed := now.Sub(c.ioUpdatedAt).Seconds()
+ for i := range disks {
+ disk := &disks[i]
+ disk.ReadIOPS, disk.WriteIOPS, disk.ReadBytesPerSec, disk.WriteBytesPerSec = 0, 0, 0, 0
+ disk.ReadLatencyMs, disk.WriteLatencyMs, disk.LatencyMs, disk.Utilization, disk.AverageQueue, disk.IOPressureSeconds = 0, 0, 0, 0, 0, 0
+ current, ok := snapshots[disk.Name]
+ if !ok {
+ continue
+ }
+ disk.QueueDepth = current.inFlight
+ if old, found := c.ioPrevious[disk.Name]; found && elapsed > 0 && diskIOCountersMonotonic(old, current) {
+ readOps := current.reads - old.reads
+ writeOps := current.writes - old.writes
+ disk.ReadIOPS = float64(readOps) / elapsed
+ disk.WriteIOPS = float64(writeOps) / elapsed
+ disk.ReadBytesPerSec = float64(current.readSectors-old.readSectors) * 512 / elapsed
+ disk.WriteBytesPerSec = float64(current.writeSectors-old.writeSectors) * 512 / elapsed
+ if readOps > 0 {
+ disk.ReadLatencyMs = float64(current.readMS-old.readMS) / float64(readOps)
+ }
+ if writeOps > 0 {
+ disk.WriteLatencyMs = float64(current.writeMS-old.writeMS) / float64(writeOps)
+ }
+ if readOps+writeOps > 0 {
+ disk.LatencyMs = float64(current.readMS-old.readMS+current.writeMS-old.writeMS) / float64(readOps+writeOps)
+ }
+ disk.Utilization = min(float64(current.ioMS-old.ioMS)/(elapsed*10), 100)
+ disk.AverageQueue = float64(current.weightedMS-old.weightedMS) / (elapsed * 1000)
+ latencyLimit := 20.0
+ if disk.Type == "HDD" {
+ latencyLimit = 50
+ }
+ pressured := disk.Utilization >= 90 || (disk.LatencyMs >= latencyLimit && disk.ReadIOPS+disk.WriteIOPS >= 1) || disk.AverageQueue >= 4
+ if pressured {
+ if c.ioPressureSince[disk.Name].IsZero() {
+ c.ioPressureSince[disk.Name] = now
+ }
+ disk.IOPressureSeconds = now.Sub(c.ioPressureSince[disk.Name]).Seconds()
+ } else {
+ delete(c.ioPressureSince, disk.Name)
+ }
+ }
+ c.ioPrevious[disk.Name] = current
+ }
+ c.ioUpdatedAt = now
+}
+
+func diskIOCountersMonotonic(old, current diskIOSnapshot) bool {
+ return current.reads >= old.reads && current.readSectors >= old.readSectors && current.readMS >= old.readMS && current.writes >= old.writes && current.writeSectors >= old.writeSectors && current.writeMS >= old.writeMS && current.ioMS >= old.ioMS && current.weightedMS >= old.weightedMS
+}
+
+func readDiskIOStats(path string) map[string]diskIOSnapshot {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil
+ }
+ result := map[string]diskIOSnapshot{}
+ for _, line := range strings.Split(string(data), "\n") {
+ f := strings.Fields(line)
+ if len(f) < 14 || !isPhysicalDiskName(f[2]) {
+ continue
+ }
+ values := make([]uint64, 11)
+ for i := 0; i < 11; i++ {
+ values[i], _ = strconv.ParseUint(f[i+3], 10, 64)
+ }
+ result[f[2]] = diskIOSnapshot{reads: values[0], readSectors: values[2], readMS: values[3], writes: values[4], writeSectors: values[6], writeMS: values[7], inFlight: values[8], ioMS: values[9], weightedMS: values[10]}
+ }
+ return result
+}
+
func (c *diskCollector) missingDisks() []string {
c.mu.Lock()
defer c.mu.Unlock()
diff --git a/disks_test.go b/disks_test.go
index 7f4977e..84be4e1 100644
--- a/disks_test.go
+++ b/disks_test.go
@@ -2,6 +2,8 @@ package main
import (
"encoding/json"
+ "os"
+ "path/filepath"
"testing"
)
@@ -17,6 +19,22 @@ func TestIsPhysicalDiskName(t *testing.T) {
}
}
+func TestReadDiskIOStats(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "diskstats")
+ data := " 8 0 sda 100 2 300 40 50 3 700 80 4 90 120 0 0 0 0 0 0\n 7 0 loop0 999 0 999 0 999 0 999 0 0 0 0\n"
+ if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ stats := readDiskIOStats(path)
+ disk, ok := stats["sda"]
+ if !ok || disk.reads != 100 || disk.readSectors != 300 || disk.writes != 50 || disk.writeSectors != 700 || disk.inFlight != 4 || disk.ioMS != 90 || disk.weightedMS != 120 {
+ t.Fatalf("unexpected stats: %+v", disk)
+ }
+ if _, exists := stats["loop0"]; exists {
+ t.Fatal("virtual device must be ignored")
+ }
+}
+
func TestSumFilesystemUsage(t *testing.T) {
device := lsblkDevice{Children: []lsblkDevice{
{FSUsed: json.RawMessage(`100`), FSAvail: json.RawMessage(`300`)},
diff --git a/history.go b/history.go
index 1ef0e13..b46d168 100644
--- a/history.go
+++ b/history.go
@@ -44,6 +44,9 @@ func (s *Store) AddEntityMetrics(metrics dashboardMetrics) {
s.AddEntityHistory("guest", fmt.Sprintf("%d · %s", g.VMID, g.Name), ts, &cpu, &mem)
}
for _, d := range metrics.Disks {
+ iops := d.ReadIOPS + d.WriteIOPS
+ util := d.Utilization
+ s.AddEntityHistory("disk-io", d.Name+" · "+d.Model, ts, &iops, &util)
if d.Temperature == nil {
continue
}
diff --git a/web.go b/web.go
index 442085d..21f90dd 100644
--- a/web.go
+++ b/web.go
@@ -518,7 +518,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
Заполнение—
Точка монтирования/
Поиск накопителей…
- Температура физических дисковОтдельно по каждому диску
+ Температура и I/O физических дисковОтдельно по каждому диску
Температура
IOPS
Utilization