Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72869dcf5f | ||
|
|
1296d052a5 |
35
README.md
35
README.md
@@ -32,7 +32,7 @@ ssh root@192.168.0.10
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
apt-get update
|
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
|
cd /tmp
|
||||||
curl -fL \
|
curl -fL \
|
||||||
@@ -401,3 +401,36 @@ curl -fsSL https://git.myown.center/maxim/ProxmoxDash/raw/branch/main/scripts/un
|
|||||||
go test ./...
|
go test ./...
|
||||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o proxmox-cpu-dashboard .
|
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.
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -72,7 +71,7 @@ func (s *Store) QueueAgentAction(agentID, action, target string) (AgentAction, e
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "updates-refresh", "system-update":
|
case "updates-refresh", "system-update", "system-reboot":
|
||||||
valid = target == ""
|
valid = target == ""
|
||||||
}
|
}
|
||||||
if !valid {
|
if !valid {
|
||||||
@@ -172,6 +171,8 @@ func executeAgentCommand(command AgentCommand) AgentActionResult {
|
|||||||
output = removeAgentContainer(command.Target)
|
output = removeAgentContainer(command.Target)
|
||||||
case "system-update":
|
case "system-update":
|
||||||
output = updateAgentSystem()
|
output = updateAgentSystem()
|
||||||
|
case "system-reboot":
|
||||||
|
output = scheduleAgentReboot()
|
||||||
case "updates-refresh":
|
case "updates-refresh":
|
||||||
guestInventory.Lock()
|
guestInventory.Lock()
|
||||||
guestInventory.value.CollectedAt = time.Time{}
|
guestInventory.value.CollectedAt = time.Time{}
|
||||||
@@ -210,25 +211,59 @@ func removeAgentContainer(target string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateAgentSystem() string {
|
func updateAgentSystem() string {
|
||||||
if output := runAgentCombinedCommand(3*time.Minute, "apt-get", "update"); strings.HasPrefix(output, "ERROR: ") {
|
aptGet, err := exec.LookPath("apt-get")
|
||||||
return output
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
command := exec.CommandContext(ctx, "apt-get", "-y", "upgrade")
|
|
||||||
command.Env = append(os.Environ(), "DEBIAN_FRONTEND=noninteractive")
|
|
||||||
output, err := command.CombinedOutput()
|
|
||||||
message := strings.TrimSpace(string(output))
|
|
||||||
if len(message) > 3500 {
|
|
||||||
message = message[len(message)-3500:]
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "ERROR: " + err.Error() + ": " + message
|
return "ERROR: apt-get не найден; обновление поддерживается для Debian/Ubuntu"
|
||||||
|
}
|
||||||
|
if output := runPrivilegedTransient(5*time.Minute, "update-lists", aptGet, "update"); strings.HasPrefix(output, "ERROR: ") {
|
||||||
|
return explainSystemUpdateError(output)
|
||||||
|
}
|
||||||
|
message := runPrivilegedTransient(30*time.Minute, "upgrade", aptGet, "-y", "upgrade")
|
||||||
|
if strings.HasPrefix(message, "ERROR: ") {
|
||||||
|
return explainSystemUpdateError(message)
|
||||||
}
|
}
|
||||||
guestInventory.Lock()
|
guestInventory.Lock()
|
||||||
guestInventory.value.CollectedAt = time.Time{}
|
guestInventory.value.CollectedAt = time.Time{}
|
||||||
guestInventory.Unlock()
|
guestInventory.Unlock()
|
||||||
return firstNonEmpty(message, "Обновления системы установлены")
|
return "Обновления системы установлены; список пакетов будет обновлён в следующем отчёте агента"
|
||||||
|
}
|
||||||
|
|
||||||
|
func explainSystemUpdateError(message string) string {
|
||||||
|
if strings.Contains(strings.ToLower(message), "read-only file system") {
|
||||||
|
return "ERROR: файловая система агента доступна только для чтения. Обновите Dashboard и агент до v0.19.2 или новее, затем повторите операцию."
|
||||||
|
}
|
||||||
|
if len(message) > 1800 {
|
||||||
|
message = message[len(message)-1800:]
|
||||||
|
}
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
func runPrivilegedTransient(timeout time.Duration, suffix, name string, args ...string) string {
|
||||||
|
systemdRun, err := exec.LookPath("systemd-run")
|
||||||
|
if err != nil {
|
||||||
|
return "ERROR: systemd-run не найден; невозможно безопасно выйти из read-only sandbox агента"
|
||||||
|
}
|
||||||
|
unit := fmt.Sprintf("proxmox-dashboard-%s-%d", suffix, time.Now().UnixNano())
|
||||||
|
commandArgs := []string{"--quiet", "--wait", "--collect", "--pipe", "--unit=" + unit, "--setenv=DEBIAN_FRONTEND=noninteractive", name}
|
||||||
|
commandArgs = append(commandArgs, args...)
|
||||||
|
return runAgentCombinedCommand(timeout, systemdRun, commandArgs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scheduleAgentReboot() string {
|
||||||
|
systemdRun, err := exec.LookPath("systemd-run")
|
||||||
|
if err != nil {
|
||||||
|
return "ERROR: systemd-run не найден"
|
||||||
|
}
|
||||||
|
systemctl, err := exec.LookPath("systemctl")
|
||||||
|
if err != nil {
|
||||||
|
return "ERROR: systemctl не найден"
|
||||||
|
}
|
||||||
|
unit := fmt.Sprintf("proxmox-dashboard-reboot-%d", time.Now().UnixNano())
|
||||||
|
output := runAgentCombinedCommand(15*time.Second, systemdRun, "--quiet", "--collect", "--unit="+unit, "--on-active=20s", systemctl, "reboot")
|
||||||
|
if strings.HasPrefix(output, "ERROR: ") {
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
return "Перезагрузка запланирована через 20 секунд; кратковременный offline агента ожидаем"
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateAgentContainer(target string, recreate bool) string {
|
func updateAgentContainer(target string, recreate bool) string {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -42,6 +43,12 @@ func TestAgentActionAllowlistAndLifecycle(t *testing.T) {
|
|||||||
if err != nil || len(items) != 3 || items[0].Status != "completed" {
|
if err != nil || len(items) != 3 || items[0].Status != "completed" {
|
||||||
t.Fatalf("items=%#v err=%v", items, err)
|
t.Fatalf("items=%#v err=%v", items, err)
|
||||||
}
|
}
|
||||||
|
if _, err = store.QueueAgentAction("a", "system-reboot", ""); err != nil {
|
||||||
|
t.Fatalf("system reboot rejected: %v", err)
|
||||||
|
}
|
||||||
|
if _, err = store.QueueAgentAction("a", "system-reboot", "now"); err == nil {
|
||||||
|
t.Fatal("system reboot with unexpected target accepted")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestManualServiceLinkGroupsOfflineAlerts(t *testing.T) {
|
func TestManualServiceLinkGroupsOfflineAlerts(t *testing.T) {
|
||||||
@@ -61,3 +68,10 @@ func TestManualServiceLinkGroupsOfflineAlerts(t *testing.T) {
|
|||||||
t.Fatalf("group alert missing: %#v", grouped)
|
t.Fatalf("group alert missing: %#v", grouped)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExplainReadOnlySystemUpdateError(t *testing.T) {
|
||||||
|
message := explainSystemUpdateError("ERROR: apt failed: Read-only file system")
|
||||||
|
if !strings.Contains(message, "v0.19.2") {
|
||||||
|
t.Fatalf("read-only failure is not actionable: %s", message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
16
alerts.go
16
alerts.go
@@ -263,10 +263,13 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler
|
|||||||
}
|
}
|
||||||
var networkErrors uint64
|
var networkErrors uint64
|
||||||
for _, iface := range metrics.Network.Interfaces {
|
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 {
|
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 {
|
if !metrics.Network.Health.CheckedAt.IsZero() && !metrics.Network.Health.GatewayUp {
|
||||||
add("network-gateway", "critical", "Сеть", "Недоступен локальный шлюз", firstNonEmpty(metrics.Network.Health.Gateway, "Default gateway не определён."))
|
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 {
|
for _, name := range metrics.Network.Health.DisappearedInterfaces {
|
||||||
add("network-interface-missing-"+name, "warning", "Сеть", "Исчез сетевой интерфейс", name+" больше не присутствует в /proc/net/dev.")
|
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 {
|
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()+" назад.")
|
add("monitor-stale", "critical", "Dashboard", "Данные мониторинга устарели", "Последний успешный сбор был "+time.Since(metrics.Monitor.LastSuccessfulCollection).Round(time.Second).String()+" назад.")
|
||||||
}
|
}
|
||||||
|
|||||||
21
cpu.go
21
cpu.go
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -39,6 +40,7 @@ type CPUMetrics struct {
|
|||||||
Load5 float64 `json:"load5"`
|
Load5 float64 `json:"load5"`
|
||||||
Load15 float64 `json:"load15"`
|
Load15 float64 `json:"load15"`
|
||||||
Uptime uint64 `json:"uptimeSeconds"`
|
Uptime uint64 `json:"uptimeSeconds"`
|
||||||
|
TemperatureCelsius *float64 `json:"temperatureCelsius"`
|
||||||
Temperatures []Temperature `json:"temperatures"`
|
Temperatures []Temperature `json:"temperatures"`
|
||||||
TopProcesses []AgentProcess `json:"topProcesses"`
|
TopProcesses []AgentProcess `json:"topProcesses"`
|
||||||
CollectedAt time.Time `json:"collectedAt"`
|
CollectedAt time.Time `json:"collectedAt"`
|
||||||
@@ -90,10 +92,11 @@ func (c *cpuCollector) collect() (CPUMetrics, error) {
|
|||||||
load1, load5, load15, _ := readLoadAverage()
|
load1, load5, load15, _ := readLoadAverage()
|
||||||
uptime, _ := readUptime()
|
uptime, _ := readUptime()
|
||||||
|
|
||||||
|
temperatures := readTemperatures()
|
||||||
return CPUMetrics{
|
return CPUMetrics{
|
||||||
CPUStatic: c.static, UsagePercent: usage, FrequencyMHz: frequency,
|
CPUStatic: c.static, UsagePercent: usage, FrequencyMHz: frequency,
|
||||||
Load1: load1, Load5: load5, Load15: load15, Uptime: uptime,
|
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
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,3 +288,19 @@ func isCPUTemperatureChip(name string) bool {
|
|||||||
return false
|
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
|
||||||
|
}
|
||||||
|
|||||||
24
cpu_temperature_test.go
Normal file
24
cpu_temperature_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,12 +99,10 @@ func historyPointFromMetrics(metrics dashboardMetrics) HistoryPoint {
|
|||||||
NetworkTransmit: metrics.Network.TransmitPerSec, UPSCharge: metrics.UPS.ChargePercent,
|
NetworkTransmit: metrics.Network.TransmitPerSec, UPSCharge: metrics.UPS.ChargePercent,
|
||||||
UPSLoad: metrics.UPS.LoadPercent,
|
UPSLoad: metrics.UPS.LoadPercent,
|
||||||
}
|
}
|
||||||
if len(metrics.CPU.Temperatures) > 0 {
|
if temperature := cpuTemperature(metrics.CPU.Temperatures); temperature != nil {
|
||||||
for _, temperature := range metrics.CPU.Temperatures {
|
point.CPUTemperature = *temperature
|
||||||
point.CPUTemperature += temperature.Celsius
|
|
||||||
}
|
|
||||||
point.CPUTemperature /= float64(len(metrics.CPU.Temperatures))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var zfsSize, zfsUsed uint64
|
var zfsSize, zfsUsed uint64
|
||||||
for _, pool := range metrics.ZFS.Pools {
|
for _, pool := range metrics.ZFS.Pools {
|
||||||
zfsSize += pool.SizeBytes
|
zfsSize += pool.SizeBytes
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ func TestHistoryPointFromMetrics(t *testing.T) {
|
|||||||
Guests: GuestsMetrics{Guests: []GuestMetrics{{Status: "running", CPU: .5, MaxCPU: 4, MemoryBytes: 50, MaxMemory: 100}}},
|
Guests: GuestsMetrics{Guests: []GuestMetrics{{Status: "running", CPU: .5, MaxCPU: 4, MemoryBytes: 50, MaxMemory: 100}}},
|
||||||
}
|
}
|
||||||
point := historyPointFromMetrics(metrics)
|
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)
|
t.Fatalf("неожиданная историческая точка: %+v", point)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
39
network.go
39
network.go
@@ -51,6 +51,11 @@ type NetworkInterface struct {
|
|||||||
ReceiveDrops uint64 `json:"receiveDrops"`
|
ReceiveDrops uint64 `json:"receiveDrops"`
|
||||||
TransmitDrops uint64 `json:"transmitDrops"`
|
TransmitDrops uint64 `json:"transmitDrops"`
|
||||||
NewErrors uint64 `json:"newErrors"`
|
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"`
|
NewDrops uint64 `json:"newDrops"`
|
||||||
ReceivePerSec float64 `json:"receivePerSec"`
|
ReceivePerSec float64 `json:"receivePerSec"`
|
||||||
TransmitPerSec float64 `json:"transmitPerSec"`
|
TransmitPerSec float64 `json:"transmitPerSec"`
|
||||||
@@ -83,13 +88,14 @@ type networkCollector struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newNetworkCollector(path string) (*networkCollector, error) {
|
func newNetworkCollector(path string) (*networkCollector, error) {
|
||||||
snapshot, _, _, err := readNetworkSnapshot(path)
|
snapshot, _, details, err := readNetworkSnapshot(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
_, _, details, _ := readNetworkSnapshot(path)
|
|
||||||
previous := map[string]NetworkInterface{}
|
previous := map[string]NetworkInterface{}
|
||||||
for _, v := range details {
|
for _, v := range details {
|
||||||
|
decorateInterface(&v)
|
||||||
|
collectInterfaceFaults(&v)
|
||||||
previous[v.Name] = v
|
previous[v.Name] = v
|
||||||
}
|
}
|
||||||
known := map[string]bool{}
|
known := map[string]bool{}
|
||||||
@@ -116,16 +122,11 @@ func (c *networkCollector) collect() (NetworkMetrics, error) {
|
|||||||
for i := range details {
|
for i := range details {
|
||||||
v := &details[i]
|
v := &details[i]
|
||||||
decorateInterface(v)
|
decorateInterface(v)
|
||||||
if old, ok := c.previousInterfaces[v.Name]; ok && elapsed > 0 && v.ReceiveBytes >= old.ReceiveBytes && v.TransmitBytes >= old.TransmitBytes {
|
collectInterfaceFaults(v)
|
||||||
v.ReceivePerSec = float64(v.ReceiveBytes-old.ReceiveBytes) / elapsed
|
if old, ok := c.previousInterfaces[v.Name]; ok {
|
||||||
v.TransmitPerSec = float64(v.TransmitBytes-old.TransmitBytes) / elapsed
|
updateInterfaceDeltas(v, old, 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
v.LinkDown = interfaceLinkDown(*v)
|
||||||
}
|
}
|
||||||
c.previousInterfaces = map[string]NetworkInterface{}
|
c.previousInterfaces = map[string]NetworkInterface{}
|
||||||
for _, v := range details {
|
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"
|
health.Summary, health.LikelyCause, health.RecommendedAction = "Хост не видит локальный шлюз", "Проблема между Proxmox и роутером: линк, bridge, bond или VLAN", "Проверьте кабель, carrier и настройки vmbr/bond/VLAN"
|
||||||
case health.PacketLoss > 0:
|
case health.PacketLoss > 0:
|
||||||
health.Summary = fmt.Sprintf("Нестабильная локальная сеть: потеряно %.1f%% пакетов", health.PacketLoss)
|
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:
|
case !health.DNSUp && health.InternetUp:
|
||||||
health.Summary, health.LikelyCause, health.RecommendedAction = "Интернет доступен, но DNS не отвечает", "Проблема DNS-сервера или /etc/resolv.conf", "Проверьте настроенные DNS и попробуйте разрешить домен вручную"
|
health.Summary, health.LikelyCause, health.RecommendedAction = "Интернет доступен, но DNS не отвечает", "Проблема DNS-сервера или /etc/resolv.conf", "Проверьте настроенные DNS и попробуйте разрешить домен вручную"
|
||||||
case health.DNSUp && !health.InternetUp:
|
case health.DNSUp && !health.InternetUp:
|
||||||
health.Summary, health.LikelyCause, health.RecommendedAction = "Локальная сеть и DNS работают, но внешняя проверка недоступна", "Проблема выше роутера, блокировка HTTPS или недоступность провайдера", "Проверьте маршрут наружу, firewall и доступ к внешним адресам"
|
health.Summary, health.LikelyCause, health.RecommendedAction = "Локальная сеть и DNS работают, но внешняя проверка недоступна", "Проблема выше роутера, блокировка HTTPS или недоступность провайдера", "Проверьте маршрут наружу, firewall и доступ к внешним адресам"
|
||||||
}
|
}
|
||||||
for _, v := range interfaces {
|
for _, v := range interfaces {
|
||||||
if v.Kind == "Physical" && (!v.Carrier || v.State == "down") {
|
if v.LinkDown {
|
||||||
health.Summary, health.LikelyCause, health.RecommendedAction = v.Name+": физический линк не поднят", "Нет carrier либо интерфейс административно выключен", "Проверьте кабель, порт коммутатора и состояние интерфейса"
|
health.Summary, health.LikelyCause, health.RecommendedAction = v.Name+": линк не поднят", "Нет carrier либо интерфейс административно выключен", "Проверьте кабель, порт коммутатора и состояние интерфейса"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if v.NewErrors+v.NewDrops > 0 {
|
if v.NewErrors+v.NewFaults > 0 {
|
||||||
health.Summary = fmt.Sprintf("%s: за последний интервал появилось %d сетевых ошибок/потерь", v.Name, v.NewErrors+v.NewDrops)
|
health.Summary = fmt.Sprintf("%s: за последний интервал появилось %d изменений счётчиков сетевых ошибок", v.Name, v.NewErrors+v.NewFaults)
|
||||||
health.LikelyCause, health.RecommendedAction = "Счётчики интерфейса сейчас растут из-за перегрузки, кабеля, MTU или duplex", "Проверьте скорость и duplex, кабель, порт коммутатора и MTU на обоих концах"
|
health.LikelyCause, health.RecommendedAction = "Счётчики интерфейса сейчас растут из-за перегрузки, кабеля, MTU или duplex", "Проверьте скорость и duplex, кабель, порт коммутатора и MTU на обоих концах"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -195,6 +196,10 @@ func decorateInterface(v *NetworkInterface) {
|
|||||||
v.Master = filepath.Base(target)
|
v.Master = filepath.Base(target)
|
||||||
}
|
}
|
||||||
switch {
|
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"):
|
case strings.HasPrefix(v.Name, "vmbr"):
|
||||||
v.Kind = "Bridge"
|
v.Kind = "Bridge"
|
||||||
case strings.HasPrefix(v.Name, "bond"):
|
case strings.HasPrefix(v.Name, "bond"):
|
||||||
@@ -273,7 +278,7 @@ func readNetworkSnapshot(path string) (networkSnapshot, []string, []NetworkInter
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fields := strings.Fields(values)
|
fields := strings.Fields(values)
|
||||||
if len(fields) < 9 {
|
if len(fields) < 16 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
rx, errRX := strconv.ParseUint(fields[0], 10, 64)
|
rx, errRX := strconv.ParseUint(fields[0], 10, 64)
|
||||||
|
|||||||
103
network_faults.go
Normal file
103
network_faults.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
67
network_faults_test.go
Normal file
67
network_faults_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,13 +49,13 @@ func TestExplainNetworkHealthIgnoresOldInterfaceCounters(t *testing.T) {
|
|||||||
t.Fatalf("старый накопленный счётчик вызвал текущую тревогу: %+v", health)
|
t.Fatalf("старый накопленный счётчик вызвал текущую тревогу: %+v", health)
|
||||||
}
|
}
|
||||||
explainNetworkHealth(&health, []NetworkInterface{{Name: "eno1", Carrier: true, State: "up", ReceiveDrops: 43, NewDrops: 1}})
|
explainNetworkHealth(&health, []NetworkInterface{{Name: "eno1", Carrier: true, State: "up", ReceiveDrops: 43, NewDrops: 1}})
|
||||||
if !strings.Contains(health.Summary, "последний интервал") {
|
if health.Summary != "Сеть работает штатно" {
|
||||||
t.Fatalf("рост счётчика не объяснён: %+v", health)
|
t.Fatalf("software drops вызвали тревогу: %+v", health)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInterfaceVMID(t *testing.T) {
|
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 {
|
if got := interfaceVMID(name); got != want {
|
||||||
t.Fatalf("%s: got %d want %d", name, got, want)
|
t.Fatalf("%s: got %d want %d", name, got, want)
|
||||||
}
|
}
|
||||||
|
|||||||
16
scripts/build-release.sh
Executable file
16
scripts/build-release.sh
Executable file
@@ -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
|
||||||
Reference in New Issue
Block a user