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
@@ -680,7 +680,7 @@ function render(x){const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs||{av el('memUsage').textContent=fixed(m.usagePercent);el('memBar').style.width=Math.min(m.usagePercent,100)+'%';el('memUsed').textContent=size(m.usedBytes);el('memAvailable').textContent=size(m.availableBytes);el('dMemUsed').textContent=size(m.usedBytes);el('dMemAvailable').textContent=size(m.availableBytes);el('dMemTotal').textContent=size(m.totalBytes);el('dMemCache').textContent=size(m.cachedBytes);el('dMemBuffers').textContent=size(m.buffersBytes);el('dMemPercent').textContent=fixed(m.usagePercent)+'%';el('dSwapUsed').textContent=size(m.swapUsedBytes);el('dSwapTotal').textContent=size(m.swapTotalBytes);el('dSwapPercent').textContent=fixed(m.swapPercent)+'%'; const failed=disks.filter(d=>d.smartStatus==='Ошибка').length,attention=disks.filter(d=>d.smartStatus==='Требует внимания').length,available=disks.filter(d=>d.smartAvailable).length;let health=failed?'Ошибка':attention?'Внимание':available===disks.length&&disks.length?'Исправны':available?'Частично':'Нет данных'; el('diskCount').textContent=disks.length;el('diskCountLabel').textContent=disks.length===1?' диск':' дисков';el('diskHealth').textContent=health;el('diskUsage').textContent=fixed(s.usagePercent)+'%';el('diskBar').style.width=Math.min(s.usagePercent,100)+'%';el('dDiskUsed').textContent=size(s.usedBytes);el('dDiskFree').textContent=size(s.freeBytes);el('dDiskTotal').textContent=size(s.totalBytes);el('dDiskPercent').textContent=fixed(s.usagePercent)+'%'; - el('diskList').innerHTML=disks.length?disks.map(d=>{const badge=d.smartStatus==='Ошибка'?'bad':d.smartStatus==='Исправен'?'':'warn',reason=(d.attentionReasons||[]).join(' · '),tip=reason?' tabindex="0" data-reason="'+safe(reason)+'"':'';return '
'+safe(d.model)+'
'+safe(d.path)+' · SN: '+safe(d.serial)+'
'+safe(d.smartStatus)+'
Тип / объём'+safe(d.type)+' · '+size(d.sizeBytes)+'
Занято'+(d.usedBytes==null?'—':size(d.usedBytes)+' · '+fixed(d.usagePercent)+'%')+'
Температура / износ'+(d.temperatureCelsius==null?'—':fixed(d.temperatureCelsius)+' °C')+' · '+(d.wearUsedPercent==null?'—':fixed(d.wearUsedPercent)+'%')+'
Reallocated / Pending'+d.reallocated+' / '+d.pending+'
Uncorrectable / CRC'+d.uncorrectable+' / '+d.crcErrors+'
Последний self-test'+safe(d.lastSelfTest||'—')+(d.lastSelfTestAt?' · '+new Date(d.lastSelfTestAt).toLocaleDateString('ru-RU'):'')+(d.shortTestStarted?' · запущен новый':'')+'
'}).join(''):'Физические накопители не найдены'; + el('diskList').innerHTML=disks.length?disks.map(d=>{const ioWarn=d.ioPressureSeconds>=60,badge=d.smartStatus==='Ошибка'?'bad':d.smartStatus==='Исправен'?(ioWarn?'warn':''):'warn',reasons=[...(d.attentionReasons||[]),...(ioWarn?['Длительная I/O-нагрузка: '+fixed(d.ioPressureSeconds)+' сек.']:[])],reason=reasons.join(' · '),tip=reason?' tabindex="0" data-reason="'+safe(reason)+'"':'';return '
'+safe(d.model)+'
'+safe(d.path)+' · SN: '+safe(d.serial)+'
'+(ioWarn?'Высокий I/O':safe(d.smartStatus))+'
Тип / объём'+safe(d.type)+' · '+size(d.sizeBytes)+'
Занято'+(d.usedBytes==null?'—':size(d.usedBytes)+' · '+fixed(d.usagePercent)+'%')+'
Температура / износ'+(d.temperatureCelsius==null?'—':fixed(d.temperatureCelsius)+' °C')+' · '+(d.wearUsedPercent==null?'—':fixed(d.wearUsedPercent)+'%')+'
IOPS · чтение / запись'+fixed(d.readIops)+' / '+fixed(d.writeIops)+'
Скорость · чтение / запись'+rate(d.readBytesPerSec)+' / '+rate(d.writeBytesPerSec)+'
Latency · чтение / запись'+fixed(d.readLatencyMs)+' / '+fixed(d.writeLatencyMs)+' ms
Utilization / очередь'+fixed(d.utilizationPercent)+'% / '+fixed(d.averageQueue)+' ('+d.queueDepth+' сейчас)
Reallocated / Pending'+d.reallocated+' / '+d.pending+'
Uncorrectable / CRC'+d.uncorrectable+' / '+d.crcErrors+'
Последний self-test'+safe(d.lastSelfTest||'—')+(d.lastSelfTestAt?' · '+new Date(d.lastSelfTestAt).toLocaleDateString('ru-RU'):'')+(d.shortTestStarted?' · запущен новый':'')+'
'}).join(''):'Физические накопители не найдены'; const totalRate=n.receivePerSec+n.transmitPerSec,nh=n.health||{};el('netInterfaces').textContent=n.interface||'Сетевые интерфейсы';el('netTotal').textContent=(totalRate/1048576).toFixed(2);el('netBar').style.width=Math.min(totalRate/125000000*100,100)+'%';el('netDown').textContent=rate(n.receivePerSec);el('netUp').textContent=rate(n.transmitPerSec);el('dNetDown').textContent=rate(n.receivePerSec);el('dNetUp').textContent=rate(n.transmitPerSec);el('dNetTotal').textContent=rate(totalRate);el('dNetReceived').textContent=size(n.receiveBytes);el('dNetSent').textContent=size(n.transmitBytes);el('dNetInterfaces').textContent=n.interface||'—';el('dNetGateway').textContent=(nh.gateway||'Не определён')+' · '+(nh.gatewayUp?'доступен':'нет ответа')+(nh.latencyMs?' · '+fixed(nh.latencyMs)+' ms':'');el('dNetDNS').textContent=nh.dnsUp?'Работает':'Ошибка';el('dNetInternet').textContent=nh.internetUp?'Доступен':'Недоступен';el('dNetExternalIP').textContent=(nh.externalIp||'—')+(nh.externalIpChanged?' · изменился':''); const pools=z.pools||[],zTotal=pools.reduce((a,p)=>a+p.sizeBytes,0),zUsed=pools.reduce((a,p)=>a+p.allocatedBytes,0),zPercent=zTotal?zUsed/zTotal*100:0,zBad=pools.filter(p=>p.health!=='ONLINE').length;el('zfsCount').textContent=pools.length;el('zfsHealth').textContent=!z.available?'Не найден':zBad?zBad+' требуют внимания':pools.length+' ONLINE';el('zfsUsed').textContent=zTotal?size(zUsed)+' / '+size(zTotal)+' · '+fixed(zPercent)+'%':'—';el('zfsBar').style.width=Math.min(zPercent,100)+'%'; const datasets=z.datasets||[],arc=z.arc||{};el('zfsArcSize').textContent=arc.available?size(arc.sizeBytes):'—';el('zfsArcTarget').textContent=arc.available?size(arc.targetBytes):'—';el('zfsArcHit').textContent=arc.available?fixed(arc.hitRatio)+'%':'—';el('zfsDatasetCount').textContent=datasets.length;el('zfsList').innerHTML=pools.length?pools.map(p=>{const bad=p.health!=='ONLINE'||p.readErrors+p.writeErrors+p.checksumErrors>0,reason=(p.attentionReasons||[]).join(' · '),tip=reason?' tabindex="0" data-reason="'+safe(reason)+'"':'',poolDatasets=datasets.filter(d=>d.pool===p.name),vdevs=p.vdevs||[];return '
'+safe(p.name)+'
Scrub: '+safe(p.scrubStatus||p.scan||'нет данных')+(p.lastScrubAt?' · '+new Date(p.lastScrubAt).toLocaleString('ru-RU'):'')+(p.scrubDuration?' · '+safe(p.scrubDuration):'')+'
'+safe(p.health)+'
Объём / свободно'+size(p.sizeBytes)+' / '+size(p.freeBytes)+'
Заполнение'+fixed(p.capacityPercent)+'%
Read / write / checksum'+p.readErrors+' / '+p.writeErrors+' / '+p.checksumErrors+'
Vdev · data / special / cache / log
'+(vdevs.length?vdevs.map(v=>''+safe(v.class)+' · '+safe(v.name)+' · '+safe(v.state)+' · '+v.read+'/'+v.write+'/'+v.checksum+'').join(''):'Нет данных')+'
Datasets
'+(poolDatasets.length?poolDatasets.map(d=>''+safe(d.name)+' · '+fixed(d.capacityPercent)+'% · '+size(d.usedBytes)+'').join(''):'Нет datasets')+'
'}).join(''):''+(z.available?'ZFS-пулы не найдены':'Команда zpool недоступна')+''; @@ -703,12 +703,12 @@ const percent=v=>fixed(v)+'%',temperature=v=>fixed(v)+' °C',megabytes=v=>fixed( const chartConfigs={cpuChart:{series:[{label:'CPU',color:'#55d58a',value:p=>p.cpuUsage,format:percent},{label:'Температура',color:'#ff9b5c',value:p=>p.cpuTemperature,format:temperature}]},memoryChart:{series:[{label:'RAM',color:'#9d8cff',value:p=>p.memoryUsage,format:percent},{label:'Swap',color:'#ff78b8',value:p=>p.swapUsage,format:percent}]},storageChart:{series:[{label:'Раздел /',color:'#ff9b5c',value:p=>p.rootUsage,format:percent}]},networkChart:{max:0,series:[{label:'Приём',color:'#43c6d9',value:p=>p.networkReceive/1048576,format:megabytes},{label:'Передача',color:'#65ddaa',value:p=>p.networkTransmit/1048576,format:megabytes}]},zfsChart:{series:[{label:'ZFS',color:'#6577ff',value:p=>p.zfsUsage,format:percent}]},upsChart:{series:[{label:'Заряд',color:'#55d58a',value:p=>p.upsCharge,format:percent},{label:'Нагрузка',color:'#d5e85a',value:p=>p.upsLoad,format:percent}]},guestsChart:{series:[{label:'CPU',color:'#ff78b8',value:p=>p.guestsCpu,format:percent},{label:'RAM',color:'#9d8cff',value:p=>p.guestsMemory,format:percent}]}}; const historyCache=new Map(); async function loadHistory(hours){const cached=historyCache.get(hours);if(cached&&Date.now()-cached.loadedAt<60000)return cached.points;const response=await fetch('/api/history?hours='+hours);if(!response.ok)throw new Error('history');const points=await response.json()||[];historyCache.set(hours,{points,loadedAt:Date.now()});return points} -const entityChartKinds={storageChart:{kind:'disk',format:temperature},zfsChart:{kind:'zfs',format:percent},guestsChart:{kind:'guest',format:percent}},chartColors=['#55d58a','#6ba8ff','#ff9b5c','#9d8cff','#ff78b8','#43c6d9','#d5e85a','#ff665c','#6577ff','#65ddaa']; -async function loadEntityChart(id,hours){const definition=entityChartKinds[id],response=await fetch('/api/entity-history?kind='+definition.kind+'&hours='+hours);if(!response.ok)throw new Error('entity history');const data=await response.json()||[],timestamps=new Map(),series=data.map((item,index)=>{const key='entity'+index;item.points.forEach(p=>{const point=timestamps.get(p.timestamp)||{timestamp:p.timestamp};point[key]=p.value1;timestamps.set(p.timestamp,point)});return {label:item.name,color:chartColors[index%chartColors.length],value:p=>p[key],format:definition.format}});chartConfigs[id]={series};return [...timestamps.values()].sort((a,b)=>a.timestamp-b.timestamp)} +const entityChartKinds={storageChart:{kind:'disk',format:temperature},diskIOChart:{kind:'disk-io',format:v=>fixed(v)+' IOPS',dynamic:true},diskUtilChart:{kind:'disk-io',format:percent,value:'value2'},zfsChart:{kind:'zfs',format:percent},guestsChart:{kind:'guest',format:percent}},chartColors=['#55d58a','#6ba8ff','#ff9b5c','#9d8cff','#ff78b8','#43c6d9','#d5e85a','#ff665c','#6577ff','#65ddaa']; +async function loadEntityChart(id,hours){const definition=entityChartKinds[id],response=await fetch('/api/entity-history?kind='+definition.kind+'&hours='+hours);if(!response.ok)throw new Error('entity history');const data=await response.json()||[],timestamps=new Map(),series=data.map((item,index)=>{const key='entity'+index;item.points.forEach(p=>{const point=timestamps.get(p.timestamp)||{timestamp:p.timestamp};point[key]=p[definition.value||'value1'];timestamps.set(p.timestamp,point)});return {label:item.name,color:chartColors[index%chartColors.length],value:p=>p[key],format:definition.format}});chartConfigs[id]={series,max:definition.dynamic?0:undefined};return [...timestamps.values()].sort((a,b)=>a.timestamp-b.timestamp)} function drawChart(id,points){const canvas=el(id),config=chartConfigs[id];if(!canvas||!config||!canvas.offsetWidth)return;const series=config.series,dpr=window.devicePixelRatio||1,w=canvas.offsetWidth,h=200;canvas.width=w*dpr;canvas.height=h*dpr;const ctx=canvas.getContext('2d');ctx.scale(dpr,dpr);const styles=getComputedStyle(document.documentElement),text=styles.getPropertyValue('--muted').trim(),line=styles.getPropertyValue('--line').trim();ctx.clearRect(0,0,w,h);ctx.font='11px system-ui';ctx.fillStyle=text;if(points.length<2){ctx.textAlign='center';ctx.fillText('История появится после нескольких минут работы',w/2,h/2);canvas._chartState=null;return}const values=series.flatMap(s=>points.map(p=>s.value(p)).filter(v=>v!=null&&Number.isFinite(v))),max=config.max===0?Math.max(...values,1)*1.1:100,pad={l:38,r:12,t:30,b:25},pw=w-pad.l-pad.r,ph=h-pad.t-pad.b;ctx.strokeStyle=line;ctx.lineWidth=1;for(let i=0;i<=4;i++){const y=pad.t+ph*i/4;ctx.beginPath();ctx.moveTo(pad.l,y);ctx.lineTo(w-pad.r,y);ctx.stroke();ctx.fillStyle=text;ctx.textAlign='right';ctx.fillText((max*(1-i/4)).toFixed(max<10?1:0),pad.l-7,y+4)}series.forEach((s,index)=>{ctx.strokeStyle=s.color;ctx.lineWidth=2;ctx.beginPath();let started=false;points.forEach((p,i)=>{const v=s.value(p);if(v==null||!Number.isFinite(v))return;const x=pad.l+pw*i/(points.length-1),y=pad.t+ph*(1-v/(max||1));if(!started){ctx.moveTo(x,y);started=true}else ctx.lineTo(x,y)});ctx.stroke();ctx.fillStyle=s.color;ctx.textAlign='left';ctx.fillText(s.label,pad.l+index*130,15)});const first=new Date(points[0].timestamp*1000),last=new Date(points[points.length-1].timestamp*1000),timeOptions={hour:'2-digit',minute:'2-digit'};ctx.fillStyle=text;ctx.textAlign='left';ctx.fillText(first.toLocaleString('ru-RU',timeOptions),pad.l,h-6);ctx.textAlign='right';ctx.fillText(last.toLocaleString('ru-RU',timeOptions),w-pad.r,h-6);canvas._chartState={points,series,pad,pw,w}} function setupChartHover(canvas){canvas.addEventListener('mousemove',event=>{const state=canvas._chartState,section=canvas.closest('.chart-section'),tooltip=section&§ion.querySelector('.chart-tooltip');if(!state||!tooltip)return;const rect=canvas.getBoundingClientRect(),x=event.clientX-rect.left,index=Math.max(0,Math.min(state.points.length-1,Math.round((x-state.pad.l)/state.pw*(state.points.length-1)))),point=state.points[index],date=new Date(point.timestamp*1000);tooltip.innerHTML='
'+date.toLocaleString('ru-RU')+'
'+state.series.map(s=>{const value=s.value(point),shown=value==null||!Number.isFinite(value)?'—':s.format(value);return '
'+s.label+''+shown+'
'}).join('');tooltip.classList.add('visible');const sectionRect=section.getBoundingClientRect(),left=Math.min(event.clientX-sectionRect.left+12,section.clientWidth-tooltip.offsetWidth-10);tooltip.style.left=Math.max(10,left)+'px';tooltip.style.top=Math.max(55,event.clientY-sectionRect.top-tooltip.offsetHeight-12)+'px'});canvas.addEventListener('mouseleave',()=>{const tooltip=canvas.closest('.chart-section').querySelector('.chart-tooltip');tooltip.classList.remove('visible')})} -async function showHistory(section){const hours=Number(section.dataset.hours||1),canvas=section.querySelector('canvas');try{drawChart(canvas.id,entityChartKinds[canvas.id]?await loadEntityChart(canvas.id,hours):await loadHistory(hours))}catch(e){drawChart(canvas.id,[])}} -function setupDetailTabs(){document.querySelectorAll('dialog').forEach(dialog=>{const chart=dialog.querySelector('.chart-section');if(!chart)return;const head=dialog.querySelector('.modal-head'),tabs=document.createElement('div'),realtime=document.createElement('div');tabs.className='detail-tabs';tabs.innerHTML='';realtime.className='detail-panel';realtime.dataset.detailPanel='realtime';while(head.nextElementSibling&&head.nextElementSibling!==chart)realtime.appendChild(head.nextElementSibling);head.after(tabs);tabs.after(realtime);chart.classList.add('detail-panel');chart.dataset.detailPanel='history';chart.dataset.hours='1';chart.hidden=true;const title=chart.querySelector('.chart-head strong'),chartHead=chart.querySelector('.chart-head');chartHead.innerHTML='';chartHead.appendChild(title);const ranges=document.createElement('div');ranges.className='range-switch';ranges.innerHTML='';chartHead.appendChild(ranges);const tooltip=document.createElement('div');tooltip.className='chart-tooltip';chart.appendChild(tooltip);setupChartHover(chart.querySelector('canvas'));tabs.querySelectorAll('[data-detail-tab]').forEach(tab=>tab.onclick=()=>{tabs.querySelectorAll('.detail-tab').forEach(t=>t.classList.toggle('active',t===tab));realtime.hidden=tab.dataset.detailTab!=='realtime';chart.hidden=tab.dataset.detailTab!=='history';if(!chart.hidden)setTimeout(()=>showHistory(chart),20)});ranges.querySelectorAll('[data-hours]').forEach(button=>button.onclick=()=>{chart.dataset.hours=button.dataset.hours;ranges.querySelectorAll('.range-button').forEach(b=>b.classList.toggle('active',b===button));showHistory(chart)})})} +async function showHistory(section){const hours=Number(section.dataset.hours||1);await Promise.all([...section.querySelectorAll('canvas')].map(async canvas=>{try{drawChart(canvas.id,entityChartKinds[canvas.id]?await loadEntityChart(canvas.id,hours):await loadHistory(hours))}catch(e){drawChart(canvas.id,[])}}))} +function setupDetailTabs(){document.querySelectorAll('dialog').forEach(dialog=>{const chart=dialog.querySelector('.chart-section');if(!chart)return;const head=dialog.querySelector('.modal-head'),tabs=document.createElement('div'),realtime=document.createElement('div');tabs.className='detail-tabs';tabs.innerHTML='';realtime.className='detail-panel';realtime.dataset.detailPanel='realtime';while(head.nextElementSibling&&head.nextElementSibling!==chart)realtime.appendChild(head.nextElementSibling);head.after(tabs);tabs.after(realtime);chart.classList.add('detail-panel');chart.dataset.detailPanel='history';chart.dataset.hours='1';chart.hidden=true;const title=chart.querySelector('.chart-head strong'),chartHead=chart.querySelector('.chart-head');chartHead.innerHTML='';chartHead.appendChild(title);const ranges=document.createElement('div');ranges.className='range-switch';ranges.innerHTML='';chartHead.appendChild(ranges);const tooltip=document.createElement('div');tooltip.className='chart-tooltip';chart.appendChild(tooltip);chart.querySelectorAll('canvas').forEach(setupChartHover);tabs.querySelectorAll('[data-detail-tab]').forEach(tab=>tab.onclick=()=>{tabs.querySelectorAll('.detail-tab').forEach(t=>t.classList.toggle('active',t===tab));realtime.hidden=tab.dataset.detailTab!=='realtime';chart.hidden=tab.dataset.detailTab!=='history';if(!chart.hidden)setTimeout(()=>showHistory(chart),20)});ranges.querySelectorAll('[data-hours]').forEach(button=>button.onclick=()=>{chart.dataset.hours=button.dataset.hours;ranges.querySelectorAll('.range-button').forEach(b=>b.classList.toggle('active',b===button));showHistory(chart)})})} setupDetailTabs(); document.querySelectorAll('[data-open]').forEach(b=>b.onclick=()=>el(b.dataset.open).showModal()); document.querySelectorAll('dialog').forEach(d=>{d.querySelector('.close').onclick=()=>d.close();d.onclick=e=>{if(e.target===d)d.close()}});