1026 lines
199 KiB
Go
1026 lines
199 KiB
Go
package main
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
type dashboardMetrics struct {
|
||
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,
|
||
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()
|
||
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", "fwbr", "fwpr", "fwln"}
|
||
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()
|
||
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
|
||
}
|
||
result := agentReportResponse{Accepted: true}
|
||
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 = `<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Proxmox Host Monitor</title>
|
||
<script>document.documentElement.dataset.theme=localStorage.getItem('proxmox-theme')||'dark'</script>
|
||
<style>
|
||
:root{color-scheme:dark;--bg:#080d12;--panel:#111920;--line:#26323c;--muted:#8d9ba8;--text:#f2f5f7;--green:#55d58a;--violet:#9d8cff}
|
||
html[data-theme="light"]{color-scheme:light;--bg:#eef2f4;--panel:#fff;--line:#d8e0e5;--muted:#647481;--text:#142029;--green:#168650;--violet:#6557d9}
|
||
*{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(circle at 78% -8%,#173226 0,transparent 34%),var(--bg);color:var(--text);font:15px/1.45 Inter,ui-sans-serif,system-ui,-apple-system,sans-serif}
|
||
button{font:inherit}main{width:min(1060px,calc(100% - 32px));margin:auto;padding:46px 0}
|
||
header{display:flex;align-items:end;justify-content:space-between;margin-bottom:22px}.eyebrow{color:var(--green);font:700 11px/1 monospace;letter-spacing:.16em;text-transform:uppercase}
|
||
h1{font-size:clamp(28px,5vw,44px);letter-spacing:-.045em;margin:9px 0 0}.header-actions,.status{display:flex;align-items:center;gap:10px}.status{color:var(--muted);font-size:13px}.dot{width:8px;height:8px;border-radius:50%;background:#f39a56}.dot.live{background:var(--green);box-shadow:0 0 15px var(--green)}.settings-button{width:38px;height:38px;border:1px solid var(--line);border-radius:12px;background:var(--panel);color:var(--text);cursor:pointer;font-size:18px}.settings-button:hover{border-color:var(--muted)}
|
||
.cards{display:grid;grid-template-columns:1fr 1fr;grid-auto-rows:1fr;gap:16px}.card{appearance:none;display:flex;flex-direction:column;text-align:left;color:inherit;width:100%;padding:0;background:linear-gradient(145deg,rgba(18,28,36,.98),rgba(12,19,25,.98));border:1px solid var(--line);border-radius:20px;overflow:hidden;cursor:pointer;transition:transform .18s,border-color .18s,box-shadow .18s}.card:hover{transform:translateY(-2px);border-color:#3d4e5c;box-shadow:0 20px 55px rgba(0,0,0,.22)}.card:focus-visible{outline:2px solid var(--green);outline-offset:3px}
|
||
.card-head{display:flex;justify-content:space-between;align-items:flex-start;gap:20px;min-height:106px;padding:23px 24px 18px}.card-head>div:first-child{min-width:0}.label{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.1em}.name{font-size:17px;font-weight:650;margin-top:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:350px}.percent{display:inline-flex;align-items:baseline;gap:.22em;flex:0 0 auto;font:700 35px/.95 ui-monospace,monospace;letter-spacing:-.04em}.percent small{font-size:16px;color:var(--green);letter-spacing:-.02em}
|
||
.bar{height:5px;background:#222e37;margin:0 24px;overflow:hidden;border-radius:20px}.fill{height:100%;width:0;background:linear-gradient(90deg,var(--green),#afe36d);transition:width .5s ease}.memory .fill{background:linear-gradient(90deg,var(--violet),#72b9ff)}
|
||
.storage .fill{background:linear-gradient(90deg,#ffb35c,#ff755c)}.network .fill{background:linear-gradient(90deg,#43c6d9,#65ddaa)}.zfs .fill{background:linear-gradient(90deg,#46c0ff,#6577ff)}.ups .fill{background:linear-gradient(90deg,#d5e85a,#55d58a)}.guests .fill{background:linear-gradient(90deg,#ff78b8,#9d8cff)}.backups .fill{background:linear-gradient(90deg,#ffcf5c,#55d58a)}.alert-center{grid-column:1/-1}.alert-center .fill{background:linear-gradient(90deg,#ffb35c,#ff665c)}
|
||
.quick{display:grid;grid-template-columns:1fr 1fr;margin-top:19px;border-top:1px solid var(--line)}.quick div{padding:16px 24px}.quick div+div{border-left:1px solid var(--line)}.quick strong{display:block;font:650 20px/1.2 ui-monospace,monospace;margin-top:5px}.hint{color:#657581;font-size:11px;margin:auto 24px 18px;padding-top:14px}
|
||
dialog{width:min(760px,calc(100% - 28px));max-height:calc(100vh - 32px);padding:0;color:var(--text);background:#101820;border:1px solid #34434f;border-radius:22px;box-shadow:0 30px 100px #000b;overflow:auto}dialog::backdrop{background:rgba(3,7,10,.75);backdrop-filter:blur(6px)}
|
||
.modal-head{position:sticky;top:0;z-index:2;display:flex;align-items:flex-start;justify-content:space-between;padding:26px 28px;background:rgba(16,24,32,.94);backdrop-filter:blur(14px);border-bottom:1px solid var(--line)}.modal-head h2{font-size:24px;letter-spacing:-.03em;margin:5px 0 0}.close{width:38px;height:38px;border-radius:50%;border:1px solid var(--line);background:#17222b;color:var(--text);font-size:22px;cursor:pointer}.detail-grid{display:grid;grid-template-columns:repeat(3,1fr)}.detail{padding:22px 27px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.detail:nth-child(3n){border-right:0}.detail strong{display:block;font:650 24px/1.2 ui-monospace,monospace;margin-top:7px}.wide{padding:24px 28px}.temps{display:flex;flex-wrap:wrap;gap:9px;margin-top:12px}.temp{padding:7px 11px;border:1px solid var(--line);border-radius:99px}.temp b{color:var(--green);margin-left:7px}.empty{color:var(--muted);font-size:13px}
|
||
.disk-list{display:grid;gap:12px;padding:16px}.disk-item{padding:19px;border:1px solid var(--line);border-radius:15px;background:#0c141a}.disk-top{display:flex;justify-content:space-between;gap:20px}.disk-model{font-size:17px;font-weight:650}.disk-meta{color:var(--muted);font:12px/1.5 ui-monospace,monospace;margin-top:4px;overflow-wrap:anywhere}.badge{position:relative;height:max-content;padding:5px 9px;border:1px solid #2d6245;border-radius:99px;color:var(--green);font-size:11px;white-space:nowrap}.badge.warn{color:#ffb35c;border-color:#6d4c2c}.badge.bad{color:#ff755c;border-color:#71362f}.badge[data-reason]:hover::after,.badge[data-reason]:focus::after{content:attr(data-reason);position:absolute;z-index:5;right:0;bottom:calc(100% + 10px);width:280px;padding:11px 13px;border:1px solid #4c5963;border-radius:10px;background:#1a242c;color:var(--text);font:12px/1.45 Inter,system-ui,sans-serif;white-space:normal;box-shadow:0 14px 35px #0008}.disk-stats{display:grid;grid-template-columns:repeat(6,1fr);gap:14px;margin-top:18px}.disk-stat span{display:block;color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.08em}.disk-stat b{display:block;margin-top:5px;font:600 14px ui-monospace,monospace}
|
||
.settings-tabs{display:flex;gap:6px;padding:12px 26px 0}.settings-tab{padding:9px 13px;border:1px solid transparent;border-radius:10px;background:transparent;color:var(--muted);cursor:pointer}.settings-tab.active{background:var(--panel);border-color:var(--line);color:var(--text)}.settings-body{padding:22px 26px 28px}.settings-panel[hidden]{display:none}.settings-group+.settings-group{margin-top:25px}.theme-options{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:11px}.theme-option{padding:13px;border:1px solid var(--line);border-radius:12px;background:var(--panel);color:var(--text);cursor:pointer}.theme-option.active{border-color:var(--green);box-shadow:inset 0 0 0 1px var(--green)}.setting-list{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:11px}.setting-item{display:flex;align-items:center;gap:10px;padding:11px 12px;border:1px solid var(--line);border-radius:11px}.setting-item input{accent-color:var(--green);width:17px;height:17px}.threshold-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}.threshold-item{display:flex;align-items:center;justify-content:space-between;gap:15px;padding:11px 12px;border:1px solid var(--line);border-radius:11px}.threshold-item span{font-size:13px}.threshold-item input{width:78px;padding:7px 8px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--text);font:600 14px ui-monospace,monospace}.save-settings{width:100%;margin-top:16px;padding:12px;border:0;border-radius:11px;background:var(--green);color:#07120c;font-weight:700;cursor:pointer}.settings-note{color:var(--muted);font-size:12px;margin-top:10px}.card[hidden]{display:none}
|
||
.detail-tabs{display:flex;gap:6px;padding:12px 26px;border-bottom:1px solid var(--line)}.detail-tab{padding:9px 13px;border:1px solid transparent;border-radius:10px;background:transparent;color:var(--muted);cursor:pointer}.detail-tab.active{background:var(--panel);border-color:var(--line);color:var(--text)}.detail-panel[hidden]{display:none}.chart-section{position:relative;padding:22px 25px}.chart-head{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:12px}.range-switch{display:flex;gap:4px;padding:3px;border:1px solid var(--line);border-radius:10px}.range-button{padding:5px 9px;border:0;border-radius:7px;background:transparent;color:var(--muted);font-size:11px;cursor:pointer}.range-button.active{background:var(--panel);color:var(--text);box-shadow:0 1px 5px #0003}.metric-chart{display:block;width:100%;height:200px;border-radius:12px;background:rgba(100,130,150,.05);cursor:crosshair}.chart-tooltip{display:none;position:absolute;z-index:4;min-width:165px;padding:10px 12px;border:1px solid #40505c;border-radius:10px;background:#17222b;color:var(--text);font-size:12px;line-height:1.55;pointer-events:none;box-shadow:0 12px 32px #0008}.chart-tooltip.visible{display:block}.chart-tooltip-time{color:var(--muted);margin-bottom:4px}.chart-tooltip-value{display:flex;align-items:center;justify-content:space-between;gap:18px}.chart-tooltip-value i{width:7px;height:7px;border-radius:50%;margin-right:7px;display:inline-block}
|
||
.alert-list{display:grid;gap:10px;padding:16px}.alert-item{display:grid;grid-template-columns:auto 1fr;gap:13px;padding:17px 18px;border:1px solid var(--line);border-radius:14px;background:#0c141a}.alert-icon{width:10px;height:10px;border-radius:50%;margin-top:6px;background:#6ba8ff;box-shadow:0 0 14px currentColor}.alert-item.warning .alert-icon{background:#ffb35c}.alert-item.critical .alert-icon{background:#ff665c}.alert-title{font-weight:650}.alert-source{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.08em;margin-bottom:4px}.alert-message{color:var(--muted);font-size:13px;margin-top:4px}.all-good{padding:45px 24px;text-align:center}.all-good strong{display:block;font-size:22px;margin-bottom:7px}.all-good span{color:var(--muted)}
|
||
html[data-theme="light"] body{background:radial-gradient(circle at 78% -8%,#cde9da 0,transparent 34%),var(--bg)}html[data-theme="light"] .card{background:linear-gradient(145deg,#fff,#f7f9fa)}html[data-theme="light"] dialog{background:#fff;box-shadow:0 30px 80px #4e64752b}html[data-theme="light"] .modal-head{background:rgba(255,255,255,.94)}html[data-theme="light"] .close{background:#f1f4f6}html[data-theme="light"] .disk-item,html[data-theme="light"] .alert-item{background:#f7f9fa}html[data-theme="light"] .bar{background:#e2e8ec}html[data-theme="light"] dialog::backdrop{background:rgba(30,45,55,.35)}html[data-theme="light"] .badge[data-reason]:hover::after,html[data-theme="light"] .badge[data-reason]:focus::after,html[data-theme="light"] .chart-tooltip{background:#fff;box-shadow:0 12px 32px #4e647533}
|
||
footer{color:#657581;font-size:12px;margin-top:17px}
|
||
.maintenance-banner{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:-7px 0 18px;padding:12px 16px;border:1px solid #6d4c2c;border-radius:13px;background:rgba(255,179,92,.08);color:#ffb35c}.maintenance-banner[hidden]{display:none}.maintenance-banner strong{color:var(--text)}.maintenance-banner small{display:block;color:var(--muted);margin-top:2px}.maintenance-targets{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:10px;max-height:240px;overflow:auto}.maintenance-targets .setting-item{background:var(--panel)}
|
||
@media(max-width:700px){main{padding:28px 0}.cards{grid-template-columns:1fr}header{align-items:flex-start;flex-direction:column;gap:14px}.detail-grid{grid-template-columns:repeat(2,1fr)}.detail:nth-child(3n){border-right:1px solid var(--line)}.detail:nth-child(2n){border-right:0}.name{max-width:220px}.disk-stats,.setting-list,.threshold-grid{grid-template-columns:repeat(2,1fr)}.service-form,.maintenance-grid{grid-template-columns:1fr}.maintenance-grid .detail{border-right:0}.chart-head{align-items:flex-start;flex-direction:column}.detail-tabs,.settings-tabs,.system-tabs{overflow-x:auto}}
|
||
.save-settings:disabled{cursor:wait;opacity:.55}[data-settings-panel="email"] .threshold-item input{width:min(210px,52vw)}.ack-alert{margin-top:11px;padding:6px 10px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--text);font-size:11px;cursor:pointer}.history-state{display:inline-block;margin-top:8px;color:var(--muted);font-size:11px}.alert-panel[hidden]{display:none}
|
||
.maintenance .fill{background:linear-gradient(90deg,#55d58a,#43c6d9)}.card-order{display:grid;gap:7px;margin-top:11px}.order-item{display:flex;align-items:center;gap:10px;padding:9px 10px;border:1px solid var(--line);border-radius:10px;background:var(--panel)}.order-item.dragging{opacity:.45}.order-handle{cursor:grab;color:var(--muted);font-size:17px}.order-item span:nth-child(2){flex:1}.order-button{width:30px;height:28px;border:1px solid var(--line);border-radius:7px;background:var(--bg);color:var(--text);cursor:pointer}
|
||
.services .fill{background:linear-gradient(90deg,#55d58a,#43c6d9)}.system-health .fill{background:linear-gradient(90deg,#6ba8ff,#9d8cff)}.service-form{display:grid;grid-template-columns:1fr 1fr;gap:12px}.field{display:grid;gap:6px;min-width:0}.field>span{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.07em}.field small{color:var(--muted);font-size:11px}.field input,.field select{width:100%;min-width:0;padding:11px 12px;border:1px solid var(--line);border-radius:9px;background:var(--bg);color:var(--text)}.check-field{display:flex;align-items:center;gap:10px;padding:12px;border:1px solid var(--line);border-radius:9px}.check-field input{width:18px;height:18px;accent-color:var(--green)}.service-actions{display:flex;flex-wrap:wrap;gap:7px;margin-top:10px}.service-actions button,.open-address{padding:7px 10px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--text);cursor:pointer;text-decoration:none}.status-up{color:var(--green)}.status-down{color:#ff665c}.issue-message{overflow-wrap:anywhere;line-height:1.6;margin-top:12px}.full-span{grid-column:1/-1}.endpoint-list{display:grid;gap:10px;margin-top:16px}.endpoint{padding:14px;border:1px solid var(--line);border-radius:12px;background:rgba(100,130,150,.04)}.endpoint-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.endpoint-address{color:var(--muted);font:12px ui-monospace,monospace;overflow-wrap:anywhere}.certificate{margin-top:11px;padding-top:11px;border-top:1px solid var(--line);color:var(--muted);font-size:12px}.certificate b{color:var(--text)}.maintenance-grid .detail{min-width:0}.maintenance-grid .detail strong{font-size:clamp(15px,2.2vw,21px);overflow-wrap:anywhere;word-break:break-word}.system-tabs{display:flex;align-items:center;gap:6px;padding:12px 26px;border-bottom:1px solid var(--line)}.system-panel[hidden]{display:none}.system-sort{margin-left:auto;padding:8px 11px;border:1px solid var(--line);border-radius:9px;background:var(--panel);color:var(--text);cursor:pointer;white-space:nowrap}
|
||
.snapshots .fill{background:linear-gradient(90deg,#9d8cff,#ff78b8)}.tasks .fill{background:linear-gradient(90deg,#43c6d9,#6ba8ff)}.homelab-map .fill{background:linear-gradient(90deg,#55d58a,#9d8cff)}.map-board{display:grid;grid-template-columns:170px 1fr 1fr 1fr;gap:14px;padding:18px;min-width:680px}.map-column{display:grid;align-content:start;gap:9px}.map-column-title{color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.1em}.map-node{padding:12px;border:1px solid var(--line);border-radius:12px;background:var(--panel)}.map-node.good{border-color:#2d6245}.map-node.bad{border-color:#71362f}.map-node.warn{border-color:#6d4c2c}.map-node strong{display:block}.map-node small{display:block;color:var(--muted);margin-top:3px}.map-scroll{overflow-x:auto}.backup-check{margin-top:9px;color:var(--muted);font-size:12px}.task-filter{display:flex;gap:6px;padding:12px 16px;overflow-x:auto}
|
||
.topology-board{padding:18px;min-width:620px}.topology-host{margin-bottom:14px}.topology-grid{display:grid;grid-template-columns:repeat(2,minmax(270px,1fr));gap:12px}.topology-group{padding:14px;border:1px solid var(--line);border-radius:15px;background:rgba(100,130,150,.04)}.topology-group-head{display:flex;justify-content:space-between;gap:10px;padding-bottom:10px;border-bottom:1px solid var(--line)}.topology-section{margin-top:11px}.topology-section-title{color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.08em;margin-bottom:6px}.topology-items{display:flex;flex-wrap:wrap;gap:6px}.topology-chip{padding:6px 9px;border:1px solid var(--line);border-radius:9px;background:var(--panel);font-size:12px}.topology-chip.good{border-color:#2d6245}.topology-chip.bad{border-color:#71362f}.topology-unassigned{margin-top:14px}@media(max-width:700px){.topology-grid{grid-template-columns:1fr}}
|
||
.monitor .fill{background:linear-gradient(90deg,#55d58a,#43c6d9)}.shutdown-plan{display:grid;gap:8px;margin-top:12px}.shutdown-step{padding:10px 12px;border:1px solid var(--line);border-radius:10px}.infra-status{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin:14px 0}.infra-status>div{padding:11px;border:1px solid var(--line);border-radius:10px}.infra-status b{display:block;margin-top:4px}@media(max-width:700px){.infra-status{grid-template-columns:1fr 1fr}}
|
||
.agents .fill{background:linear-gradient(90deg,#55d58a,#6ba8ff)}.agent-command{display:block;width:100%;padding:12px;border:1px solid var(--line);border-radius:10px;background:var(--bg);color:var(--text);font:12px/1.5 ui-monospace,monospace;overflow-wrap:anywhere;white-space:pre-wrap}.agent-containers{display:flex;flex-wrap:wrap;gap:7px;margin-top:13px}
|
||
.agent-detail-panel{padding:16px}.agent-detail-panel[hidden]{display:none}.agent-detail-summary{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.agent-detail-summary>div{padding:14px;border:1px solid var(--line);border-radius:12px}.agent-detail-summary b{display:block;margin-top:5px;font:600 16px ui-monospace,monospace}.agent-filter{display:flex;gap:6px;margin-bottom:12px}.agent-filter button{padding:7px 10px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--text);cursor:pointer}.agent-filter button.active{border-color:var(--green);color:var(--green)}@media(max-width:700px){.agent-detail-summary{grid-template-columns:1fr 1fr}}
|
||
.diagnostics .fill{background:linear-gradient(90deg,#43c6d9,#9d8cff)}#diagnosticsDialog{width:min(1180px,calc(100% - 28px))}.diagnostic-chain{display:grid;grid-template-columns:minmax(175px,1fr) 24px minmax(210px,1.35fr) 24px minmax(120px,.7fr) 24px minmax(160px,.9fr);align-items:stretch;gap:8px;padding:8px 0}.diagnostic-node{min-width:0;padding:14px;border:1px solid var(--line);border-radius:11px;background:var(--panel);overflow:hidden}.diagnostic-node .label,.diagnostic-node strong{display:block}.diagnostic-node strong{margin-top:6px;font-size:16px;line-height:1.25;overflow-wrap:anywhere}.diagnostic-node.bad{border-color:#71362f}.diagnostic-arrow{align-self:center;text-align:center;color:var(--muted);font-size:20px}.service-log{margin-top:10px;padding:9px 11px;border-left:2px solid var(--line);background:var(--bg);font:11px/1.5 ui-monospace,monospace;overflow-wrap:anywhere}.process-table{display:grid;gap:7px}.process-row{display:grid;grid-template-columns:minmax(140px,2fr) repeat(4,minmax(70px,1fr));gap:8px;padding:10px;border:1px solid var(--line);border-radius:10px;align-items:center}.process-row small{display:block;color:var(--muted);overflow-wrap:anywhere}.agent-problem-summary{display:flex;flex-wrap:wrap;gap:7px;margin-top:12px}.disk-list[hidden]{display:none!important}#agentHistoryDetails{position:relative}@media(max-width:850px){.diagnostic-chain{display:flex;overflow-x:auto}.diagnostic-node{min-width:190px}.diagnostic-arrow{min-width:24px}}@media(max-width:700px){.process-row{grid-template-columns:1fr 1fr}.agent-filter{overflow-x:auto}}
|
||
</style>
|
||
</head>
|
||
<body><main>
|
||
<header><div><div class="eyebrow">Proxmox host telemetry</div><h1>Host overview</h1></div><div class="header-actions"><div class="status"><i class="dot" id="dot"></i><span id="status">Подключение…</span></div><button class="settings-button" id="managementButton" aria-label="Управление" title="Сервисы, агенты и плановые работы">⌘</button><button class="settings-button" id="settingsButton" aria-label="Настройки" title="Настройки Dashboard">⚙</button></div></header>
|
||
<div class="maintenance-banner" id="maintenanceBanner" hidden><div><strong>Плановые работы</strong><small id="maintenanceBannerReason"></small></div><span id="maintenanceBannerEnds"></span></div>
|
||
<section class="cards">
|
||
<button class="card alert-center" data-card="alerts" data-open="alertsDialog" aria-label="Открыть центр уведомлений">
|
||
<div class="card-head"><div><div class="label">Notification center</div><div class="name" id="alertHeadline">Проверка состояния…</div></div><div class="percent"><span id="alertCount">—</span><small> событий</small></div></div>
|
||
<div class="bar"><div class="fill" id="alertBar"></div></div>
|
||
<div class="quick"><div><span class="label">Критические</span><strong id="alertCritical">—</strong></div><div><span class="label">Предупреждения</span><strong id="alertWarnings">—</strong></div></div>
|
||
<div class="hint">Нажмите, чтобы посмотреть все уведомления →</div>
|
||
</button>
|
||
<button class="card" data-card="cpu" data-open="cpuDialog" aria-label="Открыть подробности процессора">
|
||
<div class="card-head"><div><div class="label">CPU</div><div class="name" id="cpuName">Загрузка данных…</div></div><div class="percent"><span id="cpuUsage">—</span><small>%</small></div></div>
|
||
<div class="bar"><div class="fill" id="cpuBar"></div></div>
|
||
<div class="quick"><div><span class="label">Средняя температура</span><strong id="cpuTemp">—</strong></div><div><span class="label">Ядра / потоки</span><strong id="cpuTopology">—</strong></div></div>
|
||
<div class="hint">Нажмите, чтобы посмотреть подробности →</div>
|
||
</button>
|
||
<button class="card memory" data-card="memory" data-open="memoryDialog" aria-label="Открыть подробности оперативной памяти">
|
||
<div class="card-head"><div><div class="label">Memory</div><div class="name">Оперативная память</div></div><div class="percent"><span id="memUsage">—</span><small>%</small></div></div>
|
||
<div class="bar"><div class="fill" id="memBar"></div></div>
|
||
<div class="quick"><div><span class="label">Используется</span><strong id="memUsed">—</strong></div><div><span class="label">Доступно</span><strong id="memAvailable">—</strong></div></div>
|
||
<div class="hint">Нажмите, чтобы посмотреть подробности →</div>
|
||
</button>
|
||
<button class="card storage" data-card="storage" data-open="storageDialog" aria-label="Открыть подробности системного диска">
|
||
<div class="card-head"><div><div class="label">Storage</div><div class="name">Физические диски</div></div><div class="percent"><span id="diskCount">—</span><small id="diskCountLabel"> дисков</small></div></div>
|
||
<div class="bar"><div class="fill" id="diskBar"></div></div>
|
||
<div class="quick"><div><span class="label">SMART</span><strong id="diskHealth">—</strong></div><div><span class="label">Системный раздел</span><strong id="diskUsage">—</strong></div></div>
|
||
<div class="hint">Нажмите, чтобы посмотреть подробности →</div>
|
||
</button>
|
||
<button class="card network" data-card="network" data-open="networkDialog" aria-label="Открыть подробности сети">
|
||
<div class="card-head"><div><div class="label">Network</div><div class="name" id="netInterfaces">Сетевые интерфейсы</div></div><div class="percent"><span id="netTotal">—</span><small> MB/s</small></div></div>
|
||
<div class="bar"><div class="fill" id="netBar"></div></div>
|
||
<div class="quick"><div><span class="label">Получение ↓</span><strong id="netDown">—</strong></div><div><span class="label">Передача ↑</span><strong id="netUp">—</strong></div></div>
|
||
<div class="hint">Нажмите, чтобы посмотреть подробности →</div>
|
||
</button>
|
||
<button class="card zfs" data-card="zfs" data-open="zfsDialog" aria-label="Открыть подробности ZFS">
|
||
<div class="card-head"><div><div class="label">ZFS</div><div class="name">Пулы хранения</div></div><div class="percent"><span id="zfsCount">—</span><small> пулов</small></div></div>
|
||
<div class="bar"><div class="fill" id="zfsBar"></div></div>
|
||
<div class="quick"><div><span class="label">Все пулы</span><strong id="zfsHealth">—</strong></div><div><span class="label">Суммарно занято</span><strong id="zfsUsed">—</strong></div></div>
|
||
<div class="hint">Нажмите, чтобы посмотреть подробности →</div>
|
||
</button>
|
||
<button class="card ups" data-card="ups" data-open="upsDialog" aria-label="Открыть подробности ИБП">
|
||
<div class="card-head"><div><div class="label">UPS</div><div class="name" id="upsName">Источник бесперебойного питания</div></div><div class="percent"><span id="upsCharge">—</span><small>%</small></div></div>
|
||
<div class="bar"><div class="fill" id="upsBar"></div></div>
|
||
<div class="quick"><div><span class="label">Состояние</span><strong id="upsStatus">—</strong></div><div><span class="label">Автономность</span><strong id="upsRuntime">—</strong></div></div>
|
||
<div class="hint">Нажмите, чтобы посмотреть подробности →</div>
|
||
</button>
|
||
<button class="card monitor" data-card="monitor" data-open="monitorDialog" aria-label="Открыть самоконтроль дашборда">
|
||
<div class="card-head"><div><div class="label">Monitoring health</div><div class="name">Самоконтроль</div></div><div class="percent"><span id="monitorState">—</span></div></div><div class="bar"><div class="fill" id="monitorBar"></div></div>
|
||
<div class="quick"><div><span class="label">Последний сбор</span><strong id="monitorLast">—</strong></div><div><span class="label">База</span><strong id="monitorDB">—</strong></div></div><div class="hint">Watchdog, история и свежесть данных →</div>
|
||
</button>
|
||
<button class="card guests" data-card="guests" data-open="guestsDialog" aria-label="Открыть подробности VM и LXC">
|
||
<div class="card-head"><div><div class="label">Virtualization</div><div class="name">VM и LXC</div></div><div class="percent"><span id="guestRunning">—</span><small id="guestTotal"> / —</small></div></div>
|
||
<div class="bar"><div class="fill" id="guestBar"></div></div>
|
||
<div class="quick"><div><span class="label">CPU VM и LXC</span><strong id="guestCPU">—</strong></div><div><span class="label">Память VM и LXC</span><strong id="guestMemory">—</strong></div></div>
|
||
<div class="hint">Нажмите, чтобы посмотреть подробности →</div>
|
||
</button>
|
||
<button class="card agents" data-card="agents" data-open="agentsDialog" aria-label="Открыть агенты VM и LXC">
|
||
<div class="card-head"><div><div class="label">Guest telemetry</div><div class="name">Агенты VM и LXC</div></div><div class="percent"><span id="agentOnline">—</span><small id="agentTotal"> / —</small></div></div>
|
||
<div class="bar"><div class="fill" id="agentBar"></div></div>
|
||
<div class="quick"><div><span class="label">Docker-контейнеры</span><strong id="agentContainers">—</strong></div><div><span class="label">Проблемы</span><strong id="agentProblems">—</strong></div></div>
|
||
<div class="hint">Системные метрики и Docker внутри VM/LXC →</div>
|
||
</button>
|
||
<button class="card maintenance" data-card="maintenance" data-open="maintenanceDialog" aria-label="Открыть сведения Proxmox">
|
||
<div class="card-head"><div><div class="label">Proxmox</div><div class="name" id="maintenanceVersion">Обслуживание хоста</div></div><div class="percent"><span id="maintenanceUpdates">—</span><small> updates</small></div></div>
|
||
<div class="bar"><div class="fill" id="maintenanceBar"></div></div>
|
||
<div class="quick"><div><span class="label">Перезагрузка</span><strong id="maintenanceReboot">—</strong></div><div><span class="label">Ядро</span><strong id="maintenanceKernel">—</strong></div></div>
|
||
<div class="hint">Нажмите, чтобы посмотреть обслуживание →</div>
|
||
</button>
|
||
<button class="card backups" data-card="backups" data-open="backupsDialog" aria-label="Открыть подробности резервных копий">
|
||
<div class="card-head"><div><div class="label">Backups</div><div class="name">Резервные копии</div></div><div class="percent"><span id="backupProtected">—</span><small id="backupTotal"> / —</small></div></div>
|
||
<div class="bar"><div class="fill" id="backupBar"></div></div>
|
||
<div class="quick"><div><span class="label" id="backupFreshLabel">Свежие · 48 часов</span><strong id="backupFresh">—</strong></div><div><span class="label">Без копии</span><strong id="backupMissing">—</strong></div></div>
|
||
<div class="hint">Нажмите, чтобы посмотреть подробности →</div>
|
||
</button>
|
||
<button class="card services" data-card="services" data-open="servicesDialog" aria-label="Открыть мониторинг сервисов">
|
||
<div class="card-head"><div><div class="label">Homelab services</div><div class="name">Сервисы</div></div><div class="percent"><span id="serviceUp">—</span><small id="serviceTotal"> / —</small></div></div>
|
||
<div class="bar"><div class="fill" id="serviceBar"></div></div>
|
||
<div class="quick"><div><span class="label">Доступность · сутки</span><strong id="serviceUptime">—</strong></div><div><span class="label">Средний отклик</span><strong id="serviceLatency">—</strong></div></div>
|
||
<div class="hint">Нажмите, чтобы управлять проверками →</div>
|
||
</button>
|
||
<button class="card system-health" data-card="system" data-open="systemDialog" aria-label="Открыть системные события">
|
||
<div class="card-head"><div><div class="label">System health</div><div class="name">Система и ядро</div></div><div class="percent"><span id="systemIssues">—</span><small> ошибок</small></div></div>
|
||
<div class="bar"><div class="fill" id="systemBar"></div></div>
|
||
<div class="quick"><div><span class="label">Упавшие службы</span><strong id="failedUnits">—</strong></div><div><span class="label">Ошибки ядра</span><strong id="kernelErrors">—</strong></div></div>
|
||
<div class="hint">Нажмите, чтобы посмотреть журнал →</div>
|
||
</button>
|
||
<button class="card snapshots" data-card="snapshots" data-open="snapshotsDialog" aria-label="Открыть snapshots">
|
||
<div class="card-head"><div><div class="label">Snapshots</div><div class="name">Снимки VM и LXC</div></div><div class="percent"><span id="snapshotCount">—</span><small> шт.</small></div></div><div class="bar"><div class="fill" id="snapshotBar"></div></div>
|
||
<div class="quick"><div><span class="label">Старше 30 дней</span><strong id="oldSnapshots">—</strong></div><div><span class="label">VM/LXC со снимками</span><strong id="snapshotGuests">—</strong></div></div><div class="hint">Нажмите, чтобы посмотреть снимки →</div>
|
||
</button>
|
||
<button class="card tasks" data-card="tasks" data-open="tasksDialog" aria-label="Открыть задачи Proxmox">
|
||
<div class="card-head"><div><div class="label">Proxmox activity</div><div class="name">События и задачи</div></div><div class="percent"><span id="taskCount">—</span><small> задач</small></div></div><div class="bar"><div class="fill" id="taskBar"></div></div>
|
||
<div class="quick"><div><span class="label">Ошибки</span><strong id="failedTasks">—</strong></div><div><span class="label">Последняя операция</span><strong id="lastTask">—</strong></div></div><div class="hint">Нажмите, чтобы посмотреть журнал →</div>
|
||
</button>
|
||
<button class="card homelab-map" data-card="map" data-open="mapDialog" aria-label="Открыть карту homelab">
|
||
<div class="card-head"><div><div class="label">Topology</div><div class="name">Карта homelab</div></div><div class="percent"><span id="mapNodes">—</span><small> узлов</small></div></div><div class="bar"><div class="fill" id="mapBar"></div></div>
|
||
<div class="quick"><div><span class="label">VM / LXC</span><strong id="mapGuests">—</strong></div><div><span class="label">Сервисы / storage</span><strong id="mapResources">—</strong></div></div><div class="hint">Proxmox → VM/LXC → сервисы → storage →</div>
|
||
</button>
|
||
<button class="card diagnostics" data-card="diagnostics" data-open="diagnosticsDialog" aria-label="Открыть диагностику homelab">
|
||
<div class="card-head"><div><div class="label">Root cause</div><div class="name">Диагностика homelab</div></div><div class="percent"><span id="diagnosticProblems">—</span><small> проблем</small></div></div><div class="bar"><div class="fill" id="diagnosticBar"></div></div>
|
||
<div class="quick"><div><span class="label">Связано сервисов</span><strong id="diagnosticLinks">—</strong></div><div><span class="label">Агенты с проблемами</span><strong id="diagnosticAgents">—</strong></div></div><div class="hint">VM/LXC → процесс → порт → сервис →</div>
|
||
</button>
|
||
</section>
|
||
<footer>Обновление каждые 2 секунды · данные читаются напрямую с хоста</footer>
|
||
</main>
|
||
|
||
<dialog id="alertsDialog">
|
||
<div class="modal-head"><div><div class="label">Notification center</div><h2>Центр уведомлений</h2></div><button class="close" aria-label="Закрыть">×</button></div>
|
||
<div class="settings-tabs"><button class="settings-tab active" data-alert-tab="active">Активные</button><button class="settings-tab" data-alert-tab="history">История</button></div>
|
||
<div class="alert-list alert-panel" data-alert-panel="active" id="alertList"><span class="empty">Проверка состояния системы…</span></div>
|
||
<div class="alert-panel" data-alert-panel="history" hidden><div class="wide" style="padding-bottom:4px"><div class="threshold-grid"><label class="field"><span>События за дату</span><input type="date" id="alertHistoryDate"></label><button class="theme-option" id="alertHistoryAll" style="align-self:end">Показать последние</button></div></div><div class="alert-list" id="alertHistory"><span class="empty">Загрузка истории…</span></div></div>
|
||
</dialog>
|
||
|
||
<dialog id="cpuDialog">
|
||
<div class="modal-head"><div><div class="label">CPU details</div><h2 id="cpuDetailName">Процессор</h2></div><button class="close" aria-label="Закрыть">×</button></div>
|
||
<div class="detail-grid">
|
||
<div class="detail"><span class="label">Загрузка</span><strong id="dCpuUsage">—</strong></div><div class="detail"><span class="label">Сокеты</span><strong id="dSockets">—</strong></div><div class="detail"><span class="label">Физические ядра</span><strong id="dCores">—</strong></div>
|
||
<div class="detail"><span class="label">Логические потоки</span><strong id="dThreads">—</strong></div><div class="detail"><span class="label">Средняя частота</span><strong id="dFreq">—</strong></div><div class="detail"><span class="label">Uptime</span><strong id="dUptime">—</strong></div>
|
||
<div class="detail"><span class="label">Load · 1 мин</span><strong id="dLoad1">—</strong></div><div class="detail"><span class="label">Load · 5 мин</span><strong id="dLoad5">—</strong></div><div class="detail"><span class="label">Load · 15 мин</span><strong id="dLoad15">—</strong></div>
|
||
</div>
|
||
<div class="wide"><span class="label">Температурные датчики</span><div class="temps" id="dTemps"><span class="empty">Поиск датчиков…</span></div></div>
|
||
<div class="chart-section"><div class="chart-head"><strong>CPU и температура</strong><span>Последние 7 дней</span></div><canvas class="metric-chart" id="cpuChart"></canvas></div>
|
||
</dialog>
|
||
|
||
<dialog id="memoryDialog">
|
||
<div class="modal-head"><div><div class="label">Memory details</div><h2>Оперативная память</h2></div><button class="close" aria-label="Закрыть">×</button></div>
|
||
<div class="detail-grid">
|
||
<div class="detail"><span class="label">Используется</span><strong id="dMemUsed">—</strong></div><div class="detail"><span class="label">Доступно</span><strong id="dMemAvailable">—</strong></div><div class="detail"><span class="label">Всего</span><strong id="dMemTotal">—</strong></div>
|
||
<div class="detail"><span class="label">Кэш</span><strong id="dMemCache">—</strong></div><div class="detail"><span class="label">Буферы</span><strong id="dMemBuffers">—</strong></div><div class="detail"><span class="label">Загрузка RAM</span><strong id="dMemPercent">—</strong></div>
|
||
<div class="detail"><span class="label">Swap используется</span><strong id="dSwapUsed">—</strong></div><div class="detail"><span class="label">Swap всего</span><strong id="dSwapTotal">—</strong></div><div class="detail"><span class="label">Загрузка swap</span><strong id="dSwapPercent">—</strong></div>
|
||
</div>
|
||
<div class="chart-section"><div class="chart-head"><strong>RAM и swap</strong><span>Последние 7 дней</span></div><canvas class="metric-chart" id="memoryChart"></canvas></div>
|
||
</dialog>
|
||
|
||
<dialog id="storageDialog">
|
||
<div class="modal-head"><div><div class="label">Storage details</div><h2>Физические диски</h2></div><button class="close" aria-label="Закрыть">×</button></div>
|
||
<div class="detail-grid">
|
||
<div class="detail"><span class="label">Используется</span><strong id="dDiskUsed">—</strong></div><div class="detail"><span class="label">Свободно</span><strong id="dDiskFree">—</strong></div><div class="detail"><span class="label">Общий объём</span><strong id="dDiskTotal">—</strong></div>
|
||
<div class="detail"><span class="label">Заполнение</span><strong id="dDiskPercent">—</strong></div><div class="detail"><span class="label">Точка монтирования</span><strong>/</strong></div>
|
||
</div>
|
||
<div class="disk-list" id="diskList"><span class="empty">Поиск накопителей…</span></div><div class="wide"><span class="label">Расшифровка источников I/O</span><div class="disk-list" id="ioAttributionList"><span class="empty">Накопление данных…</span></div></div>
|
||
<div class="chart-section"><div class="chart-head"><strong>Температура и I/O физических дисков</strong><span>Отдельно по каждому диску</span></div><div class="label">Температура</div><canvas class="metric-chart" id="storageChart"></canvas><div class="label">IOPS</div><canvas class="metric-chart" id="diskIOChart"></canvas><div class="label">Utilization</div><canvas class="metric-chart" id="diskUtilChart"></canvas></div>
|
||
</dialog>
|
||
|
||
<dialog id="networkDialog">
|
||
<div class="modal-head"><div><div class="label">Network details</div><h2>Сетевой трафик</h2></div><button class="close" aria-label="Закрыть">×</button></div>
|
||
<div class="detail-grid">
|
||
<div class="detail"><span class="label">Получение сейчас</span><strong id="dNetDown">—</strong></div><div class="detail"><span class="label">Передача сейчас</span><strong id="dNetUp">—</strong></div><div class="detail"><span class="label">Суммарная скорость</span><strong id="dNetTotal">—</strong></div>
|
||
<div class="detail"><span class="label">Получено с запуска</span><strong id="dNetReceived">—</strong></div><div class="detail"><span class="label">Передано с запуска</span><strong id="dNetSent">—</strong></div><div class="detail"><span class="label">Интерфейсы</span><strong id="dNetInterfaces">—</strong></div>
|
||
</div>
|
||
<div class="wide"><span class="label">Доступность инфраструктуры</span><div class="infra-status"><div><span>Шлюз</span><b id="dNetGateway">—</b></div><div><span>DNS</span><b id="dNetDNS">—</b></div><div><span>Интернет</span><b id="dNetInternet">—</b></div><div><span>Внешний IP</span><b id="dNetExternalIP">—</b></div></div></div>
|
||
<div class="disk-list" id="networkInterfacesList"></div>
|
||
<div class="chart-section"><div class="chart-head"><strong>Входящий и исходящий трафик</strong><span>Последние 7 дней</span></div><canvas class="metric-chart" id="networkChart"></canvas></div>
|
||
</dialog>
|
||
|
||
<dialog id="zfsDialog">
|
||
<div class="modal-head"><div><div class="label">ZFS details</div><h2>Пулы хранения</h2></div><button class="close" aria-label="Закрыть">×</button></div>
|
||
<div class="wide"><span class="label">ARC</span><div class="infra-status"><div><span>Размер</span><b id="zfsArcSize">—</b></div><div><span>Цель</span><b id="zfsArcTarget">—</b></div><div><span>Hit ratio</span><b id="zfsArcHit">—</b></div><div><span>Datasets</span><b id="zfsDatasetCount">—</b></div></div></div><div class="disk-list" id="zfsList"><span class="empty">Поиск ZFS-пулов…</span></div>
|
||
<div class="chart-section"><div class="chart-head"><strong>Заполнение каждого ZFS-пула</strong><span>Отдельно по каждому пулу</span></div><canvas class="metric-chart" id="zfsChart"></canvas></div>
|
||
</dialog>
|
||
|
||
<dialog id="upsDialog">
|
||
<div class="modal-head"><div><div class="label">UPS details</div><h2 id="dUpsName">Источник бесперебойного питания</h2></div><button class="close" aria-label="Закрыть">×</button></div>
|
||
<div class="detail-grid">
|
||
<div class="detail"><span class="label">Состояние</span><strong id="dUpsStatus">—</strong></div><div class="detail"><span class="label">Заряд</span><strong id="dUpsCharge">—</strong></div><div class="detail"><span class="label">Автономность</span><strong id="dUpsRuntime">—</strong></div>
|
||
<div class="detail"><span class="label">Нагрузка</span><strong id="dUpsLoad">—</strong></div><div class="detail"><span class="label">Входное напряжение</span><strong id="dUpsInput">—</strong></div><div class="detail"><span class="label">Выходное напряжение</span><strong id="dUpsOutput">—</strong></div>
|
||
<div class="detail"><span class="label">Частота</span><strong id="dUpsFrequency">—</strong></div><div class="detail"><span class="label">Батарея</span><strong id="dUpsBattery">—</strong></div><div class="detail"><span class="label">Температура</span><strong id="dUpsTemp">—</strong></div>
|
||
<div class="detail"><span class="label">Связь с UPS</span><strong id="dUpsCommunication">—</strong></div><div class="detail"><span class="label">Дата батареи</span><strong id="dUpsBatteryDate">—</strong></div><div class="detail"><span class="label">Возраст батареи</span><strong id="dUpsBatteryAge">—</strong></div>
|
||
</div>
|
||
<div class="wide"><span class="label">Источник данных</span><div class="temps"><span class="temp" id="dUpsSource">Не настроен</span></div></div>
|
||
<div class="chart-section"><div class="chart-head"><strong>Заряд и нагрузка UPS</strong><span>Последние 7 дней</span></div><canvas class="metric-chart" id="upsChart"></canvas></div>
|
||
</dialog>
|
||
|
||
<dialog id="monitorDialog"><div class="modal-head"><div><div class="label">Dashboard health</div><h2>Самоконтроль мониторинга</h2></div><button class="close" aria-label="Закрыть">×</button></div><div class="detail-grid"><div class="detail"><span class="label">Последний успешный сбор</span><strong id="dMonitorLast">—</strong></div><div class="detail"><span class="label">Длительность цикла</span><strong id="dMonitorDuration">—</strong></div><div class="detail"><span class="label">Последняя запись истории</span><strong id="dMonitorHistory">—</strong></div><div class="detail"><span class="label">Свободно для базы</span><strong id="dMonitorDB">—</strong></div><div class="detail"><span class="label">Systemd watchdog</span><strong id="dMonitorWatchdog">—</strong></div><div class="detail"><span class="label">Ошибка</span><strong id="dMonitorError">—</strong></div></div></dialog>
|
||
|
||
<dialog id="guestsDialog">
|
||
<div class="modal-head"><div><div class="label">Virtualization details</div><h2>VM и LXC</h2></div><button class="close" aria-label="Закрыть">×</button></div>
|
||
<div class="disk-list" id="guestList"><span class="empty">Получение списка VM и LXC…</span></div>
|
||
<div class="chart-section"><div class="chart-head"><strong>CPU каждой VM и LXC</strong><span>Отдельно по каждой VM и LXC</span></div><canvas class="metric-chart" id="guestsChart"></canvas></div>
|
||
</dialog>
|
||
|
||
<dialog id="backupsDialog">
|
||
<div class="modal-head"><div><div class="label">Backup details</div><h2>Резервные копии VM и LXC</h2></div><button class="close" aria-label="Закрыть">×</button></div>
|
||
<div class="disk-list" id="backupList"><span class="empty">Получение списка резервных копий…</span></div>
|
||
</dialog>
|
||
|
||
<dialog id="maintenanceDialog">
|
||
<div class="modal-head"><div><div class="label">Proxmox maintenance</div><h2>Система и обслуживание</h2></div><button class="close" aria-label="Закрыть">×</button></div>
|
||
<div class="detail-grid maintenance-grid">
|
||
<div class="detail"><span class="label">Proxmox</span><strong id="dPveVersion">—</strong></div><div class="detail"><span class="label">Запущенное ядро</span><strong id="dKernel">—</strong></div><div class="detail"><span class="label">Новое ядро</span><strong id="dLatestKernel">—</strong></div>
|
||
<div class="detail"><span class="label">Обновления</span><strong id="dUpdates">—</strong></div><div class="detail"><span class="label">Перезагрузка</span><strong id="dReboot">—</strong></div><div class="detail"><span class="label">Последнее обновление</span><strong id="dLastUpdate">—</strong></div>
|
||
</div>
|
||
<div class="wide"><span class="label">Ближайшие задания</span><div class="disk-stats"><div class="disk-stat"><span>Backup</span><b id="dNextBackup">—</b></div><div class="disk-stat"><span>ZFS scrub</span><b id="dNextScrub">—</b></div><div class="disk-stat"><span>Проверено</span><b id="dMaintenanceChecked">—</b></div></div></div>
|
||
</dialog>
|
||
|
||
<dialog id="servicesDialog">
|
||
<div class="modal-head"><div><div class="label">Homelab monitoring</div><h2>Сервисы</h2></div><button class="close" aria-label="Закрыть">×</button></div>
|
||
<div class="disk-list" id="serviceList"><span class="empty">Добавьте первую проверку в настройках.</span></div>
|
||
</dialog>
|
||
<dialog id="openServiceDialog">
|
||
<div class="modal-head"><div><div class="label">Открыть сервис</div><h2 id="openServiceTitle">Выберите адрес</h2></div><button class="close" aria-label="Закрыть">×</button></div>
|
||
<div class="wide"><div class="settings-note" style="margin:0 0 12px">У сервиса указаны два адреса. Выберите, через какой открыть страницу.</div><div class="theme-options" id="openServiceChoices"></div></div>
|
||
</dialog>
|
||
<dialog id="systemDialog">
|
||
<div class="modal-head"><div><div class="label">System health</div><h2>Ошибки системы и ядра</h2></div><button class="close" aria-label="Закрыть">×</button></div>
|
||
<div class="detail-grid"><div class="detail"><span class="label">Ошибки · 24 часа</span><strong id="dSystemErrors">—</strong></div><div class="detail"><span class="label">Ядро</span><strong id="dKernelErrors">—</strong></div><div class="detail"><span class="label">Упавшие службы</span><strong id="dFailedUnits">—</strong></div></div>
|
||
<div class="system-tabs"><button class="detail-tab active" data-system-tab="issues">Ошибки журнала</button><button class="detail-tab" data-system-tab="services">Упавшие службы</button><button class="system-sort" id="systemSort" type="button" aria-label="Сначала показаны свежие события">Сначала свежие ↓</button></div>
|
||
<div class="disk-list system-panel" data-system-panel="issues" id="issueList"><span class="empty">Системных ошибок нет.</span></div>
|
||
<div class="disk-list system-panel" data-system-panel="services" id="failedServiceList" hidden><span class="empty">Упавших служб нет.</span></div>
|
||
</dialog>
|
||
|
||
<dialog id="snapshotsDialog"><div class="modal-head"><div><div class="label">Snapshots</div><h2>Снимки VM и LXC</h2></div><button class="close" aria-label="Закрыть">×</button></div><div class="disk-list" id="snapshotList"><span class="empty">Получение snapshots…</span></div></dialog>
|
||
<dialog id="tasksDialog"><div class="modal-head"><div><div class="label">Proxmox activity</div><h2>События и задачи</h2></div><button class="close" aria-label="Закрыть">×</button></div><div class="task-filter"><button class="detail-tab active" data-task-filter="all">Все</button><button class="detail-tab" data-task-filter="vzdump">Backup</button><button class="detail-tab" data-task-filter="qm">VM/LXC</button><button class="detail-tab" data-task-filter="error">Ошибки</button></div><div class="disk-list" id="taskList"><span class="empty">Получение задач…</span></div></dialog>
|
||
<dialog id="mapDialog"><div class="modal-head"><div><div class="label">Homelab topology</div><h2>Карта homelab</h2></div><button class="close" aria-label="Закрыть">×</button></div><div class="map-scroll"><div class="topology-board" id="mapBoard"></div></div><div class="wide settings-note">Порт от локального адреса отбрасывается. Полученный IP сравнивается с адресами VM/LXC из Proxmox. Storage берётся из конфигурации каждой VM и LXC.</div></dialog>
|
||
<dialog id="diagnosticsDialog"><div class="modal-head"><div><div class="label">Root cause explorer</div><h2>Диагностика homelab</h2></div><button class="close" aria-label="Закрыть">×</button></div><div class="wide settings-note">Связи строятся по VMID, IP, слушающему порту, PID systemd и портам Docker. Предполагаемые связи явно помечаются.</div><div class="disk-list" id="diagnosticList"><span class="empty">Ожидаем данные агентов…</span></div></dialog>
|
||
<dialog id="agentsDialog"><div class="modal-head"><div><div class="label">Guest telemetry</div><h2>Агенты VM и LXC</h2></div><button class="close" aria-label="Закрыть">×</button></div><div class="system-tabs"><button class="detail-tab active" data-agent-list-tab="agents">Агенты</button><button class="detail-tab" data-agent-list-tab="problems">Проблемы · <span id="agentProblemTabCount">0</span></button></div><div class="disk-list" data-agent-list-panel="agents" id="agentList"><span class="empty">Подключённые агенты появятся здесь.</span></div><div class="disk-list" data-agent-list-panel="problems" id="agentProblemList" hidden><span class="empty">Проблем не обнаружено.</span></div></dialog>
|
||
<dialog id="agentDetailDialog"><div class="modal-head"><div><div class="label">Guest details</div><h2 id="agentDetailTitle">Агент</h2><div class="disk-meta" id="agentDetailMeta"></div></div><button class="close" aria-label="Закрыть">×</button></div><div class="system-tabs"><button class="detail-tab active" data-agent-tab="overview">Обзор</button><button class="detail-tab" data-agent-tab="services">Службы</button><button class="detail-tab" data-agent-tab="resources">Ресурсы</button><button class="detail-tab" data-agent-tab="processes">Процессы и порты</button><button class="detail-tab" data-agent-tab="history">История</button><button class="detail-tab" data-agent-tab="issues">Ошибки</button><button class="detail-tab" data-agent-tab="docker">Docker</button></div><div class="agent-detail-panel" data-agent-panel="overview" id="agentOverview"></div><div class="agent-detail-panel" data-agent-panel="services" id="agentServiceDetails" hidden></div><div class="agent-detail-panel" data-agent-panel="resources" id="agentResourceDetails" hidden></div><div class="agent-detail-panel" data-agent-panel="processes" id="agentProcessDetails" hidden></div><div class="agent-detail-panel" data-agent-panel="history" id="agentHistoryDetails" hidden><div class="chart-head"><strong>CPU, RAM и swap</strong><div class="range-switch"><button class="range-button" data-agent-hours="1">1 час</button><button class="range-button active" data-agent-hours="24">24 часа</button><button class="range-button" data-agent-hours="168">7 дней</button></div></div><canvas class="metric-chart" id="agentHistoryChart"></canvas><div class="chart-tooltip"></div></div><div class="agent-detail-panel" data-agent-panel="issues" id="agentIssueDetails" hidden></div><div class="agent-detail-panel" data-agent-panel="docker" id="agentDockerDetails" hidden></div></dialog>
|
||
|
||
<dialog id="settingsDialog">
|
||
<div class="modal-head"><div><div class="label">Dashboard</div><h2>Настройки</h2></div><button class="close" aria-label="Закрыть">×</button></div>
|
||
<div class="settings-tabs"><button class="settings-tab active" data-settings-tab="appearance">Интерфейс</button><button class="settings-tab" data-settings-tab="alerts">Пороги</button><button class="settings-tab" data-settings-tab="email">Почта</button><button class="settings-tab" data-settings-tab="updates">Обновления</button></div>
|
||
<div class="settings-body settings-panel" data-settings-panel="appearance">
|
||
<div class="settings-group"><div class="label">Тема оформления</div><div class="theme-options"><button class="theme-option" data-theme-choice="dark">Тёмная</button><button class="theme-option" data-theme-choice="light">Светлая</button></div></div>
|
||
<div class="settings-group"><div class="label">Видимые карточки</div><div class="setting-list">
|
||
<label class="setting-item"><input type="checkbox" data-card-toggle="alerts"> Центр уведомлений</label>
|
||
<label class="setting-item"><input type="checkbox" data-card-toggle="cpu"> CPU</label><label class="setting-item"><input type="checkbox" data-card-toggle="memory"> Память</label>
|
||
<label class="setting-item"><input type="checkbox" data-card-toggle="storage"> Диски</label><label class="setting-item"><input type="checkbox" data-card-toggle="network"> Сеть</label>
|
||
<label class="setting-item"><input type="checkbox" data-card-toggle="zfs"> ZFS</label><label class="setting-item"><input type="checkbox" data-card-toggle="ups"> UPS</label><label class="setting-item"><input type="checkbox" data-card-toggle="monitor"> Самоконтроль</label>
|
||
<label class="setting-item"><input type="checkbox" data-card-toggle="guests"> VM и LXC</label><label class="setting-item"><input type="checkbox" data-card-toggle="agents"> Агенты VM и LXC</label><label class="setting-item"><input type="checkbox" data-card-toggle="maintenance"> Proxmox</label><label class="setting-item"><input type="checkbox" data-card-toggle="backups"> Резервные копии</label><label class="setting-item"><input type="checkbox" data-card-toggle="services"> Сервисы</label><label class="setting-item"><input type="checkbox" data-card-toggle="system"> Система</label><label class="setting-item"><input type="checkbox" data-card-toggle="snapshots"> Snapshots</label><label class="setting-item"><input type="checkbox" data-card-toggle="tasks"> Задачи Proxmox</label><label class="setting-item"><input type="checkbox" data-card-toggle="map"> Карта homelab</label><label class="setting-item"><input type="checkbox" data-card-toggle="diagnostics"> Диагностика homelab</label>
|
||
</div></div>
|
||
<div class="settings-group"><div class="label">Порядок карточек</div><div class="settings-note">Перетаскивайте строки или используйте стрелки.</div><div class="card-order" id="cardOrder"></div></div>
|
||
</div>
|
||
<div class="settings-body settings-panel" data-settings-panel="ups" hidden><div class="settings-group"><div class="label">Безопасное выключение при работе от батареи</div><div class="settings-note">Автоматическое выключение по умолчанию отключено. Dry-run ничего не останавливает и показывает будущий порядок.</div><div class="threshold-grid"><label class="setting-item"><input type="checkbox" id="upsShutdownEnabled"> Разрешить сценарий</label><label class="threshold-item"><span>Заряд, %</span><input type="number" id="upsShutdownCharge"></label><label class="threshold-item"><span>Автономность, сек.</span><input type="number" id="upsShutdownRuntime"></label><label class="threshold-item"><span>Ожидание VM/LXC, сек.</span><input type="number" id="upsShutdownGrace"></label><label class="field full-span"><span>Порядок VMID через запятую</span><input id="upsShutdownOrder" placeholder="101, 105, 220"><small>Неуказанные запущенные VM и LXC добавятся после них по VMID.</small></label></div><button class="save-settings" id="saveUPSShutdown">Сохранить сценарий</button><button class="theme-option" id="dryRunUPSShutdown" style="margin-top:10px;width:100%">Показать dry-run</button><div class="shutdown-plan" id="upsShutdownPlan"></div><div class="settings-note" id="upsShutdownStatus"></div></div></div>
|
||
<div class="settings-body settings-panel" data-settings-panel="alerts" hidden>
|
||
<div class="threshold-grid">
|
||
<label class="threshold-item"><span>CPU · предупреждение, %</span><input type="number" data-threshold="cpuWarning"></label><label class="threshold-item"><span>CPU · критично, %</span><input type="number" data-threshold="cpuCritical"></label>
|
||
<label class="threshold-item"><span>CPU температура · предупреждение</span><input type="number" data-threshold="cpuTempWarning"></label><label class="threshold-item"><span>CPU температура · критично</span><input type="number" data-threshold="cpuTempCritical"></label>
|
||
<label class="threshold-item"><span>RAM · предупреждение, %</span><input type="number" data-threshold="memoryWarning"></label><label class="threshold-item"><span>RAM · критично, %</span><input type="number" data-threshold="memoryCritical"></label>
|
||
<label class="threshold-item"><span>Swap · предупреждение, %</span><input type="number" data-threshold="swapWarning"></label><label class="threshold-item"><span>Storage · предупреждение, %</span><input type="number" data-threshold="storageWarning"></label>
|
||
<label class="threshold-item"><span>Storage · критично, %</span><input type="number" data-threshold="storageCritical"></label><label class="threshold-item"><span>ZFS · предупреждение, %</span><input type="number" data-threshold="zfsWarning"></label>
|
||
<label class="threshold-item"><span>ZFS · критично, %</span><input type="number" data-threshold="zfsCritical"></label><label class="threshold-item"><span>Температура HDD/SSD</span><input type="number" data-threshold="diskTempWarning"></label>
|
||
<label class="threshold-item"><span>Температура NVMe</span><input type="number" data-threshold="nvmeTempWarning"></label><label class="threshold-item"><span>UPS заряд · критично, %</span><input type="number" data-threshold="upsChargeCritical"></label>
|
||
<label class="threshold-item"><span>Максимальный возраст backup, ч</span><input type="number" data-threshold="backupMaxAgeHours"></label>
|
||
<label class="threshold-item"><span>HDD utilization, %</span><input type="number" data-threshold="diskHddUtil"></label><label class="threshold-item"><span>HDD latency, ms</span><input type="number" data-threshold="diskHddLatency"></label><label class="threshold-item"><span>HDD очередь</span><input type="number" data-threshold="diskHddQueue"></label>
|
||
<label class="threshold-item"><span>SATA SSD utilization, %</span><input type="number" data-threshold="diskSsdUtil"></label><label class="threshold-item"><span>SATA SSD latency, ms</span><input type="number" data-threshold="diskSsdLatency"></label><label class="threshold-item"><span>SATA SSD очередь</span><input type="number" data-threshold="diskSsdQueue"></label>
|
||
<label class="threshold-item"><span>NVMe utilization, %</span><input type="number" data-threshold="diskNvmeUtil"></label><label class="threshold-item"><span>NVMe latency, ms</span><input type="number" data-threshold="diskNvmeLatency"></label><label class="threshold-item"><span>NVMe очередь</span><input type="number" data-threshold="diskNvmeQueue"></label>
|
||
<label class="threshold-item"><span>Минимальная длительность, сек.</span><input type="number" data-threshold="diskIoDuration"></label><label class="field full-span"><span>Исключить диски из I/O-алертов</span><input type="text" data-threshold="diskIoExcluded" placeholder="sda, SERIAL123"><small>Имена или серийные номера через запятую.</small></label>
|
||
</div>
|
||
<button class="save-settings" id="saveThresholds">Сохранить пороги</button><div class="settings-note" id="thresholdStatus">Настройки хранятся на сервере и применяются для всех браузеров.</div>
|
||
</div>
|
||
<div class="settings-body settings-panel" data-settings-panel="maintenance-mode" hidden>
|
||
<div class="settings-group"><div class="label">Окно плановых работ</div><div class="settings-note">Связанные сбои останутся в истории с пометкой «Плановые работы», но не попадут в активные алерты и письма.</div></div>
|
||
<div class="threshold-grid" style="margin-top:12px"><label class="field full-span"><span>Причина</span><input id="maintenanceReason" placeholder="Например, обновление Vaultwarden"></label><label class="threshold-item"><span>Продолжительность, минут</span><input id="maintenanceDuration" type="number" min="5" max="43200" value="60"></label><label class="setting-item"><input type="checkbox" id="maintenanceAll"> Отключить все алерты</label></div>
|
||
<div class="settings-group"><div class="label">Сервисы</div><div class="maintenance-targets" id="maintenanceServices"><span class="empty">Загрузка сервисов…</span></div></div>
|
||
<div class="settings-group"><div class="label">VM и LXC</div><div class="maintenance-targets" id="maintenanceGuests"><span class="empty">Данные VM/LXC ещё не получены.</span></div></div>
|
||
<button class="save-settings" id="startMaintenance">Включить Maintenance</button><button class="theme-option" id="stopMaintenance" style="margin-top:9px;width:100%" disabled>Завершить плановые работы</button><div class="settings-note" id="maintenanceStatus">Режим выключен.</div>
|
||
</div>
|
||
<div class="settings-body settings-panel" data-settings-panel="services" hidden>
|
||
<div class="settings-group"><div class="label">Новая или изменяемая проверка</div><div class="service-form">
|
||
<input type="hidden" id="serviceId"><label class="field"><span>Название сервиса</span><input id="serviceName" placeholder="Например, Gitea"></label><label class="field"><span>Способ проверки</span><select id="serviceType"><option value="http">Открыть HTTP / HTTPS</option><option value="tcp">Подключиться к TCP-порту</option><option value="ping">Проверить ping</option></select></label>
|
||
<label class="field"><span>Внешний веб-адрес</span><input id="serviceURL" placeholder="https://git.myown.center"><small>Проверяется отдельно через DNS-имя.</small></label><label class="field"><span>Локальный адрес</span><input id="serviceLocal" placeholder="https://192.168.0.6:3000"><small>Проверяется отдельно из сети Proxmox.</small></label>
|
||
<label class="field"><span>Ожидаемый HTTP-код</span><input id="serviceCode" type="number" value="200"><small>Обычно 200 означает, что страница открылась успешно.</small></label><label class="field"><span>Текст, который должен быть на странице</span><input id="serviceText" placeholder="Необязательно"><small>Например, Gitea. Пустое поле отключает проверку текста.</small></label>
|
||
<label class="field"><span>Сколько ждать ответа</span><input id="serviceTimeout" type="number" value="5" min="1" max="30"><small>Таймаут в секундах, обычно достаточно 5.</small></label><label class="check-field"><input id="serviceEnabled" type="checkbox" checked><span><b>Проверка включена</b><small style="display:block">Сервис проверяется каждые 15 секунд.</small></span></label>
|
||
</div><button class="save-settings" id="saveService">Сохранить сервис</button><div class="settings-note" id="serviceStatus">Проверка выполняется каждые 15 секунд, история хранится 7 дней.</div></div>
|
||
<div class="settings-group"><div class="label">Добавленные сервисы</div><div class="disk-list" id="serviceSettingsList"><span class="empty">Список пуст.</span></div></div>
|
||
<div class="settings-group"><div class="label">Ручная связь сервиса</div><div class="settings-note">Имеет приоритет над автоматическим сопоставлением. Компонент можно выбрать как systemd-службу, Docker-контейнер или процесс.</div><div class="service-form" style="margin-top:12px"><label class="field"><span>Сервис</span><select id="linkService"></select></label><label class="field"><span>Агент VM/LXC</span><select id="linkAgent"></select></label><label class="field"><span>Тип компонента</span><select id="linkType"><option value="">Только VM/LXC</option><option value="systemd">systemd</option><option value="docker">Docker</option><option value="process">Процесс</option></select></label><label class="field"><span>Название компонента</span><input id="linkComponent" placeholder="gitea.service или vaultwarden"></label><label class="field"><span>Порт</span><input id="linkPort" type="number" min="0" max="65535" placeholder="3000"></label></div><button class="save-settings" id="saveServiceLink">Сохранить ручную связь</button><button class="theme-option" id="deleteServiceLink" style="width:100%;margin-top:8px">Удалить связь выбранного сервиса</button><div class="settings-note" id="serviceLinkStatus"></div></div>
|
||
</div>
|
||
<div class="settings-body settings-panel" data-settings-panel="agents" hidden>
|
||
<div class="settings-group"><div class="label">Подключить или обновить агент</div><div class="settings-note">Токен одноразовый и действует 15 минут. Для обновления уже подключённого агента создайте команду с тем же VMID и выполните её повторно — регистрация и история сохранятся.</div><div class="threshold-grid" style="margin-top:12px"><label class="field"><span>Адрес Dashboard из VM/LXC</span><input id="agentServerURL" placeholder="https://dashboard.example.lan"></label><label class="field"><span>VMID · необязательно</span><input id="agentVMID" type="number" min="0" placeholder="105"></label></div><button class="save-settings" id="createAgentToken">Создать команду установки или обновления</button></div>
|
||
<div class="settings-group" id="agentInstallBlock" hidden><div class="label">Выполните внутри VM/LXC от root</div><code class="agent-command" id="agentInstallCommand"></code><div class="settings-note">Для Docker-метрик служба получает доступ к /var/run/docker.sock. Сам агент не принимает входящие подключения.</div></div>
|
||
<div class="settings-group"><div class="label">Подключённые агенты</div><div id="agentSettingsList" class="disk-list" style="padding:12px 0 0"><span class="empty">Агентов пока нет.</span></div></div>
|
||
<div class="settings-group"><div class="label">Журнал управляющих действий</div><div id="agentActionLog" class="disk-list" style="padding:12px 0 0"><span class="empty">Действий пока не было.</span></div></div>
|
||
</div>
|
||
<div class="settings-body settings-panel" data-settings-panel="email" hidden>
|
||
<div class="settings-group"><div class="setting-list"><label class="setting-item"><input type="checkbox" id="emailEnabled"> Включить почтовые дайджесты</label><label class="setting-item"><input type="checkbox" id="emailSkipTLS"> Не проверять TLS-сертификат</label></div><div class="settings-note">Только для доверенного сервера во внутренней сети.</div></div>
|
||
<div class="settings-group"><div class="label">SMTP и получатель</div><div class="threshold-grid">
|
||
<label class="threshold-item"><span>SMTP-сервер</span><input id="emailHost" type="text"></label><label class="threshold-item"><span>Порт</span><input id="emailPort" type="number"></label>
|
||
<label class="threshold-item"><span>Логин</span><input id="emailUsername" type="text"></label><label class="threshold-item"><span>Пароль</span><input id="emailPassword" type="password" placeholder="Не изменять"></label>
|
||
<label class="threshold-item"><span>Отправитель</span><input id="emailFrom" type="email"></label><label class="threshold-item"><span>Получатель</span><input id="emailTo" type="email"></label>
|
||
<label class="threshold-item"><span>Интервал, минут</span><input id="emailInterval" type="number" min="5"></label>
|
||
</div></div>
|
||
<div class="settings-group"><div class="label">Важность</div><div class="setting-list"><label class="setting-item"><input type="checkbox" data-email-severity="critical"> Критические</label><label class="setting-item"><input type="checkbox" data-email-severity="warning"> Предупреждения</label><label class="setting-item"><input type="checkbox" data-email-severity="info"> Информация</label></div></div>
|
||
<div class="settings-group"><div class="label">Какие разделы отправлять</div><div class="setting-list">
|
||
<label class="setting-item"><input type="checkbox" data-email-source="CPU"> CPU</label><label class="setting-item"><input type="checkbox" data-email-source="Память"> Память</label><label class="setting-item"><input type="checkbox" data-email-source="Storage"> Storage</label><label class="setting-item"><input type="checkbox" data-email-source="Диски"> Диски</label><label class="setting-item"><input type="checkbox" data-email-source="ZFS"> ZFS</label><label class="setting-item"><input type="checkbox" data-email-source="UPS"> UPS</label><label class="setting-item"><input type="checkbox" data-email-source="VM/LXC"> VM и LXC</label><label class="setting-item"><input type="checkbox" data-email-source="Агенты"> Агенты</label><label class="setting-item"><input type="checkbox" data-email-source="Docker"> Docker</label><label class="setting-item"><input type="checkbox" data-email-source="Proxmox"> Proxmox</label><label class="setting-item"><input type="checkbox" data-email-source="Backups"> Резервные копии</label><label class="setting-item"><input type="checkbox" data-email-source="Dashboard"> Dashboard</label><label class="setting-item"><input type="checkbox" data-email-source="Сервисы"> Сервисы</label><label class="setting-item"><input type="checkbox" data-email-source="Система"> Система</label><label class="setting-item"><input type="checkbox" data-email-source="Сеть"> Сеть</label>
|
||
</div></div>
|
||
<button class="save-settings" id="saveEmail">Сохранить настройки почты</button><button class="theme-option" id="testEmail" style="width:100%;margin-top:9px">Отправить тестовое письмо</button><div class="settings-note" id="emailStatus">Пароль хранится в локальной базе сервера и не возвращается в браузер.</div>
|
||
</div>
|
||
<div class="settings-body settings-panel" data-settings-panel="updates" hidden>
|
||
<div class="detail-grid">
|
||
<div class="detail"><span class="label">Установлена</span><strong id="updateCurrent">—</strong></div><div class="detail"><span class="label">Доступна</span><strong id="updateAvailable">—</strong></div><div class="detail"><span class="label">Режим</span><strong id="updateMode">—</strong></div>
|
||
</div>
|
||
<div class="wide"><div class="label">Состояние обновлений</div><div class="temps"><span class="temp" id="updateMessage">Проверка ещё не выполнялась</span></div><button class="save-settings" id="installUpdate">Проверить и установить обновление</button><div class="settings-note" id="updateChecked">Проверка запускается автоматически раз в сутки.</div></div>
|
||
</div>
|
||
</dialog>
|
||
<dialog id="managementDialog">
|
||
<div class="modal-head"><div><div class="label">Operations</div><h2>Управление</h2></div><button class="close" aria-label="Закрыть">×</button></div>
|
||
<div class="settings-tabs"><button class="settings-tab active" data-management-tab="services">Сервисы</button><button class="settings-tab" data-management-tab="agents">Агенты</button><button class="settings-tab" data-management-tab="maintenance-mode">Плановые работы</button><button class="settings-tab" data-management-tab="ups">UPS-сценарий</button></div>
|
||
<div id="managementPanels"></div>
|
||
</dialog>
|
||
|
||
<script>
|
||
const el=id=>document.getElementById(id), fixed=n=>Number(n).toFixed(1);
|
||
const size=n=>{if(!n)return '0 B';const u=['B','KB','MB','GB','TB'],i=Math.min(Math.floor(Math.log(n)/Math.log(1024)),4);return (n/1024**i).toFixed(i>2?1:0)+' '+u[i]};
|
||
const rate=n=>size(n)+'/s';
|
||
const safe=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
const uptime=s=>{const d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60);return d?d+'д '+h+'ч':h?h+'ч '+m+'м':m+'м'};
|
||
let alertSettings={backupMaxAgeHours:48};
|
||
let maintenanceWindow={},maintenanceGuests=[],maintenanceTargetSignature='';
|
||
let lastDashboardEvent=0;
|
||
let currentServiceStatuses=[];
|
||
let currentSystemHealth={issues:[],failedServices:[]},systemSortNewest=true;
|
||
function renderSystemHealth(sh){currentSystemHealth=sh||{issues:[],failedServices:[]};const issues=[...(sh.issues||[])].sort((a,b)=>systemSortNewest?b.time-a.time:a.time-b.time);el('issueList').innerHTML=issues.length?issues.map(v=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.unit)+'</div><div class="disk-meta">'+new Date(v.time*1000).toLocaleString('ru-RU')+'</div></div><span class="badge bad">'+(v.count>1?'Повторений: '+v.count:'1 раз')+'</span></div><div class="settings-note issue-message">'+safe(v.message)+'</div></article>').join(''):'<span class="empty">Критических сообщений за 24 часа нет.</span>';el('failedServiceList').innerHTML=(sh.failedServices||[]).length?sh.failedServices.map(v=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.unit)+'</div><div class="disk-meta">'+safe(v.description||'Описание отсутствует')+'</div></div><span class="badge bad">'+safe(v.state)+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Загрузка</span><b>'+safe(v.load)+'</b></div><div class="disk-stat"><span>Состояние</span><b>'+safe(v.active)+'</b></div><div class="disk-stat"><span>Подсостояние</span><b>'+safe(v.state)+'</b></div></div></article>').join(''):'<span class="empty">Упавших systemd-служб нет.</span>';el('systemSort').textContent=systemSortNewest?'Сначала свежие ↓':'Сначала старые ↑';el('systemSort').setAttribute('aria-label',systemSortNewest?'Сначала показаны свежие события':'Сначала показаны старые события')}
|
||
function endpointHTML(v){const tls=v.tlsDays==null?'Нет TLS':v.tlsDays<0?'Сертификат истёк':v.tlsDays+' дн. до окончания',cert=v.tlsSubject?'<div class="certificate"><div>Сертификат: <b>'+safe(v.tlsSubject)+'</b></div><div>Домены: <b>'+safe((v.tlsDnsNames||[]).join(', ')||'не указаны')+'</b></div><div>Издатель: <b>'+safe(v.tlsIssuer||'—')+'</b></div><div>Для этого адреса: <b class="'+(v.tlsHostnameValid?'status-up':'status-down')+'">'+(v.tlsHostnameValid?'подходит':'не подходит')+'</b></div></div>':'';return '<div class="endpoint"><div class="endpoint-head"><div><b>'+safe(v.label)+'</b><div class="endpoint-address">'+safe(v.address)+'</div></div><span class="badge '+(v.up?'':'bad')+'">'+(v.up?'Доступен':'Недоступен')+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Отклик</span><b>'+fixed(v.latencyMs)+' ms</b></div><div class="disk-stat"><span>HTTP</span><b>'+(v.statusCode||'—')+'</b></div><div class="disk-stat"><span>TLS</span><b>'+tls+'</b></div><div class="disk-stat"><span>Uptime · сутки</span><b>'+fixed(v.uptimeDay)+'%</b></div><div class="disk-stat"><span>Uptime · неделя</span><b>'+fixed(v.uptimeWeek)+'%</b></div></div>'+cert+(v.error?'<div class="settings-note status-down">'+safe(v.error)+'</div>':'')+'</div>'}
|
||
function renderServices(svc){const items=svc.services||[];currentServiceStatuses=items;const avg=items.length?items.reduce((a,v)=>a+v.uptimeDay,0)/items.length:0,live=items.filter(v=>v.up),latency=live.length?live.reduce((a,v)=>a+v.latencyMs,0)/live.length:0;el('serviceUp').textContent=svc.up||0;el('serviceTotal').textContent=' / '+(svc.total||0);el('serviceBar').style.width=(svc.total?svc.up/svc.total*100:0)+'%';el('serviceUptime').textContent=svc.total?fixed(avg)+'%':'—';el('serviceLatency').textContent=svc.total?fixed(latency)+' ms':'—';el('serviceList').innerHTML=items.length?items.map(v=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.name)+'</div><div class="disk-meta">'+(v.endpoints||[]).length+' '+((v.endpoints||[]).length===1?'адрес':'адреса')+' проверяются независимо</div></div><span class="badge '+(v.up?'':'bad')+'">'+(v.up?'Всё доступно':'Есть проблема')+'</span></div><div class="endpoint-list">'+(v.endpoints||[]).map(endpointHTML).join('')+'</div><div class="service-actions"><button data-service-open="'+v.id+'">Открыть сервис ↗</button></div></article>').join(''):'<span class="empty">Добавьте первую проверку в настройках.</span>'}
|
||
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=>'<label class="setting-item"><input type="checkbox" data-maintenance-service="'+v.id+'" '+(serviceIDs.has(v.id)?'checked':'')+'> '+safe(v.name)+'</label>').join(''):'<span class="empty">Сервисы не добавлены.</span>';el('maintenanceGuests').innerHTML=maintenanceGuests.length?maintenanceGuests.map(v=>'<label class="setting-item"><input type="checkbox" data-maintenance-vm="'+v.vmid+'" '+(vmids.has(v.vmid)?'checked':'')+'> '+safe(v.name||('VMID '+v.vmid))+' · '+(v.type==='qemu'?'VM':'LXC')+'</label>').join(''):'<span class="empty">VM/LXC не найдены.</span>';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;el('maintenanceBanner').hidden=!active;el('maintenanceBannerReason').textContent=maintenanceWindow.reason||'';el('maintenanceBannerEnds').textContent=active?'до '+new Date(maintenanceWindow.endsAt*1000).toLocaleString('ru-RU'):'';el('stopMaintenance').disabled=!active;el('maintenanceStatus').textContent=active?'Активен до '+new Date(maintenanceWindow.endsAt*1000).toLocaleString('ru-RU')+' · '+maintenanceWindow.reason:'Режим выключен.';if(active){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),errors=g.items.reduce((a,v)=>a+v.receiveErrors+v.transmitErrors+v.receiveDrops+v.transmitDrops,0),down=primary.some(v=>v.kind==='Physical'&&!v.carrier&&(v.master||v.receiveBytes+v.transmitBytes>0));return '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(g.name)+'</div><div class="disk-meta">'+(g.vmid?'VMID '+g.vmid+' · ':'')+safe(g.type)+' · '+safe(g.items.map(v=>v.name+' ['+(v.kind||'Interface')+(v.speedMbps?' · '+v.speedMbps+'Mb/s':'')+(v.duplex?' · '+v.duplex:'')+(v.master?' → '+v.master:'')+']').join(', '))+'</div></div><span class="badge '+(errors||down?'warn':'')+'"'+(errors||down?' tabindex="0" data-reason="'+safe(down?'Нет carrier на используемом физическом интерфейсе':'Счётчики errors/drops больше нуля')+'"':'')+'>'+(down?'Нет линка':errors?'Есть потери':'Без ошибок')+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Сейчас ↓</span><b>'+rate(rx)+'</b></div><div class="disk-stat"><span>Сейчас ↑</span><b>'+rate(tx)+'</b></div><div class="disk-stat"><span>Получено</span><b>'+size(primary.reduce((a,v)=>a+v.receiveBytes,0))+'</b></div><div class="disk-stat"><span>Передано</span><b>'+size(primary.reduce((a,v)=>a+v.transmitBytes,0))+'</b></div><div class="disk-stat"><span>Ошибки / drops</span><b>'+errors+'</b></div></div></article>'}).join('')}
|
||
function renderIOAttribution(disks){const active=disks.filter(d=>d.ioCause||(d.topConsumers||[]).length);el('ioAttributionList').innerHTML=active.length?active.map(d=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(d.name)+' · '+safe(d.ioCause||'Источник не определён')+'</div><div class="disk-meta">'+safe(d.ioCauseDetail||'Собираем статистику процессов')+'</div></div><span class="badge '+(d.ioConfidence>=80?'':'warn')+'" tabindex="0" data-reason="Уверенность учитывает совпадение активной задачи Proxmox и долю I/O процессов">'+(d.ioConfidence||0)+'%</span></div><div class="topology-items">'+(d.topConsumers||[]).slice(0,5).map(v=>'<span class="topology-chip">'+safe(v.kind)+' · '+safe(v.name)+' · '+fixed(v.sharePercent)+'% · ↓'+rate(v.readBytesPerSec)+' ↑'+rate(v.writeBytesPerSec)+'</span>').join('')+'</div></article>').join(''):'<span class="empty">Для атрибуции нужно несколько realtime-циклов.</span>'}
|
||
const taskLabel=type=>({vzdump:'Backup',qmstart:'Запуск VM',qmstop:'Остановка VM',qmshutdown:'Выключение VM',qmsnapshot:'Snapshot VM',vzstart:'Запуск LXC',vzstop:'Остановка LXC',vzsnapshot:'Snapshot LXC',qmigrate:'Миграция',aptupdate:'Обновление пакетов'})[type]||type;
|
||
function explainWarningBadges(){el('snapshotList').querySelectorAll('.badge.warn').forEach(v=>{v.tabIndex=0;v.dataset.reason='Snapshot старше 30 дней. Проверьте, нужен ли он: старые снимки могут занимать место в storage.'});el('taskList').querySelectorAll('.badge.bad').forEach(v=>{v.tabIndex=0;v.dataset.reason='Задача Proxmox завершилась не со статусом OK. Откройте её журнал в интерфейсе Proxmox для подробностей.'})}
|
||
let currentTasks=[],currentTaskFilter='all';
|
||
function renderTasks(tasks,filter='all'){currentTasks=tasks;const shown=tasks.filter(v=>filter==='all'||filter==='error'&&v.status!=='OK'||filter==='qm'&&(/^(qm|vz)/.test(v.type))||v.type===filter);el('taskList').innerHTML=shown.length?shown.map(v=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(taskLabel(v.type))+(v.id?' · '+safe(v.id):'')+'</div><div class="disk-meta">'+new Date(v.starttime*1000).toLocaleString('ru-RU')+' · '+safe(v.user||'—')+'</div></div><span class="badge '+(v.status==='OK'?'':'bad')+'">'+safe(v.status||'Выполняется')+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Длительность</span><b>'+uptime(v.duration||Math.max(0,Date.now()/1000-v.starttime))+'</b></div><div class="disk-stat"><span>Узел</span><b>'+safe(v.node)+'</b></div><div class="disk-stat"><span>VMID</span><b>'+safe(v.id||'—')+'</b></div></div></article>').join(''):'<span class="empty">Задач этого типа нет.</span>'}
|
||
function renderActivity(activity,guests,services,zfs){const snapshots=activity.snapshots||[],tasks=activity.tasks||[],old=snapshots.filter(v=>v.ageSeconds>30*86400),guestCount=new Set(snapshots.map(v=>v.vmid)).size,failed=tasks.filter(v=>v.status&&v.status!=='OK');el('snapshotCount').textContent=snapshots.length;el('oldSnapshots').textContent=old.length;el('snapshotGuests').textContent=guestCount;el('snapshotBar').style.width=Math.min(old.length*20,100)+'%';el('snapshotList').innerHTML=snapshots.length?snapshots.map(v=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.guestName||('VMID '+v.vmid))+' · '+safe(v.name)+'</div><div class="disk-meta">VMID '+v.vmid+' · '+safe(v.guestType)+(v.createdAt?' · '+new Date(v.createdAt*1000).toLocaleString('ru-RU'):'')+'</div></div><span class="badge '+(v.ageSeconds>30*86400?'warn':'')+'">'+(v.ageSeconds?uptime(v.ageSeconds):'Без даты')+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Размер</span><b>'+(v.sizeBytes?size(v.sizeBytes):'Storage не сообщает')+'</b></div><div class="disk-stat"><span>Состояние VM</span><b>'+(v.running?'Запущена':'Остановлена')+'</b></div><div class="disk-stat"><span>Описание</span><b>'+safe(v.description||'—')+'</b></div></div></article>').join(''):'<span class="empty">Snapshots не найдены.</span>';el('taskCount').textContent=tasks.length;el('failedTasks').textContent=failed.length;el('lastTask').textContent=tasks.length?taskLabel(tasks[0].type):'—';el('taskBar').style.width=Math.min(failed.length*20,100)+'%';renderTasks(tasks,currentTaskFilter);renderHomelabMapV2(guests,services,zfs)}
|
||
function topologyHost(address){if(!address)return '';try{return new URL(address.includes('://')?address:'http://'+address).hostname.replace(/^\[|\]$/g,'')}catch(e){return address.replace(/^[a-z]+:\/\//i,'').split('/')[0].replace(/:\d+$/,'')}}
|
||
function topologyPort(address){if(!address)return 0;try{const u=new URL(address.includes('://')?address:'http://'+address);return Number(u.port)||(u.protocol==='https:'?443:u.protocol==='http:'?80:0)}catch(e){const match=address.match(/:([0-9]+)(?:\/|$)/);return match?Number(match[1]):0}}
|
||
function correlateMonitoredService(service,agents){const address=service.localAddress||service.url||'',host=topologyHost(address),port=topologyPort(address),agent=(agents||[]).find(a=>(a.report?.interfaces||[]).some(i=>(i.addresses||[]).includes(host))),r=agent?.report||{},listener=(r.listenPorts||[]).find(v=>v.port===port),unit=(r.services||[]).find(v=>(v.listenPorts||[]).includes(port)||(listener?.pid&&v.mainPid===listener.pid)),container=(r.containers||[]).find(v=>(v.ports||[]).some(p=>Number(String(p).split(':')[0])===port)),nameHint=[...(r.services||[]),...(r.containers||[])].find(v=>String(service.name||'').toLowerCase().includes(String(v.name||'').replace(/\.service$/,'').toLowerCase())||String(v.name||'').toLowerCase().includes(String(service.name||'').toLowerCase()));return {agent,host,port,listener,component:unit||container||nameHint,confidence:agent?(unit||container||listener?100:nameHint?65:45):0}}
|
||
let currentServiceLinks=[];const correlateMonitoredServiceAuto=correlateMonitoredService;correlateMonitoredService=function(service,agents){const manual=currentServiceLinks.find(v=>v.serviceId===service.id);if(!manual)return correlateMonitoredServiceAuto(service,agents);const agent=(agents||[]).find(a=>a.id===manual.agentId||a.vmid===manual.vmid),r=agent?.report||{},port=manual.port||topologyPort(service.localAddress||service.url||''),listener=(r.listenPorts||[]).find(v=>v.port===port),component=manual.componentType==='systemd'?(r.services||[]).find(v=>v.name===manual.componentName):manual.componentType==='docker'?(r.containers||[]).find(v=>v.name===manual.componentName||v.id===manual.componentName):(r.processes||[]).find(v=>v.name===manual.componentName||String(v.pid)===manual.componentName);return {agent,host:topologyHost(service.localAddress||service.url||''),port,listener,component:component||{name:manual.componentName},confidence:100,manual:true}}
|
||
function renderUnifiedDiagnostics(agents,services){const serviceItems=services.services||[],links=serviceItems.map(s=>({service:s,link:correlateMonitoredService(s,agents)})),agentProblems=(agents||[]).filter(a=>!a.online||(a.report?.services||[]).some(v=>v.state==='failed'||v.flapping)||(a.report?.containers||[]).some(v=>v.state!=='running'||v.health==='unhealthy')||(a.report?.oomKills||[]).length),serviceProblems=serviceItems.filter(s=>!s.up),problems=agentProblems.length+serviceProblems.length;el('diagnosticProblems').textContent=problems;el('diagnosticLinks').textContent=links.filter(v=>v.link.agent).length+' / '+links.length;el('diagnosticAgents').textContent=agentProblems.length;el('diagnosticBar').style.width=Math.min(problems*20,100)+'%';const chainHTML=({service:s,link:l})=>{const component=l.component,componentName=component?.name||l.listener?.process||'Компонент не определён',bad=!s.up||!l.agent?.online||component?.state==='failed'||component?.health==='unhealthy';return '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(s.name)+'</div><div class="disk-meta">'+safe(s.localAddress||s.url||'Адрес не указан')+'</div></div><span class="badge '+(bad?'bad':l.confidence<100?'warn':'')+'">'+(bad?'Есть проблема':l.confidence===100?'Связь подтверждена':'Предположение · '+l.confidence+'%')+'</span></div><div class="diagnostic-chain"><div class="diagnostic-node '+(!l.agent?.online?'bad':'')+'"><span class="label">VM / LXC</span><strong>'+safe(l.agent?.name||'Не сопоставлено')+'</strong><div class="disk-meta">'+(l.agent?'VMID '+l.agent.vmid+' · '+(l.agent.online?'онлайн':'агент offline'):safe(l.host||'IP неизвестен'))+'</div></div><span class="diagnostic-arrow">→</span><div class="diagnostic-node '+(component?.state==='failed'||component?.health==='unhealthy'?'bad':'')+'"><span class="label">Процесс / служба</span><strong>'+safe(componentName)+'</strong><div class="disk-meta">'+(l.listener?'PID '+(l.listener.pid||'—'):'По имени или порту')+'</div></div><span class="diagnostic-arrow">→</span><div class="diagnostic-node"><span class="label">Порт</span><strong>'+(l.port||'—')+'</strong><div class="disk-meta">'+safe(l.listener?.protocol||s.checkType||'')+'</div></div><span class="diagnostic-arrow">→</span><div class="diagnostic-node '+(!s.up?'bad':'')+'"><span class="label">Сервис</span><strong>'+(s.up?'Доступен':'Недоступен')+'</strong><div class="disk-meta">'+fixed(s.latencyMs||0)+' ms</div></div></div></article>'};const unmappedAgents=(agents||[]).filter(a=>!links.some(v=>v.link.agent?.id===a.id));el('diagnosticList').innerHTML=(links.length?links.map(chainHTML).join(''):'<span class="empty">Добавьте отслеживаемые сервисы, чтобы построить цепочки.</span>')+(unmappedAgents.length?'<div class="topology-section"><div class="topology-section-title">Агенты без привязанных веб-сервисов</div><div class="topology-items">'+unmappedAgents.map(a=>'<span class="topology-chip '+(a.online?'good':'bad')+'">'+safe(a.name)+' · VMID '+a.vmid+'</span>').join('')+'</div></div>':'')}
|
||
function renderHomelabMap(guests,services,zfs){const guestItems=(guests.guests||[]).filter(v=>!v.template),serviceItems=services.services||[],pools=zfs.pools||[],serviceOwners=new Map();serviceItems.forEach(s=>{const host=topologyHost(s.localAddress||'');const owner=guestItems.find(g=>(g.ipAddresses||[]).some(ip=>ip===host));serviceOwners.set(s.id,owner?.vmid||0)});const serviceChip=s=>{const healthy=(s.endpoints||[]).every(v=>v.up);return '<span class="topology-chip '+(healthy?'good':'bad')+'"'+(!healthy?' tabindex="0" data-reason="Один или несколько адресов сервиса недоступны"':'')+'>'+safe(s.name)+'</span>'},storageChip=name=>{const pool=pools.find(p=>p.name===name),healthy=!pool||pool.health==='ONLINE';return '<span class="topology-chip '+(healthy?'good':'bad')+'">'+safe(name)+(pool?' · '+fixed(pool.capacityPercent)+'%':'')+'</span>'};const groups=guestItems.map(g=>{const owned=serviceItems.filter(s=>serviceOwners.get(s.id)===g.vmid),storage=[...new Set(g.storage||[])],running=g.status==='running';return '<section class="topology-group"><div class="topology-group-head"><div><strong>'+safe(g.name||('VMID '+g.vmid))+'</strong><div class="disk-meta">VMID '+g.vmid+' · '+safe(g.type==='qemu'?'VM':'LXC')+' · '+safe((g.ipAddresses||[]).join(', ')||'IP не получен')+'</div></div><span class="badge '+(running?'':'warn')+'"'+(!running?' tabindex="0" data-reason="VM/LXC сейчас остановлена"':'')+'>'+(running?'Работает':'Остановлена')+'</span></div><div class="topology-section"><div class="topology-section-title">Сервисы</div><div class="topology-items">'+(owned.length?owned.map(serviceChip).join(''):'<span class="empty">Не сопоставлены</span>')+'</div></div><div class="topology-section"><div class="topology-section-title">Storage</div><div class="topology-items">'+(storage.length?storage.map(storageChip).join(''):'<span class="empty">Не определён</span>')+'</div></div></section>'}).join(''),unassigned=serviceItems.filter(s=>!serviceOwners.get(s.id)),usedStorage=new Set(guestItems.flatMap(g=>g.storage||[])),unassignedPools=pools.filter(p=>!usedStorage.has(p.name));el('mapBoard').innerHTML='<div class="topology-host map-node good"><strong>Proxmox host</strong><small>'+safe(location.hostname)+' · '+guestItems.length+' VM/LXC</small></div><div class="topology-grid">'+groups+'</div>'+((unassigned.length||unassignedPools.length)?'<section class="topology-group topology-unassigned"><div class="topology-group-head"><strong>Без автоматической привязки</strong><span class="badge warn" tabindex="0" data-reason="Для сервиса не найден совпадающий IP VM/LXC либо storage не используется в конфигурации VM/LXC">Проверьте связи</span></div><div class="topology-section"><div class="topology-section-title">Сервисы</div><div class="topology-items">'+unassigned.map(serviceChip).join('')+'</div></div><div class="topology-section"><div class="topology-section-title">Остальные ZFS-пулы</div><div class="topology-items">'+unassignedPools.map(p=>storageChip(p.name)).join('')+'</div></div></section>':'');const storage=new Set([...usedStorage,...pools.map(p=>p.name)]);el('mapGuests').textContent=guestItems.length;el('mapResources').textContent=serviceItems.length+' / '+storage.size;el('mapNodes').textContent=1+guestItems.length+serviceItems.length+storage.size;el('mapBar').style.width='100%'}
|
||
function renderHomelabMapV2(guests,services,zfs){const guestItems=(guests.guests||[]).filter(v=>!v.template),serviceItems=services.services||[],pools=zfs.pools||[],agentByVM=new Map(currentAgents.map(a=>[a.vmid,a])),links=new Map(serviceItems.map(s=>[s.id,correlateMonitoredService(s,currentAgents)])),storageChip=name=>{const pool=pools.find(p=>p.name===name),healthy=!pool||pool.health==='ONLINE';return '<span class="topology-chip '+(healthy?'good':'bad')+'">'+safe(name)+(pool?' · '+fixed(pool.capacityPercent)+'%':'')+'</span>'};const groups=guestItems.map(g=>{const agent=agentByVM.get(g.vmid),owned=serviceItems.filter(s=>links.get(s.id).agent?.vmid===g.vmid||(g.ipAddresses||[]).includes(links.get(s.id).host)),storage=[...new Set(g.storage||[])],running=g.status==='running',servicesHTML=owned.map(s=>{const link=links.get(s.id),healthy=s.up,component=link.component?.name||link.listener?.process;return '<span class="topology-chip '+(healthy?'good':'bad')+'"'+(!healthy?' tabindex="0" data-reason="Сервис недоступен"':'')+'>'+safe(s.name)+(component?' ← '+safe(component):'')+(link.port?' · :'+link.port:'')+'</span>'}).join('');return '<section class="topology-group"><div class="topology-group-head"><div><strong>'+safe(g.name||('VMID '+g.vmid))+'</strong><div class="disk-meta">VMID '+g.vmid+' · '+safe(g.type==='qemu'?'VM':'LXC')+' · '+safe((g.ipAddresses||[]).join(', ')||'IP не получен')+'</div></div><span class="badge '+(!running||agent&&!agent.online?'bad':'')+'">'+(!running?'Остановлена':agent&&!agent.online?'Агент offline':'Работает')+'</span></div><div class="topology-section"><div class="topology-section-title">Службы / Docker → порт → сервис</div><div class="topology-items">'+(servicesHTML||'<span class="empty">Не сопоставлены</span>')+'</div></div><div class="topology-section"><div class="topology-section-title">Storage</div><div class="topology-items">'+(storage.length?storage.map(storageChip).join(''):'<span class="empty">Не определён</span>')+'</div></div></section>'}).join(''),assigned=new Set(serviceItems.filter(s=>links.get(s.id).agent).map(s=>s.id)),unassigned=serviceItems.filter(s=>!assigned.has(s.id)),usedStorage=new Set(guestItems.flatMap(g=>g.storage||[])),unassignedPools=pools.filter(p=>!usedStorage.has(p.name));el('mapBoard').innerHTML='<div class="topology-host map-node good"><strong>Proxmox host</strong><small>'+safe(location.hostname)+' · '+guestItems.length+' VM/LXC · '+currentAgents.length+' агентов</small></div><div class="topology-grid">'+groups+'</div>'+((unassigned.length||unassignedPools.length)?'<section class="topology-group topology-unassigned"><div class="topology-group-head"><strong>Без автоматической привязки</strong><span class="badge warn">Недостаточно данных</span></div><div class="topology-section"><div class="topology-section-title">Сервисы</div><div class="topology-items">'+unassigned.map(s=>'<span class="topology-chip '+(s.up?'good':'bad')+'">'+safe(s.name)+'</span>').join('')+'</div></div><div class="topology-section"><div class="topology-section-title">Остальные ZFS-пулы</div><div class="topology-items">'+unassignedPools.map(p=>storageChip(p.name)).join('')+'</div></div></section>':'');const storage=new Set([...usedStorage,...pools.map(p=>p.name)]);el('mapGuests').textContent=guestItems.length;el('mapResources').textContent=serviceItems.length+' / '+storage.size;el('mapNodes').textContent=1+guestItems.length+serviceItems.length+storage.size;el('mapBar').style.width='100%'}
|
||
function agentHTML(a,settings=false){
|
||
const r=a.report||{},containers=r.containers||[],services=r.services||[],issues=r.issues||[],failed=services.filter(v=>v.state==='failed'||v.subState==='failed'),interfaces=r.interfaces||[],memoryPercent=r.memoryTotal?r.memoryUsed/r.memoryTotal*100:0,diskPercent=r.rootTotal?r.rootUsed/r.rootTotal*100:0;
|
||
return '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(a.name||a.hostname)+'</div><div class="disk-meta">'+safe(a.hostname)+' · VMID '+(a.vmid||'не указан')+' · '+safe(r.osName||r.os||'Linux')+' · '+safe(r.kernel||'ядро не определено')+' · агент '+safe(a.version||'—')+'</div></div><span class="badge '+(a.online?'':'bad')+'">'+(a.online?'Онлайн':'Не отвечает')+'</span></div><div class="disk-stats"><div class="disk-stat"><span>CPU / Load</span><b>'+fixed(r.cpuPercent||0)+'% / '+fixed(r.load1||0)+'</b></div><div class="disk-stat"><span>Память</span><b>'+size(r.memoryUsed)+' · '+fixed(memoryPercent)+'%</b></div><div class="disk-stat"><span>Раздел /</span><b>'+size(r.rootUsed)+' · '+fixed(diskPercent)+'%</b></div><div class="disk-stat"><span>Uptime</span><b>'+uptime(r.uptimeSeconds||0)+'</b></div><div class="disk-stat"><span>Обновления</span><b>'+(r.updatesAvailable||0)+' пакетов'+(r.rebootRequired?' · нужен reboot':'')+'</b></div><div class="disk-stat"><span>Проблемы</span><b>'+failed.length+' служб · '+issues.length+' ошибок</b></div></div><div class="topology-section"><div class="topology-section-title">IP-адреса и интерфейсы</div><div class="topology-items">'+(interfaces.length?interfaces.map(v=>'<span class="topology-chip">'+safe(v.name)+' · '+safe((v.addresses||[]).join(', '))+'</span>').join(''):'<span class="empty">IP-адреса не найдены</span>')+'</div></div><div class="service-actions"><button data-agent-detail="'+safe(a.id)+'">Подробнее</button>'+(settings?'<button data-agent-delete="'+safe(a.id)+'">Удалить агент</button>':'')+'</div></article>'
|
||
}
|
||
let currentAgents=[];function renderAgents(items){const agents=currentAgents=items||[],online=agents.filter(v=>v.online),containers=agents.flatMap(v=>(v.report&&v.report.containers)||[]),failedServices=agents.flatMap(v=>(v.report&&v.report.services)||[]).filter(v=>v.state==='failed'||v.subState==='failed'),problems=(agents.length-online.length)+failedServices.length+containers.filter(v=>v.state!=='running'||v.health==='unhealthy').length;el('agentOnline').textContent=online.length;el('agentTotal').textContent=' / '+agents.length;el('agentBar').style.width=(agents.length?online.length/agents.length*100:0)+'%';el('agentContainers').textContent=containers.length;el('agentProblems').textContent=problems;el('agentList').innerHTML=agents.length?agents.map(v=>agentHTML(v)).join(''):'<span class="empty">Агенты пока не подключены. Создайте команду установки в разделе «Управление».</span>';el('agentSettingsList').innerHTML=agents.length?agents.map(v=>agentHTML(v,true)).join(''):'<span class="empty">Агентов пока нет.</span>'}
|
||
function collectAgentProblems(agent){const r=agent.report||[],now=agent.lastSeen||Math.floor(Date.now()/1000),out=[],add=(severity,title,message,time=now,area='Система')=>out.push({agent,severity,title,message,time,area});if(!agent.online)add('critical','Агент не отвечает','Последний heartbeat был '+(agent.lastSeen?new Date(agent.lastSeen*1000).toLocaleString('ru-RU'):'неизвестно'),agent.lastSeen,'Связь');(r.services||[]).forEach(v=>{if(v.state==='failed'||v.subState==='failed'){const log=(v.logs||[])[0];add('critical','Упала служба '+v.name,log?.message||v.description||('Exit code '+(v.exitCode||0)),log?.time||now,'systemd')}else if(v.flapping)add('warning','Служба часто перезапускается: '+v.name,(v.restarts||0)+' рестартов; последний запуск '+(v.startedAt?new Date(v.startedAt*1000).toLocaleString('ru-RU'):'неизвестен'),v.startedAt||now,'systemd')});(r.containers||[]).forEach(v=>{if(v.state!=='running')add('warning','Docker-контейнер остановлен: '+v.name,v.status||v.state,now,'Docker');else if(v.health==='unhealthy')add('critical','Docker-контейнер unhealthy: '+v.name,'Встроенный health-check Docker не проходит',now,'Docker')});(r.filesystems||[]).forEach(v=>{if(v.usedPercent>=90)add('critical','Заканчивается место: '+v.mountpoint,'Заполнено '+fixed(v.usedPercent)+'% · '+size(v.usedBytes)+' из '+size(v.totalBytes),now,'Хранилище');if(v.inodesPercent>=90)add('critical','Заканчиваются inode: '+v.mountpoint,'Использовано '+fixed(v.inodesPercent)+'% inode',now,'Хранилище')});if((r.oomKills||[]).length){const v=r.oomKills[0];add('critical','OOM-kill: ядро завершило процесс',v.message,v.time||now,'Память')}(r.processes||[]).filter(v=>String(v.state||'').startsWith('D')).forEach(v=>add('warning','Процесс завис в D-state: '+v.name,'PID '+v.pid+' · '+v.command,now,'Процессы'));return out}
|
||
const agentHTMLBase=agentHTML;agentHTML=function(agent,settings=false){const problems=collectAgentProblems(agent),markup=agentHTMLBase(agent,settings);return markup.replace(/(<span>Проблемы<\/span><b>)[^<]*(<\/b>)/,'$1'+problems.length+' активных$2')}
|
||
function renderAgentProblems(agents){const problems=(agents||[]).flatMap(collectAgentProblems).sort((a,b)=>(a.severity==='critical'?0:1)-(b.severity==='critical'?0:1)||b.time-a.time);el('agentProblems').textContent=problems.length;el('agentProblemTabCount').textContent=problems.length;el('agentProblemList').innerHTML=problems.length?problems.map(p=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(p.title)+'</div><div class="disk-meta">'+safe(p.agent.name||p.agent.hostname)+' · VMID '+(p.agent.vmid||'—')+' · '+safe(p.area)+' · '+(p.time?new Date(p.time*1000).toLocaleString('ru-RU'):'время неизвестно')+'</div></div><span class="badge '+(p.severity==='critical'?'bad':'warn')+'">'+(p.severity==='critical'?'Критично':'Внимание')+'</span></div><div class="settings-note issue-message">'+safe(p.message)+'</div><div class="service-actions"><button data-agent-detail="'+safe(p.agent.id)+'">Открыть агент</button></div></article>').join(''):'<div class="all-good"><strong>Проблем нет</strong><span>Все агенты, службы и Docker-контейнеры работают штатно.</span></div>'}
|
||
const renderAgentsBase=renderAgents;renderAgents=function(items){renderAgentsBase(items);renderAgentProblems(currentAgents);if(el('linkAgent'))refreshServiceLinkOptions()}
|
||
function renderAgentServices(services,filter='all'){const failed=v=>v.state==='failed'||v.subState==='failed',custom=v=>['added','local','runtime'].includes(v.origin),filtered=(services||[]).filter(v=>filter==='all'||filter==='failed'&&failed(v)||filter==='system'&&v.origin==='system'||filter==='custom'&&custom(v)),originLabel=v=>v.origin==='local'?'Локальная':v.origin==='added'?'Добавленный пакет':v.origin==='runtime'?'Временная':'Системная';el('agentServiceDetails').innerHTML='<div class="agent-filter"><button data-agent-service-filter="all" class="'+(filter==='all'?'active':'')+'">Все · '+services.length+'</button><button data-agent-service-filter="system" class="'+(filter==='system'?'active':'')+'">Системные · '+services.filter(v=>v.origin==='system').length+'</button><button data-agent-service-filter="custom" class="'+(filter==='custom'?'active':'')+'">Добавленные · '+services.filter(custom).length+'</button><button data-agent-service-filter="failed" class="'+(filter==='failed'?'active':'')+'">Упали · '+services.filter(failed).length+'</button></div><div class="disk-list" style="padding:0">'+(filtered.length?filtered.map(v=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.name)+'</div><div class="disk-meta">'+safe(v.description||'Описание отсутствует')+'</div><div class="disk-meta">'+originLabel(v)+(v.package?' · пакет '+safe(v.package):'')+(v.unitPath?' · '+safe(v.unitPath):'')+'</div></div><span class="badge '+(failed(v)?'bad':'')+'">'+safe(v.subState||v.state)+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Запущена</span><b>'+(v.startedAt?new Date(v.startedAt*1000).toLocaleString('ru-RU'):'—')+'</b></div><div class="disk-stat"><span>Рестарты</span><b>'+(v.restarts||0)+'</b></div><div class="disk-stat"><span>Exit code</span><b>'+(v.exitCode||0)+'</b></div><div class="disk-stat"><span>Память</span><b>'+(v.memoryBytes?size(v.memoryBytes):'Нет accounting')+'</b></div><div class="disk-stat"><span>CPU time</span><b>'+(v.cpuSeconds?fixed(v.cpuSeconds)+' сек.':'Нет accounting')+'</b></div></div></article>').join(''):'<span class="empty">Служб в этой группе нет.</span>')+'</div>';el('agentServiceDetails').querySelectorAll('[data-agent-service-filter]').forEach(button=>button.onclick=()=>renderAgentServices(services,button.dataset.agentServiceFilter))}
|
||
function renderAgentServicesV2(services,filter='all'){const failed=v=>v.state==='failed'||v.subState==='failed',custom=v=>['added','local','runtime'].includes(v.origin),filtered=(services||[]).filter(v=>filter==='all'||filter==='failed'&&failed(v)||filter==='system'&&v.origin==='system'||filter==='custom'&&custom(v)||filter==='flapping'&&v.flapping),originLabel=v=>v.origin==='local'?'Локальная':v.origin==='added'?'Добавленный пакет':v.origin==='runtime'?'Временная':'Системная',chip=(label,value)=>value?'<span class="topology-chip">'+label+' · '+safe(value)+'</span>':'';el('agentServiceDetails').innerHTML='<div class="agent-filter"><button data-agent-service-filter="all" class="'+(filter==='all'?'active':'')+'">Все · '+services.length+'</button><button data-agent-service-filter="system" class="'+(filter==='system'?'active':'')+'">Системные · '+services.filter(v=>v.origin==='system').length+'</button><button data-agent-service-filter="custom" class="'+(filter==='custom'?'active':'')+'">Добавленные · '+services.filter(custom).length+'</button><button data-agent-service-filter="failed" class="'+(filter==='failed'?'active':'')+'">Упали · '+services.filter(failed).length+'</button><button data-agent-service-filter="flapping" class="'+(filter==='flapping'?'active':'')+'">Перезапускаются · '+services.filter(v=>v.flapping).length+'</button></div><div class="disk-list" style="padding:0">'+(filtered.length?filtered.map(v=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.name)+'</div><div class="disk-meta">'+safe(v.description||'Описание отсутствует')+'</div><div class="disk-meta">'+originLabel(v)+(v.package?' · пакет '+safe(v.package):'')+(v.unitPath?' · '+safe(v.unitPath):'')+'</div></div><span class="badge '+(failed(v)?'bad':v.flapping?'warn':'')+'">'+(v.flapping?'Частые рестарты':safe(v.subState||v.state))+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Запущена</span><b>'+(v.startedAt?new Date(v.startedAt*1000).toLocaleString('ru-RU'):'—')+'</b></div><div class="disk-stat"><span>PID / команда</span><b>'+(v.mainPid||'—')+' · '+safe(v.execStart||'—')+'</b></div><div class="disk-stat"><span>Рестарты / exit</span><b>'+(v.restarts||0)+' / '+(v.exitCode||0)+'</b></div><div class="disk-stat"><span>Память / CPU time</span><b>'+(v.memoryBytes?size(v.memoryBytes):'Нет accounting')+' / '+(v.cpuSeconds?fixed(v.cpuSeconds)+' сек.':'—')+'</b></div></div><div class="topology-section"><div class="topology-section-title">Порты и зависимости</div><div class="topology-items">'+(v.listenPorts||[]).map(p=>chip('порт',p)).join('')+(v.requires||[]).map(d=>chip('requires',d)).join('')+(v.after||[]).slice(0,6).map(d=>chip('after',d)).join('')+'</div></div>'+((v.logs||[]).length?'<div class="topology-section"><div class="topology-section-title">Последние сообщения журнала</div>'+v.logs.map(l=>'<div class="service-log">'+(l.time?new Date(l.time*1000).toLocaleString('ru-RU')+' · ':'')+safe(l.message)+'</div>').join('')+'</div>':'')+'</article>').join(''):'<span class="empty">Служб в этой группе нет.</span>')+'</div>';el('agentServiceDetails').querySelectorAll('[data-agent-service-filter]').forEach(button=>button.onclick=()=>renderAgentServicesV2(services,button.dataset.agentServiceFilter))}
|
||
function openAgentDetail(agent){const r=agent.report||{},services=r.services||[],issues=r.issues||[],containers=r.containers||[],interfaces=r.interfaces||[],filesystems=r.filesystems||[],processes=r.processes||[],ports=r.listenPorts||[],oom=r.oomKills||[],pressure=r.pressure||{},memoryPercent=r.memoryTotal?r.memoryUsed/r.memoryTotal*100:0,diskPercent=r.rootTotal?r.rootUsed/r.rootTotal*100:0;el('agentDetailTitle').textContent=agent.name||agent.hostname;el('agentDetailMeta').textContent=(agent.hostname||'')+' · VMID '+(agent.vmid||'не указан')+' · '+(r.osName||r.os||'Linux')+' · '+(r.kernel||'ядро не определено')+' · агент '+(agent.version||'—');el('agentOverview').innerHTML='<div class="agent-detail-summary"><div><span class="label">Состояние</span><b>'+(agent.online?'Онлайн':'Не отвечает')+'</b></div><div><span class="label">CPU / Load</span><b>'+fixed(r.cpuPercent||0)+'% / '+fixed(r.load1||0)+'</b></div><div><span class="label">Память</span><b>'+size(r.memoryUsed)+' · '+fixed(memoryPercent)+'%</b></div><div><span class="label">Раздел /</span><b>'+size(r.rootUsed)+' · '+fixed(diskPercent)+'%</b></div><div><span class="label">Uptime</span><b>'+uptime(r.uptimeSeconds||0)+'</b></div><div><span class="label">Обновления</span><b>'+(r.updatesAvailable||0)+' пакетов'+(r.rebootRequired?' · нужен reboot':'')+'</b></div></div><div class="topology-section"><div class="topology-section-title">IP-адреса</div><div class="topology-items">'+(interfaces.length?interfaces.map(v=>'<span class="topology-chip">'+safe(v.name)+' · '+safe((v.addresses||[]).join(', '))+'</span>').join(''):'<span class="empty">Не найдены</span>')+'</div></div><div class="settings-note">Последний heartbeat: '+new Date(agent.lastSeen*1000).toLocaleString('ru-RU')+' · системный инвентарь: '+(r.inventoryAt?new Date(r.inventoryAt*1000).toLocaleString('ru-RU'):'ещё не собран')+'</div>';renderAgentServicesV2(services);el('agentResourceDetails').innerHTML='<div class="agent-detail-summary"><div><span class="label">CPU pressure · 10/60/300с</span><b>'+fixed(pressure.cpu?.avg10||0)+' / '+fixed(pressure.cpu?.avg60||0)+' / '+fixed(pressure.cpu?.avg300||0)+'</b></div><div><span class="label">Memory pressure</span><b>'+fixed(pressure.memory?.avg10||0)+' / '+fixed(pressure.memory?.avg60||0)+' / '+fixed(pressure.memory?.avg300||0)+'</b></div><div><span class="label">I/O pressure</span><b>'+fixed(pressure.io?.avg10||0)+' / '+fixed(pressure.io?.avg60||0)+' / '+fixed(pressure.io?.avg300||0)+'</b></div></div><div class="disk-list" style="padding:12px 0">'+(filesystems.length?filesystems.map(v=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.mountpoint)+'</div><div class="disk-meta">'+safe(v.device)+' · '+safe(v.type)+'</div></div><span class="badge '+(v.usedPercent>=90||v.inodesPercent>=90?'bad':'')+'">'+fixed(v.usedPercent)+'%</span></div><div class="disk-stats"><div class="disk-stat"><span>Занято / всего</span><b>'+size(v.usedBytes)+' / '+size(v.totalBytes)+'</b></div><div class="disk-stat"><span>Inode</span><b>'+fixed(v.inodesPercent||0)+'% · '+(v.inodesUsed||0)+' / '+(v.inodesTotal||0)+'</b></div></div></article>').join(''):'<span class="empty">Файловые системы ещё не получены.</span>')+'</div>';el('agentProcessDetails').innerHTML='<div class="topology-section"><div class="topology-section-title">Слушающие порты</div><div class="topology-items">'+(ports.length?ports.map(v=>'<span class="topology-chip">'+safe(v.protocol)+' · '+safe(v.address)+':'+v.port+' · '+safe(v.process||'PID '+(v.pid||'—'))+'</span>').join(''):'<span class="empty">Не найдены или ss недоступен.</span>')+'</div></div><div class="topology-section"><div class="topology-section-title">Топ процессов</div><div class="process-table">'+(processes.length?processes.map(v=>'<div class="process-row"><div><strong>'+safe(v.name)+'</strong><small>PID '+v.pid+' · '+safe(v.command)+'</small></div><span>CPU '+fixed(v.cpuPercent)+'%</span><span>RAM '+size(v.memoryBytes)+'</span><span>Read '+size(v.readBytes)+'</span><span>Write '+size(v.writeBytes)+'</span></div>').join(''):'<span class="empty">Процессы ещё не получены.</span>')+'</div></div>';el('agentIssueDetails').innerHTML='<div class="disk-list" style="padding:0">'+(oom.map(v=>'<article class="disk-item"><div class="disk-top"><div class="disk-model">OOM kill</div><span class="badge bad">Критично</span></div><div class="service-log">'+new Date(v.time*1000).toLocaleString('ru-RU')+' · '+safe(v.message)+'</div></article>').join(''))+(issues.length?issues.map(v=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.unit||'system')+'</div><div class="disk-meta">'+(v.time?new Date(v.time*1000).toLocaleString('ru-RU'):'Время не указано')+'</div></div><span class="badge bad">Ошибка</span></div><div class="settings-note issue-message">'+safe(v.message)+'</div></article>').join(''):'<span class="empty">Ошибок уровня err и выше за 24 часа нет.</span>')+'</div>';el('agentDockerDetails').innerHTML='<div class="disk-list" style="padding:0">'+(containers.length?containers.map(v=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.name)+'</div><div class="disk-meta">'+safe(v.image)+' · '+safe(v.id)+'</div></div><span class="badge '+(v.state==='running'&&v.health!=='unhealthy'?'':'bad')+'">'+safe(v.health||v.state)+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Статус</span><b>'+safe(v.status)+'</b></div><div class="disk-stat"><span>CPU</span><b>'+fixed(v.cpuPercent||0)+'%</b></div><div class="disk-stat"><span>Память</span><b>'+size(v.memoryUsage||0)+'</b></div><div class="disk-stat"><span>Рестарты</span><b>'+v.restarts+'</b></div><div class="disk-stat"><span>Порты</span><b>'+safe((v.ports||[]).join(', ')||'—')+'</b></div></div></article>').join(''):'<span class="empty">Docker-контейнеры не обнаружены.</span>')+'</div>';const first=document.querySelector('[data-agent-tab="overview"]');document.querySelectorAll('[data-agent-tab]').forEach(t=>t.classList.toggle('active',t===first));document.querySelectorAll('[data-agent-panel]').forEach(p=>p.hidden=p.dataset.agentPanel!=='overview');el('agentDetailDialog').showModal()}
|
||
let currentDetailAgent=null;const openAgentDetailBase=openAgentDetail;openAgentDetail=function(agent){currentDetailAgent=agent;openAgentDetailBase(agent);const r=agent.report||{},processes=r.processes||[],oom=r.oomKills||[],summary='<div class="agent-detail-summary" style="margin-bottom:12px"><div><span class="label">Swap</span><b>'+size(r.swapUsed||0)+' / '+size(r.swapTotal||0)+'</b></div><div><span class="label">Процессы в D-state</span><b>'+processes.filter(v=>String(v.state||'').startsWith('D')).length+'</b></div><div><span class="label">OOM за 24 часа</span><b>'+oom.length+'</b></div></div>';el('agentResourceDetails').insertAdjacentHTML('afterbegin',summary);addAgentActionButtons(agent);loadAgentHistory(agent,24)}
|
||
function addAgentActionButtons(agent){el('agentOverview').insertAdjacentHTML('beforeend','<div class="service-actions"><button data-agent-action="updates-refresh">Обновить список пакетов</button></div>');el('agentServiceDetails').querySelectorAll('.disk-item').forEach(article=>{const name=article.querySelector('.disk-model')?.textContent;if(!name)return;article.insertAdjacentHTML('beforeend','<div class="service-actions"><button data-agent-action="systemd-restart" data-target="'+safe(name)+'">Перезапустить</button><button data-agent-action="systemd-reset-failed" data-target="'+safe(name)+'">Сбросить failed</button></div>')});el('agentDockerDetails').querySelectorAll('.disk-item').forEach(article=>{const name=article.querySelector('.disk-model')?.textContent;if(name)article.insertAdjacentHTML('beforeend','<div class="service-actions"><button data-agent-action="docker-restart" data-target="'+safe(name)+'">Перезапустить контейнер</button></div>')})}
|
||
async function queueAgentAction(action,target){if(!currentDetailAgent||!confirm('Выполнить действие «'+action+'» для '+(target||currentDetailAgent.name)+'?'))return;const response=await fetch('/api/agent-actions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agentId:currentDetailAgent.id,action,target})});alert(response.ok?'Действие поставлено в очередь. Результат появится после следующего heartbeat.':await response.text());if(response.ok)setTimeout(loadAgentActions,12000)}
|
||
async function loadAgentHistory(agent,hours=24){const kinds=['agent-resource','agent-swap'],responses=await Promise.all(kinds.map(kind=>fetch('/api/entity-history?kind='+kind+'&hours='+hours).then(r=>r.json()))),key=agent.id+' · ',resource=(responses[0]||[]).find(v=>v.name.startsWith(key)),swap=(responses[1]||[]).find(v=>v.name.startsWith(key)),points=new Map();(resource?.points||[]).forEach(p=>points.set(p.timestamp,{timestamp:p.timestamp,cpu:p.value1,ram:p.value2}));(swap?.points||[]).forEach(p=>{const item=points.get(p.timestamp)||{timestamp:p.timestamp};item.swap=p.value1;points.set(p.timestamp,item)});chartConfigs.agentHistoryChart={series:[{label:'CPU',color:'#55d58a',value:p=>p.cpu,format:percent},{label:'RAM',color:'#9d8cff',value:p=>p.ram,format:percent},{label:'Swap',color:'#ff78b8',value:p=>p.swap,format:percent}]};const canvas=el('agentHistoryChart');drawChart('agentHistoryChart',[...points.values()].sort((a,b)=>a.timestamp-b.timestamp));if(!canvas.dataset.agentHover){setupAgentHistoryHover(canvas);canvas.dataset.agentHover='1'}}
|
||
function setupAgentHistoryHover(canvas){canvas.addEventListener('mousemove',event=>{const state=canvas._chartState,tooltip=el('agentHistoryDetails').querySelector('.chart-tooltip');if(!state||!tooltip)return;const rect=canvas.getBoundingClientRect(),x=event.clientX-rect.left,index=Math.max(0,Math.min(state.points.length-1,Math.round((x-state.pad.l)/state.pw*(state.points.length-1)))),point=state.points[index];tooltip.innerHTML='<div class="chart-tooltip-time">'+new Date(point.timestamp*1000).toLocaleString('ru-RU')+'</div>'+state.series.map(s=>'<div class="chart-tooltip-value"><span><i style="background:'+s.color+'"></i>'+s.label+'</span><strong>'+(s.value(point)==null?'—':s.format(s.value(point)))+'</strong></div>').join('');tooltip.classList.add('visible');tooltip.style.left=Math.min(x+12,canvas.clientWidth-tooltip.offsetWidth-10)+'px';tooltip.style.top='55px'});canvas.addEventListener('mouseleave',()=>el('agentHistoryDetails').querySelector('.chart-tooltip').classList.remove('visible'))}
|
||
function render(x){const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs||{available:false,pools:[]},u=x.ups||{available:false},g=x.guests||{available:false,guests:[]},b=x.backups||{available:false,items:[]},mt=x.maintenance||{},alerts=x.alerts||[],disks=x.disks||[],avg=c.temperatures.length?c.temperatures.reduce((a,t)=>a+t.celsius,0)/c.temperatures.length:null;
|
||
currentServiceLinks=x.serviceLinks||[];
|
||
lastDashboardEvent=Date.now();
|
||
maintenanceGuests=(g.guests||[]).filter(v=>!v.template);applyMaintenanceState(x.maintenanceWindow||{});
|
||
renderAgents(x.agents||[]);
|
||
const update=x.update||{},updating=['queued','installing'].includes(update.state);el('updateCurrent').textContent=update.currentVersion||'—';el('updateAvailable').textContent=update.availableVersion||'—';el('updateMode').textContent=update.mode==='auto'?'Автоматически':'Уведомлять';el('updateMessage').textContent=update.message||'Проверка ещё не выполнялась';el('updateChecked').textContent=update.checkedAt?'Последняя проверка: '+new Date(update.checkedAt).toLocaleString('ru-RU'):'Проверка запускается автоматически раз в сутки.';el('installUpdate').disabled=updating;el('installUpdate').textContent=updating?'Обновление выполняется…':'Проверить и установить обновление';
|
||
const critical=alerts.filter(a=>a.severity==='critical').length,warnings=alerts.filter(a=>a.severity==='warning').length,infos=alerts.filter(a=>a.severity==='info').length;el('alertCount').textContent=alerts.length;el('alertCritical').textContent=critical;el('alertWarnings').textContent=warnings;el('alertHeadline').textContent=critical?'Требуется срочное внимание':warnings?'Есть предупреждения':infos?'Есть информационные события':'Система в норме';el('alertBar').style.width=(critical?100:warnings?65:infos?30:0)+'%';
|
||
el('alertList').innerHTML=alerts.length?alerts.map(a=>'<article class="alert-item '+safe(a.severity)+'"><i class="alert-icon"></i><div><div class="alert-source">'+safe(a.source)+' · '+(a.severity==='critical'?'Критическое':a.severity==='warning'?'Предупреждение':'Информация')+(a.firstSeen?' · С '+new Date(a.firstSeen*1000).toLocaleString('ru-RU'):'')+'</div><div class="alert-title">'+safe(a.title)+'</div><div class="alert-message">'+safe(a.message)+'</div><button class="ack-alert" data-ack="'+a.eventId+'">Принято</button></div></article>').join(''):'<div class="all-good"><strong>Всё в порядке</strong><span>Новых неподтверждённых событий нет.</span></div>';
|
||
el('cpuName').textContent=el('cpuDetailName').textContent=c.model;el('cpuUsage').textContent=fixed(c.usagePercent);el('cpuBar').style.width=Math.min(c.usagePercent,100)+'%';el('cpuTemp').textContent=avg===null?'Нет датчика':fixed(avg)+' °C';el('cpuTopology').textContent=c.physicalCores+' / '+c.logicalCpus;
|
||
el('dCpuUsage').textContent=fixed(c.usagePercent)+'%';el('dSockets').textContent=c.sockets;el('dCores').textContent=c.physicalCores;el('dThreads').textContent=c.logicalCpus;el('dFreq').textContent=(c.frequencyMhz/1000).toFixed(2)+' GHz';el('dUptime').textContent=uptime(c.uptimeSeconds);el('dLoad1').textContent=fixed(c.load1);el('dLoad5').textContent=fixed(c.load5);el('dLoad15').textContent=fixed(c.load15);el('dTemps').innerHTML=c.temperatures.length?c.temperatures.map(t=>'<span class="temp">'+t.label+'<b>'+fixed(t.celsius)+'°C</b></span>').join(''):'<span class="empty">Датчики температуры не найдены</span>';
|
||
el('memUsage').textContent=fixed(m.usagePercent);el('memBar').style.width=Math.min(m.usagePercent,100)+'%';el('memUsed').textContent=size(m.usedBytes);el('memAvailable').textContent=size(m.availableBytes);el('dMemUsed').textContent=size(m.usedBytes);el('dMemAvailable').textContent=size(m.availableBytes);el('dMemTotal').textContent=size(m.totalBytes);el('dMemCache').textContent=size(m.cachedBytes);el('dMemBuffers').textContent=size(m.buffersBytes);el('dMemPercent').textContent=fixed(m.usagePercent)+'%';el('dSwapUsed').textContent=size(m.swapUsedBytes);el('dSwapTotal').textContent=size(m.swapTotalBytes);el('dSwapPercent').textContent=fixed(m.swapPercent)+'%';
|
||
const failed=disks.filter(d=>d.smartStatus==='Ошибка').length,attention=disks.filter(d=>d.smartStatus==='Требует внимания').length,available=disks.filter(d=>d.smartAvailable).length;let health=failed?'Ошибка':attention?'Внимание':available===disks.length&&disks.length?'Исправны':available?'Частично':'Нет данных';
|
||
el('diskCount').textContent=disks.length;el('diskCountLabel').textContent=disks.length===1?' диск':' дисков';el('diskHealth').textContent=health;el('diskUsage').textContent=fixed(s.usagePercent)+'%';el('diskBar').style.width=Math.min(s.usagePercent,100)+'%';el('dDiskUsed').textContent=size(s.usedBytes);el('dDiskFree').textContent=size(s.freeBytes);el('dDiskTotal').textContent=size(s.totalBytes);el('dDiskPercent').textContent=fixed(s.usagePercent)+'%';
|
||
el('diskList').innerHTML=disks.length?disks.map(d=>{const ioWarn=d.ioPressureSeconds>=60,badge=d.smartStatus==='Ошибка'?'bad':d.smartStatus==='Исправен'?(ioWarn?'warn':''):'warn',reasons=[...(d.attentionReasons||[]),...(ioWarn?['Длительная I/O-нагрузка: '+fixed(d.ioPressureSeconds)+' сек.']:[])],reason=reasons.join(' · '),tip=reason?' tabindex="0" data-reason="'+safe(reason)+'"':'';return '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(d.model)+'</div><div class="disk-meta">'+safe(d.path)+' · SN: '+safe(d.serial)+'</div></div><span class="badge '+badge+'"'+tip+'>'+(ioWarn?'Высокий I/O':safe(d.smartStatus))+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Тип / объём</span><b>'+safe(d.type)+' · '+size(d.sizeBytes)+'</b></div><div class="disk-stat"><span>Занято</span><b>'+(d.usedBytes==null?'—':size(d.usedBytes)+' · '+fixed(d.usagePercent)+'%')+'</b></div><div class="disk-stat"><span>Температура / износ</span><b>'+(d.temperatureCelsius==null?'—':fixed(d.temperatureCelsius)+' °C')+' · '+(d.wearUsedPercent==null?'—':fixed(d.wearUsedPercent)+'%')+'</b></div><div class="disk-stat"><span>IOPS · чтение / запись</span><b>'+fixed(d.readIops)+' / '+fixed(d.writeIops)+'</b></div><div class="disk-stat"><span>Скорость · чтение / запись</span><b>'+rate(d.readBytesPerSec)+' / '+rate(d.writeBytesPerSec)+'</b></div><div class="disk-stat"><span>Latency · чтение / запись</span><b>'+fixed(d.readLatencyMs)+' / '+fixed(d.writeLatencyMs)+' ms</b></div><div class="disk-stat"><span>Utilization / очередь</span><b>'+fixed(d.utilizationPercent)+'% / '+fixed(d.averageQueue)+' ('+d.queueDepth+' сейчас)</b></div><div class="disk-stat"><span>Reallocated / Pending</span><b>'+d.reallocated+' / '+d.pending+'</b></div><div class="disk-stat"><span>Uncorrectable / CRC</span><b>'+d.uncorrectable+' / '+d.crcErrors+'</b></div><div class="disk-stat"><span>Последний self-test</span><b>'+safe(d.lastSelfTest||'—')+(d.lastSelfTestAt?' · '+new Date(d.lastSelfTestAt).toLocaleDateString('ru-RU'):'')+(d.shortTestStarted?' · запущен новый':'')+'</b></div></div></article>'}).join(''):'<span class="empty">Физические накопители не найдены</span>';
|
||
const totalRate=n.receivePerSec+n.transmitPerSec,nh=n.health||{};el('netInterfaces').textContent=n.interface||'Сетевые интерфейсы';el('netTotal').textContent=(totalRate/1048576).toFixed(2);el('netBar').style.width=Math.min(totalRate/125000000*100,100)+'%';el('netDown').textContent=rate(n.receivePerSec);el('netUp').textContent=rate(n.transmitPerSec);el('dNetDown').textContent=rate(n.receivePerSec);el('dNetUp').textContent=rate(n.transmitPerSec);el('dNetTotal').textContent=rate(totalRate);el('dNetReceived').textContent=size(n.receiveBytes);el('dNetSent').textContent=size(n.transmitBytes);el('dNetInterfaces').textContent=n.interface||'—';el('dNetGateway').textContent=(nh.gateway||'Не определён')+' · '+(nh.gatewayUp?'доступен':'нет ответа')+(nh.latencyMs?' · '+fixed(nh.latencyMs)+' ms':'');el('dNetDNS').textContent=nh.dnsUp?'Работает':'Ошибка';el('dNetInternet').textContent=nh.internetUp?'Доступен':'Недоступен';el('dNetExternalIP').textContent=(nh.externalIp||'—')+(nh.externalIpChanged?' · изменился':'');
|
||
const pools=z.pools||[],zTotal=pools.reduce((a,p)=>a+p.sizeBytes,0),zUsed=pools.reduce((a,p)=>a+p.allocatedBytes,0),zPercent=zTotal?zUsed/zTotal*100:0,zBad=pools.filter(p=>p.health!=='ONLINE').length;el('zfsCount').textContent=pools.length;el('zfsHealth').textContent=!z.available?'Не найден':zBad?zBad+' требуют внимания':pools.length+' ONLINE';el('zfsUsed').textContent=zTotal?size(zUsed)+' / '+size(zTotal)+' · '+fixed(zPercent)+'%':'—';el('zfsBar').style.width=Math.min(zPercent,100)+'%';
|
||
const datasets=z.datasets||[],arc=z.arc||{};el('zfsArcSize').textContent=arc.available?size(arc.sizeBytes):'—';el('zfsArcTarget').textContent=arc.available?size(arc.targetBytes):'—';el('zfsArcHit').textContent=arc.available?fixed(arc.hitRatio)+'%':'—';el('zfsDatasetCount').textContent=datasets.length;el('zfsList').innerHTML=pools.length?pools.map(p=>{const bad=p.health!=='ONLINE'||p.readErrors+p.writeErrors+p.checksumErrors>0,reason=(p.attentionReasons||[]).join(' · '),tip=reason?' tabindex="0" data-reason="'+safe(reason)+'"':'',poolDatasets=datasets.filter(d=>d.pool===p.name),vdevs=p.vdevs||[];return '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(p.name)+'</div><div class="disk-meta">Scrub: '+safe(p.scrubStatus||p.scan||'нет данных')+(p.lastScrubAt?' · '+new Date(p.lastScrubAt).toLocaleString('ru-RU'):'')+(p.scrubDuration?' · '+safe(p.scrubDuration):'')+'</div></div><span class="badge '+(bad?'bad':p.scrubTooOld?'warn':'')+'"'+tip+'>'+safe(p.health)+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Объём / свободно</span><b>'+size(p.sizeBytes)+' / '+size(p.freeBytes)+'</b></div><div class="disk-stat"><span>Заполнение</span><b>'+fixed(p.capacityPercent)+'%</b></div><div class="disk-stat"><span>Read / write / checksum</span><b>'+p.readErrors+' / '+p.writeErrors+' / '+p.checksumErrors+'</b></div></div><div class="topology-section"><div class="topology-section-title">Vdev · data / special / cache / log</div><div class="topology-items">'+(vdevs.length?vdevs.map(v=>'<span class="topology-chip '+(v.state==='ONLINE'?'good':'bad')+'">'+safe(v.class)+' · '+safe(v.name)+' · '+safe(v.state)+' · '+v.read+'/'+v.write+'/'+v.checksum+'</span>').join(''):'<span class="empty">Нет данных</span>')+'</div></div><div class="topology-section"><div class="topology-section-title">Datasets</div><div class="topology-items">'+(poolDatasets.length?poolDatasets.map(d=>'<span class="topology-chip '+(d.capacityPercent>=80?'bad':'good')+'">'+safe(d.name)+' · '+fixed(d.capacityPercent)+'% · '+size(d.usedBytes)+'</span>').join(''):'<span class="empty">Нет datasets</span>')+'</div></div></article>'}).join(''):'<span class="empty">'+(z.available?'ZFS-пулы не найдены':'Команда zpool недоступна')+'</span>';
|
||
const val=(v,suffix='')=>v==null?'—':fixed(v)+suffix;el('upsName').textContent=u.available?(u.model||u.name||'Источник бесперебойного питания'):'UPS не настроен';el('upsCharge').textContent=u.chargePercent==null?'—':fixed(u.chargePercent);el('upsBar').style.width=u.chargePercent==null?'0%':Math.min(u.chargePercent,100)+'%';el('upsStatus').textContent=u.available?u.statusLabel:'Не найден';el('upsRuntime').textContent=u.runtimeSeconds==null?'—':uptime(u.runtimeSeconds);el('dUpsName').textContent=u.model||u.name||'Источник бесперебойного питания';el('dUpsStatus').textContent=u.available?u.statusLabel:'Не настроен';el('dUpsCharge').textContent=val(u.chargePercent,'%');el('dUpsRuntime').textContent=u.runtimeSeconds==null?'—':uptime(u.runtimeSeconds);el('dUpsLoad').textContent=val(u.loadPercent,'%');el('dUpsInput').textContent=val(u.inputVoltage,' V');el('dUpsOutput').textContent=val(u.outputVoltage,' V');el('dUpsFrequency').textContent=val(u.inputFrequency,' Hz');el('dUpsBattery').textContent=val(u.batteryVoltage,' V');el('dUpsTemp').textContent=val(u.temperatureCelsius,' °C');el('dUpsCommunication').textContent=u.communicationOk?'Связь установлена':'Нет связи';el('dUpsBatteryDate').textContent=u.batteryDate||'Не сообщает';el('dUpsBatteryAge').textContent=u.batteryAgeDays?u.batteryAgeDays+' дней':'—';el('dUpsSource').textContent=u.available?(u.source+' · '+(u.name||'UPS')):'Установите и настройте NUT или apcupsd';
|
||
|
||
const mh=x.monitor||{},last=mh.lastSuccessfulCollection?new Date(mh.lastSuccessfulCollection):null,stale=!last||Date.now()-last.getTime()>30000,monitorProblem=stale||mh.lastCollectionError||mh.historyWriteError||mh.slowCollector;el('monitorState').textContent=monitorProblem?'Внимание':'OK';el('monitorBar').style.width=monitorProblem?'100%':'12%';el('monitorLast').textContent=last?Math.max(0,Math.round((Date.now()-last.getTime())/1000))+' сек. назад':'—';el('monitorDB').textContent=size(mh.databaseFreeBytes);el('dMonitorLast').textContent=last?last.toLocaleString('ru-RU'):'Ещё не завершён';el('dMonitorDuration').textContent=(mh.collectionDurationMs||0)+' ms';el('dMonitorHistory').textContent=mh.lastHistoryWrite?new Date(mh.lastHistoryWrite).toLocaleString('ru-RU'):'Ещё не записывалась';el('dMonitorDB').textContent=size(mh.databaseFreeBytes);el('dMonitorWatchdog').textContent=mh.watchdogEnabled?'Включён · 30 сек.':'Не активен';el('dMonitorError').textContent=mh.lastCollectionError||mh.historyWriteError||'Нет';
|
||
const guests=(g.guests||[]).filter(v=>!v.template),running=guests.filter(v=>v.status==='running'),guestCores=running.reduce((a,v)=>a+v.maxcpu,0),guestCPU=guestCores?running.reduce((a,v)=>a+v.cpu*v.maxcpu,0)/guestCores*100:0,guestMem=running.reduce((a,v)=>a+v.mem,0),guestMaxMem=running.reduce((a,v)=>a+v.maxmem,0);el('guestRunning').textContent=running.length;el('guestTotal').textContent=' / '+guests.length;el('guestBar').style.width=guests.length?(running.length/guests.length*100)+'%':'0%';el('guestCPU').textContent=g.available?fixed(guestCPU)+'%':'Не найдено';el('guestMemory').textContent=g.available?(size(guestMem)+' / '+size(guestMaxMem)):'—';
|
||
el('guestList').innerHTML=guests.length?guests.map(v=>{const live=v.status==='running',type=v.type==='qemu'?'VM':'LXC',memPercent=v.maxmem?v.mem/v.maxmem*100:0,diskPercent=v.maxdisk?v.disk/v.maxdisk*100:0,ips=(v.ipAddresses||[]).join(', ')||'IP не найден';return '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.name||('Guest '+v.vmid))+'</div><div class="disk-meta">VMID '+v.vmid+' · '+type+' · '+safe(v.node)+' · '+safe(ips)+'</div></div><span class="badge '+(live?'':'warn')+'">'+(live?'Запущен':'Остановлен')+'</span></div><div class="disk-stats"><div class="disk-stat"><span>CPU</span><b>'+(live?fixed(v.cpu*100)+'%':'—')+' / '+v.maxcpu+'</b></div><div class="disk-stat"><span>Память</span><b>'+(live?size(v.mem)+' · '+fixed(memPercent)+'%':size(v.maxmem))+'</b></div><div class="disk-stat"><span>Диск</span><b>'+(v.maxdisk?size(v.disk)+' · '+fixed(diskPercent)+'%':'—')+'</b></div><div class="disk-stat"><span>Сеть</span><b>↓ '+size(v.netin)+' · ↑ '+size(v.netout)+'</b></div><div class="disk-stat"><span>Guest Agent</span><b>'+(v.agent?'Доступен':'Нет')+'</b></div><div class="disk-stat"><span>Автозапуск</span><b>'+(v.onboot?'Да':'Нет')+'</b></div><div class="disk-stat"><span>Storage</span><b>'+safe((v.storage||[]).join(', ')||'—')+'</b></div><div class="disk-stat"><span>Uptime</span><b>'+(live?uptime(v.uptime):'—')+'</b></div></div></article>'}).join(''):'<span class="empty">'+(g.available?'VM и LXC не найдены':'Команда pvesh недоступна')+'</span>';
|
||
const pveShort=(mt.proxmoxVersion||'Proxmox VE').split('(')[0].trim();el('maintenanceVersion').textContent=pveShort;el('maintenanceUpdates').textContent=mt.availableUpdates??'—';el('maintenanceBar').style.width=Math.min((mt.availableUpdates||0)*5,100)+'%';el('maintenanceReboot').textContent=(mt.rebootRequired||mt.newKernelAvailable)?'Требуется':'Не требуется';el('maintenanceKernel').textContent=mt.kernelVersion||'—';el('dPveVersion').textContent=pveShort;el('dPveVersion').title=mt.proxmoxVersion||'';el('dKernel').textContent=mt.kernelVersion||'—';el('dLatestKernel').textContent=mt.latestInstalledKernel||'—';el('dUpdates').textContent=(mt.availableUpdates??'—')+' пакетов';el('dReboot').textContent=(mt.rebootRequired||mt.newKernelAvailable)?'Требуется':'Не требуется';el('dLastUpdate').textContent=mt.lastSystemUpdate?new Date(mt.lastSystemUpdate).toLocaleString('ru-RU'):'—';el('dNextBackup').textContent=mt.nextBackup||'Расписание не найдено';el('dNextScrub').textContent=mt.nextScrub||'Таймер не найден';el('dMaintenanceChecked').textContent=mt.checkedAt?new Date(mt.checkedAt).toLocaleString('ru-RU'):'—';
|
||
const backupLimitHours=alertSettings.backupMaxAgeHours||48,backupLimit=backupLimitHours*3600,backupMap=new Map((b.items||[]).map(item=>[item.vmid,item])),now=Date.now()/1000,protectedGuests=guests.filter(v=>backupMap.has(v.vmid)),freshGuests=protectedGuests.filter(v=>now-backupMap.get(v.vmid).ctime<=backupLimit),missing=guests.length-protectedGuests.length;el('backupFreshLabel').textContent='Свежие · '+backupLimitHours+' часов';el('backupProtected').textContent=protectedGuests.length;el('backupTotal').textContent=' / '+guests.length;el('backupBar').style.width=guests.length?(protectedGuests.length/guests.length*100)+'%':'0%';el('backupFresh').textContent=b.available?freshGuests.length:'Не найдено';el('backupMissing').textContent=b.available?missing:'—';
|
||
el('backupList').innerHTML=guests.length?guests.map(v=>{const item=backupMap.get(v.vmid),type=v.type==='qemu'?'VM':'LXC';if(!item)return '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.name||('Guest '+v.vmid))+'</div><div class="disk-meta">VMID '+v.vmid+' · '+type+'</div></div><span class="badge bad">Копии нет</span></div></article>';const age=now-item.ctime,fresh=age<=backupLimit,date=new Date(item.ctime*1000).toLocaleString('ru-RU'),task=(x.activity?.tasks||[]).find(t=>t.type==='vzdump'&&String(t.id)===String(v.vmid)),change=item.sizeChangePercent==null?'Нет предыдущей':(item.sizeChangePercent>=0?'+':'')+fixed(item.sizeChangePercent)+'%';return '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.name||('Guest '+v.vmid))+'</div><div class="disk-meta">VMID '+v.vmid+' · '+type+' · '+date+'</div></div><span class="badge '+(fresh?'':'warn')+'">'+(fresh?'Актуальна':'Старше '+backupLimitHours+' ч')+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Хранилище</span><b>'+safe(item.storage)+'</b></div><div class="disk-stat"><span>Размер</span><b>'+size(item.size)+'</b></div><div class="disk-stat"><span>К предыдущей</span><b>'+change+'</b></div><div class="disk-stat"><span>Последнее задание</span><b>'+(task?safe(task.status)+' · '+uptime(task.duration):'Общее задание')+'</b></div><div class="disk-stat"><span>Возраст</span><b>'+uptime(age)+'</b></div><div class="disk-stat"><span>Формат</span><b>'+safe(item.format)+'</b></div></div><div class="service-actions"><button data-verify-backup="'+safe(item.volid)+'">Проверить читаемость</button></div><div class="backup-check" data-backup-result="'+safe(item.volid)+'"></div></article>'}).join(''):'<span class="empty">'+(b.available?'VM и LXC не найдены':'Данные резервных копий недоступны')+'</span>';
|
||
const svc=x.services||{services:[],up:0,total:0};renderServices(svc);
|
||
const sh=x.systemHealth||{},issueCount=sh.systemErrors||0;el('systemIssues').textContent=issueCount;el('systemBar').style.width=Math.min(issueCount*10,100)+'%';el('failedUnits').textContent=sh.failedUnits??'—';el('kernelErrors').textContent=sh.kernelErrors??'—';el('dSystemErrors').textContent=issueCount;el('dKernelErrors').textContent=sh.kernelErrors||0;el('dFailedUnits').textContent=sh.failedUnits||0;renderSystemHealth(sh);
|
||
renderNetworkGroups(n.interfaces||[]);
|
||
renderActivity(x.activity||{},g,svc,z);renderUnifiedDiagnostics(x.agents||[],svc);renderIOAttribution(disks);explainWarningBadges();
|
||
el('dot').classList.add('live');el('status').textContent='Данные поступают';
|
||
}
|
||
const percent=v=>fixed(v)+'%',temperature=v=>fixed(v)+' °C',megabytes=v=>fixed(v)+' MB/s';
|
||
const chartConfigs={cpuChart:{series:[{label:'CPU',color:'#55d58a',value:p=>p.cpuUsage,format:percent},{label:'Температура',color:'#ff9b5c',value:p=>p.cpuTemperature,format:temperature}]},memoryChart:{series:[{label:'RAM',color:'#9d8cff',value:p=>p.memoryUsage,format:percent},{label:'Swap',color:'#ff78b8',value:p=>p.swapUsage,format:percent}]},storageChart:{series:[{label:'Раздел /',color:'#ff9b5c',value:p=>p.rootUsage,format:percent}]},networkChart:{max:0,series:[{label:'Приём',color:'#43c6d9',value:p=>p.networkReceive/1048576,format:megabytes},{label:'Передача',color:'#65ddaa',value:p=>p.networkTransmit/1048576,format:megabytes}]},zfsChart:{series:[{label:'ZFS',color:'#6577ff',value:p=>p.zfsUsage,format:percent}]},upsChart:{series:[{label:'Заряд',color:'#55d58a',value:p=>p.upsCharge,format:percent},{label:'Нагрузка',color:'#d5e85a',value:p=>p.upsLoad,format:percent}]},guestsChart:{series:[{label:'CPU',color:'#ff78b8',value:p=>p.guestsCpu,format:percent},{label:'RAM',color:'#9d8cff',value:p=>p.guestsMemory,format:percent}]}};
|
||
const historyCache=new Map();
|
||
async function loadHistory(hours){const cached=historyCache.get(hours);if(cached&&Date.now()-cached.loadedAt<60000)return cached.points;const response=await fetch('/api/history?hours='+hours);if(!response.ok)throw new Error('history');const points=await response.json()||[];historyCache.set(hours,{points,loadedAt:Date.now()});return points}
|
||
const entityChartKinds={storageChart:{kind:'disk',format:temperature},diskIOChart:{kind:'disk-io',format:v=>fixed(v)+' IOPS',dynamic:true},diskUtilChart:{kind:'disk-io',format:percent,value:'value2'},zfsChart:{kind:'zfs',format:percent},guestsChart:{kind:'guest',format:percent}},chartColors=['#55d58a','#6ba8ff','#ff9b5c','#9d8cff','#ff78b8','#43c6d9','#d5e85a','#ff665c','#6577ff','#65ddaa'];
|
||
async function loadEntityChart(id,hours){const definition=entityChartKinds[id],response=await fetch('/api/entity-history?kind='+definition.kind+'&hours='+hours);if(!response.ok)throw new Error('entity history');const data=await response.json()||[],timestamps=new Map(),series=data.map((item,index)=>{const key='entity'+index;item.points.forEach(p=>{const point=timestamps.get(p.timestamp)||{timestamp:p.timestamp};point[key]=p[definition.value||'value1'];timestamps.set(p.timestamp,point)});return {label:item.name,color:chartColors[index%chartColors.length],value:p=>p[key],format:definition.format}});chartConfigs[id]={series,max:definition.dynamic?0:undefined};return [...timestamps.values()].sort((a,b)=>a.timestamp-b.timestamp)}
|
||
function drawChart(id,points){const canvas=el(id),config=chartConfigs[id];if(!canvas||!config||!canvas.offsetWidth)return;const series=config.series,dpr=window.devicePixelRatio||1,w=canvas.offsetWidth,h=200;canvas.width=w*dpr;canvas.height=h*dpr;const ctx=canvas.getContext('2d');ctx.scale(dpr,dpr);const styles=getComputedStyle(document.documentElement),text=styles.getPropertyValue('--muted').trim(),line=styles.getPropertyValue('--line').trim();ctx.clearRect(0,0,w,h);ctx.font='11px system-ui';ctx.fillStyle=text;if(points.length<2){ctx.textAlign='center';ctx.fillText('История появится после нескольких минут работы',w/2,h/2);canvas._chartState=null;return}const values=series.flatMap(s=>points.map(p=>s.value(p)).filter(v=>v!=null&&Number.isFinite(v))),max=config.max===0?Math.max(...values,1)*1.1:100,pad={l:38,r:12,t:30,b:25},pw=w-pad.l-pad.r,ph=h-pad.t-pad.b;ctx.strokeStyle=line;ctx.lineWidth=1;for(let i=0;i<=4;i++){const y=pad.t+ph*i/4;ctx.beginPath();ctx.moveTo(pad.l,y);ctx.lineTo(w-pad.r,y);ctx.stroke();ctx.fillStyle=text;ctx.textAlign='right';ctx.fillText((max*(1-i/4)).toFixed(max<10?1:0),pad.l-7,y+4)}series.forEach((s,index)=>{ctx.strokeStyle=s.color;ctx.lineWidth=2;ctx.beginPath();let started=false;points.forEach((p,i)=>{const v=s.value(p);if(v==null||!Number.isFinite(v))return;const x=pad.l+pw*i/(points.length-1),y=pad.t+ph*(1-v/(max||1));if(!started){ctx.moveTo(x,y);started=true}else ctx.lineTo(x,y)});ctx.stroke();ctx.fillStyle=s.color;ctx.textAlign='left';ctx.fillText(s.label,pad.l+index*130,15)});const first=new Date(points[0].timestamp*1000),last=new Date(points[points.length-1].timestamp*1000),timeOptions={hour:'2-digit',minute:'2-digit'};ctx.fillStyle=text;ctx.textAlign='left';ctx.fillText(first.toLocaleString('ru-RU',timeOptions),pad.l,h-6);ctx.textAlign='right';ctx.fillText(last.toLocaleString('ru-RU',timeOptions),w-pad.r,h-6);canvas._chartState={points,series,pad,pw,w}}
|
||
function setupChartHover(canvas){canvas.addEventListener('mousemove',event=>{const state=canvas._chartState,section=canvas.closest('.chart-section'),tooltip=section&§ion.querySelector('.chart-tooltip');if(!state||!tooltip)return;const rect=canvas.getBoundingClientRect(),x=event.clientX-rect.left,index=Math.max(0,Math.min(state.points.length-1,Math.round((x-state.pad.l)/state.pw*(state.points.length-1)))),point=state.points[index],date=new Date(point.timestamp*1000);tooltip.innerHTML='<div class="chart-tooltip-time">'+date.toLocaleString('ru-RU')+'</div>'+state.series.map(s=>{const value=s.value(point),shown=value==null||!Number.isFinite(value)?'—':s.format(value);return '<div class="chart-tooltip-value"><span><i style="background:'+s.color+'"></i>'+s.label+'</span><strong>'+shown+'</strong></div>'}).join('');tooltip.classList.add('visible');const sectionRect=section.getBoundingClientRect(),left=Math.min(event.clientX-sectionRect.left+12,section.clientWidth-tooltip.offsetWidth-10);tooltip.style.left=Math.max(10,left)+'px';tooltip.style.top=Math.max(55,event.clientY-sectionRect.top-tooltip.offsetHeight-12)+'px'});canvas.addEventListener('mouseleave',()=>{const tooltip=canvas.closest('.chart-section').querySelector('.chart-tooltip');tooltip.classList.remove('visible')})}
|
||
async function showHistory(section){const hours=Number(section.dataset.hours||1);await Promise.all([...section.querySelectorAll('canvas')].map(async canvas=>{try{drawChart(canvas.id,entityChartKinds[canvas.id]?await loadEntityChart(canvas.id,hours):await loadHistory(hours))}catch(e){drawChart(canvas.id,[])}}))}
|
||
function setupDetailTabs(){document.querySelectorAll('dialog').forEach(dialog=>{const chart=dialog.querySelector('.chart-section');if(!chart)return;const head=dialog.querySelector('.modal-head'),tabs=document.createElement('div'),realtime=document.createElement('div');tabs.className='detail-tabs';tabs.innerHTML='<button class="detail-tab active" data-detail-tab="realtime">Реальное время</button><button class="detail-tab" data-detail-tab="history">История</button>';realtime.className='detail-panel';realtime.dataset.detailPanel='realtime';while(head.nextElementSibling&&head.nextElementSibling!==chart)realtime.appendChild(head.nextElementSibling);head.after(tabs);tabs.after(realtime);chart.classList.add('detail-panel');chart.dataset.detailPanel='history';chart.dataset.hours='1';chart.hidden=true;const title=chart.querySelector('.chart-head strong'),chartHead=chart.querySelector('.chart-head');chartHead.innerHTML='';chartHead.appendChild(title);const ranges=document.createElement('div');ranges.className='range-switch';ranges.innerHTML='<button class="range-button active" data-hours="1">1 час</button><button class="range-button" data-hours="24">24 часа</button><button class="range-button" data-hours="168">7 дней</button>';chartHead.appendChild(ranges);const tooltip=document.createElement('div');tooltip.className='chart-tooltip';chart.appendChild(tooltip);chart.querySelectorAll('canvas').forEach(setupChartHover);tabs.querySelectorAll('[data-detail-tab]').forEach(tab=>tab.onclick=()=>{tabs.querySelectorAll('.detail-tab').forEach(t=>t.classList.toggle('active',t===tab));realtime.hidden=tab.dataset.detailTab!=='realtime';chart.hidden=tab.dataset.detailTab!=='history';if(!chart.hidden)setTimeout(()=>showHistory(chart),20)});ranges.querySelectorAll('[data-hours]').forEach(button=>button.onclick=()=>{chart.dataset.hours=button.dataset.hours;ranges.querySelectorAll('.range-button').forEach(b=>b.classList.toggle('active',b===button));showHistory(chart)})})}
|
||
setupDetailTabs();
|
||
document.querySelectorAll('[data-open]').forEach(b=>b.onclick=()=>el(b.dataset.open).showModal());
|
||
document.querySelectorAll('dialog').forEach(d=>{d.querySelector('.close').onclick=()=>d.close();d.onclick=e=>{if(e.target===d)d.close()}});
|
||
document.querySelectorAll('[data-system-tab]').forEach(tab=>tab.onclick=()=>{document.querySelectorAll('[data-system-tab]').forEach(v=>v.classList.toggle('active',v===tab));document.querySelectorAll('[data-system-panel]').forEach(v=>v.hidden=v.dataset.systemPanel!==tab.dataset.systemTab)});
|
||
el('systemSort').onclick=()=>{systemSortNewest=!systemSortNewest;renderSystemHealth(currentSystemHealth)};
|
||
document.querySelectorAll('[data-agent-tab]').forEach(tab=>tab.onclick=()=>{document.querySelectorAll('[data-agent-tab]').forEach(v=>v.classList.toggle('active',v===tab));document.querySelectorAll('[data-agent-panel]').forEach(v=>v.hidden=v.dataset.agentPanel!==tab.dataset.agentTab);if(tab.dataset.agentTab==='history'&¤tDetailAgent)setTimeout(()=>loadAgentHistory(currentDetailAgent,Number(document.querySelector('[data-agent-hours].active')?.dataset.agentHours||24)),20)});
|
||
el('agentDetailDialog').onclick=event=>{if(event.target===el('agentDetailDialog')){el('agentDetailDialog').close();return}const button=event.target.closest('[data-agent-action]');if(button)queueAgentAction(button.dataset.agentAction,button.dataset.target||'')};document.querySelectorAll('[data-agent-hours]').forEach(button=>button.onclick=()=>{document.querySelectorAll('[data-agent-hours]').forEach(v=>v.classList.toggle('active',v===button));if(currentDetailAgent)loadAgentHistory(currentDetailAgent,Number(button.dataset.agentHours))});
|
||
document.querySelectorAll('[data-agent-list-tab]').forEach(tab=>tab.onclick=()=>{document.querySelectorAll('[data-agent-list-tab]').forEach(v=>v.classList.toggle('active',v===tab));document.querySelectorAll('[data-agent-list-panel]').forEach(v=>v.hidden=v.dataset.agentListPanel!==tab.dataset.agentListTab)});
|
||
el('agentList').onclick=event=>{const button=event.target.closest('[data-agent-detail]');if(!button)return;const agent=currentAgents.find(v=>v.id===button.dataset.agentDetail);if(agent)openAgentDetail(agent)};
|
||
el('agentProblemList').onclick=event=>{const button=event.target.closest('[data-agent-detail]');if(!button)return;const agent=currentAgents.find(v=>v.id===button.dataset.agentDetail);if(agent)openAgentDetail(agent)};
|
||
el('serviceList').onclick=event=>{const button=event.target.closest('[data-service-open]');if(!button)return;const service=currentServiceStatuses.find(v=>v.id===Number(button.dataset.serviceOpen));if(!service)return;const choices=(service.endpoints||[]).filter(v=>/^https?:\/\//.test(v.address));if(choices.length===1){window.open(choices[0].address,'_blank','noopener');return}if(!choices.length)return;el('openServiceTitle').textContent=service.name;el('openServiceChoices').innerHTML=choices.map(v=>'<a class="theme-option" href="'+safe(v.address)+'" target="_blank" rel="noopener">'+safe(v.label)+'<small style="display:block;color:var(--muted);margin-top:4px">'+safe(v.address)+'</small></a>').join('');el('openServiceDialog').showModal()};
|
||
document.querySelectorAll('[data-task-filter]').forEach(button=>button.onclick=()=>{currentTaskFilter=button.dataset.taskFilter;document.querySelectorAll('[data-task-filter]').forEach(v=>v.classList.toggle('active',v===button));renderTasks(currentTasks,currentTaskFilter);explainWarningBadges()});
|
||
el('backupList').onclick=async event=>{const button=event.target.closest('[data-verify-backup]');if(!button)return;const target=el('backupList').querySelector('[data-backup-result="'+CSS.escape(button.dataset.verifyBackup)+'"]');button.disabled=true;target.textContent='Проверяем метаданные и читаемость…';try{const response=await fetch('/api/backups/verify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({volid:button.dataset.verifyBackup})}),result=await response.json();target.textContent=(result.ok?'✓ ':'⚠ ')+result.message;target.className='backup-check '+(result.ok?'status-up':'status-down')}catch(e){target.textContent='Не удалось выполнить проверку.'}finally{button.disabled=false}};
|
||
el('alertList').onclick=async event=>{const button=event.target.closest('[data-ack]');if(!button)return;button.disabled=true;const response=await fetch('/api/alerts/acknowledge',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({eventId:Number(button.dataset.ack)})});if(response.ok)button.closest('.alert-item').remove();else button.disabled=false};
|
||
async function loadAlertHistory(){const date=el('alertHistoryDate').value,response=await fetch('/api/alerts/history'+(date?'?date='+encodeURIComponent(date):''));if(!response.ok)return;const events=await response.json()||[];el('alertHistory').innerHTML=events.length?events.map(a=>{const state=a.planned?'Плановые работы · '+safe(a.maintenanceReason||'Причина не указана')+(a.resolvedAt?' · завершено '+new Date(a.resolvedAt*1000).toLocaleString('ru-RU'):''):a.acknowledgedAt?'Принято · '+new Date(a.acknowledgedAt*1000).toLocaleString('ru-RU'):a.resolvedAt?'Завершено · '+new Date(a.resolvedAt*1000).toLocaleString('ru-RU'):'Активно';return '<article class="alert-item '+safe(a.severity)+'"><i class="alert-icon"></i><div><div class="alert-source">'+safe(a.source)+' · '+new Date(a.firstSeen*1000).toLocaleString('ru-RU')+'</div><div class="alert-title">'+safe(a.title)+'</div><div class="alert-message">'+safe(a.message)+'</div><span class="history-state">'+state+'</span></div></article>'}).join(''):'<div class="all-good"><strong>За выбранную дату событий нет</strong><span>Выберите другую дату или покажите последние события.</span></div>'}
|
||
el('alertHistoryDate').onchange=loadAlertHistory;el('alertHistoryAll').onclick=()=>{el('alertHistoryDate').value='';loadAlertHistory()};
|
||
document.querySelectorAll('[data-alert-tab]').forEach(tab=>tab.onclick=()=>{document.querySelectorAll('[data-alert-tab]').forEach(t=>t.classList.toggle('active',t===tab));document.querySelectorAll('[data-alert-panel]').forEach(p=>p.hidden=p.dataset.alertPanel!==tab.dataset.alertTab);if(tab.dataset.alertTab==='history')loadAlertHistory()});
|
||
const cardNames=['alerts','cpu','memory','storage','network','zfs','ups','monitor','guests','agents','maintenance','backups','services','system','snapshots','tasks','map','diagnostics'],cardLabels={alerts:'Центр уведомлений',cpu:'CPU',memory:'Память',storage:'Диски',network:'Сеть',zfs:'ZFS',ups:'UPS',monitor:'Самоконтроль',guests:'VM и LXC',agents:'Агенты VM и LXC',maintenance:'Proxmox',backups:'Резервные копии',services:'Сервисы',system:'Система и ядро',snapshots:'Snapshots',tasks:'Задачи Proxmox',map:'Карта homelab',diagnostics:'Диагностика homelab'};let hiddenCards=[],cardOrder=[];try{hiddenCards=JSON.parse(localStorage.getItem('proxmox-hidden-cards')||'[]');cardOrder=JSON.parse(localStorage.getItem('proxmox-card-order')||'[]')}catch(e){}cardOrder=[...cardOrder.filter(x=>cardNames.includes(x)),...cardNames.filter(x=>!cardOrder.includes(x))];
|
||
function renderCardOrder(){el('cardOrder').innerHTML=cardOrder.map(name=>'<div class="order-item" draggable="true" data-order-card="'+name+'"><span class="order-handle">⠿</span><span>'+cardLabels[name]+'</span><button class="order-button" data-move="up" aria-label="Выше">↑</button><button class="order-button" data-move="down" aria-label="Ниже">↓</button></div>').join('')}
|
||
function saveCardOrder(){localStorage.setItem('proxmox-card-order',JSON.stringify(cardOrder));applyCardSettings();renderCardOrder()}
|
||
function applyCardSettings(){const container=document.querySelector('.cards');cardOrder.forEach(name=>{const card=document.querySelector('[data-card="'+name+'"]');if(card)container.appendChild(card)});cardNames.forEach(name=>{const visible=!hiddenCards.includes(name),card=document.querySelector('[data-card="'+name+'"]'),toggle=document.querySelector('[data-card-toggle="'+name+'"]');if(card)card.hidden=!visible;if(toggle)toggle.checked=visible})}
|
||
document.querySelectorAll('[data-card-toggle]').forEach(toggle=>toggle.onchange=()=>{const name=toggle.dataset.cardToggle;hiddenCards=toggle.checked?hiddenCards.filter(v=>v!==name):[...new Set([...hiddenCards,name])];localStorage.setItem('proxmox-hidden-cards',JSON.stringify(hiddenCards));applyCardSettings()});renderCardOrder();applyCardSettings();
|
||
el('cardOrder').onclick=event=>{const button=event.target.closest('[data-move]'),item=event.target.closest('[data-order-card]');if(!button||!item)return;const index=cardOrder.indexOf(item.dataset.orderCard),next=button.dataset.move==='up'?index-1:index+1;if(next<0||next>=cardOrder.length)return;[cardOrder[index],cardOrder[next]]=[cardOrder[next],cardOrder[index]];saveCardOrder()};let draggedCard='';el('cardOrder').addEventListener('dragstart',event=>{const item=event.target.closest('[data-order-card]');if(!item)return;draggedCard=item.dataset.orderCard;item.classList.add('dragging')});el('cardOrder').addEventListener('dragend',event=>{event.target.closest('[data-order-card]')?.classList.remove('dragging');draggedCard=''});el('cardOrder').addEventListener('dragover',event=>event.preventDefault());el('cardOrder').addEventListener('drop',event=>{event.preventDefault();const target=event.target.closest('[data-order-card]');if(!target||!draggedCard||target.dataset.orderCard===draggedCard)return;const from=cardOrder.indexOf(draggedCard),to=cardOrder.indexOf(target.dataset.orderCard);cardOrder.splice(from,1);cardOrder.splice(to,0,draggedCard);saveCardOrder()});
|
||
function setTheme(theme){document.documentElement.dataset.theme=theme;localStorage.setItem('proxmox-theme',theme);document.querySelectorAll('[data-theme-choice]').forEach(b=>b.classList.toggle('active',b.dataset.themeChoice===theme));document.querySelectorAll('.chart-section:not([hidden])').forEach(showHistory)}document.querySelectorAll('[data-theme-choice]').forEach(b=>b.onclick=()=>setTheme(b.dataset.themeChoice));setTheme(document.documentElement.dataset.theme);el('settingsButton').onclick=()=>el('settingsDialog').showModal();
|
||
['services','agents','maintenance-mode','ups'].forEach((name,index)=>{const panel=document.querySelector('[data-settings-panel="'+name+'"]');if(panel){panel.dataset.managementPanel=name;panel.hidden=index!==0;el('managementPanels').appendChild(panel)}});
|
||
function selectManagement(tab){document.querySelectorAll('[data-management-tab]').forEach(t=>t.classList.toggle('active',t===tab));document.querySelectorAll('[data-management-panel]').forEach(p=>p.hidden=p.dataset.managementPanel!==tab.dataset.managementTab)}el('managementButton').onclick=()=>{selectManagement(document.querySelector('[data-management-tab].active')||document.querySelector('[data-management-tab]'));el('managementDialog').showModal()};document.querySelectorAll('[data-management-tab]').forEach(tab=>tab.onclick=()=>selectManagement(tab));
|
||
document.querySelectorAll('[data-settings-tab]').forEach(tab=>tab.onclick=()=>{document.querySelectorAll('[data-settings-tab]').forEach(t=>t.classList.toggle('active',t===tab));document.querySelectorAll('[data-settings-panel]').forEach(p=>p.hidden=p.dataset.settingsPanel!==tab.dataset.settingsTab)});
|
||
el('agentServerURL').value=location.origin;
|
||
el('createAgentToken').onclick=async()=>{const button=el('createAgentToken'),server=el('agentServerURL').value.trim().replace(/\/$/,''),vmid=Number(el('agentVMID').value)||0;if(!/^https?:\/\//.test(server)){alert('Укажите полный адрес Dashboard, доступный из VM/LXC.');return}button.disabled=true;button.textContent='Создаём токен…';try{const response=await fetch('/api/agents/enrollment-token',{method:'POST'});if(!response.ok)throw new Error(await response.text());const value=await response.json(),quote=v=>"'"+String(v).replaceAll("'","'\"'\"'")+"'";el('agentInstallCommand').textContent='curl -fsSL https://git.myown.center/maxim/ProxmoxDash/raw/branch/main/scripts/install-agent.sh | DASHBOARD_URL='+quote(server)+' ENROLL_TOKEN='+quote(value.token)+' VMID='+quote(vmid)+' sh';el('agentInstallBlock').hidden=false}catch(e){alert('Не удалось создать токен: '+e.message)}finally{button.disabled=false;button.textContent='Создать команду установки или обновления'}};
|
||
el('agentSettingsList').onclick=async event=>{const detail=event.target.closest('[data-agent-detail]');if(detail){const agent=currentAgents.find(v=>v.id===detail.dataset.agentDetail);if(agent)openAgentDetail(agent);return}const button=event.target.closest('[data-agent-delete]');if(!button||!confirm('Удалить агент? Для повторного подключения потребуется новый токен.'))return;button.disabled=true;const response=await fetch('/api/agents?id='+encodeURIComponent(button.dataset.agentDelete),{method:'DELETE'});if(response.ok)button.closest('.disk-item').remove();else{button.disabled=false;alert(await response.text())}};
|
||
async function loadAgentActions(){const response=await fetch('/api/agent-actions');if(!response.ok)return;const items=await response.json()||[];el('agentActionLog').innerHTML=items.length?items.map(v=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.agentName)+' · '+safe(v.action)+'</div><div class="disk-meta">'+safe(v.target||'без цели')+' · '+new Date(v.requestedAt*1000).toLocaleString('ru-RU')+'</div></div><span class="badge '+(v.status==='failed'?'bad':v.status==='completed'?'':'warn')+'">'+safe(v.status)+'</span></div>'+(v.result?'<div class="settings-note">'+safe(v.result)+'</div>':'')+'</article>').join(''):'<span class="empty">Действий пока не было.</span>'}loadAgentActions().catch(()=>{});
|
||
async function loadThresholds(){const response=await fetch('/api/settings/alerts');if(!response.ok)return;alertSettings=await response.json();document.querySelectorAll('[data-threshold]').forEach(input=>input.value=alertSettings[input.dataset.threshold]??'')}
|
||
el('saveThresholds').onclick=async()=>{const values={};document.querySelectorAll('[data-threshold]').forEach(input=>values[input.dataset.threshold]=input.dataset.threshold==='diskIoExcluded'?input.value:Number(input.value));const status=el('thresholdStatus');status.textContent='Сохранение…';const response=await fetch('/api/settings/alerts',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(values)});if(response.ok){alertSettings=await response.json();status.textContent='Пороги сохранены и уже применяются.'}else{status.textContent=await response.text()||'Не удалось сохранить настройки.'}};loadThresholds().catch(()=>{});
|
||
let configuredServices=[];
|
||
async function loadServices(){const response=await fetch('/api/services');if(!response.ok)return;configuredServices=await response.json()||[];el('serviceSettingsList').innerHTML=configuredServices.length?configuredServices.map(v=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.name)+'</div><div class="disk-meta">'+safe([v.url&&('Внешний: '+v.url),v.localAddress&&('Локальный: '+v.localAddress)].filter(Boolean).join(' · '))+'</div></div><span class="badge '+(v.enabled?'':'warn')+'">'+(v.enabled?'Включён':'Выключен')+'</span></div><div class="service-actions"><button data-service-edit="'+v.id+'">Изменить</button><button data-service-delete="'+v.id+'">Удалить</button></div></article>').join(''):'<span class="empty">Список пуст.</span>'}
|
||
function refreshServiceLinkOptions(){const serviceValue=el('linkService').value,agentValue=el('linkAgent').value;el('linkService').innerHTML=configuredServices.map(v=>'<option value="'+v.id+'">'+safe(v.name)+'</option>').join('');el('linkAgent').innerHTML='<option value="">Выберите агент</option>'+currentAgents.map(v=>'<option value="'+safe(v.id)+'">'+safe(v.name)+' · VMID '+v.vmid+'</option>').join('');if(serviceValue)el('linkService').value=serviceValue;if(agentValue)el('linkAgent').value=agentValue}
|
||
async function loadServiceLinks(){const response=await fetch('/api/service-links');if(!response.ok)return;currentServiceLinks=await response.json()||[];refreshServiceLinkOptions();showSelectedServiceLink()}
|
||
function showSelectedServiceLink(){const id=Number(el('linkService').value),link=currentServiceLinks.find(v=>v.serviceId===id);el('linkAgent').value=link?.agentId||'';el('linkType').value=link?.componentType||'';el('linkComponent').value=link?.componentName||'';el('linkPort').value=link?.port||'';el('serviceLinkStatus').textContent=link?'Связь сохранена и имеет приоритет над автоматической.':'Для сервиса используется автоматическое сопоставление.'}
|
||
el('linkService').onchange=showSelectedServiceLink;el('saveServiceLink').onclick=async()=>{const agent=currentAgents.find(v=>v.id===el('linkAgent').value),value={serviceId:Number(el('linkService').value),agentId:el('linkAgent').value,vmid:agent?.vmid||0,componentType:el('linkType').value,componentName:el('linkComponent').value.trim(),port:Number(el('linkPort').value)||0},response=await fetch('/api/service-links',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(value)});el('serviceLinkStatus').textContent=response.ok?'Ручная связь сохранена.':await response.text();if(response.ok)loadServiceLinks()};el('deleteServiceLink').onclick=async()=>{const id=Number(el('linkService').value);if(!id||!confirm('Удалить ручную связь?'))return;const response=await fetch('/api/service-links?serviceId='+id,{method:'DELETE'});if(response.ok)loadServiceLinks()};
|
||
function resetServiceForm(){el('serviceId').value='';el('serviceName').value='';el('serviceType').value='http';el('serviceURL').value='';el('serviceLocal').value='';el('serviceCode').value=200;el('serviceText').value='';el('serviceTimeout').value=5;el('serviceEnabled').checked=true}
|
||
el('saveService').onclick=async()=>{const value={id:Number(el('serviceId').value)||0,name:el('serviceName').value.trim(),checkType:el('serviceType').value,url:el('serviceURL').value.trim(),localAddress:el('serviceLocal').value.trim(),expectedCode:Number(el('serviceCode').value)||200,expectedText:el('serviceText').value,timeoutSeconds:Number(el('serviceTimeout').value)||5,enabled:el('serviceEnabled').checked},status=el('serviceStatus');status.textContent='Сохранение…';const response=await fetch('/api/services',{method:value.id?'PUT':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(value)});status.textContent=response.ok?'Сервис сохранён. Первая проверка появится в течение 15 секунд.':await response.text();if(response.ok){resetServiceForm();loadServices()}};
|
||
el('serviceSettingsList').onclick=async event=>{const edit=event.target.closest('[data-service-edit]'),del=event.target.closest('[data-service-delete]');if(edit){const v=configuredServices.find(x=>x.id===Number(edit.dataset.serviceEdit));if(!v)return;el('serviceId').value=v.id;el('serviceName').value=v.name;el('serviceType').value=v.checkType;el('serviceURL').value=v.url;el('serviceLocal').value=v.localAddress||'';el('serviceCode').value=v.expectedCode;el('serviceText').value=v.expectedText||'';el('serviceTimeout').value=v.timeoutSeconds;el('serviceEnabled').checked=v.enabled;return}if(del&&confirm('Удалить сервис и его историю?')){const response=await fetch('/api/services?id='+del.dataset.serviceDelete,{method:'DELETE'});if(response.ok)loadServices()}};loadServices().then(()=>{maintenanceTargetSignature='';renderMaintenanceTargets();return loadServiceLinks()}).catch(()=>{});
|
||
el('maintenanceAll').onchange=toggleMaintenanceTargets;
|
||
async function loadMaintenance(){const response=await fetch('/api/settings/maintenance');if(!response.ok)return;applyMaintenanceState(await response.json())}
|
||
el('startMaintenance').onclick=async()=>{const duration=Number(el('maintenanceDuration').value),value={endsAt:Math.floor(Date.now()/1000)+duration*60,reason:el('maintenanceReason').value.trim(),allAlerts:el('maintenanceAll').checked,serviceIds:[...document.querySelectorAll('[data-maintenance-service]:checked')].map(v=>Number(v.dataset.maintenanceService)),vmids:[...document.querySelectorAll('[data-maintenance-vm]:checked')].map(v=>Number(v.dataset.maintenanceVm))},status=el('maintenanceStatus');status.textContent='Включаем режим…';const response=await fetch('/api/settings/maintenance',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(value)});if(response.ok){maintenanceTargetSignature='';applyMaintenanceState(await response.json())}else status.textContent=await response.text()};
|
||
el('stopMaintenance').onclick=async()=>{const response=await fetch('/api/settings/maintenance',{method:'DELETE'});if(response.ok){maintenanceTargetSignature='';applyMaintenanceState(await response.json())}else el('maintenanceStatus').textContent=await response.text()};loadMaintenance().catch(()=>{});
|
||
function upsShutdownForm(){return {enabled:el('upsShutdownEnabled').checked,chargePercent:Number(el('upsShutdownCharge').value),runtimeSeconds:Number(el('upsShutdownRuntime').value),graceSeconds:Number(el('upsShutdownGrace').value),guestOrder:el('upsShutdownOrder').value.split(',').map(v=>Number(v.trim())).filter(v=>v>0)}}
|
||
async function loadUPSShutdown(){const response=await fetch('/api/settings/ups-shutdown');if(!response.ok)return;const s=await response.json();el('upsShutdownEnabled').checked=!!s.enabled;el('upsShutdownCharge').value=s.chargePercent||15;el('upsShutdownRuntime').value=s.runtimeSeconds||600;el('upsShutdownGrace').value=s.graceSeconds||120;el('upsShutdownOrder').value=(s.guestOrder||[]).join(', ')}
|
||
el('saveUPSShutdown').onclick=async()=>{const status=el('upsShutdownStatus');status.textContent='Сохранение…';const response=await fetch('/api/settings/ups-shutdown',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(upsShutdownForm())});status.textContent=response.ok?(upsShutdownForm().enabled?'Сценарий сохранён и разрешён.':'Сценарий сохранён, автоматическое выполнение отключено.'):await response.text()};
|
||
el('dryRunUPSShutdown').onclick=async()=>{const saved=await fetch('/api/settings/ups-shutdown',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(upsShutdownForm())});if(!saved.ok){el('upsShutdownStatus').textContent=await saved.text();return}const response=await fetch('/api/ups/shutdown-dry-run',{method:'POST'}),steps=await response.json();el('upsShutdownPlan').innerHTML=(steps||[]).map(v=>'<div class="shutdown-step"><b>'+v.order+'. '+safe(v.target)+'</b><div class="disk-meta">'+safe(v.action)+' · '+safe(v.detail)+'</div></div>').join('');el('upsShutdownStatus').textContent='Dry-run завершён: ни одна VM и хост не были остановлены.'};loadUPSShutdown().catch(()=>{});
|
||
function emailForm(){return {enabled:el('emailEnabled').checked,skipTlsVerify:el('emailSkipTLS').checked,host:el('emailHost').value.trim(),port:Number(el('emailPort').value),username:el('emailUsername').value.trim(),password:el('emailPassword').value,from:el('emailFrom').value.trim(),to:el('emailTo').value.trim(),intervalMinutes:Number(el('emailInterval').value),severities:[...document.querySelectorAll('[data-email-severity]:checked')].map(x=>x.dataset.emailSeverity),sources:[...document.querySelectorAll('[data-email-source]:checked')].map(x=>x.dataset.emailSource)}}
|
||
async function loadEmail(){const response=await fetch('/api/settings/email');if(!response.ok)return;const s=await response.json();el('emailEnabled').checked=s.enabled;el('emailSkipTLS').checked=!!s.skipTlsVerify;el('emailHost').value=s.host||'';el('emailPort').value=s.port||587;el('emailUsername').value=s.username||'';el('emailPassword').value='';el('emailPassword').placeholder=s.hasPassword?'Сохранён · не изменять':'Пароль';el('emailFrom').value=s.from||'';el('emailTo').value=s.to||'';el('emailInterval').value=s.intervalMinutes||60;document.querySelectorAll('[data-email-severity]').forEach(x=>x.checked=(s.severities||[]).includes(x.dataset.emailSeverity));document.querySelectorAll('[data-email-source]').forEach(x=>x.checked=(s.sources||[]).includes(x.dataset.emailSource))}
|
||
el('saveEmail').onclick=async()=>{const status=el('emailStatus');status.textContent='Сохранение…';const response=await fetch('/api/settings/email',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(emailForm())});status.textContent=response.ok?'Настройки сохранены.':await response.text();if(response.ok)loadEmail()};
|
||
el('testEmail').onclick=async()=>{const status=el('emailStatus');status.textContent='Отправляем тестовое письмо…';const saved=await fetch('/api/settings/email',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(emailForm())});if(!saved.ok){status.textContent=await saved.text();return}const response=await fetch('/api/settings/email/test',{method:'POST'});status.textContent=response.ok?'Тестовое письмо отправлено.':await response.text()};loadEmail().catch(()=>{});
|
||
el('installUpdate').onclick=async()=>{const button=el('installUpdate');button.disabled=true;button.textContent='Ставим в очередь…';el('updateMessage').textContent='Создаём запрос на обновление…';try{const response=await fetch('/api/update/request',{method:'POST'});if(!response.ok)throw new Error(await response.text());const status=await response.json();el('updateMessage').textContent=status.message;button.textContent='Обновление выполняется…'}catch(e){button.disabled=false;button.textContent='Попробовать ещё раз';el('updateMessage').textContent='Не удалось запустить обновление.'}};
|
||
const events=new EventSource('/api/events');events.onmessage=e=>render(JSON.parse(e.data));events.onerror=()=>{el('dot').classList.remove('live');el('status').textContent='Переподключение…'};
|
||
setInterval(()=>{if(lastDashboardEvent&&Date.now()-lastDashboardEvent>15000){el('dot').classList.remove('live');el('status').textContent='Данные устарели · переподключение…';el('monitorState').textContent='STALE';el('monitorBar').style.width='100%'}},3000);
|
||
</script></body></html>`
|