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—
Дата батареи—
Возраст батареи—
Источник данных
Не настроен
Заряд и нагрузка UPSПоследние 7 дней
Последний успешный сбор—
Длительность цикла—
Последняя запись истории—
Свободно для базы—
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. Предполагаемые связи явно помечаются.
Ожидаем данные агентов…
Подключённые агенты появятся здесь.
Тема оформления
Видимые карточки
Порядок карточек
Перетаскивайте строки или используйте стрелки.
`