From ead9aaa713bd837f6951a329c87d0a49c65da390 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 15 Oct 2026 22:34:55 +0300 Subject: [PATCH] Expand UPS telemetry, charts and raw diagnostics with power estimates --- README.md | 17 ++++++++ history.go | 29 +++++++++++++ ups.go | 49 +++++++++++++++++++-- ups_preview_test.go | 68 +++++++++++++++++++++++++++++ ups_test.go | 101 ++++++++++++++++++++++++++++++++++++++++++++ web.go | 36 +++++++++------- 6 files changed, 280 insertions(+), 20 deletions(-) create mode 100644 ups_preview_test.go diff --git a/README.md b/README.md index ee43581..5eb271d 100644 --- a/README.md +++ b/README.md @@ -691,3 +691,20 @@ HTTP/CrowdSec-импульсы также начинаются в городе, показываются до 500 наиболее активных городов; остальные события и IP без города остаются в точках стран. TCP/UDP/ICMP пока отображаются на уровне стран. Город может относиться к провайдеру/VPN, а не фактическому местоположению человека. + +## Подробности UPS (v0.27.0) + +Карточка UPS показывает питание, батарею, пороги и диагностику, а также исходные +параметры `upsc` / `apcaccess`. Расчётная мощность помечается ≈ и вычисляется как +номинальная активная мощность в ваттах × процент нагрузки / 100. Если UPS передаёт +`ups.realpower`, используется измеренное значение. Без номинала оценка не строится. + +Для NUT/CyberPower (CPS / vendor 0764) частота в диапазоне 450–650 Гц +корректируется делением на 10 с явной пометкой и сохранением исходного значения. +Корректные 49,8/50/60 Гц повторно не преобразуются. Настройки NUT не меняются. +Невалидные даты батареи вроде `CPS` остаются только в исходных параметрах. + +История 1 час / 24 часа / 7 дней: заряд и нагрузка, мощность, входное/выходное +напряжение, напряжение батареи, автономность и частота. Запись раз в минуту, +новые ряды заполняются после обновления; при потере связи сохраняются пропуски. +Пороги UPS/NUT показаны отдельно от сценария выключения Dashboard. diff --git a/history.go b/history.go index f46476e..14498e1 100644 --- a/history.go +++ b/history.go @@ -28,6 +28,31 @@ func runHistoryCollector(collect func() (dashboardMetrics, error), store *Store) func (s *Store) AddEntityMetrics(metrics dashboardMetrics) { ts := time.Now().Unix() + u := metrics.UPS + usable := func(v *float64) *float64 { + if !u.Available || !u.CommunicationOK { + return nil + } + return v + } + for _, item := range []struct { + kind, name string + value *float64 + }{ + {"ups-voltage", "Вход", u.InputVoltage}, {"ups-voltage", "Выход", u.OutputVoltage}, + {"ups-battery", "Батарея", u.BatteryVoltage}, {"ups-runtime", "Автономность", u.RuntimeSeconds}, + {"ups-frequency", "Частота", u.InputFrequency}, + } { + s.AddEntityHistory(item.kind, item.name, ts, usable(item.value), nil) + } + var measured, estimated *float64 + if u.PowerEstimated { + estimated = u.Power + } else { + measured = u.Power + } + s.AddEntityHistory("ups-power", "Измеренная", ts, usable(measured), nil) + s.AddEntityHistory("ups-power", "Расчётная ≈", ts, usable(estimated), nil) for _, p := range metrics.ZFS.Pools { v := p.CapacityPercent s.AddEntityHistory("zfs", p.Name, ts, &v, nil) @@ -99,6 +124,10 @@ func historyPointFromMetrics(metrics dashboardMetrics) HistoryPoint { NetworkTransmit: metrics.Network.TransmitPerSec, UPSCharge: metrics.UPS.ChargePercent, UPSLoad: metrics.UPS.LoadPercent, } + if !metrics.UPS.Available || !metrics.UPS.CommunicationOK { + point.UPSCharge = nil + point.UPSLoad = nil + } if temperature := cpuTemperature(metrics.CPU.Temperatures); temperature != nil { point.CPUTemperature = *temperature } diff --git a/ups.go b/ups.go index 63ce07d..02ca24d 100644 --- a/ups.go +++ b/ups.go @@ -2,6 +2,7 @@ package main import ( "context" + "math" "os/exec" "strconv" "strings" @@ -10,6 +11,13 @@ import ( ) type UPSMetrics struct { + Raw map[string]string `json:"raw"` + NominalPower *float64 `json:"nominalPowerWatts"` + Power *float64 `json:"powerWatts"` + PowerEstimated bool `json:"powerEstimated"` + InputFrequencyRaw *float64 `json:"inputFrequencyRaw"` + FrequencyCorrected bool `json:"frequencyCorrected"` + Available bool `json:"available"` Source string `json:"source"` Name string `json:"name"` @@ -92,14 +100,16 @@ func readAPCUPSD() (UPSMetrics, bool) { } raw := parseColonValues(string(output)) values := map[string]string{ - "device.model": raw["MODEL"], "ups.status": raw["STATUS"], + "device.model": raw["MODEL"], "ups.realpower.nominal": raw["NOMPOWER"], "ups.status": raw["STATUS"], "battery.charge": raw["BCHARGE"], "battery.runtime": minutesToSeconds(raw["TIMELEFT"]), "ups.load": raw["LOADPCT"], "input.voltage": raw["LINEV"], "output.voltage": raw["OUTPUTV"], "input.frequency": raw["LINEFREQ"], "battery.voltage": raw["BATTV"], "ups.temperature": raw["ITEMP"], "battery.date": raw["BATTDATE"], "battery.charge.low": raw["MBATTCHG"], "battery.runtime.low": minutesToSeconds(raw["MINTIMEL"]), } - return upsFromValues("apcupsd", raw["UPSNAME"], values), true + m := upsFromValues("apcupsd", raw["UPSNAME"], values) + m.Raw = raw + return m, true } func parseColonValues(output string) map[string]string { @@ -116,7 +126,7 @@ func parseColonValues(output string) map[string]string { func upsFromValues(source, name string, values map[string]string) UPSMetrics { status := values["ups.status"] metrics := UPSMetrics{ - Available: true, CommunicationOK: true, Source: source, Name: name, Model: values["device.model"], + Available: true, CommunicationOK: true, Source: source, Name: name, Model: firstNonEmpty(values["device.model"], values["ups.model"]), Raw: values, Status: status, StatusLabel: upsStatusLabel(status), ChargePercent: numberPointer(values["battery.charge"]), RuntimeSeconds: numberPointer(values["battery.runtime"]), LoadPercent: numberPointer(values["ups.load"]), InputVoltage: numberPointer(values["input.voltage"]), @@ -124,13 +134,44 @@ func upsFromValues(source, name string, values map[string]string) UPSMetrics { BatteryVoltage: numberPointer(values["battery.voltage"]), Temperature: numberPointer(firstNonEmpty(values["ups.temperature"], values["battery.temperature"])), BatteryDate: firstNonEmpty(values["battery.date"], values["battery.mfr.date"]), ReplaceBattery: strings.Contains(status, "RB"), LowBatteryCharge: numberPointer(values["battery.charge.low"]), LowBatteryRuntime: numberPointer(values["battery.runtime.low"]), } + metrics.NominalPower = numberPointer(values["ups.realpower.nominal"]) + metrics.Power = numberPointer(values["ups.realpower"]) + if metrics.Power != nil && *metrics.Power < 0 { + metrics.Power = nil + } + if metrics.NominalPower != nil && *metrics.NominalPower <= 0 { + metrics.NominalPower = nil + } + if metrics.Power == nil && metrics.NominalPower != nil && metrics.LoadPercent != nil && *metrics.LoadPercent >= 0 { + watts := *metrics.NominalPower * *metrics.LoadPercent / 100 + if !math.IsInf(watts, 0) { + metrics.Power = &watts + metrics.PowerEstimated = true + } + } + metrics.InputFrequencyRaw = metrics.InputFrequency + mfr := strings.ToLower(firstNonEmpty(values["device.mfr"], values["ups.mfr"])) + cyberpower := mfr == "cps" || strings.Contains(mfr, "cyberpower") || values["ups.vendorid"] == "0764" + if source == "NUT" && cyberpower && metrics.InputFrequency != nil && *metrics.InputFrequency >= 450 && *metrics.InputFrequency <= 650 { + hz := *metrics.InputFrequency / 10 + metrics.InputFrequency = &hz + metrics.FrequencyCorrected = true + } if metrics.BatteryDate != "" { + validDate := false for _, layout := range []string{"2006/01/02", "2006-01-02", "01/02/2006", "02/01/2006"} { if date, err := time.Parse(layout, metrics.BatteryDate); err == nil { + if date.After(time.Now()) { + continue + } + validDate = true metrics.BatteryAgeDays = int(time.Since(date).Hours() / 24) break } } + if !validDate { + metrics.BatteryDate = "" + } } return metrics } @@ -141,7 +182,7 @@ func numberPointer(value string) *float64 { return nil } number, err := strconv.ParseFloat(strings.TrimSuffix(field[0], "%"), 64) - if err != nil { + if err != nil || math.IsNaN(number) || math.IsInf(number, 0) { return nil } return &number diff --git a/ups_preview_test.go b/ups_preview_test.go new file mode 100644 index 0000000..96179f1 --- /dev/null +++ b/ups_preview_test.go @@ -0,0 +1,68 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "strconv" + "strings" + "testing" + "time" +) + +func TestUPSPreview(t *testing.T) { + if os.Getenv("DASHBOARD_UPS_PREVIEW") != "1" { + t.Skip("manual visual fixture") + } + s, a := trafficFixture(t) + _ = a + u := upsFromValues("NUT", "cyberpower", parseColonValues(cyberPowerSample)) + m := dashboardMetrics{UPS: u, CPU: CPUMetrics{CPUStatic: CPUStatic{Model: "DEMO / искусственные данные"}, Temperatures: []Temperature{}}, Timestamp: time.Now()} + s.AddEntityMetrics(m) + for i := 0; i < 30; i++ { + ts := time.Now().Add(time.Duration(i-30) * time.Minute).Unix() + v := 220 + float64(i%6) + out := 225.0 + power := 150 + float64(i) + runtime := 450.0 + freq := 49.8 + bat := 13.4 + s.AddEntityHistory("ups-voltage", "Вход", ts, &v, nil) + s.AddEntityHistory("ups-voltage", "Выход", ts, &out, nil) + s.AddEntityHistory("ups-power", "Расчётная ≈", ts, &power, nil) + s.AddEntityHistory("ups-runtime", "Автономность", ts, &runtime, nil) + s.AddEntityHistory("ups-frequency", "Частота", ts, &freq, nil) + s.AddEntityHistory("ups-battery", "Батарея", ts, &bat, nil) + p := historyPointFromMetrics(m) + p.Timestamp = ts + s.AddHistory(p) + } + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + writeJSON(w, map[string]any{}, nil) + return + } + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(strings.Replace(dashboardHTML, "", "<title>DEMO / ", 1))) + }) + mux.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + b, _ := json.Marshal(m) + fmt.Fprintf(w, "data: %s\n\n", b) + w.(http.Flusher).Flush() + <-r.Context().Done() + }) + mux.HandleFunc("/api/history", func(w http.ResponseWriter, r *http.Request) { + h, _ := strconv.Atoi(r.URL.Query().Get("hours")) + v, e := s.History(h) + writeJSON(w, v, e) + }) + mux.HandleFunc("/api/entity-history", func(w http.ResponseWriter, r *http.Request) { + h, _ := strconv.Atoi(r.URL.Query().Get("hours")) + v, e := s.EntityHistory(r.URL.Query().Get("kind"), h) + writeJSON(w, v, e) + }) + t.Fatal(http.ListenAndServe("127.0.0.1:19106", mux)) +} diff --git a/ups_test.go b/ups_test.go index 8605a12..aba45c1 100644 --- a/ups_test.go +++ b/ups_test.go @@ -12,3 +12,104 @@ func TestUPSParsing(t *testing.T) { t.Fatalf("неожиданный статус: %s", metrics.StatusLabel) } } + +const cyberPowerSample = `device.mfr: CPS +device.model: VP700ELCD +battery.charge: 100 +battery.charge.low: 10 +battery.charge.warning: 20 +battery.mfr.date: CPS +battery.runtime: 450 +battery.runtime.low: 300 +battery.type: PbAcid +battery.voltage: 13.4 +battery.voltage.nominal: 12 +input.frequency: 498.0 +input.transfer.high: 295 +input.transfer.low: 167 +input.voltage: 224.0 +input.voltage.nominal: 230 +output.voltage: 225.0 +ups.beeper.status: disabled +ups.delay.shutdown: 20 +ups.delay.start: 30 +ups.load: 46 +ups.realpower.nominal: 390 +ups.status: OL +ups.test.result: No test initiated +driver.name: usbhid-ups +driver.version: 2.8.1 +` + +func TestCyberPowerFrequencyPowerAndRaw(t *testing.T) { + raw := parseColonValues(cyberPowerSample) + m := upsFromValues("NUT", "cyberpower", raw) + if !m.FrequencyCorrected || *m.InputFrequency != 49.8 || *m.InputFrequencyRaw != 498 || m.Raw["input.frequency"] != "498.0" { + t.Fatalf("frequency %+v", m) + } + if m.Power == nil || *m.Power != 179.4 || !m.PowerEstimated { + t.Fatalf("power %+v", m) + } + if m.BatteryDate != "" || m.BatteryAgeDays != 0 || m.Raw["battery.mfr.date"] != "CPS" { + t.Fatal("invalid date not preserved as raw only") + } + raw["input.frequency"] = "49.8" + raw["ups.realpower"] = "165" + m = upsFromValues("NUT", "ups", raw) + if m.FrequencyCorrected || *m.InputFrequency != 49.8 || m.PowerEstimated || *m.Power != 165 { + t.Fatal("double correction or ignored measured power") + } + raw["device.mfr"] = "Other" + raw["input.frequency"] = "498" + if m = upsFromValues("NUT", "ups", raw); m.FrequencyCorrected || *m.InputFrequency != 498 { + t.Fatal("other vendor corrected") + } + for _, v := range []string{"NaN", "Inf", "-Inf", ""} { + if numberPointer(v) != nil { + t.Fatal("nonfinite accepted") + } + } + if m = upsFromValues("NUT", "ups", map[string]string{"ups.load": "46"}); m.Power != nil { + t.Fatal("invented power without nominal") + } +} +func TestUPSHistoryRecordsCorrectedValuesAndGaps(t *testing.T) { + s, a := trafficFixture(t) + _ = a + u := upsFromValues("NUT", "cyberpower", parseColonValues(cyberPowerSample)) + s.AddEntityMetrics(dashboardMetrics{UPS: u}) + for _, tc := range []struct { + kind, name string + want float64 + }{{"ups-frequency", "Частота", 49.8}, {"ups-power", "Расчётная ≈", 179.4}, {"ups-runtime", "Автономность", 450}} { + rows, err := s.EntityHistory(tc.kind, 1) + if err != nil { + t.Fatal(err) + } + found := false + for _, r := range rows { + if r.Name == tc.name { + found = true + if r.Points[0].Value1 == nil || *r.Points[0].Value1 != tc.want { + t.Fatalf("bad %s: %+v", tc.kind, r) + } + } + } + if !found { + t.Fatal("missing history") + } + } + u.Available = false + u.CommunicationOK = false + s.AddEntityMetrics(dashboardMetrics{UPS: u}) + rows, _ := s.EntityHistory("ups-frequency", 1) + for _, r := range rows { + if r.Points[len(r.Points)-1].Value1 != nil { + t.Fatal("stale reading stored as live") + } + } + p := historyPointFromMetrics(dashboardMetrics{UPS: u}) + if p.UPSCharge != nil || p.UPSLoad != nil { + t.Fatal("stale charge stored") + } +} diff --git a/web.go b/web.go index e7b2bea..71e71a3 100644 --- a/web.go +++ b/web.go @@ -631,7 +631,7 @@ footer{color:#657581;font-size:12px;margin-top:17px} <div class="card-head"><div><div class="label">UPS</div><div class="name" id="upsName">Источник бесперебойного питания</div></div><div class="percent"><span id="upsCharge">—</span><small>%</small></div></div> <div class="bar"><div class="fill" id="upsBar"></div></div> <div class="quick"><div><span class="label">Состояние</span><strong id="upsStatus">—</strong></div><div><span class="label">Автономность</span><strong id="upsRuntime">—</strong></div></div> - <div class="hint">Нажмите, чтобы посмотреть подробности →</div> + <div class="hint" id="upsPowerSummary">Нажмите, чтобы посмотреть подробности →</div> </button> <button class="card monitor" data-card="monitor" data-open="monitorDialog" aria-label="Открыть самоконтроль дашборда"> <div class="card-head"><div><div class="label">Monitoring health</div><div class="name">Самоконтроль</div></div><div class="percent"><span id="monitorState">—</span></div></div><div class="bar"><div class="fill" id="monitorBar"></div></div> @@ -753,17 +753,7 @@ footer{color:#657581;font-size:12px;margin-top:17px} <div class="chart-section"><div class="chart-head"><strong>Заполнение каждого ZFS-пула</strong><span>Отдельно по каждому пулу</span></div><canvas class="metric-chart" id="zfsChart"></canvas></div> </dialog> -<dialog id="upsDialog"> - <div class="modal-head"><div><div class="label">UPS details</div><h2 id="dUpsName">Источник бесперебойного питания</h2></div><button class="close" aria-label="Закрыть">×</button></div> - <div class="detail-grid"> - <div class="detail"><span class="label">Состояние</span><strong id="dUpsStatus">—</strong></div><div class="detail"><span class="label">Заряд</span><strong id="dUpsCharge">—</strong></div><div class="detail"><span class="label">Автономность</span><strong id="dUpsRuntime">—</strong></div> - <div class="detail"><span class="label">Нагрузка</span><strong id="dUpsLoad">—</strong></div><div class="detail"><span class="label">Входное напряжение</span><strong id="dUpsInput">—</strong></div><div class="detail"><span class="label">Выходное напряжение</span><strong id="dUpsOutput">—</strong></div> - <div class="detail"><span class="label">Частота</span><strong id="dUpsFrequency">—</strong></div><div class="detail"><span class="label">Батарея</span><strong id="dUpsBattery">—</strong></div><div class="detail"><span class="label">Температура</span><strong id="dUpsTemp">—</strong></div> - <div class="detail"><span class="label">Связь с UPS</span><strong id="dUpsCommunication">—</strong></div><div class="detail"><span class="label">Дата батареи</span><strong id="dUpsBatteryDate">—</strong></div><div class="detail"><span class="label">Возраст батареи</span><strong id="dUpsBatteryAge">—</strong></div> - </div> - <div class="wide"><span class="label">Источник данных</span><div class="temps"><span class="temp" id="dUpsSource">Не настроен</span></div></div> - <div class="chart-section"><div class="chart-head"><strong>Заряд и нагрузка UPS</strong><span>Последние 7 дней</span></div><canvas class="metric-chart" id="upsChart"></canvas></div> -</dialog> +<dialog id="upsDialog"><div class="modal-head"><div><div class="label">UPS details</div><h2 id="dUpsName">Источник бесперебойного питания</h2></div><button class="close" aria-label="Закрыть">×</button></div><h3>Питание</h3><div class="detail-grid"><div class="detail"><span class="label">Состояние</span><strong id="dUpsStatus">—</strong></div><div class="detail"><span class="label">Нагрузка</span><strong id="dUpsLoad">—</strong></div><div class="detail"><span class="label">Мощность</span><strong id="dUpsPower">—</strong></div><div class="detail"><span class="label">Номинальная мощность</span><strong id="dUpsNominalPower">—</strong></div><div class="detail"><span class="label">Входное напряжение</span><strong id="dUpsInput">—</strong></div><div class="detail"><span class="label">Выходное напряжение</span><strong id="dUpsOutput">—</strong></div><div class="detail"><span class="label">Частота</span><strong id="dUpsFrequency">—</strong></div><div class="detail"><span class="label">Номинальное напряжение</span><strong id="dUpsInputNominal">—</strong></div></div><p class="settings-note" id="dUpsPowerNote"></p><p class="settings-note" id="dUpsFrequencyNote"></p><h3>Батарея</h3><div class="detail-grid"><div class="detail"><span class="label">Заряд</span><strong id="dUpsCharge">—</strong></div><div class="detail"><span class="label">Автономность</span><strong id="dUpsRuntime">—</strong></div><div class="detail"><span class="label">Напряжение батареи</span><strong id="dUpsBattery">—</strong></div><div class="detail"><span class="label">Номинал батареи</span><strong id="dUpsBatteryNominal">—</strong></div><div class="detail"><span class="label">Тип батареи</span><strong id="dUpsBatteryType">—</strong></div><div class="detail"><span class="label">Температура</span><strong id="dUpsTemp">—</strong></div><div class="detail"><span class="label">Дата батареи</span><strong id="dUpsBatteryDate">—</strong></div><div class="detail"><span class="label">Возраст батареи</span><strong id="dUpsBatteryAge">—</strong></div></div><h3>Пороги и диагностика</h3><div class="detail-grid"><div class="detail"><span class="label">Пороги переключения</span><strong id="dUpsTransfer">—</strong></div><div class="detail"><span class="label">Предупреждение о заряде</span><strong id="dUpsChargeWarning">—</strong></div><div class="detail"><span class="label">Низкий заряд</span><strong id="dUpsChargeLow">—</strong></div><div class="detail"><span class="label">Низкая автономность</span><strong id="dUpsRuntimeLow">—</strong></div><div class="detail"><span class="label">Звуковой сигнал</span><strong id="dUpsBeeper">—</strong></div><div class="detail"><span class="label">Самотест</span><strong id="dUpsTest">—</strong></div><div class="detail"><span class="label">Задержка выключения</span><strong id="dUpsShutdownDelay">—</strong></div><div class="detail"><span class="label">Задержка запуска</span><strong id="dUpsStartDelay">—</strong></div><div class="detail"><span class="label">Связь с UPS</span><strong id="dUpsCommunication">—</strong></div><div class="detail"><span class="label">Драйвер NUT</span><strong id="dUpsDriver">—</strong></div></div><p class="settings-note">Пороги сообщены UPS/NUT. Сценарий выключения Dashboard настраивается отдельно.</p><div class="wide"><span class="label">Источник данных</span><div class="temps"><span class="temp" id="dUpsSource">Не настроен</span></div></div><details><summary>Исходные параметры UPS / NUT</summary><p class="settings-note" id="dUpsRawNote"></p><pre id="dUpsRaw" style="white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px;max-height:360px;overflow:auto"></pre></details><div class="chart-section"><div class="chart-head"><strong>История UPS</strong></div><p class="settings-note">Запись раз в минуту. Новые графики заполняются после обновления. Расчётная мощность не является измерением.</p><h3>Заряд и нагрузка · %</h3><canvas class="metric-chart" id="upsChart"></canvas><h3>Мощность · Вт</h3><canvas class="metric-chart" id="upsPowerChart"></canvas><h3>Вход / выход · В</h3><canvas class="metric-chart" id="upsVoltageChart"></canvas><h3>Батарея · В</h3><canvas class="metric-chart" id="upsBatteryChart"></canvas><h3>Автономность · мин</h3><canvas class="metric-chart" id="upsRuntimeChart"></canvas><h3>Частота · Гц</h3><canvas class="metric-chart" id="upsFrequencyChart"></canvas></div></dialog> <dialog id="monitorDialog"><div class="modal-head"><div><div class="label">Dashboard health</div><h2>Самоконтроль мониторинга</h2></div><button class="close" aria-label="Закрыть">×</button></div><div class="detail-grid"><div class="detail"><span class="label">Последний успешный сбор</span><strong id="dMonitorLast">—</strong></div><div class="detail"><span class="label">Длительность цикла</span><strong id="dMonitorDuration">—</strong></div><div class="detail"><span class="label">Последняя запись истории</span><strong id="dMonitorHistory">—</strong></div><div class="detail"><span class="label">Свободно для базы</span><strong id="dMonitorDB">—</strong></div><div class="detail"><span class="label">Systemd watchdog</span><strong id="dMonitorWatchdog">—</strong></div><div class="detail"><span class="label">Ошибка</span><strong id="dMonitorError">—</strong></div></div></dialog> @@ -979,7 +969,21 @@ function render(x){el('trafficLink').hidden=!x.trafficSettings?.enabled;const c= 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':'')+(nh.packetLoss?' · потери '+fixed(nh.packetLoss)+'%':'');el('dNetDNS').textContent=nh.dnsUp?'Работает':'Ошибка';el('dNetInternet').textContent=nh.internetUp?'Доступен':'Недоступен';el('dNetExternalIP').textContent=(nh.externalIp||'—')+(nh.externalIpChanged?' · изменился':'');el('dNetSummary').textContent=nh.summary||'Диагностика ещё не завершена';el('dNetCause').textContent=nh.likelyCause||'—';el('dNetAction').textContent=nh.recommendedAction||'—'; 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 '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(p.name)+'</div><div class="disk-meta">Scrub: '+safe(p.scrubStatus||p.scan||'нет данных')+(p.lastScrubAt?' · '+new Date(p.lastScrubAt).toLocaleString('ru-RU'):'')+(p.scrubDuration?' · '+safe(p.scrubDuration):'')+'</div></div><span class="badge '+(bad?'bad':p.scrubTooOld?'warn':'')+'"'+tip+'>'+safe(p.health)+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Объём / свободно</span><b>'+size(p.sizeBytes)+' / '+size(p.freeBytes)+'</b></div><div class="disk-stat"><span>Заполнение</span><b>'+fixed(p.capacityPercent)+'%</b></div><div class="disk-stat"><span>Read / write / checksum</span><b>'+p.readErrors+' / '+p.writeErrors+' / '+p.checksumErrors+'</b></div></div><div class="topology-section"><div class="topology-section-title">Vdev · data / special / cache / log</div><div class="topology-items">'+(vdevs.length?vdevs.map(v=>'<span class="topology-chip '+(v.state==='ONLINE'?'good':'bad')+'">'+safe(v.class)+' · '+safe(v.name)+' · '+safe(v.state)+' · '+v.read+'/'+v.write+'/'+v.checksum+'</span>').join(''):'<span class="empty">Нет данных</span>')+'</div></div><div class="topology-section"><div class="topology-section-title">Datasets</div><div class="topology-items">'+(poolDatasets.length?poolDatasets.map(d=>'<span class="topology-chip '+(d.capacityPercent>=80?'bad':'good')+'">'+safe(d.name)+' · '+fixed(d.capacityPercent)+'% · '+size(d.usedBytes)+'</span>').join(''):'<span class="empty">Нет datasets</span>')+'</div></div></article>'}).join(''):'<span class="empty">'+(z.available?'ZFS-пулы не найдены':'Команда zpool недоступна')+'</span>'; - 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('dUpsCommunication').textContent=u.communicationOk?'Связь установлена':'Нет связи';el('dUpsBatteryDate').textContent=u.batteryDate||'Не сообщает';el('dUpsBatteryAge').textContent=u.batteryAgeDays?u.batteryAgeDays+' дней':'—';el('dUpsSource').textContent=u.available?(u.source+' · '+(u.name||'UPS')):'Установите и настройте NUT или apcupsd'; + const upsDuration=v=>v==null?'—':Math.floor(v/60)+' мин '+Math.floor(v%60)+' сек';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:(u.source?'Нет связи':'Не найден');el('upsRuntime').textContent=u.runtimeSeconds==null?'—':upsDuration(u.runtimeSeconds);el('dUpsName').textContent=u.model||u.name||'Источник бесперебойного питания';el('dUpsStatus').textContent=u.available?u.statusLabel:(u.source?'Нет связи · последние данные':'Не настроен');el('dUpsCharge').textContent=val(u.chargePercent,'%');el('dUpsRuntime').textContent=u.runtimeSeconds==null?'—':upsDuration(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('dUpsCommunication').textContent=u.communicationOk?'Связь установлена':'Нет связи';el('dUpsBatteryDate').textContent=u.batteryDate||'Не сообщает';el('dUpsBatteryAge').textContent=u.batteryAgeDays?u.batteryAgeDays+' дней':'—';el('dUpsSource').textContent=u.available?(u.source+' · '+(u.name||'UPS')):'Установите и настройте NUT или apcupsd'; + const raw=u.raw||{},rawVal=(key,suffix='')=>raw[key]?raw[key]+suffix:'Не сообщает'; + el('upsPowerSummary').textContent=u.powerWatts==null?'Нажмите, чтобы посмотреть подробности →':(u.powerEstimated?'≈ ':'')+fixed(u.powerWatts)+' Вт · нагрузка '+val(u.loadPercent,'%')+' · подробнее →'; + el('dUpsPower').textContent=(u.powerEstimated&&u.powerWatts!=null?'≈ ':'')+val(u.powerWatts,' Вт');el('dUpsNominalPower').textContent=val(u.nominalPowerWatts,' Вт'); + el('dUpsPowerNote').textContent=u.powerEstimated?'Оценка: номинальная активная мощность × нагрузка / 100. Показания в ваттах UPS не передаёт.':'Мощность измерена UPS, если доступна.'; + el('dUpsFrequencyNote').textContent=u.frequencyCorrected?'Коррекция масштаба CyberPower: '+u.inputFrequencyRaw+' → '+fixed(u.inputFrequency,1)+' Гц (÷10). Исходное значение сохранено ниже.':''; + el('dUpsFrequency').textContent=u.inputFrequency==null?'—':fixed(u.inputFrequency,1)+' Гц'+(u.frequencyCorrected?' · исправлено':''); + el('dUpsInputNominal').textContent=rawVal('input.voltage.nominal',' В');el('dUpsBatteryNominal').textContent=rawVal('battery.voltage.nominal',' В');el('dUpsBatteryType').textContent=raw['battery.type']==='PbAcid'?'Свинцово-кислотная (PbAcid)':rawVal('battery.type'); + el('dUpsTransfer').textContent=raw['input.transfer.low']&&raw['input.transfer.high']?raw['input.transfer.low']+'–'+raw['input.transfer.high']+' В':'Не сообщает'; + el('dUpsChargeWarning').textContent=rawVal('battery.charge.warning','%');el('dUpsChargeLow').textContent=val(u.lowBatteryCharge,'%');el('dUpsRuntimeLow').textContent=u.lowBatteryRuntime==null?'—':upsDuration(u.lowBatteryRuntime); + el('dUpsBeeper').textContent=({'disabled':'Отключён','enabled':'Включён','muted':'Приглушён'})[raw['ups.beeper.status']]||rawVal('ups.beeper.status'); + el('dUpsTest').textContent=raw['ups.test.result']==='No test initiated'?'Тест не запускался':rawVal('ups.test.result');el('dUpsShutdownDelay').textContent=rawVal('ups.delay.shutdown',' сек.');el('dUpsStartDelay').textContent=rawVal('ups.delay.start',' сек.'); + el('dUpsDriver').textContent=[raw['driver.name'],raw['driver.version'],raw['driver.version.data']].filter(Boolean).join(' · ')||'Не сообщает'; + el('dUpsRaw').textContent=Object.keys(raw).sort().map(k=>k+': '+raw[k]).join('\n')||'Нет данных';el('dUpsRawNote').textContent=u.communicationOk?'Оригинальные значения без преобразований.':'Связь потеряна: показаны последние известные значения, они могут быть устаревшими.'; + const mh=x.monitor||{},last=mh.lastSuccessfulCollection?new Date(mh.lastSuccessfulCollection):null,stale=!last||Date.now()-last.getTime()>30000,monitorProblem=stale||mh.lastCollectionError||mh.historyWriteError||mh.slowCollector;el('monitorState').textContent=monitorProblem?'Внимание':'OK';el('monitorBar').style.width=monitorProblem?'100%':'12%';el('monitorLast').textContent=last?Math.max(0,Math.round((Date.now()-last.getTime())/1000))+' сек. назад':'—';el('monitorDB').textContent=size(mh.databaseFreeBytes);el('dMonitorLast').textContent=last?last.toLocaleString('ru-RU'):'Ещё не завершён';el('dMonitorDuration').textContent=(mh.collectionDurationMs||0)+' ms';el('dMonitorHistory').textContent=mh.lastHistoryWrite?new Date(mh.lastHistoryWrite).toLocaleString('ru-RU'):'Ещё не записывалась';el('dMonitorDB').textContent=size(mh.databaseFreeBytes);el('dMonitorWatchdog').textContent=mh.watchdogEnabled?'Включён · 30 сек.':'Не активен';el('dMonitorError').textContent=mh.lastCollectionError||mh.historyWriteError||'Нет'; 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)):'—'; @@ -997,9 +1001,9 @@ const percent=v=>fixed(v)+'%',temperature=v=>fixed(v)+' °C',megabytes=v=>fixed( const chartConfigs={cpuChart:{floorMax:100,series:[{label:'CPU',color:'#55d58a',value:p=>p.cpuUsage,format:percent},{label:'Макс. °C CPU',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},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.floorMax?Math.max(config.floorMax,...values)*1.05: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}} +const entityChartKinds={upsVoltageChart:{kind:'ups-voltage',format:v=>fixed(v,1)+' В',dynamic:true},upsBatteryChart:{kind:'ups-battery',format:v=>fixed(v,1)+' В',dynamic:true},upsPowerChart:{kind:'ups-power',format:v=>fixed(v,1)+' Вт',dynamic:true},upsRuntimeChart:{kind:'ups-runtime',format:v=>fixed(v,1)+' мин',dynamic:true,scale:1/60},upsFrequencyChart:{kind:'ups-frequency',format:v=>fixed(v,1)+' Гц',dynamic:true},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.filter(item=>!definition.kind.startsWith('ups-')||item.points.some(p=>p.value1!=null)).map((item,index)=>{const key='entity'+index;item.points.forEach(p=>{const point=timestamps.get(p.timestamp)||{timestamp:p.timestamp};const value=p[definition.value||'value1'];point[key]=value==null?null:value*(definition.scale||1);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.floorMax?Math.max(config.floorMax,...values)*1.05: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)){started=false;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='<div class="chart-tooltip-time">'+date.toLocaleString('ru-RU')+'</div>'+state.series.map(s=>{const value=s.value(point),shown=value==null||!Number.isFinite(value)?'—':s.format(value);return '<div class="chart-tooltip-value"><span><i style="background:'+s.color+'"></i>'+s.label+'</span><strong>'+shown+'</strong></div>'}).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);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='<button class="detail-tab active" data-detail-tab="realtime">Реальное время</button><button class="detail-tab" data-detail-tab="history">История</button>';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='<button class="range-button active" data-hours="1">1 час</button><button class="range-button" data-hours="24">24 часа</button><button class="range-button" data-hours="168">7 дней</button>';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)})})}