diff --git a/README.md b/README.md index d211ceb..a034eb1 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ ssh root@192.168.0.10 ```bash apt-get update -apt-get install -y curl ca-certificates tar smartmontools iproute2 iputils-ping +apt-get install -y curl ca-certificates tar smartmontools iproute2 iputils-ping ethtool cd /tmp curl -fL \ @@ -401,3 +401,36 @@ curl -fsSL https://git.myown.center/maxim/ProxmoxDash/raw/branch/main/scripts/un go test ./... GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o proxmox-cpu-dashboard . ``` + +## Сетевые ошибки и температура CPU (v0.19.3) + +RX/TX drops отображаются отдельно как информационные счётчики. Их рост сам по +себе не вызывает аварийный алерт: в том числе LLDP на Linux bridge. Настройки +Keenetic, LLDP и group_fwd_mask менять не требуется. Для VM учитываются tap, +для LXC — veth; fwbr/fwpr/fwln остаются в группе интерфейсов хоста. + +Новые rx_errors/tx_errors, CRC/frame/FIFO/missed, carrier/link-down вызывают +критический алерт. Sysfs позволяет отделить rx_missed_errors от RX drops, +которые в /proc/net/dev объединены. Для Atlantic дополнительно читаются +InErrors, InDroppedDma, AllocFails, SkbAllocFails и Restarts через ethtool -S. +Установите `ethtool` на существующем хосте для этой дополнительной диагностики. +При отсутствии утилиты остаются стандартные счётчики sysfs. Счётчики ошибок +могут пересекаться: их сумма — прирост счётчиков, а не число уникальных пакетов. +После перезапуска и сброса счётчиков старые значения не создают новые алерты. + +Карточка CPU и новые точки истории показывают максимум доступных датчиков CPU, +а не среднее Tctl/Tdie/CCD или ядер. На AMD Tctl может включать управляющее +смещение; подписи и значения всех датчиков доступны в подробностях CPU. +История снимается раз в минуту, а на диапазоне 7 дней усредняется по 10 минутам, +поэтому короткие пики между измерениями не гарантированно попадут на график. +Старые точки до обновления остаются средними: исходные значения датчиков в них +не сохранены. Масштаб графика расширяется для значений выше 100 °C. + +Релиз Linux amd64 собирается с номером версии и без CGO: + +```bash +./scripts/build-release.sh v0.19.3 +``` + +Скрипт запускает тесты и создаёт в dist бинарник, SHA-256 и установочный архив, +совместимые со встроенным обновлением из Gitea Releases. diff --git a/alerts.go b/alerts.go index 5287666..7d5b044 100644 --- a/alerts.go +++ b/alerts.go @@ -263,10 +263,13 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler } var networkErrors uint64 for _, iface := range metrics.Network.Interfaces { - networkErrors += iface.NewErrors + iface.NewDrops + networkErrors += iface.NewErrors + iface.NewFaults + if iface.LinkDown { + add("network-link-"+iface.Name, "critical", "Сеть", iface.Name+": нет линка", "Используемый интерфейс потерял carrier или перешёл в down.") + } } if networkErrors > 0 { - add("network-errors", "warning", "Сеть", "Появились новые сетевые ошибки или потери", fmt.Sprintf("За последний интервал добавилось: %d. Старые счётчики не создают активный алерт.", networkErrors)) + add("network-errors", "critical", "Сеть", "Появились новые сетевые ошибки", fmt.Sprintf("Прирост счётчиков ошибок: %d (счётчики могут пересекаться). Software RX/TX drops показаны отдельно и не создают этот алерт.", networkErrors)) } if !metrics.Network.Health.CheckedAt.IsZero() && !metrics.Network.Health.GatewayUp { add("network-gateway", "critical", "Сеть", "Недоступен локальный шлюз", firstNonEmpty(metrics.Network.Health.Gateway, "Default gateway не определён.")) @@ -286,14 +289,7 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler for _, name := range metrics.Network.Health.DisappearedInterfaces { add("network-interface-missing-"+name, "warning", "Сеть", "Исчез сетевой интерфейс", name+" больше не присутствует в /proc/net/dev.") } - for _, iface := range metrics.Network.Interfaces { - if iface.Kind == "Physical" && !iface.Carrier && (iface.Master != "" || iface.ReceiveBytes+iface.TransmitBytes > 0) { - add("network-link-"+iface.Name, "warning", "Сеть", iface.Name+": нет carrier", "Физический линк не поднят.") - } - if (iface.Kind == "Bridge" || iface.Kind == "Bond" || iface.Kind == "VLAN") && iface.State == "down" { - add("network-state-"+iface.Name, "warning", "Сеть", iface.Name+": интерфейс выключен", iface.Kind+" находится в состоянии down.") - } - } + if !metrics.Monitor.LastSuccessfulCollection.IsZero() && time.Since(metrics.Monitor.LastSuccessfulCollection) > 30*time.Second { add("monitor-stale", "critical", "Dashboard", "Данные мониторинга устарели", "Последний успешный сбор был "+time.Since(metrics.Monitor.LastSuccessfulCollection).Round(time.Second).String()+" назад.") } diff --git a/cpu.go b/cpu.go index afd1d92..be1270d 100644 --- a/cpu.go +++ b/cpu.go @@ -3,6 +3,7 @@ package main import ( "bufio" "fmt" + "math" "os" "path/filepath" "strconv" @@ -33,15 +34,16 @@ type CPUStatic struct { type CPUMetrics struct { CPUStatic - UsagePercent float64 `json:"usagePercent"` - FrequencyMHz float64 `json:"frequencyMhz"` - Load1 float64 `json:"load1"` - Load5 float64 `json:"load5"` - Load15 float64 `json:"load15"` - Uptime uint64 `json:"uptimeSeconds"` - Temperatures []Temperature `json:"temperatures"` - TopProcesses []AgentProcess `json:"topProcesses"` - CollectedAt time.Time `json:"collectedAt"` + UsagePercent float64 `json:"usagePercent"` + FrequencyMHz float64 `json:"frequencyMhz"` + Load1 float64 `json:"load1"` + Load5 float64 `json:"load5"` + Load15 float64 `json:"load15"` + Uptime uint64 `json:"uptimeSeconds"` + TemperatureCelsius *float64 `json:"temperatureCelsius"` + Temperatures []Temperature `json:"temperatures"` + TopProcesses []AgentProcess `json:"topProcesses"` + CollectedAt time.Time `json:"collectedAt"` } type Temperature struct { @@ -90,10 +92,11 @@ func (c *cpuCollector) collect() (CPUMetrics, error) { load1, load5, load15, _ := readLoadAverage() uptime, _ := readUptime() + temperatures := readTemperatures() return CPUMetrics{ CPUStatic: c.static, UsagePercent: usage, FrequencyMHz: frequency, Load1: load1, Load5: load5, Load15: load15, Uptime: uptime, - Temperatures: readTemperatures(), TopProcesses: collectAgentProcesses(), CollectedAt: time.Now(), + Temperatures: temperatures, TemperatureCelsius: cpuTemperature(temperatures), TopProcesses: collectAgentProcesses(), CollectedAt: time.Now(), }, nil } @@ -285,3 +288,19 @@ func isCPUTemperatureChip(name string) bool { return false } } + +// cpuTemperature is the hottest CPU sensor, shared by live and historical views. +// Do not average Tctl/Tdie with cooler cores or CCDs. +func cpuTemperature(temperatures []Temperature) *float64 { + var hottest *float64 + for _, t := range temperatures { + if math.IsNaN(t.Celsius) || math.IsInf(t.Celsius, 0) || t.Celsius <= 0 { + continue + } + if hottest == nil || t.Celsius > *hottest { + value := t.Celsius + hottest = &value + } + } + return hottest +} diff --git a/cpu_temperature_test.go b/cpu_temperature_test.go new file mode 100644 index 0000000..1f05994 --- /dev/null +++ b/cpu_temperature_test.go @@ -0,0 +1,24 @@ +package main + +import ( + "math" + "testing" +) + +func TestCPUTemperatureSharedWithHistory(t *testing.T) { + temperatures := []Temperature{{Label: "k10temp · Tctl", Celsius: 62}, {Label: "k10temp · Tccd1", Celsius: 38}, {Label: "k10temp · Tccd2", Celsius: 41}} + live := cpuTemperature(temperatures) + point := historyPointFromMetrics(dashboardMetrics{CPU: CPUMetrics{Temperatures: temperatures}}) + if live == nil || *live != 62 || point.CPUTemperature != *live { + t.Fatalf("live/history disagree: %v, %+v", live, point) + } +} + +func TestCPUTemperatureInvalidSensors(t *testing.T) { + if cpuTemperature([]Temperature{{Celsius: math.NaN()}, {Celsius: math.Inf(1)}, {Celsius: 0}}) != nil { + t.Fatal("invalid temperature selected") + } + if cpuTemperature(nil) != nil { + t.Fatal("missing sensor should stay missing") + } +} diff --git a/history.go b/history.go index 5f8f5cd..f46476e 100644 --- a/history.go +++ b/history.go @@ -99,12 +99,10 @@ func historyPointFromMetrics(metrics dashboardMetrics) HistoryPoint { NetworkTransmit: metrics.Network.TransmitPerSec, UPSCharge: metrics.UPS.ChargePercent, UPSLoad: metrics.UPS.LoadPercent, } - if len(metrics.CPU.Temperatures) > 0 { - for _, temperature := range metrics.CPU.Temperatures { - point.CPUTemperature += temperature.Celsius - } - point.CPUTemperature /= float64(len(metrics.CPU.Temperatures)) + if temperature := cpuTemperature(metrics.CPU.Temperatures); temperature != nil { + point.CPUTemperature = *temperature } + var zfsSize, zfsUsed uint64 for _, pool := range metrics.ZFS.Pools { zfsSize += pool.SizeBytes diff --git a/history_test.go b/history_test.go index 6c77343..38adbbe 100644 --- a/history_test.go +++ b/history_test.go @@ -8,7 +8,7 @@ func TestHistoryPointFromMetrics(t *testing.T) { Guests: GuestsMetrics{Guests: []GuestMetrics{{Status: "running", CPU: .5, MaxCPU: 4, MemoryBytes: 50, MaxMemory: 100}}}, } point := historyPointFromMetrics(metrics) - if point.CPUTemperature != 50 || point.GuestsCPU != 50 || point.GuestsMemory != 50 { + if point.CPUTemperature != 60 || point.GuestsCPU != 50 || point.GuestsMemory != 50 { t.Fatalf("неожиданная историческая точка: %+v", point) } } diff --git a/network.go b/network.go index e03f237..f49e467 100644 --- a/network.go +++ b/network.go @@ -43,26 +43,31 @@ type NetworkHealth 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"` - NewErrors uint64 `json:"newErrors"` - NewDrops uint64 `json:"newDrops"` - ReceivePerSec float64 `json:"receivePerSec"` - TransmitPerSec float64 `json:"transmitPerSec"` - VMID int `json:"vmid,omitempty"` - GuestName string `json:"guestName,omitempty"` - GuestType string `json:"guestType,omitempty"` - Kind string `json:"kind"` - State string `json:"state"` - Carrier bool `json:"carrier"` - SpeedMbps int `json:"speedMbps,omitempty"` - Duplex string `json:"duplex,omitempty"` - Master string `json:"master,omitempty"` + 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"` + NewErrors uint64 `json:"newErrors"` + NewReceiveDrops uint64 `json:"newReceiveDrops"` + NewTransmitDrops uint64 `json:"newTransmitDrops"` + FaultCounters map[string]uint64 `json:"faultCounters,omitempty"` + NewFaults uint64 `json:"newFaults"` + LinkDown bool `json:"linkDown"` + NewDrops uint64 `json:"newDrops"` + ReceivePerSec float64 `json:"receivePerSec"` + TransmitPerSec float64 `json:"transmitPerSec"` + VMID int `json:"vmid,omitempty"` + GuestName string `json:"guestName,omitempty"` + GuestType string `json:"guestType,omitempty"` + Kind string `json:"kind"` + State string `json:"state"` + Carrier bool `json:"carrier"` + SpeedMbps int `json:"speedMbps,omitempty"` + Duplex string `json:"duplex,omitempty"` + Master string `json:"master,omitempty"` } type networkSnapshot struct { @@ -83,13 +88,14 @@ type networkCollector struct { } func newNetworkCollector(path string) (*networkCollector, error) { - snapshot, _, _, err := readNetworkSnapshot(path) + snapshot, _, details, err := readNetworkSnapshot(path) if err != nil { return nil, err } - _, _, details, _ := readNetworkSnapshot(path) previous := map[string]NetworkInterface{} for _, v := range details { + decorateInterface(&v) + collectInterfaceFaults(&v) previous[v.Name] = v } known := map[string]bool{} @@ -116,16 +122,11 @@ func (c *networkCollector) collect() (NetworkMetrics, error) { for i := range details { v := &details[i] decorateInterface(v) - 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 - if v.ReceiveErrors >= old.ReceiveErrors && v.TransmitErrors >= old.TransmitErrors { - v.NewErrors = v.ReceiveErrors - old.ReceiveErrors + v.TransmitErrors - old.TransmitErrors - } - if v.ReceiveDrops >= old.ReceiveDrops && v.TransmitDrops >= old.TransmitDrops { - v.NewDrops = v.ReceiveDrops - old.ReceiveDrops + v.TransmitDrops - old.TransmitDrops - } + collectInterfaceFaults(v) + if old, ok := c.previousInterfaces[v.Name]; ok { + updateInterfaceDeltas(v, old, elapsed) } + v.LinkDown = interfaceLinkDown(*v) } c.previousInterfaces = map[string]NetworkInterface{} for _, v := range details { @@ -166,19 +167,19 @@ func explainNetworkHealth(health *NetworkHealth, interfaces []NetworkInterface) health.Summary, health.LikelyCause, health.RecommendedAction = "Хост не видит локальный шлюз", "Проблема между Proxmox и роутером: линк, bridge, bond или VLAN", "Проверьте кабель, carrier и настройки vmbr/bond/VLAN" case health.PacketLoss > 0: health.Summary = fmt.Sprintf("Нестабильная локальная сеть: потеряно %.1f%% пакетов", health.PacketLoss) - health.LikelyCause, health.RecommendedAction = "Перегрузка или ошибки линка между Proxmox и шлюзом", "Проверьте errors/drops, кабель, порт коммутатора и duplex" + health.LikelyCause, health.RecommendedAction = "Перегрузка или ошибки линка между Proxmox и шлюзом", "Проверьте аппаратные ошибки, кабель, порт коммутатора и duplex" case !health.DNSUp && health.InternetUp: health.Summary, health.LikelyCause, health.RecommendedAction = "Интернет доступен, но DNS не отвечает", "Проблема DNS-сервера или /etc/resolv.conf", "Проверьте настроенные DNS и попробуйте разрешить домен вручную" case health.DNSUp && !health.InternetUp: health.Summary, health.LikelyCause, health.RecommendedAction = "Локальная сеть и DNS работают, но внешняя проверка недоступна", "Проблема выше роутера, блокировка HTTPS или недоступность провайдера", "Проверьте маршрут наружу, firewall и доступ к внешним адресам" } for _, v := range interfaces { - if v.Kind == "Physical" && (!v.Carrier || v.State == "down") { - health.Summary, health.LikelyCause, health.RecommendedAction = v.Name+": физический линк не поднят", "Нет carrier либо интерфейс административно выключен", "Проверьте кабель, порт коммутатора и состояние интерфейса" + if v.LinkDown { + health.Summary, health.LikelyCause, health.RecommendedAction = v.Name+": линк не поднят", "Нет carrier либо интерфейс административно выключен", "Проверьте кабель, порт коммутатора и состояние интерфейса" return } - if v.NewErrors+v.NewDrops > 0 { - health.Summary = fmt.Sprintf("%s: за последний интервал появилось %d сетевых ошибок/потерь", v.Name, v.NewErrors+v.NewDrops) + if v.NewErrors+v.NewFaults > 0 { + health.Summary = fmt.Sprintf("%s: за последний интервал появилось %d изменений счётчиков сетевых ошибок", v.Name, v.NewErrors+v.NewFaults) health.LikelyCause, health.RecommendedAction = "Счётчики интерфейса сейчас растут из-за перегрузки, кабеля, MTU или duplex", "Проверьте скорость и duplex, кабель, порт коммутатора и MTU на обоих концах" return } @@ -195,6 +196,10 @@ func decorateInterface(v *NetworkInterface) { v.Master = filepath.Base(target) } switch { + case strings.HasPrefix(v.Name, "fwbr"): + v.Kind = "Firewall bridge" + case strings.HasPrefix(v.Name, "fwpr") || strings.HasPrefix(v.Name, "fwln"): + v.Kind = "Firewall link" case strings.HasPrefix(v.Name, "vmbr"): v.Kind = "Bridge" case strings.HasPrefix(v.Name, "bond"): @@ -273,7 +278,7 @@ func readNetworkSnapshot(path string) (networkSnapshot, []string, []NetworkInter continue } fields := strings.Fields(values) - if len(fields) < 9 { + if len(fields) < 16 { continue } rx, errRX := strconv.ParseUint(fields[0], 10, 64) diff --git a/network_faults.go b/network_faults.go new file mode 100644 index 0000000..47dd353 --- /dev/null +++ b/network_faults.go @@ -0,0 +1,103 @@ +package main + +import ( + "context" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +func counterDelta(current, previous uint64) uint64 { + if current < previous { + return 0 + } + return current - previous +} + +func updateInterfaceDeltas(v *NetworkInterface, old NetworkInterface, elapsed float64) { + if elapsed <= 0 { + return + } + v.ReceivePerSec = float64(counterDelta(v.ReceiveBytes, old.ReceiveBytes)) / elapsed + v.TransmitPerSec = float64(counterDelta(v.TransmitBytes, old.TransmitBytes)) / elapsed + v.NewErrors = counterDelta(v.ReceiveErrors, old.ReceiveErrors) + counterDelta(v.TransmitErrors, old.TransmitErrors) + v.NewReceiveDrops = counterDelta(v.ReceiveDrops, old.ReceiveDrops) + v.NewTransmitDrops = counterDelta(v.TransmitDrops, old.TransmitDrops) + v.NewDrops = v.NewReceiveDrops + v.NewTransmitDrops + for name, value := range v.FaultCounters { + if previous, ok := old.FaultCounters[name]; ok { + v.NewFaults += counterDelta(value, previous) + } + } + // A transition matters even if the interface has not carried traffic yet. + v.LinkDown = old.Carrier && !v.Carrier +} + +func interfaceLinkDown(v NetworkInterface) bool { + if v.Kind == "Firewall bridge" || v.Kind == "Firewall link" { + return false + } + if v.LinkDown { + return true + } + used := v.Master != "" || v.ReceiveBytes+v.TransmitBytes > 0 + if v.Kind == "Physical" { + return used && (!v.Carrier || v.State == "down") + } + return used && v.State == "down" +} + +func collectInterfaceFaults(v *NetworkInterface) { + base := filepath.Join("/sys/class/net", v.Name) + // /proc/net/dev folds missed packets into RX drop. Sysfs keeps software + // drops separate from rx_missed_errors, which must still trigger an alert. + if n, err := strconv.ParseUint(readTrimmed(filepath.Join(base, "statistics/rx_dropped")), 10, 64); err == nil { + v.ReceiveDrops = n + } + v.FaultCounters = map[string]uint64{} + for _, name := range []string{"rx_crc_errors", "rx_frame_errors", "rx_fifo_errors", "rx_missed_errors", "tx_fifo_errors", "tx_carrier_errors", "carrier_down_count"} { + path := filepath.Join(base, "statistics", name) + if name == "carrier_down_count" { + path = filepath.Join(base, name) + } + if n, err := strconv.ParseUint(readTrimmed(path), 10, 64); err == nil { + v.FaultCounters[name] = n + } + } + // Only Atlantic exposes the driver-specific names below. No configuration + // changes, and a missing ethtool is not itself a network failure. + driver, err := filepath.EvalSymlinks(filepath.Join(base, "device/driver")) + if err != nil || filepath.Base(driver) != "atlantic" { + return + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if out, err := exec.CommandContext(ctx, "ethtool", "-S", v.Name).Output(); err == nil { + for k, n := range parseAtlanticFaults(string(out)) { + v.FaultCounters["atlantic:"+k] = n + } + } +} + +func parseAtlanticFaults(output string) map[string]uint64 { + result := map[string]uint64{} + for _, line := range strings.Split(output, "\n") { + key, value, ok := strings.Cut(strings.TrimSpace(line), ":") + if !ok { + continue + } + fields := strings.Fields(key) + if len(fields) == 0 { + continue + } + switch fields[len(fields)-1] { + case "InErrors", "InDroppedDma", "AllocFails", "SkbAllocFails", "Restarts": + if n, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64); err == nil { + result[key] = n + } + } + } + return result +} diff --git a/network_faults_test.go b/network_faults_test.go new file mode 100644 index 0000000..3514a93 --- /dev/null +++ b/network_faults_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "strings" + "testing" +) + +func TestNetworkDropsDoNotAlert(t *testing.T) { + for _, name := range []string{"enp6s0", "vmbr0", "fwbr100i0", "tap100i0"} { + v := NetworkInterface{Name: name, Carrier: true, State: "up", NewDrops: 15, NewReceiveDrops: 15} + metrics := dashboardMetrics{Network: NetworkMetrics{Interfaces: []NetworkInterface{v}}} + for _, a := range evaluateAlerts(metrics, AlertThresholds{}) { + if a.ID == "network-errors" { + t.Fatalf("%s: drops triggered alert", name) + } + } + v.NewErrors = 1 + metrics.Network.Interfaces[0] = v + found := false + for _, a := range evaluateAlerts(metrics, AlertThresholds{}) { + if a.ID == "network-errors" { + found = a.Severity == "critical" + } + } + if !found { + t.Fatalf("%s: errors did not trigger critical alert", name) + } + } +} + +func TestNetworkHardwareFaultAndLinkAlerts(t *testing.T) { + for _, v := range []NetworkInterface{{Name: "eth0", NewFaults: 1}, {Name: "eth0", LinkDown: true}} { + found := false + for _, a := range evaluateAlerts(dashboardMetrics{Network: NetworkMetrics{Interfaces: []NetworkInterface{v}}}, AlertThresholds{}) { + if strings.HasPrefix(a.ID, "network-") && a.Severity == "critical" { + found = true + } + } + if !found { + t.Fatalf("no critical alert for %+v", v) + } + } +} + +func TestNetworkCounterResetsAndFaultBaselines(t *testing.T) { + old := NetworkInterface{ReceiveBytes: 100, ReceiveErrors: 20, TransmitErrors: 4, ReceiveDrops: 8, FaultCounters: map[string]uint64{"rx_missed_errors": 10, "atlantic:InErrors": 9}, Carrier: true} + next := NetworkInterface{ReceiveBytes: 1, ReceiveErrors: 1, TransmitErrors: 6, ReceiveDrops: 10, FaultCounters: map[string]uint64{"rx_missed_errors": 12, "atlantic:InErrors": 1, "new": 100}, Carrier: false} + updateInterfaceDeltas(&next, old, 2) + if next.NewErrors != 2 || next.NewFaults != 2 || next.NewReceiveDrops != 2 || next.ReceivePerSec != 0 || !next.LinkDown { + t.Fatalf("bad deltas: %+v", next) + } +} + +func TestAtlanticFaults(t *testing.T) { + got := parseAtlanticFaults("NIC statistics:\n InErrors: 2\n InDroppedDma: 3\n Queue[0] AllocFails: 4\n Queue[1] SkbAllocFails: 5\n Queue[0] Restarts: 6\n Queue[0] InErrors: 7\n InPackets: 100\n Queue[0] XdpDrop: 50\n InErrorsBad: nope") + if len(got) != 6 || got["InDroppedDma"] != 3 || got["Queue[0] Restarts"] != 6 { + t.Fatalf("bad counters: %v", got) + } +} + +func TestFirewallNotAttributedToGuest(t *testing.T) { + n := NetworkMetrics{Interfaces: []NetworkInterface{{Name: "fwbr100i0", ReceiveDrops: 100}, {Name: "tap100i0"}}} + decorateNetworkInterfaces(&n, GuestsMetrics{}) + if n.Interfaces[0].VMID != 0 || n.Interfaces[1].VMID != 100 { + t.Fatalf("bad VM attribution: %+v", n) + } +} diff --git a/network_test.go b/network_test.go index 30ca1be..00e1d06 100644 --- a/network_test.go +++ b/network_test.go @@ -49,13 +49,13 @@ func TestExplainNetworkHealthIgnoresOldInterfaceCounters(t *testing.T) { t.Fatalf("старый накопленный счётчик вызвал текущую тревогу: %+v", health) } explainNetworkHealth(&health, []NetworkInterface{{Name: "eno1", Carrier: true, State: "up", ReceiveDrops: 43, NewDrops: 1}}) - if !strings.Contains(health.Summary, "последний интервал") { - t.Fatalf("рост счётчика не объяснён: %+v", health) + if health.Summary != "Сеть работает штатно" { + t.Fatalf("software drops вызвали тревогу: %+v", health) } } func TestInterfaceVMID(t *testing.T) { - for name, want := range map[string]int{"tap100i0": 100, "veth108i0": 108, "fwbr113i0": 113, "vmbr0": 0, "enp6s0": 0} { + for name, want := range map[string]int{"tap100i0": 100, "veth108i0": 108, "fwbr113i0": 0, "vmbr0": 0, "enp6s0": 0} { if got := interfaceVMID(name); got != want { t.Fatalf("%s: got %d want %d", name, got, want) } diff --git a/scripts/build-release.sh b/scripts/build-release.sh new file mode 100755 index 0000000..181ec75 --- /dev/null +++ b/scripts/build-release.sh @@ -0,0 +1,16 @@ +#!/bin/sh +set -eu +version=${1:?Usage: build-release.sh vX.Y.Z} +cd "$(dirname "$0")/.." +go test ./... +mkdir -p dist +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=$version" -o dist/proxmox-dashboard-linux-amd64 . +(cd dist && shasum -a 256 proxmox-dashboard-linux-amd64 > proxmox-dashboard-linux-amd64.sha256) +stage=$(mktemp -d) +trap 'rm -rf "$stage"' EXIT INT TERM +package="$stage/proxmox-dashboard-install" +mkdir -p "$package/dist" "$package/scripts" +cp dist/proxmox-dashboard-linux-amd64 dist/proxmox-dashboard-linux-amd64.sha256 "$package/dist/" +cp scripts/install.sh scripts/install-agent.sh scripts/uninstall-agent.sh scripts/proxmox-dashboard-update "$package/scripts/" +cp ./*.service ./*.timer ./*.path README.md "$package/" +tar -czf dist/proxmox-dashboard-install-amd64.tar.gz -C "$stage" proxmox-dashboard-install diff --git a/web.go b/web.go index 27fe425..413e3ed 100644 --- a/web.go +++ b/web.go @@ -105,7 +105,7 @@ func decorateNetworkInterfaces(network *NetworkMetrics, guests GuestsMetrics) { } } func interfaceVMID(name string) int { - prefixes := []string{"tap", "veth", "fwbr", "fwpr", "fwln"} + prefixes := []string{"tap", "veth"} for _, prefix := range prefixes { if !strings.HasPrefix(name, prefix) { continue @@ -907,7 +907,14 @@ function renderServices(svc){const items=svc.services||[];currentServiceStatuses function renderMaintenanceTargets(){const signature=JSON.stringify([configuredServices.map(v=>[v.id,v.name]),maintenanceGuests.map(v=>[v.vmid,v.name,v.type]),maintenanceWindow.serviceIds||[],maintenanceWindow.vmids||[]]);if(signature===maintenanceTargetSignature)return;maintenanceTargetSignature=signature;const serviceIDs=new Set(maintenanceWindow.serviceIds||[]),vmids=new Set(maintenanceWindow.vmids||[]);el('maintenanceServices').innerHTML=configuredServices.length?configuredServices.map(v=>'').join(''):'Сервисы не добавлены.';el('maintenanceGuests').innerHTML=maintenanceGuests.length?maintenanceGuests.map(v=>'').join(''):'VM/LXC не найдены.';toggleMaintenanceTargets()} function toggleMaintenanceTargets(){const disabled=el('maintenanceAll').checked;document.querySelectorAll('[data-maintenance-service],[data-maintenance-vm]').forEach(v=>v.disabled=disabled)} function applyMaintenanceState(window){maintenanceWindow=window||{};const active=!!maintenanceWindow.active,automatic=!!maintenanceWindow.automatic;el('maintenanceBanner').hidden=!active;el('maintenanceBannerReason').textContent=(automatic?'Автоматический режим · ':'')+(maintenanceWindow.reason||'');el('maintenanceBannerEnds').textContent=active?(automatic?'до завершения backup':'до '+new Date(maintenanceWindow.endsAt*1000).toLocaleString('ru-RU')):'';el('stopMaintenance').disabled=!active||automatic;el('maintenanceStatus').textContent=active?(automatic?'Автоматически включён на время backup выбранных VM/LXC и связанных сервисов.':'Активен до '+new Date(maintenanceWindow.endsAt*1000).toLocaleString('ru-RU')+' · '+maintenanceWindow.reason):'Режим выключен.';if(active&&!automatic){el('maintenanceReason').value=maintenanceWindow.reason||'';el('maintenanceDuration').value=Math.max(5,Math.ceil((maintenanceWindow.endsAt-Date.now()/1000)/60));el('maintenanceAll').checked=!!maintenanceWindow.allAlerts}renderMaintenanceTargets()} -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),total=g.items.reduce((a,v)=>a+v.receiveErrors+v.transmitErrors+v.receiveDrops+v.transmitDrops,0),fresh=g.items.reduce((a,v)=>a+(v.newErrors||0)+(v.newDrops||0),0),down=primary.some(v=>v.kind==='Physical'&&!v.carrier&&(v.master||v.receiveBytes+v.transmitBytes>0));return '
'+safe(g.name)+'
'+(g.vmid?'VMID '+g.vmid+' · ':'')+safe(g.type)+' · '+safe(g.items.map(v=>v.name+' ['+(v.kind||'Interface')+(v.speedMbps?' · '+v.speedMbps+'Mb/s':'')+(v.duplex?' · '+v.duplex:'')+(v.master?' → '+v.master:'')+']').join(', '))+'
'+(down?'Нет линка':fresh?'Новые потери: '+fresh:'Сейчас без потерь')+'
Сейчас ↓'+rate(rx)+'
Сейчас ↑'+rate(tx)+'
Получено'+size(primary.reduce((a,v)=>a+v.receiveBytes,0))+'
Передано'+size(primary.reduce((a,v)=>a+v.transmitBytes,0))+'
Новые / всего errors+drops'+fresh+' / '+total+'
'}).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),fresh=primary.reduce((a,v)=>a+(v.newErrors||0)+(v.newFaults||0),0),down=primary.some(v=>v.linkDown); + const counters=primary.map(v=>'
'+safe(v.name)+': errors RX/TX '+v.receiveErrors+'/'+v.transmitErrors+' (+'+(v.newErrors||0)+'); RX drops '+v.receiveDrops+' (+'+(v.newReceiveDrops||0)+'); TX drops '+v.transmitDrops+' (+'+(v.newTransmitDrops||0)+')'+Object.entries(v.faultCounters||{}).map(([k,n])=>' · '+safe(k)+': '+n).join('')+'
').join(''); + return '
'+safe(g.name)+'
'+(g.vmid?'VMID '+g.vmid+' · ':'')+safe(g.type)+' · '+safe(g.items.map(v=>v.name+' ['+(v.kind||'Interface')+(v.speedMbps?' · '+v.speedMbps+'Mb/s':'')+(v.duplex?' · '+v.duplex:'')+(v.master?' → '+v.master:'')+']').join(', '))+'
'+(down?'Нет линка':fresh?'Новые ошибки':'Нет новых ошибок')+'
Сейчас ↓'+rate(rx)+'
Сейчас ↑'+rate(tx)+'
Получено'+size(primary.reduce((a,v)=>a+v.receiveBytes,0))+'
Передано'+size(primary.reduce((a,v)=>a+v.transmitBytes,0))+'
'+counters+'
RX/TX drops — информационные счётчики, сами по себе не означают сетевую аварию.'+(g.vmid?' Состояние гостя определяется по tap/veth.':' fwbr/fwpr/fwln относятся к инфраструктуре хоста.')+'
' + }).join('') +} function renderIOAttribution(disks){const active=disks.filter(d=>d.ioCause||(d.topConsumers||[]).length);el('ioAttributionList').innerHTML=active.length?active.map(d=>'
'+safe(d.name)+' · '+safe(d.ioCause||'Источник не определён')+'
'+safe(d.ioCauseDetail||'Собираем статистику процессов')+'
'+(d.ioConfidence||0)+'%
'+(d.topConsumers||[]).slice(0,5).map(v=>''+safe(v.kind)+' · '+safe(v.name)+' · '+fixed(v.sharePercent)+'% · ↓'+rate(v.readBytesPerSec)+' ↑'+rate(v.writeBytesPerSec)+'').join('')+'
').join(''):'Для атрибуции нужно несколько realtime-циклов.'} const taskLabel=type=>({vzdump:'Backup',qmstart:'Запуск VM',qmstop:'Остановка VM',qmshutdown:'Выключение VM',qmsnapshot:'Snapshot VM',vzstart:'Запуск LXC',vzstop:'Остановка LXC',vzsnapshot:'Snapshot LXC',qmigrate:'Миграция',aptupdate:'Обновление пакетов'})[type]||type; function explainWarningBadges(){el('snapshotList').querySelectorAll('.badge.warn').forEach(v=>{v.tabIndex=0;v.dataset.reason='Snapshot старше 30 дней. Проверьте, нужен ли он: старые снимки могут занимать место в storage.'});el('taskList').querySelectorAll('.badge.bad').forEach(v=>{v.tabIndex=0;v.dataset.reason='Задача Proxmox завершилась не со статусом OK. Откройте её журнал в интерфейсе Proxmox для подробностей.'})} @@ -939,7 +946,7 @@ async function queueAgentAction(action,target){const labels={'system-update':'у async function watchAgentAction(id,status){for(let attempt=0;attempt<180;attempt++){await new Promise(resolve=>setTimeout(resolve,1000));const response=await fetch('/api/agent-actions');if(!response.ok)continue;const items=await response.json()||[],item=items.find(v=>v.id===id);if(!item)continue;status.textContent=item.status==='queued'?'Ожидаем агента…':item.status==='running'?'Выполняется на агенте…':item.status==='completed'?'Готово: '+(item.result||'успешно'):'Ошибка: '+(item.result||'команда не выполнена');if(item.status==='completed'||item.status==='failed'){status.className='settings-note '+(item.status==='failed'?'status-down':'status-up');document.querySelectorAll('#agentDetailDialog [data-agent-action]').forEach(v=>v.disabled=false);await loadAgentActions();return}}status.className='settings-note status-down';status.textContent='Агент не вернул результат за 3 минуты. Проверьте его состояние в истории действий.';document.querySelectorAll('#agentDetailDialog [data-agent-action]').forEach(v=>v.disabled=false)} async function loadAgentHistory(agent,hours=24){const kinds=['agent-resource','agent-swap'],responses=await Promise.all(kinds.map(kind=>fetch('/api/entity-history?kind='+kind+'&hours='+hours).then(r=>r.json()))),key=agent.id+' · ',resource=(responses[0]||[]).find(v=>v.name.startsWith(key)),swap=(responses[1]||[]).find(v=>v.name.startsWith(key)),points=new Map();(resource?.points||[]).forEach(p=>points.set(p.timestamp,{timestamp:p.timestamp,cpu:p.value1,ram:p.value2}));(swap?.points||[]).forEach(p=>{const item=points.get(p.timestamp)||{timestamp:p.timestamp};item.swap=p.value1;points.set(p.timestamp,item)});chartConfigs.agentHistoryChart={series:[{label:'CPU',color:'#55d58a',value:p=>p.cpu,format:percent},{label:'RAM',color:'#9d8cff',value:p=>p.ram,format:percent},{label:'Swap',color:'#ff78b8',value:p=>p.swap,format:percent}]};const canvas=el('agentHistoryChart');drawChart('agentHistoryChart',[...points.values()].sort((a,b)=>a.timestamp-b.timestamp));if(!canvas.dataset.agentHover){setupAgentHistoryHover(canvas);canvas.dataset.agentHover='1'}} function setupAgentHistoryHover(canvas){canvas.addEventListener('mousemove',event=>{const state=canvas._chartState,tooltip=el('agentHistoryDetails').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];tooltip.innerHTML='
'+new Date(point.timestamp*1000).toLocaleString('ru-RU')+'
'+state.series.map(s=>'
'+s.label+''+(s.value(point)==null?'—':s.format(s.value(point)))+'
').join('');tooltip.classList.add('visible');tooltip.style.left=Math.min(x+12,canvas.clientWidth-tooltip.offsetWidth-10)+'px';tooltip.style.top='55px'});canvas.addEventListener('mouseleave',()=>el('agentHistoryDetails').querySelector('.chart-tooltip').classList.remove('visible'))} -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; +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.temperatureCelsius??null; currentServiceLinks=x.serviceLinks||[]; lastDashboardEvent=Date.now(); maintenanceGuests=(g.guests||[]).filter(v=>!v.template);applyMaintenanceState(x.maintenanceWindow||{}); @@ -947,7 +954,7 @@ function render(x){const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs||{av 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)+'%'; el('alertList').innerHTML=alerts.length?alerts.map(a=>'
'+safe(a.source)+' · '+(a.severity==='critical'?'Критическое':a.severity==='warning'?'Предупреждение':'Информация')+(a.firstSeen?' · С '+new Date(a.firstSeen*1000).toLocaleString('ru-RU'):'')+'
'+safe(a.title)+'
'+safe(a.message)+'
').join(''):'
Всё в порядкеНовых неподтверждённых событий нет.
'; - el('cpuName').textContent=el('cpuDetailName').textContent=c.model;el('cpuUsage').textContent=fixed(c.usagePercent);el('cpuBar').style.width=Math.min(c.usagePercent,100)+'%';el('cpuTemp').textContent=avg===null?'Нет датчика':fixed(avg)+' °C';el('cpuTopology').textContent=c.physicalCores+' / '+c.logicalCpus; + el('cpuName').textContent=el('cpuDetailName').textContent=c.model;el('cpuUsage').textContent=fixed(c.usagePercent);el('cpuBar').style.width=Math.min(c.usagePercent,100)+'%';el('cpuTemp').title='Максимум датчиков CPU; тот же показатель записывается в историю';el('cpuTemp').textContent=avg===null?'Нет датчика':fixed(avg)+' °C';el('cpuTopology').textContent=c.physicalCores+' / '+c.logicalCpus; el('dCpuUsage').textContent=fixed(c.usagePercent)+'%';el('dSockets').textContent=c.sockets;el('dCores').textContent=c.physicalCores;el('dThreads').textContent=c.logicalCpus;el('dFreq').textContent=(c.frequencyMhz/1000).toFixed(2)+' GHz';el('dUptime').textContent=uptime(c.uptimeSeconds);el('dLoad1').textContent=fixed(c.load1);el('dLoad5').textContent=fixed(c.load5);el('dLoad15').textContent=fixed(c.load15);el('dTemps').innerHTML=c.temperatures.length?c.temperatures.map(t=>''+safe(t.label)+''+fixed(t.celsius)+'°C').join(''):'Датчики температуры не найдены';const cpuProcesses=(c.topProcesses||[]).slice().sort((a,b)=>b.cpuPercent-a.cpuPercent).slice(0,12);el('cpuProcessList').innerHTML=cpuProcesses.length?cpuProcesses.map(v=>'
'+safe(v.name)+'PID '+v.pid+' · '+safe(v.command)+'
CPU '+processCPU(v)+'RAM '+size(v.memoryBytes)+'Read '+size(v.readBytes)+'Write '+size(v.writeBytes)+'
').join(''):'Процессы не получены.'; 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?'Частично':'Нет данных'; @@ -971,12 +978,12 @@ function render(x){const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs||{av 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 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.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 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}} 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);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)})})}