7 Commits

Author SHA1 Message Date
Maxim
f1ff15e344 Add read-only guest monitoring agents 2026-08-06 20:24:13 +03:00
Maxim
5401b5b0f2 Add maintenance windows for planned work 2026-08-06 19:35:33 +03:00
Maxim
c01b4b6630 Fix service monitor connection leak 2026-08-05 20:04:29 +03:00
Maxim
a14001b373 Treat historical SMART failures as resolved 2026-08-03 21:17:15 +03:00
Maxim
05450bc949 Improve system health sorting and dashboard spacing 2026-08-03 21:09:30 +03:00
Maxim
8241fba6f4 Add IO attribution anomaly detection and alert correlation 2026-08-03 18:02:13 +03:00
Maxim
98afa2517b Add per-disk IO performance monitoring 2026-08-03 17:32:28 +03:00
22 changed files with 1758 additions and 139 deletions

View File

@@ -121,6 +121,13 @@ ss -lntp | grep 9105
Панель показывает:
- изменение критичных SMART-счётчиков, результаты self-test и исчезновение дисков;
- realtime read/write IOPS, MB/s, latency, utilization и глубину очереди каждого диска;
- недельную историю IOPS/utilization и алерты при устойчивом I/O-давлении;
- вероятный источник I/O: VM/LXC, процесс хоста или активная backup/snapshot/migration-задача;
- отдельные пороги HDD, SATA SSD и NVMe с исключениями по имени или серийному номеру;
- недельные baseline-аномалии IOPS, latency, температуры, RAM, ZFS и длительности backup;
- корреляцию связанных событий в одну причинно-следственную цепочку;
- время первого появления и последнего наблюдения каждого уведомления;
- автоматический короткий SMART self-test раз в неделю для дисков без свежего теста;
- datasets, vdev-классы, ошибки read/write/checksum, scrub и ARC ZFS;
- доступность шлюза, DNS и интернета, packet loss, latency и внешний IP;
@@ -315,6 +322,34 @@ apcaccess status
- `GET /api/events` — поток метрик SSE с интервалом две секунды.
- `GET/POST/PUT/DELETE /api/services` — управление проверками сервисов.
- `GET /api/services/history` — семидневная история доступности и отклика.
- `GET/PUT/DELETE /api/settings/maintenance` — состояние, запуск и досрочное завершение плановых работ.
- `GET/DELETE /api/agents` — подключённые гостевые агенты.
- `POST /api/agents/enrollment-token` — одноразовый токен регистрации на 15 минут.
## Maintenance-режим
Во вкладке `Настройки → Maintenance` можно на заданное время отключить все
алерты либо выбрать конкретные сервисы и VM/LXC. Подавленные события не
отправляются по почте и не отображаются как активные, но сохраняются в истории
с причиной плановых работ. После окончания окна обычный мониторинг включается
автоматически.
## Агенты VM и LXC
Read-only агент показывает состояние Linux внутри VM/LXC: load average, память,
корневой раздел, uptime, версию агента, Docker-контейнеры, health-check и число
перезапусков. Агент сам подключается к Dashboard каждые 10 секунд; открывать
входящий порт внутри гостевой системы не нужно.
Откройте `Настройки → Агенты`, укажите адрес Dashboard, доступный из VM/LXC, и
VMID. Кнопка создаст готовую команду установки. Одноразовый токен действует 15
минут и после регистрации заменяется индивидуальным секретом агента.
В первой версии агент не выполняет команды и не перезапускает службы или
контейнеры. Для чтения Docker ему требуется доступ к `/var/run/docker.sock`,
поэтому служба устанавливается от root. Сам Dashboard пока не имеет
авторизации: используйте локальную сеть или HTTPS через доверенный reverse
proxy и не публикуйте агентские API напрямую в интернет.
## Сборка

View File

@@ -57,7 +57,7 @@ func (c *activityCollector) collect(guests GuestsMetrics) ProxmoxActivity {
value := c.value
c.mu.RUnlock()
c.mu.Lock()
if !c.refreshing && time.Since(time.Unix(value.CheckedAt, 0)) > 10*time.Minute {
if !c.refreshing && time.Since(time.Unix(value.CheckedAt, 0)) > 15*time.Second {
c.refreshing = true
go c.refreshWithGuests(guests)
}

457
agent.go Normal file
View File

@@ -0,0 +1,457 @@
package main
import (
"bytes"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"syscall"
"time"
)
const agentOfflineAfter = 45 * time.Second
type agentOptions struct {
Server, EnrollmentToken, Name, ConfigPath string
VMID int
}
type agentConfig struct {
Server string `json:"server"`
ID string `json:"id"`
Secret string `json:"secret"`
Name string `json:"name"`
VMID int `json:"vmid"`
}
type AgentContainer struct {
ID string `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
State string `json:"state"`
Status string `json:"status"`
Health string `json:"health,omitempty"`
Restarts int64 `json:"restarts"`
CPUPercent float64 `json:"cpuPercent"`
MemoryUsage uint64 `json:"memoryUsage"`
Ports []string `json:"ports,omitempty"`
}
type AgentReport struct {
Hostname string `json:"hostname"`
MachineID string `json:"machineId"`
Version string `json:"version"`
OS string `json:"os"`
Arch string `json:"arch"`
UptimeSeconds int64 `json:"uptimeSeconds"`
Load1 float64 `json:"load1"`
CPUPercent float64 `json:"cpuPercent"`
MemoryTotal uint64 `json:"memoryTotal"`
MemoryUsed uint64 `json:"memoryUsed"`
RootTotal uint64 `json:"rootTotal"`
RootUsed uint64 `json:"rootUsed"`
DockerAvailable bool `json:"dockerAvailable"`
DockerError string `json:"dockerError,omitempty"`
Containers []AgentContainer `json:"containers"`
CollectedAt int64 `json:"collectedAt"`
}
type ManagedAgent struct {
ID string `json:"id"`
Name string `json:"name"`
VMID int `json:"vmid"`
Hostname string `json:"hostname"`
Version string `json:"version"`
LastSeen int64 `json:"lastSeen"`
EnrolledAt int64 `json:"enrolledAt"`
Online bool `json:"online"`
RemoteAddress string `json:"remoteAddress"`
Report AgentReport `json:"report"`
}
type agentEnrollmentRequest struct {
Token, Name, Hostname, MachineID string
VMID int
}
func randomHex(bytesCount int) (string, error) {
b := make([]byte, bytesCount)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func tokenHash(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
func (s *Store) CreateAgentEnrollmentToken() (string, error) {
token, err := randomHex(24)
if err != nil {
return "", err
}
_, err = s.db.Exec(`INSERT INTO agent_enrollment_tokens(token_hash,created_at,expires_at) VALUES(?,?,?)`, tokenHash(token), time.Now().Unix(), time.Now().Add(15*time.Minute).Unix())
return token, err
}
func (s *Store) EnrollAgent(req agentEnrollmentRequest) (agentConfig, error) {
tx, err := s.db.Begin()
if err != nil {
return agentConfig{}, err
}
defer tx.Rollback()
var expires int64
if err = tx.QueryRow(`SELECT expires_at FROM agent_enrollment_tokens WHERE token_hash=? AND used_at IS NULL`, tokenHash(req.Token)).Scan(&expires); err != nil || expires < time.Now().Unix() {
return agentConfig{}, errors.New("одноразовый токен недействителен или истёк")
}
id, err := randomHex(12)
if err != nil {
return agentConfig{}, err
}
secret, err := randomHex(32)
if err != nil {
return agentConfig{}, err
}
name := strings.TrimSpace(req.Name)
if name == "" {
name = req.Hostname
}
now := time.Now().Unix()
if _, err = tx.Exec(`INSERT INTO agents(id,name,vmid,hostname,machine_id,secret_hash,enrolled_at,last_seen,report_json,remote_address) VALUES(?,?,?,?,?,?,?,?,?,?)`, id, name, req.VMID, req.Hostname, req.MachineID, tokenHash(secret), now, now, `{}`, ""); err != nil {
return agentConfig{}, err
}
if _, err = tx.Exec(`UPDATE agent_enrollment_tokens SET used_at=? WHERE token_hash=?`, now, tokenHash(req.Token)); err != nil {
return agentConfig{}, err
}
if err = tx.Commit(); err != nil {
return agentConfig{}, err
}
return agentConfig{ID: id, Secret: secret, Name: name, VMID: req.VMID}, nil
}
func (s *Store) SaveAgentReport(id, secret, remote string, report AgentReport) error {
var expected string
if s.db.QueryRow(`SELECT secret_hash FROM agents WHERE id=?`, id).Scan(&expected) != nil || !hmac.Equal([]byte(expected), []byte(tokenHash(secret))) {
return errors.New("агент не авторизован")
}
data, _ := json.Marshal(report)
_, err := s.db.Exec(`UPDATE agents SET hostname=?,version=?,last_seen=?,report_json=?,remote_address=? WHERE id=?`, report.Hostname, report.Version, time.Now().Unix(), string(data), remote, id)
return err
}
func (s *Store) Agents() ([]ManagedAgent, error) {
rows, err := s.db.Query(`SELECT id,name,vmid,hostname,version,enrolled_at,last_seen,report_json,remote_address FROM agents ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
result := []ManagedAgent{}
for rows.Next() {
var a ManagedAgent
var raw string
if err = rows.Scan(&a.ID, &a.Name, &a.VMID, &a.Hostname, &a.Version, &a.EnrolledAt, &a.LastSeen, &raw, &a.RemoteAddress); err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(raw), &a.Report)
a.Online = time.Since(time.Unix(a.LastSeen, 0)) < agentOfflineAfter
result = append(result, a)
}
return result, rows.Err()
}
func (s *Store) DeleteAgent(id string) error {
_, err := s.db.Exec(`DELETE FROM agents WHERE id=?`, id)
return err
}
func runGuestAgent(opts agentOptions) error {
config, err := loadAgentConfig(opts.ConfigPath)
if err != nil && !os.IsNotExist(err) {
return err
}
if opts.Server != "" {
config.Server = strings.TrimRight(opts.Server, "/")
}
if opts.Name != "" {
config.Name = opts.Name
}
if opts.VMID > 0 {
config.VMID = opts.VMID
}
if config.Server == "" {
return errors.New("укажите --agent-server")
}
if config.ID == "" {
if opts.EnrollmentToken == "" {
return errors.New("для первого запуска нужен --agent-enroll-token")
}
config, err = enrollRemote(config, opts.EnrollmentToken)
if err != nil {
return err
}
if err = saveAgentConfig(opts.ConfigPath, config); err != nil {
return err
}
}
client := &http.Client{Timeout: 12 * time.Second}
for {
report := collectAgentReport()
data, _ := json.Marshal(report)
req, _ := http.NewRequest(http.MethodPost, config.Server+"/api/agent/report", bytes.NewReader(data))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+config.ID+"."+config.Secret)
response, requestErr := client.Do(req)
if requestErr == nil {
io.Copy(io.Discard, response.Body)
response.Body.Close()
if response.StatusCode >= 300 {
requestErr = fmt.Errorf("сервер вернул %s", response.Status)
}
}
if requestErr != nil {
fmt.Fprintf(os.Stderr, "agent: %v\n", requestErr)
}
time.Sleep(10 * time.Second)
}
}
func enrollRemote(config agentConfig, token string) (agentConfig, error) {
hostname, _ := os.Hostname()
machineID, _ := os.ReadFile("/etc/machine-id")
payload := agentEnrollmentRequest{Token: token, Name: config.Name, VMID: config.VMID, Hostname: hostname, MachineID: strings.TrimSpace(string(machineID))}
data, _ := json.Marshal(payload)
response, err := http.Post(config.Server+"/api/agent/enroll", "application/json", bytes.NewReader(data))
if err != nil {
return config, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
message, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
return config, fmt.Errorf("регистрация: %s", strings.TrimSpace(string(message)))
}
var credentials agentConfig
if err = json.NewDecoder(response.Body).Decode(&credentials); err != nil {
return config, err
}
credentials.Server = config.Server
return credentials, nil
}
func loadAgentConfig(path string) (agentConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return agentConfig{}, err
}
var c agentConfig
err = json.Unmarshal(data, &c)
return c, err
}
func saveAgentConfig(path string, c agentConfig) error {
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
data, _ := json.MarshalIndent(c, "", " ")
return os.WriteFile(path, data, 0600)
}
func collectAgentReport() AgentReport {
hostname, _ := os.Hostname()
machineID, _ := os.ReadFile("/etc/machine-id")
report := AgentReport{Hostname: hostname, MachineID: strings.TrimSpace(string(machineID)), Version: version, OS: runtime.GOOS, Arch: runtime.GOARCH, CollectedAt: time.Now().Unix()}
if data, err := os.ReadFile("/proc/uptime"); err == nil {
fields := strings.Fields(string(data))
if len(fields) > 0 {
value, _ := strconv.ParseFloat(fields[0], 64)
report.UptimeSeconds = int64(value)
}
}
if data, err := os.ReadFile("/proc/loadavg"); err == nil {
fields := strings.Fields(string(data))
if len(fields) > 0 {
report.Load1, _ = strconv.ParseFloat(fields[0], 64)
}
}
report.CPUPercent = sampleAgentCPU()
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
values := map[string]uint64{}
for _, line := range strings.Split(string(data), "\n") {
f := strings.Fields(line)
if len(f) >= 2 {
v, _ := strconv.ParseUint(f[1], 10, 64)
values[strings.TrimSuffix(f[0], ":")] = v * 1024
}
}
report.MemoryTotal = values["MemTotal"]
available := values["MemAvailable"]
if report.MemoryTotal > available {
report.MemoryUsed = report.MemoryTotal - available
}
}
var stat syscall.Statfs_t
if syscall.Statfs("/", &stat) == nil {
report.RootTotal = stat.Blocks * uint64(stat.Bsize)
free := stat.Bavail * uint64(stat.Bsize)
if report.RootTotal > free {
report.RootUsed = report.RootTotal - free
}
}
report.Containers, report.DockerError = collectDockerContainers()
report.DockerAvailable = report.DockerError == ""
return report
}
func sampleAgentCPU() float64 {
total1, idle1 := readAgentCPU()
time.Sleep(100 * time.Millisecond)
total2, idle2 := readAgentCPU()
if total2 <= total1 {
return 0
}
totalDelta, idleDelta := total2-total1, idle2-idle1
return float64(totalDelta-idleDelta) / float64(totalDelta) * 100
}
func readAgentCPU() (uint64, uint64) {
data, err := os.ReadFile("/proc/stat")
if err != nil {
return 0, 0
}
line := strings.SplitN(string(data), "\n", 2)[0]
fields := strings.Fields(line)
if len(fields) < 5 {
return 0, 0
}
var total uint64
values := make([]uint64, 0, len(fields)-1)
for _, field := range fields[1:] {
v, _ := strconv.ParseUint(field, 10, 64)
values = append(values, v)
total += v
}
idle := values[3]
if len(values) > 4 {
idle += values[4]
}
return total, idle
}
func collectDockerContainers() ([]AgentContainer, string) {
if _, err := os.Stat("/var/run/docker.sock"); err != nil {
return []AgentContainer{}, "Docker socket недоступен"
}
transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", "/var/run/docker.sock")
}}
client := &http.Client{Transport: transport, Timeout: 5 * time.Second}
response, err := client.Get("http://docker/containers/json?all=1")
if err != nil {
return nil, err.Error()
}
defer response.Body.Close()
if response.StatusCode != 200 {
return nil, response.Status
}
var raw []struct {
ID, Image, State, Status string
Names []string
Ports []struct {
IP string
PrivatePort, PublicPort int
Type string
}
}
if err = json.NewDecoder(response.Body).Decode(&raw); err != nil {
return nil, err.Error()
}
result := make([]AgentContainer, 0, len(raw))
for _, v := range raw {
name := strings.TrimPrefix(firstString(v.Names), "/")
item := AgentContainer{ID: shortID(v.ID), Name: name, Image: v.Image, State: v.State, Status: v.Status}
for _, p := range v.Ports {
if p.PublicPort > 0 {
item.Ports = append(item.Ports, fmt.Sprintf("%d:%d/%s", p.PublicPort, p.PrivatePort, p.Type))
}
}
inspect, inspectErr := client.Get("http://docker/containers/" + v.ID + "/json")
if inspectErr == nil {
var detail struct {
RestartCount int64
State struct{ Health *struct{ Status string } }
}
if json.NewDecoder(inspect.Body).Decode(&detail) == nil {
item.Restarts = detail.RestartCount
if detail.State.Health != nil {
item.Health = detail.State.Health.Status
}
}
inspect.Body.Close()
}
statsResponse, statsErr := client.Get("http://docker/containers/" + v.ID + "/stats?stream=false")
if statsErr == nil {
var stats struct {
CPUStats struct {
CPUUsage struct {
TotalUsage uint64 `json:"total_usage"`
} `json:"cpu_usage"`
SystemCPUUsage uint64 `json:"system_cpu_usage"`
OnlineCPUs uint64 `json:"online_cpus"`
} `json:"cpu_stats"`
PreCPUStats struct {
CPUUsage struct {
TotalUsage uint64 `json:"total_usage"`
} `json:"cpu_usage"`
SystemCPUUsage uint64 `json:"system_cpu_usage"`
} `json:"precpu_stats"`
MemoryStats struct {
Usage uint64 `json:"usage"`
Stats map[string]uint64 `json:"stats"`
} `json:"memory_stats"`
}
if json.NewDecoder(statsResponse.Body).Decode(&stats) == nil {
cpuDelta := stats.CPUStats.CPUUsage.TotalUsage - stats.PreCPUStats.CPUUsage.TotalUsage
systemDelta := stats.CPUStats.SystemCPUUsage - stats.PreCPUStats.SystemCPUUsage
cpus := stats.CPUStats.OnlineCPUs
if cpus == 0 {
cpus = 1
}
if systemDelta > 0 {
item.CPUPercent = float64(cpuDelta) / float64(systemDelta) * float64(cpus) * 100
}
item.MemoryUsage = stats.MemoryStats.Usage
if cache := stats.MemoryStats.Stats["inactive_file"]; item.MemoryUsage > cache {
item.MemoryUsage -= cache
}
}
statsResponse.Body.Close()
}
result = append(result, item)
}
return result, ""
}
func firstString(v []string) string {
if len(v) > 0 {
return v[0]
}
return ""
}
func shortID(v string) string {
if len(v) > 12 {
return v[:12]
}
return v
}

57
agent_test.go Normal file
View File

@@ -0,0 +1,57 @@
package main
import (
"testing"
"time"
)
func TestAgentEnrollmentTokenIsSingleUse(t *testing.T) {
store, err := openStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
token, err := store.CreateAgentEnrollmentToken()
if err != nil {
t.Fatal(err)
}
credentials, err := store.EnrollAgent(agentEnrollmentRequest{Token: token, Name: "docker-01", Hostname: "vm105", VMID: 105})
if err != nil {
t.Fatal(err)
}
if credentials.ID == "" || credentials.Secret == "" {
t.Fatal("credentials were not generated")
}
if _, err = store.EnrollAgent(agentEnrollmentRequest{Token: token, Hostname: "duplicate"}); err == nil {
t.Fatal("token was accepted twice")
}
}
func TestAgentReportAuthenticationAndOfflineState(t *testing.T) {
store, err := openStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
token, _ := store.CreateAgentEnrollmentToken()
credentials, _ := store.EnrollAgent(agentEnrollmentRequest{Token: token, Name: "docker", Hostname: "vm", VMID: 101})
report := AgentReport{Hostname: "vm", Version: "test", DockerAvailable: true, Containers: []AgentContainer{{ID: "abc", Name: "web", State: "running"}}}
if err = store.SaveAgentReport(credentials.ID, "wrong", "127.0.0.1", report); err == nil {
t.Fatal("invalid secret accepted")
}
if err = store.SaveAgentReport(credentials.ID, credentials.Secret, "127.0.0.1", report); err != nil {
t.Fatal(err)
}
agents, err := store.Agents()
if err != nil {
t.Fatal(err)
}
if len(agents) != 1 || !agents[0].Online || len(agents[0].Report.Containers) != 1 {
t.Fatalf("unexpected agents: %#v", agents)
}
_, _ = store.db.Exec(`UPDATE agents SET last_seen=? WHERE id=?`, time.Now().Add(-time.Minute).Unix(), credentials.ID)
agents, _ = store.Agents()
if agents[0].Online {
t.Fatal("stale agent is online")
}
}

View File

@@ -6,16 +6,18 @@ import (
)
type AlertEvent struct {
EventID int64 `json:"eventId"`
ID string `json:"id"`
Severity string `json:"severity"`
Source string `json:"source"`
Title string `json:"title"`
Message string `json:"message"`
FirstSeen int64 `json:"firstSeen"`
LastSeen int64 `json:"lastSeen"`
ResolvedAt *int64 `json:"resolvedAt,omitempty"`
AcknowledgedAt *int64 `json:"acknowledgedAt,omitempty"`
EventID int64 `json:"eventId"`
ID string `json:"id"`
Severity string `json:"severity"`
Source string `json:"source"`
Title string `json:"title"`
Message string `json:"message"`
FirstSeen int64 `json:"firstSeen"`
LastSeen int64 `json:"lastSeen"`
ResolvedAt *int64 `json:"resolvedAt,omitempty"`
AcknowledgedAt *int64 `json:"acknowledgedAt,omitempty"`
Planned bool `json:"planned"`
MaintenanceReason string `json:"maintenanceReason,omitempty"`
}
type RecoveryEvent struct {
@@ -24,13 +26,17 @@ type RecoveryEvent struct {
}
func (s *Store) SyncAlerts(alerts []Alert) ([]Alert, error) {
return s.SyncAlertsWithMaintenance(alerts, nil, "")
}
func (s *Store) SyncAlertsWithMaintenance(alerts, plannedAlerts []Alert, maintenanceReason string) ([]Alert, error) {
tx, err := s.db.Begin()
if err != nil {
return alerts, err
}
defer tx.Rollback()
now := time.Now().Unix()
rows, err := tx.Query(`SELECT id,alert_key FROM alert_events WHERE resolved_at IS NULL`)
rows, err := tx.Query(`SELECT id,alert_key,planned FROM alert_events WHERE resolved_at IS NULL`)
if err != nil {
return alerts, err
}
@@ -38,28 +44,43 @@ func (s *Store) SyncAlerts(alerts []Alert) ([]Alert, error) {
for rows.Next() {
var id int64
var key string
if err := rows.Scan(&id, &key); err != nil {
var planned bool
if err := rows.Scan(&id, &key, &planned); err != nil {
rows.Close()
return alerts, err
}
active[key] = id
active[alertEventKey(key, planned)] = id
}
rows.Close()
seen := map[string]bool{}
for _, alert := range alerts {
seen[alert.ID] = true
if id, ok := active[alert.ID]; ok {
_, err = tx.Exec(`UPDATE alert_events SET severity=?,source=?,title=?,message=?,last_seen=? WHERE id=?`, alert.Severity, alert.Source, alert.Title, alert.Message, now, id)
} else {
result, insertErr := tx.Exec(`INSERT INTO alert_events(alert_key,severity,source,title,message,first_seen,last_seen) VALUES(?,?,?,?,?,?,?)`, alert.ID, alert.Severity, alert.Source, alert.Title, alert.Message, now, now)
if insertErr != nil {
return alerts, insertErr
syncGroup := func(group []Alert, planned bool) error {
for _, alert := range group {
key := alertEventKey(alert.ID, planned)
seen[key] = true
reason := ""
if planned {
reason = maintenanceReason
}
if id, ok := active[key]; ok {
_, err = tx.Exec(`UPDATE alert_events SET severity=?,source=?,title=?,message=?,last_seen=?,maintenance_reason=? WHERE id=?`, alert.Severity, alert.Source, alert.Title, alert.Message, now, reason, id)
} else {
result, insertErr := tx.Exec(`INSERT INTO alert_events(alert_key,severity,source,title,message,first_seen,last_seen,planned,maintenance_reason) VALUES(?,?,?,?,?,?,?,?,?)`, alert.ID, alert.Severity, alert.Source, alert.Title, alert.Message, now, now, planned, reason)
if insertErr != nil {
return insertErr
}
active[key], _ = result.LastInsertId()
}
if err != nil {
return err
}
active[alert.ID], _ = result.LastInsertId()
}
if err != nil {
return alerts, err
}
return nil
}
if err = syncGroup(alerts, false); err != nil {
return alerts, err
}
if err = syncGroup(plannedAlerts, true); err != nil {
return alerts, err
}
for key, id := range active {
if !seen[key] {
@@ -74,7 +95,7 @@ func (s *Store) SyncAlerts(alerts []Alert) ([]Alert, error) {
if err = tx.Commit(); err != nil {
return alerts, err
}
rows, err = s.db.Query(`SELECT id,alert_key,severity,source,title,message FROM alert_events WHERE resolved_at IS NULL AND acknowledged_at IS NULL ORDER BY CASE severity WHEN 'critical' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END,first_seen DESC`)
rows, err = s.db.Query(`SELECT id,alert_key,severity,source,title,message,first_seen,last_seen FROM alert_events WHERE resolved_at IS NULL AND acknowledged_at IS NULL AND planned=0 ORDER BY CASE severity WHEN 'critical' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END,first_seen DESC`)
if err != nil {
return alerts, err
}
@@ -82,7 +103,7 @@ func (s *Store) SyncAlerts(alerts []Alert) ([]Alert, error) {
var visible []Alert
for rows.Next() {
var a Alert
if err = rows.Scan(&a.EventID, &a.ID, &a.Severity, &a.Source, &a.Title, &a.Message); err != nil {
if err = rows.Scan(&a.EventID, &a.ID, &a.Severity, &a.Source, &a.Title, &a.Message, &a.FirstSeen, &a.LastSeen); err != nil {
return alerts, err
}
visible = append(visible, a)
@@ -90,6 +111,13 @@ func (s *Store) SyncAlerts(alerts []Alert) ([]Alert, error) {
return visible, rows.Err()
}
func alertEventKey(id string, planned bool) string {
if planned {
return id + "\x00planned"
}
return id + "\x00active"
}
func (s *Store) AcknowledgeAlert(eventID int64) error {
_, err := s.db.Exec(`UPDATE alert_events SET acknowledged_at=? WHERE id=? AND acknowledged_at IS NULL`, time.Now().Unix(), eventID)
return err
@@ -99,7 +127,7 @@ func (s *Store) AlertHistory(limit int) ([]AlertEvent, error) {
if limit < 1 || limit > 500 {
limit = 200
}
rows, err := s.db.Query(`SELECT id,alert_key,severity,source,title,message,first_seen,last_seen,resolved_at,acknowledged_at FROM alert_events ORDER BY first_seen DESC LIMIT ?`, limit)
rows, err := s.db.Query(`SELECT id,alert_key,severity,source,title,message,first_seen,last_seen,resolved_at,acknowledged_at,planned,maintenance_reason FROM alert_events ORDER BY first_seen DESC LIMIT ?`, limit)
if err != nil {
return nil, err
}
@@ -108,7 +136,7 @@ func (s *Store) AlertHistory(limit int) ([]AlertEvent, error) {
for rows.Next() {
var e AlertEvent
var resolved, acked sql.NullInt64
if err = rows.Scan(&e.EventID, &e.ID, &e.Severity, &e.Source, &e.Title, &e.Message, &e.FirstSeen, &e.LastSeen, &resolved, &acked); err != nil {
if err = rows.Scan(&e.EventID, &e.ID, &e.Severity, &e.Source, &e.Title, &e.Message, &e.FirstSeen, &e.LastSeen, &resolved, &acked, &e.Planned, &e.MaintenanceReason); err != nil {
return nil, err
}
if resolved.Valid {
@@ -123,7 +151,7 @@ func (s *Store) AlertHistory(limit int) ([]AlertEvent, error) {
}
func (s *Store) PendingEmailAlerts() ([]Alert, error) {
rows, err := s.db.Query(`SELECT id,alert_key,severity,source,title,message FROM alert_events WHERE resolved_at IS NULL AND acknowledged_at IS NULL AND email_sent_at IS NULL ORDER BY first_seen`)
rows, err := s.db.Query(`SELECT id,alert_key,severity,source,title,message FROM alert_events WHERE resolved_at IS NULL AND acknowledged_at IS NULL AND email_sent_at IS NULL AND planned=0 ORDER BY first_seen`)
if err != nil {
return nil, err
}
@@ -155,7 +183,7 @@ func (s *Store) MarkAlertsEmailed(alerts []Alert) error {
}
func (s *Store) PendingImmediateAlerts() ([]Alert, error) {
rows, err := s.db.Query(`SELECT id,alert_key,severity,source,title,message FROM alert_events WHERE resolved_at IS NULL AND acknowledged_at IS NULL AND email_sent_at IS NULL AND (severity='critical' OR alert_key='ups-battery') ORDER BY first_seen`)
rows, err := s.db.Query(`SELECT id,alert_key,severity,source,title,message FROM alert_events WHERE resolved_at IS NULL AND acknowledged_at IS NULL AND email_sent_at IS NULL AND planned=0 AND (severity='critical' OR alert_key='ups-battery') ORDER BY first_seen`)
if err != nil {
return nil, err
}
@@ -171,7 +199,7 @@ func (s *Store) PendingImmediateAlerts() ([]Alert, error) {
return out, rows.Err()
}
func (s *Store) PendingRecoveries() ([]RecoveryEvent, error) {
rows, err := s.db.Query(`SELECT id,alert_key,severity,source,title,message FROM alert_events WHERE resolved_at IS NOT NULL AND email_sent_at IS NOT NULL AND recovery_sent_at IS NULL ORDER BY resolved_at`)
rows, err := s.db.Query(`SELECT id,alert_key,severity,source,title,message FROM alert_events WHERE resolved_at IS NOT NULL AND email_sent_at IS NOT NULL AND recovery_sent_at IS NULL AND planned=0 ORDER BY resolved_at`)
if err != nil {
return nil, err
}

View File

@@ -10,7 +10,7 @@ func TestAlertLifecycle(t *testing.T) {
defer store.Close()
alert := Alert{ID: "disk", Severity: "warning", Source: "Диски", Title: "SMART", Message: "Проверьте диск"}
visible, err := store.SyncAlerts([]Alert{alert})
if err != nil || len(visible) != 1 || visible[0].EventID == 0 {
if err != nil || len(visible) != 1 || visible[0].EventID == 0 || visible[0].FirstSeen == 0 || visible[0].LastSeen == 0 {
t.Fatalf("unexpected new alert: %+v %v", visible, err)
}
firstID := visible[0].EventID

100
alerts.go
View File

@@ -8,12 +8,14 @@ import (
)
type Alert struct {
EventID int64 `json:"eventId,omitempty"`
ID string `json:"id"`
Severity string `json:"severity"`
Source string `json:"source"`
Title string `json:"title"`
Message string `json:"message"`
EventID int64 `json:"eventId,omitempty"`
ID string `json:"id"`
Severity string `json:"severity"`
Source string `json:"source"`
Title string `json:"title"`
Message string `json:"message"`
FirstSeen int64 `json:"firstSeen,omitempty"`
LastSeen int64 `json:"lastSeen,omitempty"`
}
func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Alert {
@@ -61,6 +63,14 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler
add("root-storage", "warning", "Storage", "Мало места на системном разделе", fmt.Sprintf("Раздел / заполнен на %.1f%%.", metrics.Storage.UsagePercent))
}
for _, disk := range metrics.Disks {
diskExcluded := false
for _, value := range strings.Split(thresholds.DiskIOExcluded, ",") {
value = strings.TrimSpace(value)
if value != "" && (strings.EqualFold(value, disk.Name) || strings.EqualFold(value, disk.Serial)) {
diskExcluded = true
break
}
}
if disk.SMARTStatus == "Ошибка" {
add("disk-smart-"+disk.Name, "critical", "Диски", disk.Model+": ошибка SMART", joinReasons(disk.AttentionReasons, "SMART сообщает о неисправности диска."))
} else if disk.SMARTStatus == "Требует внимания" {
@@ -78,6 +88,17 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler
if len(disk.CounterChanges) > 0 {
add("disk-growth-"+disk.Name, "critical", "Диски", disk.Model+": ухудшились SMART-счётчики", strings.Join(disk.CounterChanges, " · "))
}
if !diskExcluded && disk.IOPressureSeconds >= thresholds.DiskIODuration {
severity := "warning"
if disk.Utilization >= 98 || disk.LatencyMs >= 100 {
severity = "critical"
}
cause := disk.IOCause
if cause == "" {
cause = "источник пока не определён"
}
add("disk-io-pressure-"+disk.Name, severity, "Диски", disk.Model+": длительная I/O-нагрузка", fmt.Sprintf("%s занят на %.1f%%; вероятная причина: %s (уверенность %d%%). %.0f IOPS, %.1f MB/s, latency %.1f ms, очередь %.1f.", disk.Name, disk.Utilization, cause, disk.IOConfidence, disk.ReadIOPS+disk.WriteIOPS, (disk.ReadBytesPerSec+disk.WriteBytesPerSec)/1048576, disk.LatencyMs, disk.AverageQueue))
}
if disk.SelfTestNeverRun {
add("disk-selftest-"+disk.Name, "warning", "Диски", disk.Model+": нет завершённого SMART self-test", "Короткий тест будет автоматически запущен; проверьте его результат после завершения.")
}
@@ -176,6 +197,22 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler
add("guests-stopped", "info", "VM/LXC", "Есть остановленные гости", fmt.Sprintf("Остановлено VM/LXC: %d. Это уведомление информационное.", stopped))
}
}
for _, agent := range metrics.Agents {
vmSuffix := fmt.Sprintf("-%d", agent.VMID)
if !agent.Online {
add("agent-offline-"+agent.ID+vmSuffix, "critical", "Агенты", agent.Name+": агент не отвечает", fmt.Sprintf("Последний heartbeat: %s назад. VMID %d.", time.Since(time.Unix(agent.LastSeen, 0)).Round(time.Second), agent.VMID))
continue
}
if agent.Report.DockerAvailable {
for _, container := range agent.Report.Containers {
if container.State != "running" {
add("agent-container-stopped-"+agent.ID+"-"+container.ID+vmSuffix, "warning", "Docker", agent.Name+": контейнер "+container.Name+" остановлен", fmt.Sprintf("%s · VMID %d.", container.Status, agent.VMID))
} else if container.Health == "unhealthy" {
add("agent-container-health-"+agent.ID+"-"+container.ID+vmSuffix, "critical", "Docker", agent.Name+": контейнер "+container.Name+" unhealthy", fmt.Sprintf("Docker health-check не проходит · VMID %d.", agent.VMID))
}
}
}
}
for _, service := range metrics.Services.Services {
for _, endpoint := range service.Endpoints {
if !endpoint.Up {
@@ -229,9 +266,6 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler
if !metrics.Monitor.LastSuccessfulCollection.IsZero() && time.Since(metrics.Monitor.LastSuccessfulCollection) > 30*time.Second {
add("monitor-stale", "critical", "Dashboard", "Данные мониторинга устарели", "Последний успешный сбор был "+time.Since(metrics.Monitor.LastSuccessfulCollection).Round(time.Second).String()+" назад.")
}
if metrics.Monitor.SlowCollector {
add("monitor-slow", "warning", "Dashboard", "Сбор метрик выполняется слишком долго", fmt.Sprintf("Последний цикл занял %d ms.", metrics.Monitor.CollectionDurationMs))
}
if metrics.Monitor.HistoryWriteError != "" {
add("monitor-history", "critical", "Dashboard", "Ошибка записи истории", metrics.Monitor.HistoryWriteError)
}
@@ -259,6 +293,9 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler
add("task-failed-"+task.UPID, severity, "Proxmox", task.Type+": задача завершилась ошибкой", fmt.Sprintf("VMID %s, пользователь %s, статус %s.", task.ID, task.User, task.Status))
}
}
for _, trend := range metrics.Trends {
add(trend.ID, trend.Severity, trend.Source, trend.Title, trend.Message)
}
priority := map[string]int{"critical": 0, "warning": 1, "info": 2}
sort.SliceStable(alerts, func(i, j int) bool {
@@ -270,6 +307,51 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler
return alerts
}
func correlateAlerts(metrics dashboardMetrics, alerts []Alert) []Alert {
pressureAlerts := map[string]bool{}
for _, a := range alerts {
if strings.HasPrefix(a.ID, "disk-io-pressure-") {
pressureAlerts[strings.TrimPrefix(a.ID, "disk-io-pressure-")] = true
}
}
var pressure []DiskMetrics
for _, d := range metrics.Disks {
if pressureAlerts[d.Name] && d.IOCause != "" {
pressure = append(pressure, d)
}
}
if len(pressure) == 0 {
return alerts
}
var down []string
for _, service := range metrics.Services.Services {
for _, endpoint := range service.Endpoints {
if !endpoint.Up {
down = append(down, service.Name)
break
}
}
}
d := pressure[0]
chain := d.IOCause + " → " + d.Name + fmt.Sprintf(" util %.1f%% → latency %.1f ms", d.Utilization, d.LatencyMs)
if len(down) > 0 {
chain += " → недоступны/медленны сервисы: " + strings.Join(down, ", ")
}
severity := "warning"
if len(down) > 0 || d.Utilization >= 98 {
severity = "critical"
}
correlated := Alert{ID: "correlation-io-" + d.Name, Severity: severity, Source: "Корреляция", Title: "Обнаружена связанная цепочка I/O-событий", Message: chain + fmt.Sprintf(". Уверенность причины %d%%.", d.IOConfidence)}
filtered := []Alert{correlated}
for _, a := range alerts {
if strings.HasPrefix(a.ID, "disk-io-pressure-") || strings.HasPrefix(a.ID, "service-") {
continue
}
filtered = append(filtered, a)
}
return filtered
}
func joinReasons(reasons []string, fallback string) string {
if len(reasons) == 0 {
return fallback

View File

@@ -16,3 +16,12 @@ func TestEvaluateAlerts(t *testing.T) {
t.Fatalf("неверная сортировка уведомлений: %+v", alerts)
}
}
func TestSlowCollectionDoesNotCreateAlert(t *testing.T) {
metrics := dashboardMetrics{Monitor: MonitorHealth{SlowCollector: true, CollectionDurationMs: 12308}}
for _, alert := range evaluateAlerts(metrics, defaultThresholds()) {
if alert.ID == "monitor-slow" {
t.Fatalf("медленный цикл не должен создавать уведомление: %+v", alert)
}
}
}

187
disks.go
View File

@@ -13,46 +13,69 @@ import (
)
type DiskMetrics struct {
Name string `json:"name"`
Path string `json:"path"`
Model string `json:"model"`
Serial string `json:"serial"`
Type string `json:"type"`
Protocol string `json:"protocol"`
SizeBytes uint64 `json:"sizeBytes"`
UsedBytes *uint64 `json:"usedBytes"`
UsagePercent *float64 `json:"usagePercent"`
SMARTStatus string `json:"smartStatus"`
SMARTAvailable bool `json:"smartAvailable"`
AttentionReasons []string `json:"attentionReasons"`
Temperature *float64 `json:"temperatureCelsius"`
PowerOnHours *uint64 `json:"powerOnHours"`
WearUsedPercent *float64 `json:"wearUsedPercent"`
Reallocated uint64 `json:"reallocated"`
Pending uint64 `json:"pending"`
Uncorrectable uint64 `json:"uncorrectable"`
CRCErrors uint64 `json:"crcErrors"`
MediaErrors uint64 `json:"mediaErrors"`
CounterChanges []string `json:"counterChanges"`
LastSelfTest string `json:"lastSelfTest"`
LastSelfTestAt string `json:"lastSelfTestAt"`
SelfTestNeverRun bool `json:"selfTestNeverRun"`
ShortTestStarted bool `json:"shortTestStarted"`
Name string `json:"name"`
Path string `json:"path"`
Model string `json:"model"`
Serial string `json:"serial"`
Type string `json:"type"`
Protocol string `json:"protocol"`
SizeBytes uint64 `json:"sizeBytes"`
UsedBytes *uint64 `json:"usedBytes"`
UsagePercent *float64 `json:"usagePercent"`
SMARTStatus string `json:"smartStatus"`
SMARTAvailable bool `json:"smartAvailable"`
AttentionReasons []string `json:"attentionReasons"`
SMARTHistory []string `json:"smartHistory"`
Temperature *float64 `json:"temperatureCelsius"`
PowerOnHours *uint64 `json:"powerOnHours"`
WearUsedPercent *float64 `json:"wearUsedPercent"`
Reallocated uint64 `json:"reallocated"`
Pending uint64 `json:"pending"`
Uncorrectable uint64 `json:"uncorrectable"`
CRCErrors uint64 `json:"crcErrors"`
MediaErrors uint64 `json:"mediaErrors"`
SMARTErrorCount uint64 `json:"smartErrorCount"`
CounterChanges []string `json:"counterChanges"`
LastSelfTest string `json:"lastSelfTest"`
LastSelfTestAt string `json:"lastSelfTestAt"`
SelfTestNeverRun bool `json:"selfTestNeverRun"`
ShortTestStarted bool `json:"shortTestStarted"`
ReadIOPS float64 `json:"readIops"`
WriteIOPS float64 `json:"writeIops"`
ReadBytesPerSec float64 `json:"readBytesPerSec"`
WriteBytesPerSec float64 `json:"writeBytesPerSec"`
ReadLatencyMs float64 `json:"readLatencyMs"`
WriteLatencyMs float64 `json:"writeLatencyMs"`
LatencyMs float64 `json:"latencyMs"`
Utilization float64 `json:"utilizationPercent"`
QueueDepth uint64 `json:"queueDepth"`
AverageQueue float64 `json:"averageQueue"`
IOPressureSeconds float64 `json:"ioPressureSeconds"`
IOCause string `json:"ioCause"`
IOCauseDetail string `json:"ioCauseDetail"`
IOConfidence int `json:"ioConfidence"`
TopConsumers []IOConsumer `json:"topConsumers"`
}
type diskIOSnapshot struct{ reads, readSectors, readMS, writes, writeSectors, writeMS, inFlight, ioMS, weightedMS uint64 }
type diskCollector struct {
mu sync.Mutex
cached []DiskMetrics
updatedAt time.Time
previous map[string]DiskMetrics
missing []string
lastTests map[string]time.Time
mu sync.Mutex
cached []DiskMetrics
updatedAt time.Time
previous map[string]DiskMetrics
missing []string
lastTests map[string]time.Time
ioPrevious map[string]diskIOSnapshot
ioUpdatedAt time.Time
ioPressureSince map[string]time.Time
}
func (c *diskCollector) collect() []DiskMetrics {
func (c *diskCollector) collect(thresholds AlertThresholds) []DiskMetrics {
c.mu.Lock()
defer c.mu.Unlock()
if time.Since(c.updatedAt) < time.Minute && c.cached != nil {
c.applyDiskIO(c.cached, thresholds)
return c.cached
}
current := readPhysicalDisks()
@@ -87,10 +110,91 @@ func (c *diskCollector) collect() []DiskMetrics {
}
}
c.cached = current
c.applyDiskIO(c.cached, thresholds)
c.updatedAt = time.Now()
return c.cached
}
func (c *diskCollector) applyDiskIO(disks []DiskMetrics, thresholds AlertThresholds) {
now := time.Now()
snapshots := readDiskIOStats("/proc/diskstats")
if c.ioPrevious == nil {
c.ioPrevious = map[string]diskIOSnapshot{}
c.ioPressureSince = map[string]time.Time{}
}
elapsed := now.Sub(c.ioUpdatedAt).Seconds()
for i := range disks {
disk := &disks[i]
disk.ReadIOPS, disk.WriteIOPS, disk.ReadBytesPerSec, disk.WriteBytesPerSec = 0, 0, 0, 0
disk.ReadLatencyMs, disk.WriteLatencyMs, disk.LatencyMs, disk.Utilization, disk.AverageQueue, disk.IOPressureSeconds = 0, 0, 0, 0, 0, 0
current, ok := snapshots[disk.Name]
if !ok {
continue
}
disk.QueueDepth = current.inFlight
if old, found := c.ioPrevious[disk.Name]; found && elapsed > 0 && diskIOCountersMonotonic(old, current) {
readOps := current.reads - old.reads
writeOps := current.writes - old.writes
disk.ReadIOPS = float64(readOps) / elapsed
disk.WriteIOPS = float64(writeOps) / elapsed
disk.ReadBytesPerSec = float64(current.readSectors-old.readSectors) * 512 / elapsed
disk.WriteBytesPerSec = float64(current.writeSectors-old.writeSectors) * 512 / elapsed
if readOps > 0 {
disk.ReadLatencyMs = float64(current.readMS-old.readMS) / float64(readOps)
}
if writeOps > 0 {
disk.WriteLatencyMs = float64(current.writeMS-old.writeMS) / float64(writeOps)
}
if readOps+writeOps > 0 {
disk.LatencyMs = float64(current.readMS-old.readMS+current.writeMS-old.writeMS) / float64(readOps+writeOps)
}
disk.Utilization = min(float64(current.ioMS-old.ioMS)/(elapsed*10), 100)
disk.AverageQueue = float64(current.weightedMS-old.weightedMS) / (elapsed * 1000)
utilLimit, latencyLimit, queueLimit := thresholds.DiskSSDUtil, thresholds.DiskSSDLatency, thresholds.DiskSSDQueue
if disk.Type == "HDD" {
utilLimit, latencyLimit, queueLimit = thresholds.DiskHDDUtil, thresholds.DiskHDDLatency, thresholds.DiskHDDQueue
} else if strings.Contains(disk.Type, "NVMe") {
utilLimit, latencyLimit, queueLimit = thresholds.DiskNVMeUtil, thresholds.DiskNVMeLatency, thresholds.DiskNVMeQueue
}
pressured := disk.Utilization >= utilLimit || (disk.LatencyMs >= latencyLimit && disk.ReadIOPS+disk.WriteIOPS >= 1) || disk.AverageQueue >= queueLimit
if pressured {
if c.ioPressureSince[disk.Name].IsZero() {
c.ioPressureSince[disk.Name] = now
}
disk.IOPressureSeconds = now.Sub(c.ioPressureSince[disk.Name]).Seconds()
} else {
delete(c.ioPressureSince, disk.Name)
}
}
c.ioPrevious[disk.Name] = current
}
c.ioUpdatedAt = now
}
func diskIOCountersMonotonic(old, current diskIOSnapshot) bool {
return current.reads >= old.reads && current.readSectors >= old.readSectors && current.readMS >= old.readMS && current.writes >= old.writes && current.writeSectors >= old.writeSectors && current.writeMS >= old.writeMS && current.ioMS >= old.ioMS && current.weightedMS >= old.weightedMS
}
func readDiskIOStats(path string) map[string]diskIOSnapshot {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
result := map[string]diskIOSnapshot{}
for _, line := range strings.Split(string(data), "\n") {
f := strings.Fields(line)
if len(f) < 14 || !isPhysicalDiskName(f[2]) {
continue
}
values := make([]uint64, 11)
for i := 0; i < 11; i++ {
values[i], _ = strconv.ParseUint(f[i+3], 10, 64)
}
result[f[2]] = diskIOSnapshot{reads: values[0], readSectors: values[2], readMS: values[3], writes: values[4], writeSectors: values[6], writeMS: values[7], inFlight: values[8], ioMS: values[9], weightedMS: values[10]}
}
return result
}
func (c *diskCollector) missingDisks() []string {
c.mu.Lock()
defer c.mu.Unlock()
@@ -101,7 +205,7 @@ func diskCounterChanges(old, current DiskMetrics) []string {
checks := []struct {
label string
old, now uint64
}{{"Reallocated вырос", old.Reallocated, current.Reallocated}, {"Pending вырос", old.Pending, current.Pending}, {"Uncorrectable вырос", old.Uncorrectable, current.Uncorrectable}, {"CRC-ошибки выросли", old.CRCErrors, current.CRCErrors}, {"Media errors выросли", old.MediaErrors, current.MediaErrors}}
}{{"Reallocated вырос", old.Reallocated, current.Reallocated}, {"Pending вырос", old.Pending, current.Pending}, {"Uncorrectable вырос", old.Uncorrectable, current.Uncorrectable}, {"CRC-ошибки выросли", old.CRCErrors, current.CRCErrors}, {"Media errors выросли", old.MediaErrors, current.MediaErrors}, {"Ошибки SMART-журнала выросли", old.SMARTErrorCount, current.SMARTErrorCount}}
var changes []string
for _, check := range checks {
if check.now > check.old {
@@ -386,16 +490,23 @@ func applyATAAttributes(disk *DiskMetrics, smart smartctlJSON) {
wear := float64(100 - attribute.Value)
disk.WearUsedPercent = &wear
}
if attribute.WhenFailed != "" && attribute.WhenFailed != "-" {
if strings.EqualFold(attribute.WhenFailed, "FAILING_NOW") {
reason := attribute.Name + " вышел за порог"
if attribute.Raw.String != "" {
reason += " (" + attribute.Raw.String + ")"
}
disk.AttentionReasons = append(disk.AttentionReasons, reason)
} else if attribute.WhenFailed != "" && attribute.WhenFailed != "-" {
reason := attribute.Name + " ранее выходил за порог"
if attribute.Raw.String != "" {
reason += " (" + attribute.Raw.String + ")"
}
disk.SMARTHistory = append(disk.SMARTHistory, reason)
}
}
if smart.ATAErrorLog.Summary.Count > 0 {
disk.AttentionReasons = append(disk.AttentionReasons, "Ошибок в SMART-журнале: "+strconv.Itoa(smart.ATAErrorLog.Summary.Count))
disk.SMARTErrorCount = uint64(smart.ATAErrorLog.Summary.Count)
disk.SMARTHistory = append(disk.SMARTHistory, "Записей в SMART-журнале: "+strconv.Itoa(smart.ATAErrorLog.Summary.Count))
}
}
@@ -409,12 +520,12 @@ func applySMARTExitStatus(disk *DiskMetrics, status int) {
disk.AttentionReasons = append(disk.AttentionReasons, "Критический SMART-атрибут достиг порога")
}
if status&32 != 0 {
disk.AttentionReasons = append(disk.AttentionReasons, "SMART-атрибут ранее находился ниже порога")
disk.SMARTHistory = append(disk.SMARTHistory, "SMART-атрибут ранее находился ниже порога")
}
if status&64 != 0 {
disk.AttentionReasons = append(disk.AttentionReasons, "В журнале SMART обнаружены ошибки")
disk.SMARTHistory = append(disk.SMARTHistory, "В журнале SMART есть старые записи об ошибках")
}
if status&128 != 0 {
disk.AttentionReasons = append(disk.AttentionReasons, "В журнале самотестирования есть ошибки")
disk.SMARTHistory = append(disk.SMARTHistory, "В журнале самотестирования есть старые ошибки")
}
}

View File

@@ -2,6 +2,9 @@ package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -17,6 +20,22 @@ func TestIsPhysicalDiskName(t *testing.T) {
}
}
func TestReadDiskIOStats(t *testing.T) {
path := filepath.Join(t.TempDir(), "diskstats")
data := " 8 0 sda 100 2 300 40 50 3 700 80 4 90 120 0 0 0 0 0 0\n 7 0 loop0 999 0 999 0 999 0 999 0 0 0 0\n"
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
stats := readDiskIOStats(path)
disk, ok := stats["sda"]
if !ok || disk.reads != 100 || disk.readSectors != 300 || disk.writes != 50 || disk.writeSectors != 700 || disk.inFlight != 4 || disk.ioMS != 90 || disk.weightedMS != 120 {
t.Fatalf("unexpected stats: %+v", disk)
}
if _, exists := stats["loop0"]; exists {
t.Fatal("virtual device must be ignored")
}
}
func TestSumFilesystemUsage(t *testing.T) {
device := lsblkDevice{Children: []lsblkDevice{
{FSUsed: json.RawMessage(`100`), FSAvail: json.RawMessage(`300`)},
@@ -50,3 +69,37 @@ func TestApplyATAWearAndReason(t *testing.T) {
t.Fatalf("причина предупреждения не добавлена: %+v", disk)
}
}
func TestHistoricalATAFailureDoesNotRequireAttention(t *testing.T) {
var smart smartctlJSON
attribute := struct {
Name string `json:"name"`
Value int `json:"value"`
Threshold int `json:"thresh"`
WhenFailed string `json:"when_failed"`
Raw struct {
String string `json:"string"`
Value int64 `json:"value"`
} `json:"raw"`
}{Name: "Airflow_Temperature_Cel", WhenFailed: "In_the_past"}
attribute.Raw.String = "46 (Min/Max 26/47 #15)"
smart.ATAAttributes.Table = append(smart.ATAAttributes.Table, attribute)
disk := DiskMetrics{}
applyATAAttributes(&disk, smart)
applySMARTExitStatus(&disk, 32|64|128)
if len(disk.AttentionReasons) != 0 {
t.Fatalf("историческая ошибка стала активной: %+v", disk.AttentionReasons)
}
if len(disk.SMARTHistory) != 4 {
t.Fatalf("история SMART потеряна: %+v", disk.SMARTHistory)
}
}
func TestSMARTErrorLogGrowthRequiresAttention(t *testing.T) {
old := DiskMetrics{SMARTErrorCount: 2}
current := DiskMetrics{SMARTErrorCount: 3}
changes := diskCounterChanges(old, current)
if len(changes) != 1 || !strings.Contains(changes[0], "2 → 3") {
t.Fatalf("рост журнала не обнаружен: %+v", changes)
}
}

View File

@@ -44,6 +44,11 @@ func (s *Store) AddEntityMetrics(metrics dashboardMetrics) {
s.AddEntityHistory("guest", fmt.Sprintf("%d · %s", g.VMID, g.Name), ts, &cpu, &mem)
}
for _, d := range metrics.Disks {
iops := d.ReadIOPS + d.WriteIOPS
util := d.Utilization
s.AddEntityHistory("disk-io", d.Name+" · "+d.Model, ts, &iops, &util)
latency := d.LatencyMs
s.AddEntityHistory("disk-latency", d.Name+" · "+d.Model, ts, &latency, nil)
if d.Temperature == nil {
continue
}
@@ -53,6 +58,12 @@ func (s *Store) AddEntityMetrics(metrics dashboardMetrics) {
}
s.AddEntityHistory("disk", d.Name+" · "+d.Model, ts, d.Temperature, wear)
}
for _, task := range metrics.Activity.Tasks {
if task.Type == "vzdump" && task.Duration > 0 && task.EndTime >= time.Now().Add(-15*time.Minute).Unix() {
duration := float64(task.Duration)
s.AddEntityHistory("backup-duration", "VMID "+task.ID, ts, &duration, nil)
}
}
s.PruneEntityHistory()
}

178
io_attribution.go Normal file
View File

@@ -0,0 +1,178 @@
package main
import (
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
)
type IOConsumer struct {
Kind string `json:"kind"`
Name string `json:"name"`
VMID int `json:"vmid,omitempty"`
ReadBytesPerSec float64 `json:"readBytesPerSec"`
WriteBytesPerSec float64 `json:"writeBytesPerSec"`
SharePercent float64 `json:"sharePercent"`
}
type processIOSnapshot struct {
read, write uint64
name, cmdline, cgroup string
}
type ioAttributionCollector struct {
mu sync.Mutex
previous map[int]processIOSnapshot
updatedAt time.Time
cached []IOConsumer
}
var qemuIDPattern = regexp.MustCompile(`(?:^|\s)-id\s+(\d+)`)
var lxcIDPattern = regexp.MustCompile(`(?:lxc(?:\.payload)?[./-]|pve-container@)(\d+)`)
func (c *ioAttributionCollector) collect(guests GuestsMetrics) []IOConsumer {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
current := readProcessIO()
elapsed := now.Sub(c.updatedAt).Seconds()
if c.previous == nil || elapsed <= 0 {
c.previous = current
c.updatedAt = now
return nil
}
names := map[int]string{}
for _, g := range guests.Guests {
names[g.VMID] = g.Name
}
grouped := map[string]*IOConsumer{}
var total float64
for pid, snapshot := range current {
old, ok := c.previous[pid]
if !ok || snapshot.read < old.read || snapshot.write < old.write {
continue
}
read := float64(snapshot.read-old.read) / elapsed
write := float64(snapshot.write-old.write) / elapsed
if read+write < 1024 {
continue
}
kind, name, vmid := classifyIOProcess(snapshot, names)
key := kind + ":" + strconv.Itoa(vmid) + ":" + name
consumer := grouped[key]
if consumer == nil {
consumer = &IOConsumer{Kind: kind, Name: name, VMID: vmid}
grouped[key] = consumer
}
consumer.ReadBytesPerSec += read
consumer.WriteBytesPerSec += write
total += read + write
}
var result []IOConsumer
for _, v := range grouped {
if total > 0 {
v.SharePercent = (v.ReadBytesPerSec + v.WriteBytesPerSec) / total * 100
}
result = append(result, *v)
}
sort.Slice(result, func(i, j int) bool {
return result[i].ReadBytesPerSec+result[i].WriteBytesPerSec > result[j].ReadBytesPerSec+result[j].WriteBytesPerSec
})
if len(result) > 8 {
result = result[:8]
}
c.previous = current
c.updatedAt = now
c.cached = result
return result
}
func classifyIOProcess(p processIOSnapshot, names map[int]string) (kind, name string, vmid int) {
if match := qemuIDPattern.FindStringSubmatch(p.cmdline); len(match) > 1 {
vmid, _ = strconv.Atoi(match[1])
return "VM", firstNonEmpty(names[vmid], "VM "+match[1]), vmid
}
if match := lxcIDPattern.FindStringSubmatch(p.cgroup); len(match) > 1 {
vmid, _ = strconv.Atoi(match[1])
return "LXC", firstNonEmpty(names[vmid], "LXC "+match[1]), vmid
}
return "Процесс", firstNonEmpty(p.name, "PID"), 0
}
func readProcessIO() map[int]processIOSnapshot {
result := map[int]processIOSnapshot{}
entries, _ := filepath.Glob("/proc/[0-9]*/io")
for _, path := range entries {
pid, _ := strconv.Atoi(filepath.Base(filepath.Dir(path)))
data, err := os.ReadFile(path)
if err != nil {
continue
}
var item processIOSnapshot
for _, line := range strings.Split(string(data), "\n") {
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
n, _ := strconv.ParseUint(strings.TrimSpace(value), 10, 64)
if key == "read_bytes" {
item.read = n
} else if key == "write_bytes" {
item.write = n
}
}
comm, _ := os.ReadFile(filepath.Join(filepath.Dir(path), "comm"))
cmd, _ := os.ReadFile(filepath.Join(filepath.Dir(path), "cmdline"))
cg, _ := os.ReadFile(filepath.Join(filepath.Dir(path), "cgroup"))
item.name = strings.TrimSpace(string(comm))
item.cmdline = strings.ReplaceAll(string(cmd), "\x00", " ")
item.cgroup = string(cg)
result[pid] = item
}
return result
}
func attributeDiskIO(metrics *dashboardMetrics, consumers []IOConsumer) {
active := activeIOOperation(metrics.Activity.Tasks)
for i := range metrics.Disks {
disk := &metrics.Disks[i]
disk.TopConsumers = append([]IOConsumer(nil), consumers...)
if active != "" {
disk.IOCause = active
disk.IOCauseDetail = "Активная задача Proxmox совпадает по времени с дисковой нагрузкой"
disk.IOConfidence = 75
} else if len(consumers) > 0 {
top := consumers[0]
disk.IOCause = top.Kind + " · " + top.Name
disk.IOCauseDetail = "Крупнейший потребитель host I/O: " + strconv.FormatFloat(top.SharePercent, 'f', 1, 64) + "%"
disk.IOConfidence = 55
}
}
}
func activeIOOperation(tasks []ProxmoxTask) string {
for _, task := range tasks {
if task.EndTime != 0 && task.Status != "" {
continue
}
label := ""
switch task.Type {
case "vzdump":
label = "Backup"
case "qmigrate":
label = "Миграция"
case "qmsnapshot", "vzsnapshot":
label = "Snapshot"
}
if label != "" {
if task.ID != "" {
label += " VM/LXC " + task.ID
}
return label
}
}
return ""
}

33
io_attribution_test.go Normal file
View File

@@ -0,0 +1,33 @@
package main
import (
"strings"
"testing"
)
func TestClassifyQEMUAndLXCIO(t *testing.T) {
kind, name, vmid := classifyIOProcess(processIOSnapshot{name: "qemu", cmdline: "qemu-system-x86_64 -id 105 -name guest=test"}, map[int]string{105: "Gitea"})
if kind != "VM" || name != "Gitea" || vmid != 105 {
t.Fatalf("unexpected qemu attribution: %s %s %d", kind, name, vmid)
}
kind, name, vmid = classifyIOProcess(processIOSnapshot{name: "worker", cgroup: "0::/lxc.payload.220/ns"}, map[int]string{220: "db"})
if kind != "LXC" || name != "db" || vmid != 220 {
t.Fatalf("unexpected lxc attribution: %s %s %d", kind, name, vmid)
}
}
func TestActiveIOOperation(t *testing.T) {
value := activeIOOperation([]ProxmoxTask{{Type: "vzdump", ID: "105", Status: "", EndTime: 0}})
if !strings.Contains(value, "Backup") || !strings.Contains(value, "105") {
t.Fatalf("unexpected operation: %s", value)
}
}
func TestCorrelateAlerts(t *testing.T) {
metrics := dashboardMetrics{Disks: []DiskMetrics{{Name: "sda", IOCause: "Backup VM/LXC 105", IOConfidence: 90, Utilization: 98, LatencyMs: 80}}, Services: ServicesMetrics{Services: []ServiceStatus{{MonitoredService: MonitoredService{Name: "Gitea"}, Endpoints: []ServiceEndpointStatus{{Up: false}}}}}}
alerts := []Alert{{ID: "disk-io-pressure-sda", Severity: "critical"}, {ID: "service-1-public", Severity: "critical"}, {ID: "other", Severity: "warning"}}
result := correlateAlerts(metrics, alerts)
if len(result) != 2 || result[0].Source != "Корреляция" || !strings.Contains(result[0].Message, "Gitea") {
t.Fatalf("unexpected correlation: %+v", result)
}
}

12
main.go
View File

@@ -20,11 +20,23 @@ var version = "dev"
func main() {
showVersion := flag.Bool("version", false, "show version")
agentMode := flag.Bool("agent", false, "run as a read-only guest agent")
agentServer := flag.String("agent-server", "", "dashboard URL used by the guest agent")
agentEnrollToken := flag.String("agent-enroll-token", "", "one-time enrollment token")
agentName := flag.String("agent-name", "", "agent display name")
agentVMID := flag.Int("agent-vmid", 0, "Proxmox VM/LXC ID")
agentConfig := flag.String("agent-config", "/var/lib/proxmox-dashboard-agent/agent.json", "agent configuration path")
flag.Parse()
if *showVersion {
fmt.Println(version)
return
}
if *agentMode {
if err := runGuestAgent(agentOptions{Server: *agentServer, EnrollmentToken: *agentEnrollToken, Name: *agentName, VMID: *agentVMID, ConfigPath: *agentConfig}); err != nil {
log.Fatalf("Ошибка агента: %v", err)
}
return
}
address := os.Getenv("DASHBOARD_ADDR")
if address == "" {

125
maintenance_window.go Normal file
View File

@@ -0,0 +1,125 @@
package main
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)
type MaintenanceWindow struct {
Enabled bool `json:"enabled"`
Active bool `json:"active"`
StartedAt int64 `json:"startedAt"`
EndsAt int64 `json:"endsAt"`
Reason string `json:"reason"`
AllAlerts bool `json:"allAlerts"`
ServiceIDs []int64 `json:"serviceIds"`
VMIDs []int `json:"vmids"`
}
func (s *Store) MaintenanceWindow() MaintenanceWindow {
var value string
var window MaintenanceWindow
if s.db.QueryRow(`SELECT value FROM settings WHERE key='maintenance_window'`).Scan(&value) == nil {
_ = json.Unmarshal([]byte(value), &window)
}
now := time.Now().Unix()
window.Active = window.Enabled && window.StartedAt <= now && window.EndsAt > now
if window.Enabled && window.EndsAt <= now {
window.Enabled = false
data, _ := json.Marshal(window)
_, _ = s.db.Exec(`INSERT INTO settings(key,value) VALUES('maintenance_window',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, string(data))
}
return window
}
func (s *Store) SaveMaintenanceWindow(window MaintenanceWindow) (MaintenanceWindow, error) {
now := time.Now()
window.Reason = strings.TrimSpace(window.Reason)
if window.Reason == "" {
return window, fmt.Errorf("укажите причину плановых работ")
}
if window.EndsAt <= now.Unix() || window.EndsAt > now.Add(30*24*time.Hour).Unix() {
return window, fmt.Errorf("окончание должно быть в будущем, максимум через 30 дней")
}
if !window.AllAlerts && len(window.ServiceIDs) == 0 && len(window.VMIDs) == 0 {
return window, fmt.Errorf("выберите сервис, VM/LXC или все алерты")
}
window.Enabled = true
window.Active = true
window.StartedAt = now.Unix()
window.ServiceIDs = uniqueInt64(window.ServiceIDs)
window.VMIDs = uniqueInt(window.VMIDs)
data, _ := json.Marshal(window)
_, err := s.db.Exec(`INSERT INTO settings(key,value) VALUES('maintenance_window',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, string(data))
return window, err
}
func (s *Store) StopMaintenanceWindow() (MaintenanceWindow, error) {
window := s.MaintenanceWindow()
window.Enabled = false
window.Active = false
window.EndsAt = time.Now().Unix()
data, _ := json.Marshal(window)
_, err := s.db.Exec(`INSERT INTO settings(key,value) VALUES('maintenance_window',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, string(data))
return window, err
}
func splitMaintenanceAlerts(alerts []Alert, window MaintenanceWindow) (active, planned []Alert) {
if !window.Active {
return alerts, nil
}
for _, alert := range alerts {
if maintenanceMatchesAlert(window, alert) {
planned = append(planned, alert)
} else {
active = append(active, alert)
}
}
return active, planned
}
func maintenanceMatchesAlert(window MaintenanceWindow, alert Alert) bool {
if window.AllAlerts {
return true
}
for _, id := range window.ServiceIDs {
value := strconv.FormatInt(id, 10)
if strings.HasPrefix(alert.ID, "service-"+value+"-") || strings.HasPrefix(alert.ID, "service-tls-"+value+"-") {
return true
}
}
for _, vmid := range window.VMIDs {
value := strconv.Itoa(vmid)
if strings.HasSuffix(alert.ID, "-"+value) || strings.Contains(alert.Message, "VMID "+value) {
return true
}
}
return false
}
func uniqueInt64(values []int64) []int64 {
seen := map[int64]bool{}
result := make([]int64, 0, len(values))
for _, value := range values {
if value > 0 && !seen[value] {
seen[value] = true
result = append(result, value)
}
}
return result
}
func uniqueInt(values []int) []int {
seen := map[int]bool{}
result := make([]int, 0, len(values))
for _, value := range values {
if value > 0 && !seen[value] {
seen[value] = true
result = append(result, value)
}
}
return result
}

View File

@@ -0,0 +1,60 @@
package main
import (
"testing"
"time"
)
func TestMaintenanceFiltersSelectedTargets(t *testing.T) {
window := MaintenanceWindow{Active: true, ServiceIDs: []int64{7}, VMIDs: []int{105}}
alerts := []Alert{
{ID: "service-7-local"},
{ID: "service-tls-8-public"},
{ID: "backup-old-105"},
{ID: "task-failed-x", Message: "VMID 105, status error"},
{ID: "cpu-usage"},
}
active, planned := splitMaintenanceAlerts(alerts, window)
if len(active) != 2 || len(planned) != 3 {
t.Fatalf("неверное разделение: active=%+v planned=%+v", active, planned)
}
}
func TestMaintenanceExpiresAutomatically(t *testing.T) {
store, err := openStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
window, err := store.SaveMaintenanceWindow(MaintenanceWindow{EndsAt: time.Now().Add(time.Hour).Unix(), Reason: "Обновление", AllAlerts: true})
if err != nil || !window.Active {
t.Fatalf("режим не включён: %+v %v", window, err)
}
if _, err = store.db.Exec(`UPDATE settings SET value=? WHERE key='maintenance_window'`, `{"enabled":true,"startedAt":1,"endsAt":2,"reason":"test","allAlerts":true}`); err != nil {
t.Fatal(err)
}
if expired := store.MaintenanceWindow(); expired.Active || expired.Enabled {
t.Fatalf("просроченный режим остался активным: %+v", expired)
}
}
func TestPlannedAlertOnlyAppearsInHistory(t *testing.T) {
store, err := openStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
alert := Alert{ID: "service-7-local", Severity: "critical", Source: "Сервисы", Title: "Недоступен"}
visible, err := store.SyncAlertsWithMaintenance(nil, []Alert{alert}, "Обновление")
if err != nil || len(visible) != 0 {
t.Fatalf("плановый алерт стал активным: %+v %v", visible, err)
}
history, err := store.AlertHistory(10)
if err != nil || len(history) != 1 || !history[0].Planned || history[0].MaintenanceReason != "Обновление" {
t.Fatalf("плановое событие не сохранено: %+v %v", history, err)
}
pending, err := store.PendingEmailAlerts()
if err != nil || len(pending) != 0 {
t.Fatalf("плановое событие попало в почту: %+v %v", pending, err)
}
}

63
scripts/install-agent.sh Executable file
View File

@@ -0,0 +1,63 @@
#!/bin/sh
set -eu
if [ "$(id -u)" -ne 0 ]; then
echo "Запустите команду установки от root." >&2
exit 1
fi
if [ -z "${DASHBOARD_URL:-}" ] || [ -z "${ENROLL_TOKEN:-}" ]; then
echo "Не заданы DASHBOARD_URL и ENROLL_TOKEN. Создайте команду в настройках Dashboard." >&2
exit 1
fi
GITEA_URL=${GITEA_URL:-https://git.myown.center}
GITEA_REPOSITORY=${GITEA_REPOSITORY:-maxim/ProxmoxDash}
VMID=${VMID:-0}
AGENT_NAME=${AGENT_NAME:-$(hostname)}
release_json=$(curl -fsSL "$GITEA_URL/api/v1/repos/$GITEA_REPOSITORY/releases/latest")
download_url=$(printf '%s' "$release_json" | sed -n 's/.*"browser_download_url":"\([^"]*\/proxmox-dashboard-linux-amd64\)".*/\1/p' | head -n 1)
if [ -z "$download_url" ]; then
echo "В последнем релизе не найден Linux-бинарник." >&2
exit 1
fi
install -d -m 0700 /var/lib/proxmox-dashboard-agent
temporary_binary=$(mktemp /tmp/proxmox-dashboard-agent.XXXXXX)
trap 'rm -f "$temporary_binary"' EXIT
curl -fsSL "$download_url" -o "$temporary_binary"
install -m 0755 "$temporary_binary" /usr/local/bin/proxmox-dashboard-agent
umask 077
{
printf 'DASHBOARD_URL=%s\n' "$DASHBOARD_URL"
printf 'ENROLL_TOKEN=%s\n' "$ENROLL_TOKEN"
printf 'AGENT_NAME=%s\n' "$AGENT_NAME"
printf 'VMID=%s\n' "$VMID"
} > /etc/proxmox-dashboard-agent.env
cat > /etc/systemd/system/proxmox-dashboard-agent.service <<'UNIT'
[Unit]
Description=Proxmox Dashboard read-only guest agent
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=/etc/proxmox-dashboard-agent.env
ExecStart=/usr/local/bin/proxmox-dashboard-agent --agent --agent-server ${DASHBOARD_URL} --agent-enroll-token ${ENROLL_TOKEN} --agent-name ${AGENT_NAME} --agent-vmid ${VMID}
Restart=always
RestartSec=5
NoNewPrivileges=true
ProtectHome=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/var/lib/proxmox-dashboard-agent
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable --now proxmox-dashboard-agent.service
echo "Агент установлен. Через несколько секунд он появится в Dashboard."

View File

@@ -74,6 +74,20 @@ type ServiceHistoryPoint struct {
LatencyMS float64 `json:"latencyMs"`
}
var serviceHTTPTransport = &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 32,
MaxIdleConnsPerHost: 2,
IdleConnTimeout: 30 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: time.Second,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12},
}
var serviceHTTPClient = &http.Client{Transport: serviceHTTPTransport}
func (s *Store) Services() ([]MonitoredService, error) {
rows, err := s.db.Query(`SELECT id,name,url,local_address,check_type,expected_code,expected_text,timeout_seconds,enabled FROM services ORDER BY id`)
if err != nil {
@@ -296,14 +310,12 @@ func checkServiceEndpoint(v MonitoredService, kind, label, target string) Servic
st.Error = strings.TrimSpace(string(out))
}
default:
tr := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12}}
client := &http.Client{Transport: tr, Timeout: time.Duration(v.TimeoutSeconds) * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
st.Error = err.Error()
break
}
resp, err := client.Do(req)
resp, err := serviceHTTPClient.Do(req)
if err != nil {
st.Error = err.Error()
} else {

View File

@@ -1,6 +1,9 @@
package main
import "testing"
import (
"testing"
"time"
)
func TestServiceCRUD(t *testing.T) {
store, err := openStore(":memory:")
@@ -38,3 +41,15 @@ func TestValidateServiceChecksBothHTTPAddresses(t *testing.T) {
t.Fatal(err)
}
}
func TestHTTPServiceTransportHasBoundedIdlePool(t *testing.T) {
if serviceHTTPClient.Transport != serviceHTTPTransport {
t.Fatal("проверки сервисов должны использовать общий HTTP transport")
}
if serviceHTTPTransport.IdleConnTimeout < time.Second {
t.Fatal("idle-соединения должны автоматически закрываться")
}
if serviceHTTPTransport.MaxIdleConnsPerHost < 1 || serviceHTTPTransport.MaxIdleConnsPerHost > 4 {
t.Fatalf("небезопасный размер пула на хост: %d", serviceHTTPTransport.MaxIdleConnsPerHost)
}
}

View File

@@ -31,6 +31,17 @@ type AlertThresholds struct {
NVMeTempWarning float64 `json:"nvmeTempWarning"`
UPSChargeCritical float64 `json:"upsChargeCritical"`
BackupMaxAgeHours float64 `json:"backupMaxAgeHours"`
DiskHDDUtil float64 `json:"diskHddUtil"`
DiskHDDLatency float64 `json:"diskHddLatency"`
DiskHDDQueue float64 `json:"diskHddQueue"`
DiskSSDUtil float64 `json:"diskSsdUtil"`
DiskSSDLatency float64 `json:"diskSsdLatency"`
DiskSSDQueue float64 `json:"diskSsdQueue"`
DiskNVMeUtil float64 `json:"diskNvmeUtil"`
DiskNVMeLatency float64 `json:"diskNvmeLatency"`
DiskNVMeQueue float64 `json:"diskNvmeQueue"`
DiskIODuration float64 `json:"diskIoDuration"`
DiskIOExcluded string `json:"diskIoExcluded"`
}
func defaultThresholds() AlertThresholds {
@@ -40,6 +51,7 @@ func defaultThresholds() AlertThresholds {
StorageWarning: 85, StorageCritical: 95, ZFSWarning: 80, ZFSCritical: 90,
DiskTempWarning: 60, NVMeTempWarning: 75, UPSChargeCritical: 30,
BackupMaxAgeHours: 48,
DiskHDDUtil: 90, DiskHDDLatency: 50, DiskHDDQueue: 4, DiskSSDUtil: 90, DiskSSDLatency: 20, DiskSSDQueue: 4, DiskNVMeUtil: 95, DiskNVMeLatency: 10, DiskNVMeQueue: 8, DiskIODuration: 60,
}
}
@@ -126,6 +138,8 @@ func openStore(path string) (*Store, error) {
`CREATE INDEX IF NOT EXISTS service_checks_service_ts_idx ON service_checks(service_id,ts)`,
`CREATE TABLE IF NOT EXISTS entity_history (kind TEXT NOT NULL,name TEXT NOT NULL,ts INTEGER NOT NULL,value1 REAL,value2 REAL,PRIMARY KEY(kind,name,ts))`,
`CREATE INDEX IF NOT EXISTS entity_history_kind_ts_idx ON entity_history(kind,ts)`,
`CREATE TABLE IF NOT EXISTS agent_enrollment_tokens (token_hash TEXT PRIMARY KEY, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, used_at INTEGER)`,
`CREATE TABLE IF NOT EXISTS agents (id TEXT PRIMARY KEY, name TEXT NOT NULL, vmid INTEGER NOT NULL DEFAULT 0, hostname TEXT NOT NULL DEFAULT '', machine_id TEXT NOT NULL DEFAULT '', secret_hash TEXT NOT NULL, enrolled_at INTEGER NOT NULL, last_seen INTEGER NOT NULL, version TEXT NOT NULL DEFAULT '', report_json TEXT NOT NULL DEFAULT '{}', remote_address TEXT NOT NULL DEFAULT '')`,
}
for _, statement := range statements {
if _, err := db.Exec(statement); err != nil {
@@ -135,6 +149,9 @@ func openStore(path string) (*Store, error) {
}
// Older databases get this column without requiring a separate migration tool.
_, _ = db.Exec(`ALTER TABLE alert_events ADD COLUMN recovery_sent_at INTEGER`)
_, _ = db.Exec(`ALTER TABLE alert_events ADD COLUMN planned INTEGER NOT NULL DEFAULT 0`)
_, _ = db.Exec(`ALTER TABLE alert_events ADD COLUMN maintenance_reason TEXT NOT NULL DEFAULT ''`)
_, _ = db.Exec(`DELETE FROM alert_events WHERE alert_key='monitor-slow'`)
_, _ = db.Exec(`ALTER TABLE service_checks ADD COLUMN endpoint TEXT NOT NULL DEFAULT 'public'`)
_, _ = db.Exec(`PRAGMA foreign_keys=ON`)
return &Store{db: db, path: path}, nil
@@ -218,7 +235,7 @@ func (s *Store) SaveThresholds(value AlertThresholds) error {
}
func defaultEmailSettings() EmailSettings {
return EmailSettings{Port: 587, IntervalMinutes: 60, Severities: []string{"critical", "warning"}, Sources: []string{"CPU", "Память", "Storage", "Диски", "ZFS", "UPS", "VM/LXC", "Proxmox", "Dashboard", "Сервисы", "Система", "Сеть"}}
return EmailSettings{Port: 587, IntervalMinutes: 60, Severities: []string{"critical", "warning"}, Sources: []string{"CPU", "Память", "Storage", "Диски", "ZFS", "UPS", "VM/LXC", "Proxmox", "Dashboard", "Сервисы", "Система", "Сеть", "Агенты", "Docker"}}
}
func (s *Store) EmailSettings(includePassword bool) EmailSettings {
@@ -285,6 +302,19 @@ func validateThresholds(t AlertThresholds) error {
if t.BackupMaxAgeHours < 1 || t.BackupMaxAgeHours > 24*365 {
return fmt.Errorf("возраст backup должен быть от 1 до 8760 часов")
}
for _, value := range []float64{t.DiskHDDUtil, t.DiskSSDUtil, t.DiskNVMeUtil} {
if value < 1 || value > 100 {
return fmt.Errorf("пороги utilization должны быть от 1 до 100")
}
}
for _, value := range []float64{t.DiskHDDLatency, t.DiskSSDLatency, t.DiskNVMeLatency, t.DiskHDDQueue, t.DiskSSDQueue, t.DiskNVMeQueue} {
if value < 0 {
return fmt.Errorf("пороги latency и очереди не могут быть отрицательными")
}
}
if t.DiskIODuration < 5 || t.DiskIODuration > 3600 {
return fmt.Errorf("длительность I/O-проблемы должна быть от 5 до 3600 секунд")
}
return nil
}

107
trends.go Normal file
View File

@@ -0,0 +1,107 @@
package main
import (
"database/sql"
"fmt"
"sync"
"time"
)
type entityAverage struct {
Value1, Value2 float64
Count int
}
type TrendAnomaly struct {
ID string `json:"id"`
Severity string `json:"severity"`
Source string `json:"source"`
Title string `json:"title"`
Message string `json:"message"`
}
type trendCollector struct {
mu sync.Mutex
updatedAt time.Time
cached []TrendAnomaly
}
func (s *Store) entityAverages(kind string) map[string]entityAverage {
return s.entityAveragesSince(kind, 7*24*time.Hour)
}
func (s *Store) entityAveragesSince(kind string, duration time.Duration) map[string]entityAverage {
rows, err := s.db.Query(`SELECT name,AVG(value1),AVG(value2),COUNT(*) FROM entity_history WHERE kind=? AND ts>=? GROUP BY name`, kind, time.Now().Add(-duration).Unix())
if err != nil {
return nil
}
defer rows.Close()
result := map[string]entityAverage{}
for rows.Next() {
var name string
var v1, v2 sql.NullFloat64
var count int
if rows.Scan(&name, &v1, &v2, &count) == nil {
result[name] = entityAverage{Value1: v1.Float64, Value2: v2.Float64, Count: count}
}
}
return result
}
func (c *trendCollector) collect(metrics dashboardMetrics, store *Store) []TrendAnomaly {
c.mu.Lock()
defer c.mu.Unlock()
if time.Since(c.updatedAt) < time.Minute {
return c.cached
}
var out []TrendAnomaly
iops := store.entityAverages("disk-io")
latency := store.entityAverages("disk-latency")
latencyDay := store.entityAveragesSince("disk-latency", 24*time.Hour)
temps := store.entityAverages("disk")
for _, d := range metrics.Disks {
name := d.Name + " · " + d.Model
currentIOPS := d.ReadIOPS + d.WriteIOPS
if base := iops[name]; base.Count >= 30 && base.Value1 >= 1 && currentIOPS >= base.Value1*5 {
out = append(out, TrendAnomaly{"disk-iops-" + d.Name, "warning", "Аномалии", d.Model + ": IOPS резко выше обычного", fmt.Sprintf("Сейчас %.0f IOPS, недельный baseline %.0f — рост в %.1f раза.", currentIOPS, base.Value1, currentIOPS/base.Value1)})
}
if base := latency[name]; base.Count >= 30 && base.Value1 > 0 && d.LatencyMs >= base.Value1*2 && d.LatencyMs-base.Value1 >= 10 {
out = append(out, TrendAnomaly{"disk-latency-" + d.Name, "warning", "Аномалии", d.Model + ": latency ухудшилась", fmt.Sprintf("Сейчас %.1f ms при baseline %.1f ms.", d.LatencyMs, base.Value1)})
}
if week, day := latency[name], latencyDay[name]; week.Count >= 100 && day.Count >= 30 && week.Value1 > 0 && day.Value1 >= week.Value1*1.5 && day.Value1-week.Value1 >= 5 {
out = append(out, TrendAnomaly{"disk-latency-degrading-" + d.Name, "warning", "Аномалии", d.Model + ": latency постепенно ухудшается", fmt.Sprintf("Среднее за сутки %.1f ms против недельного %.1f ms.", day.Value1, week.Value1)})
}
if d.Temperature != nil {
if base := temps[name]; base.Count >= 30 && *d.Temperature >= base.Value1+10 {
out = append(out, TrendAnomaly{"disk-temp-trend-" + d.Name, "warning", "Аномалии", d.Model + ": температура выше обычной", fmt.Sprintf("Сейчас %.1f °C, baseline %.1f °C.", *d.Temperature, base.Value1)})
}
}
}
guestAvg := store.entityAverages("guest")
for _, g := range metrics.Guests.Guests {
if g.MaxMemory == 0 {
continue
}
name := fmt.Sprintf("%d · %s", g.VMID, g.Name)
current := float64(g.MemoryBytes) / float64(g.MaxMemory) * 100
if base := guestAvg[name]; base.Count >= 30 && current >= base.Value2*1.5 && current-base.Value2 >= 15 {
out = append(out, TrendAnomaly{fmt.Sprintf("guest-memory-%d", g.VMID), "warning", "Аномалии", g.Name + ": память выше обычной", fmt.Sprintf("Сейчас %.1f%%, baseline %.1f%%.", current, base.Value2)})
}
}
zfsAverage := store.entityAverages("zfs")
for _, pool := range metrics.ZFS.Pools {
if base := zfsAverage[pool.Name]; base.Count >= 30 && pool.CapacityPercent-base.Value1 >= 5 {
out = append(out, TrendAnomaly{"zfs-growth-" + pool.Name, "warning", "Аномалии", pool.Name + ": заполнение выше недельного уровня", fmt.Sprintf("Сейчас %.1f%%, недельный baseline %.1f%%.", pool.CapacityPercent, base.Value1)})
}
}
backupAverage := store.entityAverages("backup-duration")
for _, task := range metrics.Activity.Tasks {
if task.Type != "vzdump" || task.Duration <= 0 {
continue
}
name := "VMID " + task.ID
if base := backupAverage[name]; base.Count >= 3 && float64(task.Duration) >= base.Value1*1.5 && float64(task.Duration)-base.Value1 >= 60 {
out = append(out, TrendAnomaly{"backup-duration-" + task.ID, "warning", "Аномалии", "Backup " + name + " выполнялся дольше обычного", fmt.Sprintf("Последняя длительность %s, baseline %s.", time.Duration(task.Duration)*time.Second, time.Duration(base.Value1)*time.Second)})
}
}
c.cached = out
c.updatedAt = time.Now()
return out
}

247
web.go
View File

@@ -11,28 +11,31 @@ import (
)
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"`
Services ServicesMetrics `json:"services"`
SystemHealth SystemHealthMetrics `json:"systemHealth"`
Activity ProxmoxActivity `json:"activity"`
Monitor MonitorHealth `json:"monitor"`
UPSShutdown UPSShutdownSettings `json:"upsShutdown"`
Timestamp time.Time `json:"timestamp"`
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"`
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, store *Store, monitor *monitorTracker) (dashboardMetrics, error) {
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
@@ -49,9 +52,10 @@ func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkColle
if err != nil {
return dashboardMetrics{}, err
}
thresholds := store.Thresholds()
metrics := dashboardMetrics{
CPU: cpu, Memory: memory, Storage: storage,
Disks: diskCollector.collect(), Network: network,
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(),
}
@@ -59,9 +63,14 @@ func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkColle
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()
decorateNetworkInterfaces(&metrics.Network, metrics.Guests)
metrics.Alerts = evaluateAlerts(metrics, store.Thresholds())
metrics.Alerts, err = store.SyncAlerts(metrics.Alerts)
metrics.MaintenanceWindow = store.MaintenanceWindow()
allAlerts := 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
}
@@ -121,12 +130,14 @@ func routes(collector *cpuCollector, store *Store) http.Handler {
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, store, monitor)
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
}
@@ -182,6 +193,92 @@ func routes(collector *cpuCollector, store *Store) http.Handler {
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
}
credentials := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
id, secret, ok := strings.Cut(credentials, ".")
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
}
writeJSON(w, map[string]bool{"accepted": true}, nil)
})
mux.HandleFunc("/api/alerts/history", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
@@ -367,7 +464,7 @@ button{font:inherit}main{width:min(1060px,calc(100% - 32px));margin:auto;padding
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{flex:0 0 auto;font:700 35px/.95 ui-monospace,monospace;letter-spacing:-.07em}.percent small{font-size:16px;color:var(--green)}
.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}
@@ -379,17 +476,20 @@ dialog{width:min(760px,calc(100% - 28px));max-height:calc(100vh - 32px);padding:
.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}.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;gap:6px;padding:12px 26px;border-bottom:1px solid var(--line)}.system-panel[hidden]{display:none}
.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}
</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="settingsButton" aria-label="Настройки">⚙</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>
@@ -440,9 +540,15 @@ footer{color:#657581;font-size:12px;margin-top:17px}
<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 гостей</span><strong id="guestCPU">—</strong></div><div><span class="label">Память гостей</span><strong id="guestMemory">—</strong></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>
@@ -469,7 +575,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
</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">Гостей со снимками</span><strong id="snapshotGuests">—</strong></div></div><div class="hint">Нажмите, чтобы посмотреть снимки →</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>
@@ -477,7 +583,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
</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 → гости → сервисы → storage →</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>
</section>
<footer>Обновление каждые 2 секунды · данные читаются напрямую с хоста</footer>
@@ -517,8 +623,8 @@ footer{color:#657581;font-size:12px;margin-top:17px}
<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="chart-section"><div class="chart-head"><strong>Температура физических дисков</strong><span>Отдельно по каждому диску</span></div><canvas class="metric-chart" id="storageChart"></canvas></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">
@@ -554,8 +660,8 @@ footer{color:#657581;font-size:12px;margin-top:17px}
<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">Получение списка гостей…</span></div>
<div class="chart-section"><div class="chart-head"><strong>CPU каждой VM и LXC</strong><span>Отдельно по каждому гостю</span></div><canvas class="metric-chart" id="guestsChart"></canvas></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">
@@ -583,18 +689,19 @@ footer{color:#657581;font-size:12px;margin-top:17px}
<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></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 берётся из конфигурации каждого гостя.</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="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="disk-list" id="agentList"><span class="empty">Подключённые агенты появятся здесь.</span></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="services">Сервисы</button><button class="settings-tab" data-settings-tab="ups">UPS shutdown</button><button class="settings-tab" data-settings-tab="email">Почта</button><button class="settings-tab" data-settings-tab="updates">Обновления</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="maintenance-mode">Maintenance</button><button class="settings-tab" data-settings-tab="services">Сервисы</button><button class="settings-tab" data-settings-tab="agents">Агенты</button><button class="settings-tab" data-settings-tab="ups">UPS shutdown</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">
@@ -602,11 +709,11 @@ footer{color:#657581;font-size:12px;margin-top:17px}
<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="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="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>
</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>Ожидание гостя, сек.</span><input type="number" id="upsShutdownGrace"></label><label class="field full-span"><span>Порядок VMID через запятую</span><input id="upsShutdownOrder" placeholder="101, 105, 220"><small>Неуказанные запущенные гости добавятся после них по 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="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>
@@ -617,9 +724,20 @@ footer{color:#657581;font-size:12px;margin-top:17px}
<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>
@@ -629,6 +747,11 @@ footer{color:#657581;font-size:12px;margin-top:17px}
</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>
<div class="settings-body settings-panel" data-settings-panel="agents" hidden>
<div class="settings-group"><div class="label">Подключить новую VM или LXC</div><div class="settings-note">Токен одноразовый и действует 15 минут. Агент только читает системные метрики и Docker — команд управления в этой версии нет.</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>
<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">
@@ -639,7 +762,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
</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="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>
<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>
@@ -658,29 +781,40 @@ const rate=n=>size(n)+'/s';
const safe=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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);renderHomelabMap(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 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 гостя либо 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 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 agentHTML(a,settings=false){const r=a.report||{},containers=r.containers||[],memoryPercent=r.memoryTotal?r.memoryUsed/r.memoryTotal*100:0,diskPercent=r.rootTotal?r.rootUsed/r.rootTotal*100:0,problems=containers.filter(v=>v.state!=='running'||v.health==='unhealthy');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(a.version||'—')+' · '+(a.online?'heartbeat сейчас':'последний '+new Date(a.lastSeen*1000).toLocaleString('ru-RU'))+'</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>Docker</span><b>'+(r.dockerAvailable?containers.length+' контейнеров':safe(r.dockerError||'Нет данных'))+'</b></div><div class="disk-stat"><span>Проблемы</span><b>'+problems.length+'</b></div></div><div class="agent-containers">'+(containers.length?containers.map(v=>'<span class="topology-chip '+(v.state==='running'&&v.health!=='unhealthy'?'good':'bad')+'">'+safe(v.name)+' · '+safe(v.health||v.state)+' · CPU '+fixed(v.cpuPercent||0)+'% · RAM '+size(v.memoryUsage||0)+' · restarts '+v.restarts+'</span>').join(''):'<span class="empty">Docker-контейнеры не обнаружены</span>')+'</div>'+(settings?'<div class="service-actions"><button data-agent-delete="'+safe(a.id)+'">Удалить агент</button></div>':'')+'</article>'}
function renderAgents(items){const agents=items||[],online=agents.filter(v=>v.online),containers=agents.flatMap(v=>(v.report&&v.report.containers)||[]),problems=(agents.length-online.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 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;
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'?'Предупреждение':'Информация')+'</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('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 badge=d.smartStatus==='Ошибка'?'bad':d.smartStatus==='Исправен'?'':'warn',reason=(d.attentionReasons||[]).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+'>'+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>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>';
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>';
@@ -693,33 +827,33 @@ function render(x){const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs||{av
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;el('issueList').innerHTML=(sh.issues||[]).length?sh.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">Ошибка</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('issueList').querySelectorAll('.badge').forEach((badge,index)=>{const count=(sh.issues||[])[index]?.count||1;badge.textContent=count>1?'Повторений: '+count:'1 раз'});
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);explainWarningBadges();
renderActivity(x.activity||{},g,svc,z);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},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.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};return [...timestamps.values()].sort((a,b)=>a.timestamp-b.timestamp)}
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&&section.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),canvas=section.querySelector('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);setupChartHover(chart.querySelector('canvas'));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)})})}
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)};
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 response=await fetch('/api/alerts/history');if(!response.ok)return;const events=await response.json()||[];el('alertHistory').innerHTML=events.length?events.map(a=>{const state=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>'}
async function loadAlertHistory(){const response=await fetch('/api/alerts/history');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>'}
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','maintenance','backups','services','system','snapshots','tasks','map'],cardLabels={alerts:'Центр уведомлений',cpu:'CPU',memory:'Память',storage:'Диски',network:'Сеть',zfs:'ZFS',ups:'UPS',monitor:'Самоконтроль',guests:'VM и LXC',maintenance:'Proxmox',backups:'Резервные копии',services:'Сервисы',system:'Система и ядро',snapshots:'Snapshots',tasks:'Задачи Proxmox',map:'Карта 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))];
const cardNames=['alerts','cpu','memory','storage','network','zfs','ups','monitor','guests','agents','maintenance','backups','services','system','snapshots','tasks','map'],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'};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})}
@@ -727,13 +861,20 @@ document.querySelectorAll('[data-card-toggle]').forEach(toggle=>toggle.onchange=
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();
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 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 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]=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(()=>{});
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 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().catch(()=>{});
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()}).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()};