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 '