From 92607f62d50714b6342cc44c40691bf94414fcea Mon Sep 17 00:00:00 2001 From: Maxim Date: Sat, 1 Aug 2026 12:43:05 +0300 Subject: [PATCH] Add per-resource history and guest network groups --- history.go | 32 ++++++++++++++++++++++++ network.go | 44 ++++++++++++++++++++++++--------- network_test.go | 8 ++++++ store.go | 56 +++++++++++++++++++++++++++++++++++++++++ store_test.go | 14 +++++++++++ web.go | 66 +++++++++++++++++++++++++++++++++++++++++++------ 6 files changed, 202 insertions(+), 18 deletions(-) diff --git a/history.go b/history.go index de9cab4..1ef0e13 100644 --- a/history.go +++ b/history.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "log" "time" ) @@ -15,6 +16,7 @@ func runHistoryCollector(collect func() (dashboardMetrics, error), store *Store) if err := store.AddHistory(historyPointFromMetrics(metrics)); err != nil { log.Printf("Не удалось сохранить исторические метрики: %v", err) } + store.AddEntityMetrics(metrics) } record() ticker := time.NewTicker(time.Minute) @@ -24,6 +26,36 @@ func runHistoryCollector(collect func() (dashboardMetrics, error), store *Store) } } +func (s *Store) AddEntityMetrics(metrics dashboardMetrics) { + ts := time.Now().Unix() + for _, p := range metrics.ZFS.Pools { + v := p.CapacityPercent + s.AddEntityHistory("zfs", p.Name, ts, &v, nil) + } + for _, g := range metrics.Guests.Guests { + if g.Template != 0 { + continue + } + cpu := g.CPU * 100 + var mem float64 + if g.MaxMemory > 0 { + mem = float64(g.MemoryBytes) / float64(g.MaxMemory) * 100 + } + s.AddEntityHistory("guest", fmt.Sprintf("%d · %s", g.VMID, g.Name), ts, &cpu, &mem) + } + for _, d := range metrics.Disks { + if d.Temperature == nil { + continue + } + var wear *float64 + if d.WearUsedPercent != nil { + wear = d.WearUsedPercent + } + s.AddEntityHistory("disk", d.Name+" · "+d.Model, ts, d.Temperature, wear) + } + s.PruneEntityHistory() +} + func historyPointFromMetrics(metrics dashboardMetrics) HistoryPoint { point := HistoryPoint{ Timestamp: time.Now().Unix(), CPUUsage: metrics.CPU.UsagePercent, diff --git a/network.go b/network.go index 109c034..c446c67 100644 --- a/network.go +++ b/network.go @@ -19,13 +19,18 @@ type NetworkMetrics struct { } type NetworkInterface struct { - Name string `json:"name"` - ReceiveBytes uint64 `json:"receiveBytes"` - TransmitBytes uint64 `json:"transmitBytes"` - ReceiveErrors uint64 `json:"receiveErrors"` - TransmitErrors uint64 `json:"transmitErrors"` - ReceiveDrops uint64 `json:"receiveDrops"` - TransmitDrops uint64 `json:"transmitDrops"` + Name string `json:"name"` + ReceiveBytes uint64 `json:"receiveBytes"` + TransmitBytes uint64 `json:"transmitBytes"` + ReceiveErrors uint64 `json:"receiveErrors"` + TransmitErrors uint64 `json:"transmitErrors"` + ReceiveDrops uint64 `json:"receiveDrops"` + TransmitDrops uint64 `json:"transmitDrops"` + ReceivePerSec float64 `json:"receivePerSec"` + TransmitPerSec float64 `json:"transmitPerSec"` + VMID int `json:"vmid,omitempty"` + GuestName string `json:"guestName,omitempty"` + GuestType string `json:"guestType,omitempty"` } type networkSnapshot struct { @@ -35,9 +40,10 @@ type networkSnapshot struct { } type networkCollector struct { - mu sync.Mutex - path string - previous networkSnapshot + mu sync.Mutex + path string + previous networkSnapshot + previousInterfaces map[string]NetworkInterface } func newNetworkCollector(path string) (*networkCollector, error) { @@ -45,7 +51,12 @@ func newNetworkCollector(path string) (*networkCollector, error) { if err != nil { return nil, err } - return &networkCollector{path: path, previous: snapshot}, nil + _, _, details, _ := readNetworkSnapshot(path) + previous := map[string]NetworkInterface{} + for _, v := range details { + previous[v.Name] = v + } + return &networkCollector{path: path, previous: snapshot, previousInterfaces: previous}, nil } func (c *networkCollector) collect() (NetworkMetrics, error) { @@ -62,6 +73,17 @@ func (c *networkCollector) collect() (NetworkMetrics, error) { receivedRate = float64(current.received-c.previous.received) / elapsed sentRate = float64(current.sent-c.previous.sent) / elapsed } + for i := range details { + v := &details[i] + if old, ok := c.previousInterfaces[v.Name]; ok && elapsed > 0 && v.ReceiveBytes >= old.ReceiveBytes && v.TransmitBytes >= old.TransmitBytes { + v.ReceivePerSec = float64(v.ReceiveBytes-old.ReceiveBytes) / elapsed + v.TransmitPerSec = float64(v.TransmitBytes-old.TransmitBytes) / elapsed + } + } + c.previousInterfaces = map[string]NetworkInterface{} + for _, v := range details { + c.previousInterfaces[v.Name] = v + } c.previous = current return NetworkMetrics{ Interface: strings.Join(interfaceNames, ", "), Interfaces: details, ReceiveBytes: current.received, diff --git a/network_test.go b/network_test.go index f3e74d5..ac5d93b 100644 --- a/network_test.go +++ b/network_test.go @@ -32,3 +32,11 @@ func TestReadNetworkSnapshot(t *testing.T) { t.Fatalf("unexpected interface details: %+v", details) } } + +func TestInterfaceVMID(t *testing.T) { + for name, want := range map[string]int{"tap100i0": 100, "veth108i0": 108, "fwbr113i0": 113, "vmbr0": 0, "enp6s0": 0} { + if got := interfaceVMID(name); got != want { + t.Fatalf("%s: got %d want %d", name, got, want) + } + } +} diff --git a/store.go b/store.go index d2685e0..f6ce353 100644 --- a/store.go +++ b/store.go @@ -117,6 +117,8 @@ func openStore(path string) (*Store, error) { FOREIGN KEY(service_id) REFERENCES services(id) ON DELETE CASCADE )`, `CREATE INDEX IF NOT EXISTS service_checks_service_ts_idx ON service_checks(service_id,ts)`, + `CREATE TABLE IF NOT EXISTS entity_history (kind TEXT NOT NULL,name TEXT NOT NULL,ts INTEGER NOT NULL,value1 REAL,value2 REAL,PRIMARY KEY(kind,name,ts))`, + `CREATE INDEX IF NOT EXISTS entity_history_kind_ts_idx ON entity_history(kind,ts)`, } for _, statement := range statements { if _, err := db.Exec(statement); err != nil { @@ -131,6 +133,60 @@ func openStore(path string) (*Store, error) { return &Store{db: db}, nil } +type EntityHistoryPoint struct { + Timestamp int64 `json:"timestamp"` + Value1 *float64 `json:"value1,omitempty"` + Value2 *float64 `json:"value2,omitempty"` +} +type EntityHistorySeries struct { + Name string `json:"name"` + Points []EntityHistoryPoint `json:"points"` +} + +func (s *Store) AddEntityHistory(kind, name string, ts int64, v1, v2 *float64) { + _, _ = s.db.Exec(`INSERT OR REPLACE INTO entity_history(kind,name,ts,value1,value2) VALUES(?,?,?,?,?)`, kind, name, ts, v1, v2) +} +func (s *Store) EntityHistory(kind string, hours int) ([]EntityHistorySeries, error) { + if hours < 1 { + hours = 24 + } + if hours > 168 { + hours = 168 + } + rows, err := s.db.Query(`SELECT name,ts,value1,value2 FROM entity_history WHERE kind=? AND ts>=? ORDER BY name,ts`, kind, time.Now().Add(-time.Duration(hours)*time.Hour).Unix()) + if err != nil { + return nil, err + } + defer rows.Close() + var out []EntityHistorySeries + index := map[string]int{} + for rows.Next() { + var name string + var p EntityHistoryPoint + var v1, v2 sql.NullFloat64 + if err = rows.Scan(&name, &p.Timestamp, &v1, &v2); err != nil { + return nil, err + } + if v1.Valid { + p.Value1 = &v1.Float64 + } + if v2.Valid { + p.Value2 = &v2.Float64 + } + i, ok := index[name] + if !ok { + i = len(out) + index[name] = i + out = append(out, EntityHistorySeries{Name: name}) + } + out[i].Points = append(out[i].Points, p) + } + return out, rows.Err() +} +func (s *Store) PruneEntityHistory() { + _, _ = s.db.Exec(`DELETE FROM entity_history WHERE ts= '0' && rest[end] <= '9' { + end++ + } + if end > 0 { + id, _ := strconv.Atoi(rest[:end]) + return id + } + } + return 0 +} + func routes(collector *cpuCollector, store *Store) http.Handler { mux := http.NewServeMux() network, networkErr := newNetworkCollector("/proc/net/dev") @@ -178,6 +221,11 @@ func routes(collector *cpuCollector, store *Store) http.Handler { points, err := store.History(hours) writeJSON(w, points, err) }) + mux.HandleFunc("/api/entity-history", func(w http.ResponseWriter, r *http.Request) { + hours, _ := strconv.Atoi(r.URL.Query().Get("hours")) + series, err := store.EntityHistory(r.URL.Query().Get("kind"), hours) + writeJSON(w, series, err) + }) mux.HandleFunc("/api/services", func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: @@ -318,7 +366,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
Поиск ZFS-пулов…
-
Заполнение ZFSПоследние 7 дней
+
Заполнение каждого ZFS-пулаОтдельно по каждому пулу
@@ -429,7 +477,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
Получение списка гостей…
-
Суммарные CPU и RAM гостейПоследние 7 дней
+
CPU каждой VM и LXCОтдельно по каждому гостю
@@ -530,6 +578,7 @@ let alertSettings={backupMaxAgeHours:48}; let currentServiceStatuses=[]; function endpointHTML(v){const tls=v.tlsDays==null?'Нет TLS':v.tlsDays<0?'Сертификат истёк':v.tlsDays+' дн. до окончания',cert=v.tlsSubject?'
Сертификат: '+safe(v.tlsSubject)+'
Домены: '+safe((v.tlsDnsNames||[]).join(', ')||'не указаны')+'
Издатель: '+safe(v.tlsIssuer||'—')+'
Для этого адреса: '+(v.tlsHostnameValid?'подходит':'не подходит')+'
':'';return '
'+safe(v.label)+'
'+safe(v.address)+'
'+(v.up?'Доступен':'Недоступен')+'
Отклик'+fixed(v.latencyMs)+' ms
HTTP'+(v.statusCode||'—')+'
TLS'+tls+'
Uptime · сутки'+fixed(v.uptimeDay)+'%
Uptime · неделя'+fixed(v.uptimeWeek)+'%
'+cert+(v.error?'
'+safe(v.error)+'
':'')+'
'} function renderServices(svc){const items=svc.services||[];currentServiceStatuses=items;const avg=items.length?items.reduce((a,v)=>a+v.uptimeDay,0)/items.length:0,live=items.filter(v=>v.up),latency=live.length?live.reduce((a,v)=>a+v.latencyMs,0)/live.length:0;el('serviceUp').textContent=svc.up||0;el('serviceTotal').textContent=' / '+(svc.total||0);el('serviceBar').style.width=(svc.total?svc.up/svc.total*100:0)+'%';el('serviceUptime').textContent=svc.total?fixed(avg)+'%':'—';el('serviceLatency').textContent=svc.total?fixed(latency)+' ms':'—';el('serviceList').innerHTML=items.length?items.map(v=>'
'+safe(v.name)+'
'+(v.endpoints||[]).length+' '+((v.endpoints||[]).length===1?'адрес':'адреса')+' проверяются независимо
'+(v.up?'Всё доступно':'Есть проблема')+'
'+(v.endpoints||[]).map(endpointHTML).join('')+'
').join(''):'Добавьте первую проверку в настройках.'} +function renderNetworkGroups(interfaces){const groups=new Map();interfaces.forEach(v=>{const key=v.vmid?'guest-'+v.vmid:'host',group=groups.get(key)||{vmid:v.vmid,name:v.vmid?(v.guestName||('VMID '+v.vmid)):'Интерфейсы хоста',type:v.guestType||'Host',items:[]};group.items.push(v);groups.set(key,group)});el('networkInterfacesList').innerHTML=[...groups.values()].map(g=>{const primary=g.vmid?g.items.filter(v=>v.name.startsWith('tap')||v.name.startsWith('veth')):g.items,rx=primary.reduce((a,v)=>a+v.receivePerSec,0),tx=primary.reduce((a,v)=>a+v.transmitPerSec,0),errors=g.items.reduce((a,v)=>a+v.receiveErrors+v.transmitErrors+v.receiveDrops+v.transmitDrops,0);return '
'+safe(g.name)+'
'+(g.vmid?'VMID '+g.vmid+' · ':'')+safe(g.type)+' · '+safe(g.items.map(v=>v.name).join(', '))+'
'+(errors?'Есть потери':'Без ошибок')+'
Сейчас ↓'+rate(rx)+'
Сейчас ↑'+rate(tx)+'
Получено'+size(primary.reduce((a,v)=>a+v.receiveBytes,0))+'
Передано'+size(primary.reduce((a,v)=>a+v.transmitBytes,0))+'
Ошибки / drops'+errors+'
'}).join('')} function render(x){const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs||{available:false,pools:[]},u=x.ups||{available:false},g=x.guests||{available:false,guests:[]},b=x.backups||{available:false,items:[]},mt=x.maintenance||{},alerts=x.alerts||[],disks=x.disks||[],avg=c.temperatures.length?c.temperatures.reduce((a,t)=>a+t.celsius,0)/c.temperatures.length:null; const update=x.update||{},updating=['queued','installing'].includes(update.state);el('updateCurrent').textContent=update.currentVersion||'—';el('updateAvailable').textContent=update.availableVersion||'—';el('updateMode').textContent=update.mode==='auto'?'Автоматически':'Уведомлять';el('updateMessage').textContent=update.message||'Проверка ещё не выполнялась';el('updateChecked').textContent=update.checkedAt?'Последняя проверка: '+new Date(update.checkedAt).toLocaleString('ru-RU'):'Проверка запускается автоматически раз в сутки.';el('installUpdate').disabled=updating;el('installUpdate').textContent=updating?'Обновление выполняется…':'Проверить и установить обновление'; const critical=alerts.filter(a=>a.severity==='critical').length,warnings=alerts.filter(a=>a.severity==='warning').length,infos=alerts.filter(a=>a.severity==='info').length;el('alertCount').textContent=alerts.length;el('alertCritical').textContent=critical;el('alertWarnings').textContent=warnings;el('alertHeadline').textContent=critical?'Требуется срочное внимание':warnings?'Есть предупреждения':infos?'Есть информационные события':'Система в норме';el('alertBar').style.width=(critical?100:warnings?65:infos?30:0)+'%'; @@ -541,7 +590,7 @@ function render(x){const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs||{av 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.powerOnHours==null?'—':d.powerOnHours+' ч')+'
Износ'+(d.wearUsedPercent==null?'—':fixed(d.wearUsedPercent)+'%')+'
'}).join(''):'Физические накопители не найдены'; const totalRate=n.receivePerSec+n.transmitPerSec;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||'—'; - 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?'Внимание':pools.length?'Исправны':'Нет пулов';el('zfsUsed').textContent=zTotal?size(zUsed)+' · '+fixed(zPercent)+'%':'—';el('zfsBar').style.width=Math.min(zPercent,100)+'%'; + 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)+'%'; el('zfsList').innerHTML=pools.length?pools.map(p=>{const bad=p.health!=='ONLINE',reason=(p.attentionReasons||[]).join(' · '),tip=reason?' tabindex="0" data-reason="'+safe(reason)+'"':'';return '
'+safe(p.name)+'
'+safe(p.scan||'Данные scrub отсутствуют')+'
'+safe(p.health)+'
Объём'+size(p.sizeBytes)+'
Занято'+size(p.allocatedBytes)+'
Свободно'+size(p.freeBytes)+'
Заполнение'+fixed(p.capacityPercent)+'%
Фрагментация'+fixed(p.fragmentationPercent)+'%
Dedup'+safe(p.dedupRatio)+'
'}).join(''):''+(z.available?'ZFS-пулы не найдены':'Команда zpool недоступна')+''; const val=(v,suffix='')=>v==null?'—':fixed(v)+suffix;el('upsName').textContent=u.available?(u.model||u.name||'Источник бесперебойного питания'):'UPS не настроен';el('upsCharge').textContent=u.chargePercent==null?'—':fixed(u.chargePercent);el('upsBar').style.width=u.chargePercent==null?'0%':Math.min(u.chargePercent,100)+'%';el('upsStatus').textContent=u.available?u.statusLabel:'Не найден';el('upsRuntime').textContent=u.runtimeSeconds==null?'—':uptime(u.runtimeSeconds);el('dUpsName').textContent=u.model||u.name||'Источник бесперебойного питания';el('dUpsStatus').textContent=u.available?u.statusLabel:'Не настроен';el('dUpsCharge').textContent=val(u.chargePercent,'%');el('dUpsRuntime').textContent=u.runtimeSeconds==null?'—':uptime(u.runtimeSeconds);el('dUpsLoad').textContent=val(u.loadPercent,'%');el('dUpsInput').textContent=val(u.inputVoltage,' V');el('dUpsOutput').textContent=val(u.outputVoltage,' V');el('dUpsFrequency').textContent=val(u.inputFrequency,' Hz');el('dUpsBattery').textContent=val(u.batteryVoltage,' V');el('dUpsTemp').textContent=val(u.temperatureCelsius,' °C');el('dUpsSource').textContent=u.available?(u.source+' · '+(u.name||'UPS')):'Установите и настройте NUT или apcupsd'; const guests=(g.guests||[]).filter(v=>!v.template),running=guests.filter(v=>v.status==='running'),guestCores=running.reduce((a,v)=>a+v.maxcpu,0),guestCPU=guestCores?running.reduce((a,v)=>a+v.cpu*v.maxcpu,0)/guestCores*100:0,guestMem=running.reduce((a,v)=>a+v.mem,0),guestMaxMem=running.reduce((a,v)=>a+v.maxmem,0);el('guestRunning').textContent=running.length;el('guestTotal').textContent=' / '+guests.length;el('guestBar').style.width=guests.length?(running.length/guests.length*100)+'%':'0%';el('guestCPU').textContent=g.available?fixed(guestCPU)+'%':'Не найдено';el('guestMemory').textContent=g.available?(size(guestMem)+' / '+size(guestMaxMem)):'—'; @@ -551,16 +600,19 @@ function render(x){const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs||{av el('backupList').innerHTML=guests.length?guests.map(v=>{const item=backupMap.get(v.vmid),type=v.type==='qemu'?'VM':'LXC';if(!item)return '
'+safe(v.name||('Guest '+v.vmid))+'
VMID '+v.vmid+' · '+type+'
Копии нет
';const age=now-item.ctime,fresh=age<=backupLimit,date=new Date(item.ctime*1000).toLocaleString('ru-RU');return '
'+safe(v.name||('Guest '+v.vmid))+'
VMID '+v.vmid+' · '+type+' · '+date+'
'+(fresh?'Актуальна':'Старше '+backupLimitHours+' ч')+'
Хранилище'+safe(item.storage)+'
Размер'+size(item.size)+'
Формат'+safe(item.format)+'
Возраст'+uptime(age)+'
Создана'+date+'
VMID'+v.vmid+'
'}).join(''):''+(b.available?'VM и LXC не найдены':'Данные резервных копий недоступны')+''; const svc=x.services||{services:[],up:0,total:0};renderServices(svc); const sh=x.systemHealth||{},issueCount=sh.systemErrors||0;el('systemIssues').textContent=issueCount;el('systemBar').style.width=Math.min(issueCount*10,100)+'%';el('failedUnits').textContent=sh.failedUnits??'—';el('kernelErrors').textContent=sh.kernelErrors??'—';el('dSystemErrors').textContent=issueCount;el('dKernelErrors').textContent=sh.kernelErrors||0;el('dFailedUnits').textContent=sh.failedUnits||0;el('issueList').innerHTML=(sh.issues||[]).length?sh.issues.map(v=>'
'+safe(v.unit)+'
'+new Date(v.time*1000).toLocaleString('ru-RU')+'
Ошибка
'+safe(v.message)+'
').join(''):'Критических сообщений за 24 часа нет.';el('failedServiceList').innerHTML=(sh.failedServices||[]).length?sh.failedServices.map(v=>'
'+safe(v.unit)+'
'+safe(v.description||'Описание отсутствует')+'
'+safe(v.state)+'
Загрузка'+safe(v.load)+'
Состояние'+safe(v.active)+'
Подсостояние'+safe(v.state)+'
').join(''):'Упавших systemd-служб нет.'; - el('networkInterfacesList').innerHTML=(n.interfaces||[]).map(v=>'
'+safe(v.name)+'
'+((v.receiveErrors+v.transmitErrors+v.receiveDrops+v.transmitDrops)?'Есть потери':'Без ошибок')+'
Получено'+size(v.receiveBytes)+'
Передано'+size(v.transmitBytes)+'
RX errors / drops'+v.receiveErrors+' / '+v.receiveDrops+'
TX errors / drops'+v.transmitErrors+' / '+v.transmitDrops+'
').join(''); + el('issueList').querySelectorAll('.badge').forEach((badge,index)=>{const count=(sh.issues||[])[index]?.count||1;badge.textContent=count>1?'Повторений: '+count:'1 раз'}); + renderNetworkGroups(n.interfaces||[]); el('dot').classList.add('live');el('status').textContent='Данные поступают'; } const percent=v=>fixed(v)+'%',temperature=v=>fixed(v)+' °C',megabytes=v=>fixed(v)+' MB/s'; 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)} 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,await loadHistory(hours))}catch(e){drawChart(canvas.id,[])}} +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)})})} setupDetailTabs(); document.querySelectorAll('[data-open]').forEach(b=>b.onclick=()=>el(b.dataset.open).showModal());