package main import ( "encoding/json" "fmt" "io" "net/http" "strconv" "strings" "time" ) type dashboardMetrics struct { TrafficSettings TrafficSettings `json:"trafficSettings"` CPU CPUMetrics `json:"cpu"` Memory MemoryMetrics `json:"memory"` Storage StorageMetrics `json:"storage"` Disks []DiskMetrics `json:"disks"` MissingDisks []string `json:"missingDisks"` Network NetworkMetrics `json:"network"` ZFS ZFSMetrics `json:"zfs"` UPS UPSMetrics `json:"ups"` Guests GuestsMetrics `json:"guests"` Backups BackupMetrics `json:"backups"` Alerts []Alert `json:"alerts"` Update UpdateStatus `json:"update"` Maintenance MaintenanceMetrics `json:"maintenance"` MaintenanceWindow MaintenanceWindow `json:"maintenanceWindow"` Services ServicesMetrics `json:"services"` SystemHealth SystemHealthMetrics `json:"systemHealth"` Activity ProxmoxActivity `json:"activity"` Monitor MonitorHealth `json:"monitor"` UPSShutdown UPSShutdownSettings `json:"upsShutdown"` Trends []TrendAnomaly `json:"trends"` Agents []ManagedAgent `json:"agents"` ServiceLinks []ServiceLink `json:"serviceLinks"` Timestamp time.Time `json:"timestamp"` } func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkCollector, diskCollector *diskCollector, zfsCollector *zfsCollector, upsCollector *upsCollector, guestCollector *guestCollector, backupCollector *backupCollector, maintenanceCollector *maintenanceCollector, serviceCollector *serviceCollector, systemHealth *systemHealthCollector, activity *activityCollector, ioAttribution *ioAttributionCollector, trends *trendCollector, store *Store, monitor *monitorTracker) (dashboardMetrics, error) { cpu, err := cpuCollector.collect() if err != nil { return dashboardMetrics{}, err } memory, err := readMemoryMetrics("/proc/meminfo") if err != nil { return dashboardMetrics{}, err } storage, err := readStorageMetrics("/") if err != nil { return dashboardMetrics{}, err } network, err := networkCollector.collect() if err != nil { return dashboardMetrics{}, err } thresholds := store.Thresholds() metrics := dashboardMetrics{ CPU: cpu, Memory: memory, Storage: storage, TrafficSettings: store.TrafficSettings(), Disks: diskCollector.collect(thresholds), Network: network, ZFS: zfsCollector.collect(), UPS: upsCollector.collect(), Guests: guestCollector.collect(), Backups: backupCollector.collect(), Maintenance: maintenanceCollector.collect(), Services: serviceCollector.collect(), SystemHealth: systemHealth.collect(), Update: readUpdateStatus(), Timestamp: time.Now(), } metrics.MissingDisks = diskCollector.missingDisks() metrics.Monitor = monitor.snapshot(store) metrics.UPSShutdown = store.UPSShutdownSettings() metrics.Activity = activity.collect(metrics.Guests) attributeDiskIO(&metrics, ioAttribution.collect(metrics.Guests)) metrics.Trends = trends.collect(metrics, store) metrics.Agents, _ = store.Agents() metrics.ServiceLinks, _ = store.ServiceLinks() decorateNetworkInterfaces(&metrics.Network, metrics.Guests) metrics.MaintenanceWindow = store.MaintenanceWindow() if !metrics.MaintenanceWindow.Active { metrics.MaintenanceWindow = automaticBackupMaintenance(metrics.Activity, metrics.ServiceLinks, time.Now()) } allAlerts := groupLinkedAlerts(metrics, correlateAlerts(metrics, evaluateAlerts(metrics, thresholds))) activeAlerts, plannedAlerts := splitMaintenanceAlerts(allAlerts, metrics.MaintenanceWindow) metrics.Alerts, err = store.SyncAlertsWithMaintenance(activeAlerts, plannedAlerts, metrics.MaintenanceWindow.Reason) if err != nil { return dashboardMetrics{}, err } return metrics, nil } func decorateNetworkInterfaces(network *NetworkMetrics, guests GuestsMetrics) { names := map[int]GuestMetrics{} for _, g := range guests.Guests { names[g.VMID] = g } for i := range network.Interfaces { v := &network.Interfaces[i] vmid := interfaceVMID(v.Name) if vmid == 0 { continue } v.VMID = vmid if g, ok := names[vmid]; ok { v.GuestName = g.Name if g.Type == "qemu" { v.GuestType = "VM" } else { v.GuestType = "LXC" } } } } func interfaceVMID(name string) int { prefixes := []string{"tap", "veth"} for _, prefix := range prefixes { if !strings.HasPrefix(name, prefix) { continue } rest := strings.TrimPrefix(name, prefix) end := 0 for end < len(rest) && rest[end] >= '0' && rest[end] <= '9' { end++ } if end > 0 { id, _ := strconv.Atoi(rest[:end]) return id } } return 0 } func routes(collector *cpuCollector, store *Store) http.Handler { mux := http.NewServeMux() trafficRoutes(mux, store) network, networkErr := newNetworkCollector("/proc/net/dev") disks := &diskCollector{} zfs := &zfsCollector{} ups := &upsCollector{} guests := &guestCollector{} backups := &backupCollector{} maintenance := &maintenanceCollector{} services := newServiceCollector(store) systemHealth := newSystemHealthCollector() activity := newActivityCollector() ioAttribution := &ioAttributionCollector{} trends := &trendCollector{} monitor := &monitorTracker{} go runCollectionWatchdog(monitor) go runUPSShutdownController(store, ups, guests) collect := func() (dashboardMetrics, error) { started := time.Now() metrics, err := collectDashboard(collector, network, disks, zfs, ups, guests, backups, maintenance, services, systemHealth, activity, ioAttribution, trends, store, monitor) monitor.record(started, err) return metrics, err } go runHistoryCollector(collect, store) go runEmailNotifier(collect, store) go runImmediateNotifier(collect, store) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte(dashboardHTML)) }) mux.HandleFunc("/api/cpu", func(w http.ResponseWriter, _ *http.Request) { metrics, err := collector.collect() writeJSON(w, metrics, err) }) mux.HandleFunc("/api/version", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, map[string]string{"version": version}, nil) }) mux.HandleFunc("/api/update/status", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, readUpdateStatus(), nil) }) mux.HandleFunc("/api/update/request", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } status, err := requestUpdate() writeJSON(w, status, err) }) mux.HandleFunc("/api/system", func(w http.ResponseWriter, _ *http.Request) { if networkErr != nil { writeJSON(w, nil, networkErr) return } metrics, err := collect() writeJSON(w, metrics, err) }) mux.HandleFunc("/api/settings/alerts", func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: writeJSON(w, store.Thresholds(), nil) case http.MethodPut: var thresholds AlertThresholds if err := json.NewDecoder(io.LimitReader(r.Body, 32<<10)).Decode(&thresholds); err != nil { http.Error(w, "Некорректные настройки", http.StatusBadRequest) return } if err := store.SaveThresholds(thresholds); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } writeJSON(w, thresholds, nil) default: w.WriteHeader(http.StatusMethodNotAllowed) } }) mux.HandleFunc("/api/settings/maintenance", func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: writeJSON(w, store.MaintenanceWindow(), nil) case http.MethodPut: var window MaintenanceWindow if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&window); err != nil { http.Error(w, "Некорректные настройки", http.StatusBadRequest) return } value, err := store.SaveMaintenanceWindow(window) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } writeJSON(w, value, nil) case http.MethodDelete: value, err := store.StopMaintenanceWindow() writeJSON(w, value, err) default: w.WriteHeader(http.StatusMethodNotAllowed) } }) mux.HandleFunc("/api/agents", func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: agents, err := store.Agents() writeJSON(w, agents, err) case http.MethodDelete: id := strings.TrimSpace(r.URL.Query().Get("id")) if id == "" { http.Error(w, "Не указан агент", http.StatusBadRequest) return } writeJSON(w, map[string]bool{"deleted": true}, store.DeleteAgent(id)) default: w.WriteHeader(http.StatusMethodNotAllowed) } }) mux.HandleFunc("/api/agents/enrollment-token", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } token, err := store.CreateAgentEnrollmentToken() writeJSON(w, map[string]any{"token": token, "expiresInSeconds": 900}, err) }) mux.HandleFunc("/api/agent/enroll", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } var request agentEnrollmentRequest if json.NewDecoder(io.LimitReader(r.Body, 16<<10)).Decode(&request) != nil || strings.TrimSpace(request.Token) == "" { http.Error(w, "Некорректная регистрация", http.StatusBadRequest) return } credentials, err := store.EnrollAgent(request) if err != nil { http.Error(w, err.Error(), http.StatusUnauthorized) return } writeJSON(w, credentials, nil) }) mux.HandleFunc("/api/agent/report", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } id, secret, ok := agentCredentials(r) if !ok || id == "" || secret == "" { http.Error(w, "Агент не авторизован", http.StatusUnauthorized) return } var report AgentReport if json.NewDecoder(io.LimitReader(r.Body, 2<<20)).Decode(&report) != nil { http.Error(w, "Некорректный отчёт", http.StatusBadRequest) return } if err := store.SaveAgentReport(id, secret, r.RemoteAddr, report); err != nil { http.Error(w, err.Error(), http.StatusUnauthorized) return } trafficSettings := store.TrafficSettings() result := agentReportResponse{Accepted: true, NetworkEnabled: trafficSettings.Enabled && trafficSettings.NetworkEnabled && trafficSettings.AgentID == id, TrafficEnabled: trafficSettings.Enabled && trafficSettings.AgentID == id} result.Commands, _ = store.DispatchAgentActions(id) if report.Version != version && version != "dev" { manifest, _, manifestErr := currentAgentUpdate() if manifestErr == nil { result.Update = &manifest } } writeJSON(w, result, nil) }) mux.HandleFunc("/api/agent/binary", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } id, secret, ok := agentCredentials(r) if !ok || !store.AuthenticateAgent(id, secret) { http.Error(w, "Агент не авторизован", http.StatusUnauthorized) return } manifest, path, err := currentAgentUpdate() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("X-Checksum-SHA256", manifest.SHA256) w.Header().Set("Content-Disposition", `attachment; filename="proxmox-dashboard-agent"`) http.ServeFile(w, r, path) }) mux.HandleFunc("/api/agent-actions", func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: items, err := store.AgentActions(100) writeJSON(w, items, err) case http.MethodPost: var request struct{ AgentID, Action, Target string } if json.NewDecoder(io.LimitReader(r.Body, 16<<10)).Decode(&request) != nil { http.Error(w, "Некорректное действие", http.StatusBadRequest) return } value, err := store.QueueAgentAction(request.AgentID, request.Action, request.Target) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } writeJSON(w, value, nil) default: w.WriteHeader(http.StatusMethodNotAllowed) } }) mux.HandleFunc("/api/alerts/history", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } var from, to int64 if date := strings.TrimSpace(r.URL.Query().Get("date")); date != "" { if day, parseErr := time.ParseInLocation("2006-01-02", date, time.Local); parseErr == nil { from = day.Unix() to = day.Add(24 * time.Hour).Unix() } } events, err := store.AlertHistoryBetween(500, from, to) writeJSON(w, events, err) }) mux.HandleFunc("/api/alerts/acknowledge", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } var request struct { EventID int64 `json:"eventId"` } if err := json.NewDecoder(io.LimitReader(r.Body, 1024)).Decode(&request); err != nil || request.EventID < 1 { http.Error(w, "Некорректное событие", http.StatusBadRequest) return } writeJSON(w, map[string]bool{"acknowledged": true}, store.AcknowledgeAlert(request.EventID)) }) mux.HandleFunc("/api/settings/email", func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: writeJSON(w, store.EmailSettings(false), nil) case http.MethodPut: var settings EmailSettings if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&settings); err != nil { http.Error(w, "Некорректные настройки", http.StatusBadRequest) return } if err := store.SaveEmailSettings(settings); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } writeJSON(w, store.EmailSettings(false), nil) default: w.WriteHeader(http.StatusMethodNotAllowed) } }) mux.HandleFunc("/api/settings/email/test", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } settings := store.EmailSettings(true) writeJSON(w, map[string]bool{"sent": true}, sendAlertEmail(settings, nil, true)) }) mux.HandleFunc("/api/settings/ups-shutdown", func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: writeJSON(w, store.UPSShutdownSettings(), nil) case http.MethodPut: var settings UPSShutdownSettings if json.NewDecoder(io.LimitReader(r.Body, 16<<10)).Decode(&settings) != nil { http.Error(w, "Некорректные настройки", http.StatusBadRequest) return } if err := store.SaveUPSShutdownSettings(settings); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } writeJSON(w, settings, nil) default: w.WriteHeader(http.StatusMethodNotAllowed) } }) mux.HandleFunc("/api/ups/shutdown-dry-run", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } writeJSON(w, buildShutdownDryRun(store.UPSShutdownSettings(), guests.collect()), nil) }) mux.HandleFunc("/api/history", func(w http.ResponseWriter, r *http.Request) { hours, _ := strconv.Atoi(r.URL.Query().Get("hours")) points, err := store.History(hours) writeJSON(w, points, err) }) mux.HandleFunc("/api/entity-history", func(w http.ResponseWriter, r *http.Request) { hours, _ := strconv.Atoi(r.URL.Query().Get("hours")) series, err := store.EntityHistory(r.URL.Query().Get("kind"), hours) writeJSON(w, series, err) }) mux.HandleFunc("/api/services", func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: items, err := store.Services() writeJSON(w, items, err) case http.MethodPost, http.MethodPut: var v MonitoredService if err := json.NewDecoder(io.LimitReader(r.Body, 32<<10)).Decode(&v); err != nil { http.Error(w, "Некорректные данные", 400) return } saved, err := store.SaveService(v) writeJSON(w, saved, err) case http.MethodDelete: id, _ := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64) if id < 1 { http.Error(w, "Некорректный сервис", 400) return } writeJSON(w, map[string]bool{"deleted": true}, store.DeleteService(id)) default: w.WriteHeader(http.StatusMethodNotAllowed) } }) mux.HandleFunc("/api/services/history", func(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64) hours, _ := strconv.Atoi(r.URL.Query().Get("hours")) points, err := store.ServiceHistory(id, hours) writeJSON(w, points, err) }) mux.HandleFunc("/api/service-links", func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: items, err := store.ServiceLinks() writeJSON(w, items, err) case http.MethodPut: var value ServiceLink if json.NewDecoder(io.LimitReader(r.Body, 16<<10)).Decode(&value) != nil { http.Error(w, "Некорректная связь", http.StatusBadRequest) return } saved, err := store.SaveServiceLink(value) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } writeJSON(w, saved, nil) case http.MethodDelete: id, _ := strconv.ParseInt(r.URL.Query().Get("serviceId"), 10, 64) writeJSON(w, map[string]bool{"deleted": true}, store.DeleteServiceLink(id)) default: w.WriteHeader(http.StatusMethodNotAllowed) } }) mux.HandleFunc("/api/backups/verify", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } var request struct { VolumeID string `json:"volid"` } if json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&request) != nil { http.Error(w, "Некорректный backup", http.StatusBadRequest) return } writeJSON(w, verifyBackup(request.VolumeID), nil) }) mux.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) { flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "Потоковые обновления недоступны", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() for { if networkErr != nil { return } metrics, err := collect() if err == nil { data, _ := json.Marshal(metrics) fmt.Fprintf(w, "data: %s\n\n", data) flusher.Flush() } select { case <-r.Context().Done(): return case <-ticker.C: } } }) return mux } func writeJSON(w http.ResponseWriter, value any, err error) { if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(value) } func agentCredentials(r *http.Request) (string, string, bool) { credentials := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") id, secret, ok := strings.Cut(credentials, ".") return id, secret, ok && id != "" && secret != "" } const dashboardHTML = ` Proxmox Host Monitor
Proxmox host telemetry

Host overview

Подключение…
Проверка состояния системы…
Загрузка—
Сокеты—
Физические ядра—
Логические потоки—
Средняя частота—
Uptime—
Load · 1 мин—
Load · 5 мин—
Load · 15 мин—
Температурные датчики
Поиск датчиков…
CPU и температураПоследние 7 дней
Используется—
Доступно—
Всего—
Кэш—
Буферы—
Загрузка RAM—
Swap используется—
Swap всего—
Загрузка swap—
RAM и swapПоследние 7 дней
Используется—
Свободно—
Общий объём—
Заполнение—
Точка монтирования/
Поиск накопителей…
Расшифровка источников I/O
Накопление данных…
Температура и I/O физических дисковОтдельно по каждому диску
Температура
IOPS
Utilization
Получение сейчас—
Передача сейчас—
Суммарная скорость—
Получено с запуска—
Передано с запуска—
Интерфейсы—
Доступность инфраструктуры
Шлюз—
DNS—
Интернет—
Внешний IP—
Понятная диагностика
Проверка сети…
Вероятная причина: —
Что проверить: —
Входящий и исходящий трафикПоследние 7 дней
ARC
Размер—
Цель—
Hit ratio—
Datasets—
Поиск ZFS-пулов…
Заполнение каждого ZFS-пулаОтдельно по каждому пулу

Питание

Состояние—
Нагрузка—
Мощность—
Номинальная мощность—
Входное напряжение—
Выходное напряжение—
Частота—
Номинальное напряжение—

Батарея

Заряд—
Автономность—
Напряжение батареи—
Номинал батареи—
Тип батареи—
Температура—
Дата батареи—
Возраст батареи—

Пороги и диагностика

Пороги переключения—
Предупреждение о заряде—
Низкий заряд—
Низкая автономность—
Звуковой сигнал—
Самотест—
Задержка выключения—
Задержка запуска—
Связь с UPS—
Драйвер NUT—

Пороги сообщены UPS/NUT. Сценарий выключения Dashboard настраивается отдельно.

Источник данных
Не настроен
Исходные параметры UPS / NUT

История UPS

Запись раз в минуту. Новые графики заполняются после обновления. Расчётная мощность не является измерением.

Заряд и нагрузка · %

Мощность · Вт

Вход / выход · В

Батарея · В

Автономность · мин

Частота · Гц

Последний успешный сбор—
Длительность цикла—
Последняя запись истории—
Свободно для базы—
Systemd watchdog—
Ошибка—
Получение списка VM и LXC…
CPU каждой VM и LXCОтдельно по каждой VM и LXC
Получение списка резервных копий…
Proxmox—
Запущенное ядро—
Новое ядро—
Обновления—
Перезагрузка—
Последнее обновление—
Ближайшие задания
Backup—
ZFS scrub—
Проверено—
Добавьте первую проверку в настройках.
У сервиса указаны два адреса. Выберите, через какой открыть страницу.
Ошибки · 24 часа—
Ядро—
Упавшие службы—
Системных ошибок нет.
Получение snapshots…
Получение задач…
Порт от локального адреса отбрасывается. Полученный IP сравнивается с адресами VM/LXC из Proxmox. Storage берётся из конфигурации каждой VM и LXC.
Связи строятся по VMID, IP, слушающему порту, PID systemd и портам Docker. Предполагаемые связи явно помечаются.
Ожидаем данные агентов…
Подключённые агенты появятся здесь.
Тема оформления
Видимые карточки
Порядок карточек
Перетаскивайте строки или используйте стрелки.
`