Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36b6e5711a | ||
|
|
ead9aaa713 |
24
README.md
24
README.md
@@ -691,3 +691,27 @@ 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.
|
||||
|
||||
## Компактная карточка UPS (v0.27.1)
|
||||
|
||||
Окно UPS разделено на вкладки «Обзор», «Батарея», «Диагностика», «Графики»
|
||||
и «Исходные данные». В обзоре — компактные карточки основных показателей.
|
||||
На графиках выбирается один показатель и период; технические пояснения
|
||||
коррекции частоты и расчёта мощности находятся в исходных данных.
|
||||
|
||||
29
history.go
29
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
|
||||
}
|
||||
|
||||
49
ups.go
49
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
|
||||
|
||||
68
ups_preview_test.go
Normal file
68
ups_preview_test.go
Normal file
@@ -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>", "<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))
|
||||
}
|
||||
101
ups_test.go
101
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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user